Skip to content

Commit 1117d96

Browse files
committed
Add support for embedding posts in messages.
Fix SendMessage #169
1 parent a6efc60 commit 1117d96

File tree

13 files changed

+221
-30
lines changed

13 files changed

+221
-30
lines changed

docs/docs/conversationsAndMessages.md

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ If you already have a conversation ID you can use `GetConversation` to retrieve
2222
To retrieve the messages in a conversation use `GetMessages`:
2323

2424
```c#
25-
var getMessagesResult = await agent.GetMessages(conversationId, cancellationToken: cancellationToken);
25+
var getMessagesResult = await agent.GetMessages(conversationId);
2626
```
2727

2828
The returns a [pageable list](cursorsAndPagination.md) of either `MessageView` or `DeletedMessageView` for each message in the conversation, as well
@@ -53,19 +53,58 @@ optionally the message id of the last message seen.
5353
To send a message to a conversation use `SendMessage()`:
5454

5555
```c#
56-
var sendMessageResult = await agent.SendMessage(conversationID, "hello"", cancellationToken);
56+
var sendMessageResult = await agent.SendMessage(conversationID, "hello");
5757
```
5858

5959
This returns a `MessageView` which includes the message identifier, which you can use to delete a message.
6060

61-
For rich messages, tagging DIDs, embedding links etc. you can pass an instance of `MessageInput` and specify the facets for the message.
61+
If you want to embed a link to a post provide a `StrongReference` to the post in the optional `embeddedPost` parameter on `SendMessage`.
62+
63+
Like posts mentions, hashtags and links are detected automatically. If you want to disable facet detection set the `extractFacets` parameter to false.
64+
If you want to create your own facets create an instance of `MessageInput` and pass that into `SendMessage`.
6265

6366
You can send multiple messages into multiple conversations using `SendMessageBatch()`.
6467

6568
## <a name="deleting">Deleting a message in a conversation</a>
6669

6770
To delete a message in a conversation use `DeleteMessageForSelf()`, passing the conversation id and the message id.
6871

72+
## <a name="reacting">Embedded posts in messages</a>
73+
74+
If you want to embed a link to a post provide a `StrongReference` to the post in the optional `embeddedPost` parameter on `SendMessage`.
75+
76+
To extract the post details from a `MessageView` use the `Embed` property like this:
77+
78+
```c#
79+
foreach (MessageViewBase message in getMessages.Result)
80+
{
81+
if (message is MessageView view)
82+
{
83+
var sender = getConversation.Result.Members.FirstOrDefault(m => m.Did == view.Sender.Did) ??
84+
throw new InvalidOperationException("Cannot find message sender in conversation view");
85+
86+
Console.WriteLine($"{sender}: {view.Text} {view.SentAt:g}");
87+
88+
if (view.Embed is not null &&
89+
view.Embed.Record is not null &&
90+
view.Embed.Record is ViewRecord viewRecord)
91+
{
92+
if (viewRecord.Value is Post post)
93+
{
94+
Console.WriteLine($" {viewRecord.Author}");
95+
Console.WriteLine($" {post.Text}");
96+
}
97+
}
98+
}
99+
else if (message is DeletedMessageView _)
100+
{
101+
Console.WriteLine("Deleted Message");
102+
}
103+
}
104+
```
105+
106+
The above code leaves space for handling other types of embeds should they be added by Bluesky.
107+
69108
## <a name="reacting">Message reactions</a>
70109

71110
Bluesky allows for simple message reactions in conversations. The `MessageView` you get from `getMessages` has a `Reactions` property which is a collection of `ReactionView`. To display reactions
@@ -105,10 +144,10 @@ To start a conversation you will need the DIDs of the conversation members, whic
105144
it will be restored in the direct message list.
106145

107146
```c#
108-
var memberDid = await agent.ResolveHandle("example.invalid.handle", cancellationToken);
147+
var memberDid = await agent.ResolveHandle("example.invalid.handle");
109148
List<Did> conversationMembers = new() { agent.Did!, bot2Did! };
110149

111-
var startConversationResult = await agent.GetConversationForMembers(conversationMembers, cancellationToken);
150+
var startConversationResult = await agent.GetConversationForMembers(conversationMembers);
112151
```
113152

114153
`StartConversation()` returns a `ConversationView` which includes the conversation ID, which you can then use to send messages to the chat.

history.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
# Version History
22

3+
## 0.9.5
4+
5+
### Features
6+
7+
#### idunno.Bluesky
8+
9+
* Fix serialization exception in `SendMessage`. Fixes [#169](https://github.com/blowdart/idunno.Bluesky/issues/169)
10+
* Add optional parameter, `embeddedPost` to `SendMessage` to allow for embedding of posts in direct messages.
11+
312
## 0.9.4
413

514
### Bug fixes
615

716
#### idunno.Bluesky
817

9-
* Fix cursor pagination in GetFollowers, GetBlocks, GetFollows, GetKnownFollowers, GetListBlocks, GetListMutes, GetList, GetLists, ListConversations.
18+
* Fix cursor pagination in `GetFollowers`, `GetBlocks`, `GetFollows`, `GetKnownFollowers`, `GetListBlocks`, `GetListMutes`, `GetList`, `GetLists`, and `ListConversations`.
1019

1120
## 0.9.3
1221

samples/Samples.DirectMessages/Program.cs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010

1111
using idunno.Bluesky;
1212
using idunno.Bluesky.Actor;
13+
using idunno.AtProto;
1314
using idunno.Bluesky.Chat;
1415

1516
using Samples.Common;
16-
using idunno.AtProto;
17+
using idunno.Bluesky.Embed;
18+
using System.Net.Http.Headers;
1719

1820
namespace Samples.DirectMessages
1921
{
@@ -102,6 +104,18 @@ static async Task PerformOperations(string? handle, string? password, string? au
102104
}
103105
}
104106

107+
var startConversationResult = await agent.GetConversationForMembers(["did:plc:hfgp6pj3akhqxntgqwramlbg"], cancellationToken: cancellationToken);
108+
if (startConversationResult.Succeeded)
109+
{
110+
var post = await agent.GetPostRecord("at://did:plc:hfgp6pj3akhqxntgqwramlbg/app.bsky.feed.post/3lqxyqocwx22m", cancellationToken: cancellationToken);
111+
112+
var sendMessageResult = await agent.SendMessage(
113+
startConversationResult.Result.Id,
114+
"Embedded post test",
115+
embeddedPost: post.Result!.StrongReference,
116+
cancellationToken: cancellationToken);
117+
}
118+
105119
var listConversations = await agent.ListConversations(cancellationToken: cancellationToken);
106120

107121
if (listConversations.Succeeded && listConversations.Result.Count != 0 && !cancellationToken.IsCancellationRequested)
@@ -179,6 +193,19 @@ static async Task PerformOperations(string? handle, string? password, string? au
179193
throw new InvalidOperationException("Cannot find message sender in conversation view");
180194
Console.WriteLine($"{reactionSender}: {reaction.Value} {reaction.CreatedAt:g}");
181195
}
196+
197+
if (view.Embed is not null &&
198+
view.Embed.Record is not null &&
199+
view.Embed.Record is ViewRecord viewRecord)
200+
{
201+
Console.WriteLine(" Embedded Record");
202+
203+
if (viewRecord.Value is Post post)
204+
{
205+
Console.WriteLine($" {viewRecord.Author}");
206+
Console.WriteLine($" {post.Text}");
207+
}
208+
}
182209
}
183210
else if (message is DeletedMessageView _)
184211
{

src/idunno.Bluesky/Chat/BlueskyAgent.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
using idunno.AtProto;
5+
using idunno.AtProto.Repo;
56
using idunno.Bluesky.Chat;
67
using idunno.Bluesky.RichText;
78

@@ -411,21 +412,30 @@ public async Task<AtProtoHttpResult<ICollection<MessageView>>> SendMessageBatch(
411412
/// <param name="id">The conversation identifier to send the <paramref name="message"/> to.</param>
412413
/// <param name="message">The message to send.</param>
413414
/// <param name="extractFacets">Flag indicating whether facets should be extracted from <paramref name="message" />.</param>
415+
/// <param name="embeddedPost">A <see cref="StrongReference"/> to a post that will be embedded in the message.</param>
414416
/// <param name="cancellationToken">A cancellation token that can be used by other objects or threads to receive notice of cancellation.</param>
415417
/// <returns>The task object representing the asynchronous operation.</returns>
416418
/// <exception cref="ArgumentException">Thrown when <paramref name="id"/> is null or white space.</exception>
417-
/// <exception cref="ArgumentNullException">Thrown when <paramref name="message"/> is null.</exception>
419+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="message"/> is null, or if <paramref name="embeddedPost"/> is specified but its collection is null.</exception>
420+
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="embeddedPost"/> is specified but it is not in the <see cref="CollectionNsid.Post"/> collection.</exception>
418421
/// <exception cref="AuthenticationRequiredException">Thrown when the current agent is not authenticated.</exception>
419422
public async Task<AtProtoHttpResult<MessageView>> SendMessage(
420423
string id,
421424
string message,
422425
bool extractFacets = true,
426+
StrongReference? embeddedPost = null,
423427
CancellationToken cancellationToken = default)
424428
{
425429
ArgumentException.ThrowIfNullOrWhiteSpace(id);
426430

427431
ArgumentNullException.ThrowIfNull(message);
428432

433+
if (embeddedPost is not null)
434+
{
435+
ArgumentNullException.ThrowIfNull(embeddedPost.Uri.Collection);
436+
ArgumentOutOfRangeException.ThrowIfNotEqual(embeddedPost.Uri.Collection, CollectionNsid.Post);
437+
}
438+
429439
if (!IsAuthenticated)
430440
{
431441
throw new AuthenticationRequiredException();
@@ -443,6 +453,11 @@ public async Task<AtProtoHttpResult<MessageView>> SendMessage(
443453
messageInput = new MessageInput(message, facets);
444454
}
445455

456+
if (embeddedPost is not null)
457+
{
458+
messageInput.Embed = new Embed.EmbeddedRecord(embeddedPost);
459+
}
460+
446461
return await SendMessage(
447462
id,
448463
messageInput,

src/idunno.Bluesky/Chat/MessageInput.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public MessageInput(string text, ICollection<Facet>? facets = null, EmbeddedReco
2828

2929
if (facets is not null)
3030
{
31-
Facets = new ReadOnlyCollection<Facet>(facets.ToList<Facet>().AsReadOnly());
31+
Facets = new ReadOnlyCollection<Facet>(facets.ToList().AsReadOnly());
3232
}
3333
else
3434
{
@@ -46,17 +46,17 @@ public MessageInput(string text, ICollection<Facet>? facets = null, EmbeddedReco
4646
public string Text { get; init; }
4747

4848
/// <summary>
49-
/// Gets the rich text <see cref="Facet"/>s of the message, if any.
49+
/// Gets or sets the rich text <see cref="Facet"/>s of the message, if any.
5050
/// </summary>
5151
[JsonInclude]
5252
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
53-
public IReadOnlyCollection<Facet>? Facets { get; init; }
53+
public IReadOnlyCollection<Facet>? Facets { get; set; }
5454

5555
/// <summary>
56-
/// Gets the embedded record of the message, if any.
56+
/// Gets or sets the embedded record of the message, if any.
5757
/// </summary>
5858
[JsonInclude]
5959
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
60-
public EmbeddedRecord? Embed { get; init; }
60+
public EmbeddedBase? Embed { get; set; }
6161
}
6262
}

src/idunno.Bluesky/Constants.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,16 @@ public static class EmbeddedRecordTypeDiscriminators
330330
/// </summary>
331331
public static class EmbeddedViewTypeDiscriminators
332332
{
333+
/// <summary>
334+
/// The json type discriminator for a view record.
335+
/// </summary>
336+
public const string ViewRecord = "app.bsky.embed.record#viewRecord";
337+
338+
/// <summary>
339+
/// The json type discriminator for an view of an embedded record.
340+
/// </summary>
341+
public const string EmbedView = "app.bsky.embed.record#view";
342+
333343
/// <summary>
334344
/// The json type discriminator for an view of an embedded record that cannot be found.
335345
/// </summary>
@@ -344,6 +354,27 @@ public static class EmbeddedViewTypeDiscriminators
344354
/// The json type discriminator for a view over a record that is detached
345355
/// </summary>
346356
public const string EmbedViewDetached = "app.bsky.embed.record#Detached";
357+
358+
/// <summary>
359+
/// The json type discriminator for a view over a feed generator
360+
/// </summary>
361+
public const string GeneratorView = "app.bsky.feed.defs#generatorView";
362+
363+
/// <summary>
364+
/// The json type discriminator for a view over a list
365+
/// </summary>
366+
public const string ListView = "app.bsky.graph.defs#listView";
367+
368+
/// <summary>
369+
/// The json type discriminator for a view over a list
370+
/// </summary>
371+
public const string LabelerView = "app.bsky.labeler.defs#labelerView";
372+
373+
/// <summary>
374+
/// The json type discriminator for a basic view over a starter pack
375+
/// </summary>
376+
public const string StarterPackViewBasic = "app.bsky.graph.defs#starterPackViewBasic";
377+
347378
}
348379

349380
/// <summary>

src/idunno.Bluesky/Embed/View.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
namespace idunno.Bluesky.Embed
77
{
88
/// <summary>
9-
/// Base class for views over embedded records.
9+
/// Json Polymorphic base class for embedded record views.
1010
/// </summary>
1111
[JsonPolymorphic(IgnoreUnrecognizedTypeDiscriminators = true, UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor)]
12+
[JsonDerivedType(typeof(ViewRecord), typeDiscriminator: EmbeddedViewTypeDiscriminators.ViewRecord)]
13+
[JsonDerivedType(typeof(EmbeddedView), typeDiscriminator: EmbeddedViewTypeDiscriminators.EmbedView)]
1214
[JsonDerivedType(typeof(ViewNotFound), typeDiscriminator: EmbeddedViewTypeDiscriminators.EmbedViewNotFound)]
1315
[JsonDerivedType(typeof(ViewBlocked), typeDiscriminator: EmbeddedViewTypeDiscriminators.EmbedViewBlocked)]
1416
[JsonDerivedType(typeof(ViewDetached), typeDiscriminator: EmbeddedViewTypeDiscriminators.EmbedViewDetached)]

src/idunno.Bluesky/Embed/ViewRecord.cs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using idunno.AtProto.Repo;
1111

1212
using idunno.Bluesky.Actor;
13+
using idunno.Bluesky.Record;
1314

1415
namespace idunno.Bluesky.Embed
1516
{
@@ -27,6 +28,7 @@ public sealed record ViewRecord : View
2728
/// <param name="uri">The <see cref="AtUri"/> of the record.</param>
2829
/// <param name="cid">The <see cref="AtProto.Cid"/> of the record.</param>
2930
/// <param name="author">A <see cref="ProfileViewBasic"/> of the record author.</param>
31+
/// <param name="value">A <see cref="BlueskyRecord"/>.</param>
3032
/// <param name="labels">Any labels applied to the record.</param>
3133
/// <param name="replyCount">The number of replies to this post.</param>
3234
/// <param name="repostCount">The number of times this post has been reposted.</param>
@@ -39,6 +41,7 @@ public ViewRecord(
3941
AtUri uri,
4042
Cid cid,
4143
ProfileViewBasic author,
44+
BlueskyRecord value,
4245
IReadOnlyCollection<Label>? labels,
4346
int? replyCount,
4447
int? repostCount,
@@ -59,6 +62,8 @@ public ViewRecord(
5962
QuoteCount = quoteCount;
6063
Embeds = embeds ?? new List<EmbeddedView>().AsReadOnly();
6164
IndexedAt = indexedAt;
65+
66+
Value = value;
6267
}
6368

6469
/// <summary>
@@ -89,16 +94,9 @@ public ViewRecord(
8994
public ProfileViewBasic Author { get; init; }
9095

9196
/// <summary>
92-
/// Gets the record data itself.
97+
/// Gets the record itself.
9398
/// </summary>
94-
[JsonIgnore]
95-
public JsonElement Value
96-
{
97-
get
98-
{
99-
return ExtensionData["value"];
100-
}
101-
}
99+
public BlueskyRecord Value { get; init; }
102100

103101
/// <summary>
104102
/// Gets any labels applied to the record.

src/idunno.Bluesky/SourceGenerationContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ namespace idunno.Bluesky
8888
[JsonSerializable(typeof(GetLogResponse))]
8989
[JsonSerializable(typeof(GetMessagesResponse))]
9090
[JsonSerializable(typeof(ListConversationsResponse))]
91+
[JsonSerializable(typeof(SendMessageRequest))]
9192
[JsonSerializable(typeof(SendMessageBatchRequest))]
9293
[JsonSerializable(typeof(SendMessageBatchResponse))]
9394
[JsonSerializable(typeof(UpdateReadRequest))]

src/idunno.Bluesky/View.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ namespace idunno.Bluesky
1111
/// Base class for view records.
1212
/// </summary>
1313
[JsonPolymorphic(IgnoreUnrecognizedTypeDiscriminators = true, UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor)]
14+
[JsonDerivedType(typeof(Feed.GeneratorView), typeDiscriminator: EmbeddedViewTypeDiscriminators.GeneratorView)]
15+
[JsonDerivedType(typeof(Graph.ListView), typeDiscriminator: EmbeddedViewTypeDiscriminators.ListView)]
16+
[JsonDerivedType(typeof(Labeler.LabelerView), typeDiscriminator: EmbeddedViewTypeDiscriminators.LabelerView)]
17+
[JsonDerivedType(typeof(Graph.StarterPackViewBasic), typeDiscriminator: EmbeddedViewTypeDiscriminators.StarterPackViewBasic)]
18+
[JsonDerivedType(typeof(Embed.ViewRecord), typeDiscriminator: EmbeddedViewTypeDiscriminators.ViewRecord)]
19+
[JsonDerivedType(typeof(Embed.EmbeddedView), typeDiscriminator: EmbeddedViewTypeDiscriminators.EmbedView)]
20+
1421
public record View
1522
{
1623
/// <summary>

0 commit comments

Comments
 (0)