Skip to content

Commit 9b1269c

Browse files
committed
AuthorizedMessage
1 parent b651903 commit 9b1269c

File tree

7 files changed

+154
-103
lines changed

7 files changed

+154
-103
lines changed

docs/concepts/message.md

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ bot.onMention = async (session, message) => {
4040

4141
When you publish a new message to the fediverse, you can get a `Message` object
4242
as a return value of the invoked method. For example, in the following code
43-
snippet, the `Session.publish()` method returns a `Message` object:
43+
snippet, the `Session.publish()` method returns an `AuthorizedMessage` object:
4444

4545
~~~~ typescript
4646
const session = bot.getSession("https://mydomain");
@@ -50,6 +50,24 @@ const message = await session.publish(text`Hello, world!`); // [!code highlight
5050
For more information about publishing a message, see the section right below.
5151

5252

53+
`Message` vs. `AuthorizedMessage`
54+
---------------------------------
55+
56+
There are two types of `Message` objects: `Message` and `AuthorizedMessage`.
57+
Everything you can do with the `Message` object can be done with the
58+
`AuthorizedMessage` object as well. The only difference between them is that
59+
the `AuthorizedMessage` object has additional methods that require
60+
the authorization of the author of the message.
61+
62+
For example, the [`delete()`](#deleting-a-message) method requires the
63+
authorization of the author of the message. Therefore, the `delete()` method
64+
is available only in the `AuthorizedMessage` object.
65+
66+
In general, you will get the `AuthorizedMessage` object when you publish
67+
a new message to the fediverse: [`Session.publish()`](#publishing-a-message) or
68+
[`Message.reply()`](#replying-to-a-message) method.
69+
70+
5371
Publishing a message
5472
--------------------
5573

@@ -75,9 +93,9 @@ await session.publish(text`
7593
For more information about the `Text` object, see the [*Text*
7694
section](./text.md).
7795

78-
The `Session.publish()` method returns a `Message` object that represents
79-
the message that was published. You can use this object to interact with
80-
the message, such as [deleting](./message.md#deleting-a-message) it or
96+
The `Session.publish()` method returns an `AuthorizedMessage` object that
97+
represents the message that was published. You can use this object to interact
98+
with the message, such as [deleting](./message.md#deleting-a-message) it or
8199
[replying](./message.md#replying-to-a-message) to it:
82100

83101
~~~~ typescript
@@ -375,7 +393,7 @@ object.
375393
Deleting a message
376394
------------------
377395

378-
You can delete a message by calling the `~Message.delete()` method:
396+
You can delete a message by calling the `~AuthorizedMessage.delete()` method:
379397

380398
~~~~ typescript
381399
const message = await session.publish(
@@ -386,11 +404,6 @@ setTimeout(async () => {
386404
}, 1000 * 60);
387405
~~~~
388406

389-
> [!CAUTION]
390-
> This operation is possible if only the message is published by the same
391-
> bot that calls the `delete()` method. If you try to delete a message
392-
> that is published by others, the operation will silently fail.
393-
394407

395408
Replying to a message
396409
---------------------
@@ -415,6 +428,16 @@ const reply = await message.reply(
415428
);
416429
~~~~
417430

431+
Like the `Session.publish()` method, the `~Message.reply()` method returns
432+
an `AuthorizedMessage` object that represents the reply message:
433+
434+
~~~~ typescript
435+
const reply = await message.reply(text`This reply will be deleted in a minute.`);
436+
setTimeout(async () => {
437+
await reply.delete(); // [!code highlight]
438+
}, 1000 * 60);
439+
~~~~
440+
418441
> [!TIP]
419442
> It does not mention the original author in the reply message by default.
420443
> However, you can manually [mention them](./text.md#mentions) in the reply

src/message-impl.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ Deno.test("createMessage()", async () => {
138138
assertEquals(unknownMessage.visibility, "unknown");
139139
});
140140

141-
Deno.test("MessageImpl.delete()", async () => {
141+
Deno.test("AuthorizedMessageImpl.delete()", async () => {
142142
const kv = new MemoryKvStore();
143143
const bot = new BotImpl<void>({ kv, username: "bot" });
144144
const ctx = createMockContext(bot, "https://example.com");
@@ -152,7 +152,7 @@ Deno.test("MessageImpl.delete()", async () => {
152152
to: PUBLIC_COLLECTION,
153153
cc: new URL("https://example.com/ap/actor/bot/followers"),
154154
});
155-
const msg = await createMessage<Note, void>(note, session);
155+
const msg = await createMessage<Note, void>(note, session, undefined, true);
156156
await kv.set(
157157
bot.kvPrefixes.messages,
158158
["c1c792ce-a0be-4685-b396-e59e5ef8c788"],

src/message-impl.ts

Lines changed: 90 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { unescape } from "@std/html/entities";
3939
import { generate as uuidv7 } from "@std/uuid/unstable-v7";
4040
import { FilterXSS, getDefaultWhiteList } from "xss";
4141
import type {
42+
AuthorizedMessage,
4243
Message,
4344
MessageClass,
4445
MessageShareOptions,
@@ -91,88 +92,20 @@ export class MessageImpl<T extends MessageClass, TContextData>
9192
this.updated = message.updated;
9293
}
9394

94-
async delete(): Promise<void> {
95-
const parsed = this.session.context.parseUri(this.id);
96-
if (
97-
parsed?.type !== "object" ||
98-
!messageClasses.some((cls) => parsed.class === cls)
99-
) {
100-
return;
101-
}
102-
const { id } = parsed.values;
103-
const kv = this.session.bot.kv;
104-
const listKey: KvKey = this.session.bot.kvPrefixes.messages;
105-
const lockKey: KvKey = [...listKey, "lock"];
106-
const lockId = `${id}:delete`;
107-
do {
108-
await kv.set(lockKey, lockId);
109-
const set = new Set(await kv.get<string[]>(listKey) ?? []);
110-
set.delete(id);
111-
const list = [...set];
112-
list.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
113-
await kv.set(listKey, list);
114-
} while (await kv.get(lockKey) !== lockId);
115-
const messageKey: KvKey = [...listKey, id];
116-
const createJson = await kv.get(messageKey);
117-
if (createJson == null) return;
118-
await kv.delete(messageKey);
119-
const create = await Create.fromJsonLd(createJson, this.session.context);
120-
const message = await create.getObject(this.session.context);
121-
if (message == null) return;
122-
const mentionedActorIds: Set<string> = new Set();
123-
for await (const tag of message.getTags(this.session.context)) {
124-
if (tag instanceof Mention && tag.href != null) {
125-
mentionedActorIds.add(tag.href.href);
126-
}
127-
}
128-
const promises: Promise<Object | null>[] = [];
129-
const documentLoader = await this.session.context.getDocumentLoader(
130-
this.session.bot,
131-
);
132-
for (const uri of mentionedActorIds) {
133-
promises.push(this.session.context.lookupObject(uri, { documentLoader }));
134-
}
135-
const mentionedActors = (await Promise.all(promises)).filter(isActor);
136-
const activity = new Delete({
137-
id: new URL("#delete", this.id),
138-
actor: this.session.context.getActorUri(this.session.bot.identifier),
139-
tos: create.toIds,
140-
ccs: create.ccIds,
141-
object: new Tombstone({
142-
id: this.id,
143-
}),
144-
});
145-
const excludeBaseUris = [new URL(this.session.context.origin)];
146-
await this.session.context.sendActivity(
147-
this.session.bot,
148-
"followers",
149-
activity,
150-
{ preferSharedInbox: true, excludeBaseUris },
151-
);
152-
for (const actor of mentionedActors) {
153-
await this.session.context.sendActivity(
154-
this.session.bot,
155-
actor,
156-
activity,
157-
{ preferSharedInbox: true, excludeBaseUris },
158-
);
159-
}
160-
}
161-
16295
reply(
16396
text: Text<"block", TContextData>,
16497
options?: SessionPublishOptions,
165-
): Promise<Message<Note, TContextData>>;
98+
): Promise<AuthorizedMessage<Note, TContextData>>;
16699
reply<T extends MessageClass>(
167100
text: Text<"block", TContextData>,
168101
options?: SessionPublishOptionsWithClass<T> | undefined,
169-
): Promise<Message<T, TContextData>>;
102+
): Promise<AuthorizedMessage<T, TContextData>>;
170103
reply(
171104
text: Text<"block", TContextData>,
172105
options?:
173106
| SessionPublishOptions
174107
| SessionPublishOptionsWithClass<MessageClass>,
175-
): Promise<Message<MessageClass, TContextData>> {
108+
): Promise<AuthorizedMessage<MessageClass, TContextData>> {
176109
return this.session.publish(text, {
177110
visibility: this.visibility === "unknown" ? "direct" : this.visibility,
178111
...options,
@@ -274,6 +207,78 @@ export class MessageImpl<T extends MessageClass, TContextData>
274207
}
275208
}
276209

210+
export class AuthorizedMessageImpl<T extends MessageClass, TContextData>
211+
extends MessageImpl<T, TContextData>
212+
implements AuthorizedMessage<T, TContextData> {
213+
async delete(): Promise<void> {
214+
const parsed = this.session.context.parseUri(this.id);
215+
if (
216+
parsed?.type !== "object" ||
217+
!messageClasses.some((cls) => parsed.class === cls)
218+
) {
219+
return;
220+
}
221+
const { id } = parsed.values;
222+
const kv = this.session.bot.kv;
223+
const listKey: KvKey = this.session.bot.kvPrefixes.messages;
224+
const lockKey: KvKey = [...listKey, "lock"];
225+
const lockId = `${id}:delete`;
226+
do {
227+
await kv.set(lockKey, lockId);
228+
const set = new Set(await kv.get<string[]>(listKey) ?? []);
229+
set.delete(id);
230+
const list = [...set];
231+
list.sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
232+
await kv.set(listKey, list);
233+
} while (await kv.get(lockKey) !== lockId);
234+
const messageKey: KvKey = [...listKey, id];
235+
const createJson = await kv.get(messageKey);
236+
if (createJson == null) return;
237+
await kv.delete(messageKey);
238+
const create = await Create.fromJsonLd(createJson, this.session.context);
239+
const message = await create.getObject(this.session.context);
240+
if (message == null) return;
241+
const mentionedActorIds: Set<string> = new Set();
242+
for await (const tag of message.getTags(this.session.context)) {
243+
if (tag instanceof Mention && tag.href != null) {
244+
mentionedActorIds.add(tag.href.href);
245+
}
246+
}
247+
const promises: Promise<Object | null>[] = [];
248+
const documentLoader = await this.session.context.getDocumentLoader(
249+
this.session.bot,
250+
);
251+
for (const uri of mentionedActorIds) {
252+
promises.push(this.session.context.lookupObject(uri, { documentLoader }));
253+
}
254+
const mentionedActors = (await Promise.all(promises)).filter(isActor);
255+
const activity = new Delete({
256+
id: new URL("#delete", this.id),
257+
actor: this.session.context.getActorUri(this.session.bot.identifier),
258+
tos: create.toIds,
259+
ccs: create.ccIds,
260+
object: new Tombstone({
261+
id: this.id,
262+
}),
263+
});
264+
const excludeBaseUris = [new URL(this.session.context.origin)];
265+
await this.session.context.sendActivity(
266+
this.session.bot,
267+
"followers",
268+
activity,
269+
{ preferSharedInbox: true, excludeBaseUris },
270+
);
271+
for (const actor of mentionedActors) {
272+
await this.session.context.sendActivity(
273+
this.session.bot,
274+
actor,
275+
activity,
276+
{ preferSharedInbox: true, excludeBaseUris },
277+
);
278+
}
279+
}
280+
}
281+
277282
const allowList = getDefaultWhiteList();
278283
const htmlXss = new FilterXSS({
279284
allowList: {
@@ -290,6 +295,19 @@ export async function createMessage<T extends MessageClass, TContextData>(
290295
raw: T,
291296
session: SessionImpl<TContextData>,
292297
replyTarget?: Message<MessageClass, TContextData>,
298+
authorized?: true,
299+
): Promise<AuthorizedMessage<T, TContextData>>;
300+
export async function createMessage<T extends MessageClass, TContextData>(
301+
raw: T,
302+
session: SessionImpl<TContextData>,
303+
replyTarget?: Message<MessageClass, TContextData>,
304+
authorized?: boolean,
305+
): Promise<Message<T, TContextData>>;
306+
export async function createMessage<T extends MessageClass, TContextData>(
307+
raw: T,
308+
session: SessionImpl<TContextData>,
309+
replyTarget?: Message<MessageClass, TContextData>,
310+
authorized: boolean = false,
293311
): Promise<Message<T, TContextData>> {
294312
if (raw.id == null) throw new TypeError("The raw.id is required.");
295313
else if (raw.content == null) {
@@ -352,7 +370,7 @@ export async function createMessage<T extends MessageClass, TContextData>(
352370
replyTarget = await createMessage(rt, session);
353371
}
354372
}
355-
return new MessageImpl(session, {
373+
return new (authorized ? AuthorizedMessageImpl : MessageImpl)(session, {
356374
raw,
357375
id: raw.id,
358376
actor,

src/message.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -150,15 +150,6 @@ export interface Message<T extends MessageClass, TContextData> {
150150
*/
151151
readonly updated?: Temporal.Instant;
152152

153-
/**
154-
* Deletes the message, if possible.
155-
*
156-
* If the message is not published by the bot, it will silently fail.
157-
*
158-
* If the message is already deleted, it will be a no-op.
159-
*/
160-
delete(): Promise<void>;
161-
162153
/**
163154
* Publishes a reply to the message.
164155
* @param text The content of the message.
@@ -168,7 +159,7 @@ export interface Message<T extends MessageClass, TContextData> {
168159
reply(
169160
text: Text<"block", TContextData>,
170161
options?: SessionPublishOptions,
171-
): Promise<Message<Note, TContextData>>;
162+
): Promise<AuthorizedMessage<Note, TContextData>>;
172163

173164
/**
174165
* Publishes a reply to the message.
@@ -180,7 +171,7 @@ export interface Message<T extends MessageClass, TContextData> {
180171
reply<T extends MessageClass>(
181172
text: Text<"block", TContextData>,
182173
options?: SessionPublishOptionsWithClass<T>,
183-
): Promise<Message<T, TContextData>>;
174+
): Promise<AuthorizedMessage<T, TContextData>>;
184175

185176
/**
186177
* Shares the message.
@@ -195,6 +186,20 @@ export interface Message<T extends MessageClass, TContextData> {
195186
share(options?: MessageShareOptions): Promise<SharedMessage<TContextData>>;
196187
}
197188

189+
/**
190+
* An authorized message in the ActivityPub network. Usually it is a message
191+
* published by the bot itself.
192+
*/
193+
export interface AuthorizedMessage<T extends MessageClass, TContextData>
194+
extends Message<T, TContextData> {
195+
/**
196+
* Deletes the message, if possible.
197+
*
198+
* If the message is already deleted, it will be a no-op.
199+
*/
200+
delete(): Promise<void>;
201+
}
202+
198203
/**
199204
* Options for sharing a message.
200205
*/

src/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export {
4848
} from "./message.ts";
4949
export type {
5050
Actor,
51+
AuthorizedMessage,
5152
Message,
5253
MessageClass,
5354
MessageShareOptions,

0 commit comments

Comments
 (0)