Skip to content

Commit 20ef7d4

Browse files
committed
Session.getOutbox() method
1 parent f58dc65 commit 20ef7d4

File tree

7 files changed

+341
-3
lines changed

7 files changed

+341
-3
lines changed

docs/concepts/message.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ is available only in the `AuthorizedMessage` object.
6565

6666
In general, you will get the `AuthorizedMessage` object when you publish
6767
a new message to the fediverse: [`Session.publish()`](#publishing-a-message) or
68-
[`Message.reply()`](#replying-to-a-message) method.
68+
[`Message.reply()`](#replying-to-a-message) method. Or you can get all
69+
the published messages from the bot's outbox:
70+
[`Session.getOutbox()`](#get-published-messages).
6971

7072

7173
Publishing a message
@@ -423,6 +425,41 @@ import { Place } from "@fedify/fedify/vocab";
423425
[`Place`]: https://jsr.io/@fedify/fedify/doc/vocab/~/Place
424426

425427

428+
Get published messages
429+
----------------------
430+
431+
You can get the published messages by calling the `~Session.getOutbox()` method.
432+
It returns an [`AsyncIterable`] object that yields the `AuthorizedMessage`
433+
objects:
434+
435+
~~~~ typescript
436+
for await (const message of session.getOutbox()) {
437+
console.log(message.text);
438+
}
439+
~~~~
440+
441+
The yielded messages are in the descending order of the published timestamp by
442+
default, but you can specify the order by providing
443+
the `~SessionGetOutboxOptions.order` option:
444+
445+
~~~~ typescript
446+
session.getOutbox({ order: "oldest" })
447+
~~~~
448+
449+
Or you can specify the range of the messages by providing
450+
the `~SessionGetOutboxOptions.since` and `~SessionGetOutboxOptions.until`
451+
options:
452+
453+
~~~~ typescript
454+
session.getOutbox({
455+
since: Temporal.Instant.from("2025-01-01T00:00:00Z"),
456+
until: Temporal.Instant.from("2025-01-31T23:59:59.999Z"),
457+
})
458+
~~~~
459+
460+
[`AsyncIterable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_async_iterator_and_async_iterable_protocols
461+
462+
426463
Deleting a message
427464
------------------
428465

docs/concepts/session.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ See the [*Publishing a message* section](./message.md#publishing-a-message)
102102
in the *Message* concept document.
103103

104104

105+
Get published messages
106+
----------------------
107+
108+
See the [*Get published messages* section](./message.md#get-published-messages)
109+
in the *Message* concept document.
110+
111+
105112
Following an actor
106113
------------------
107114

src/message-impl.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ import type { Text } from "./text.ts";
5555

5656
export const messageClasses = [Article, ChatMessage, Note, Question];
5757

58+
export function isMessageObject(value: unknown): value is MessageClass {
59+
return messageClasses.some((cls) => value instanceof cls);
60+
}
61+
5862
export class MessageImpl<T extends MessageClass, TContextData>
5963
implements Message<T, TContextData> {
6064
readonly session: SessionImpl<TContextData>;

src/session-impl.test.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,221 @@ Deno.test("SessionImpl.publish()", async (t) => {
261261
});
262262
});
263263

264+
Deno.test("SessionImpl.getOutbox()", async (t) => {
265+
const kv = new MemoryKvStore();
266+
const bot = new BotImpl<void>({ kv, username: "bot" });
267+
const ctx = createMockContext(bot, "https://example.com");
268+
const session = new SessionImpl(bot, ctx);
269+
270+
await kv.set(
271+
[...bot.kvPrefixes.messages, "01941f29-7c00-7fe8-ab0a-7b593990a3c0"],
272+
{
273+
"@context": "https://www.w3.org/ns/activitystreams",
274+
type: "Create",
275+
id: "https://example.com/ap/create/01941f29-7c00-7fe8-ab0a-7b593990a3c0",
276+
actor: "https://example.com/ap/actor/bot",
277+
to: ["https://example.com/ap/actor/bot/followers"],
278+
cc: ["https://www.w3.org/ns/activitystreams#Public"],
279+
object: {
280+
type: "Note",
281+
id: "https://example.com/ap/note/01941f29-7c00-7fe8-ab0a-7b593990a3c0",
282+
attributedTo: "https://example.com/ap/actor/bot",
283+
to: ["https://example.com/ap/actor/bot/followers"],
284+
cc: ["https://www.w3.org/ns/activitystreams#Public"],
285+
content: "Hello, world!",
286+
published: "2025-01-01T00:00:00Z",
287+
},
288+
published: "2025-01-01T00:00:00Z",
289+
},
290+
);
291+
await kv.set(
292+
[...bot.kvPrefixes.messages, "0194244f-d800-7873-8993-ef71ccd47306"],
293+
{
294+
"@context": "https://www.w3.org/ns/activitystreams",
295+
type: "Create",
296+
id: "https://example.com/ap/create/0194244f-d800-7873-8993-ef71ccd47306",
297+
actor: "https://example.com/ap/actor/bot",
298+
to: ["https://example.com/ap/actor/bot/followers"],
299+
cc: ["https://www.w3.org/ns/activitystreams#Public"],
300+
object: {
301+
type: "Note",
302+
id: "https://example.com/ap/note/0194244f-d800-7873-8993-ef71ccd47306",
303+
attributedTo: "https://example.com/ap/actor/bot",
304+
to: ["https://example.com/ap/actor/bot/followers"],
305+
cc: ["https://www.w3.org/ns/activitystreams#Public"],
306+
content: "Hello, world!",
307+
published: "2025-01-02T00:00:00Z",
308+
},
309+
published: "2025-01-02T00:00:00Z",
310+
},
311+
);
312+
await kv.set(
313+
[...bot.kvPrefixes.messages, "01942976-3400-7f34-872e-2cbf0f9eeac4"],
314+
{
315+
"@context": "https://www.w3.org/ns/activitystreams",
316+
type: "Create",
317+
id: "https://example.com/ap/create/01942976-3400-7f34-872e-2cbf0f9eeac4",
318+
actor: "https://example.com/ap/actor/bot",
319+
to: ["https://example.com/ap/actor/bot/followers"],
320+
cc: ["https://www.w3.org/ns/activitystreams#Public"],
321+
object: {
322+
type: "Note",
323+
id: "https://example.com/ap/note/01942976-3400-7f34-872e-2cbf0f9eeac4",
324+
attributedTo: "https://example.com/ap/actor/bot",
325+
to: ["https://example.com/ap/actor/bot/followers"],
326+
cc: ["https://www.w3.org/ns/activitystreams#Public"],
327+
content: "Hello, world!",
328+
published: "2025-01-03T00:00:00Z",
329+
},
330+
published: "2025-01-03T00:00:00Z",
331+
},
332+
);
333+
await kv.set(
334+
[...bot.kvPrefixes.messages, "01942e9c-9000-7480-a553-7a6ce737ce14"],
335+
{
336+
"@context": "https://www.w3.org/ns/activitystreams",
337+
type: "Create",
338+
id: "https://example.com/ap/create/01942e9c-9000-7480-a553-7a6ce737ce14",
339+
actor: "https://example.com/ap/actor/bot",
340+
to: ["https://example.com/ap/actor/bot/followers"],
341+
cc: ["https://www.w3.org/ns/activitystreams#Public"],
342+
object: {
343+
type: "Note",
344+
id: "https://example.com/ap/note/01942e9c-9000-7480-a553-7a6ce737ce14",
345+
attributedTo: "https://example.com/ap/actor/bot",
346+
to: ["https://example.com/ap/actor/bot/followers"],
347+
cc: ["https://www.w3.org/ns/activitystreams#Public"],
348+
content: "Hello, world!",
349+
published: "2025-01-04T00:00:00Z",
350+
},
351+
published: "2025-01-04T00:00:00Z",
352+
},
353+
);
354+
await kv.set(
355+
bot.kvPrefixes.messages,
356+
[
357+
"01941f29-7c00-7fe8-ab0a-7b593990a3c0",
358+
"0194244f-d800-7873-8993-ef71ccd47306",
359+
"01942976-3400-7f34-872e-2cbf0f9eeac4",
360+
"01942e9c-9000-7480-a553-7a6ce737ce14",
361+
],
362+
);
363+
364+
await t.step("default", async () => {
365+
const outbox = session.getOutbox({ order: "oldest" });
366+
const messages = await Array.fromAsync(outbox);
367+
assertEquals(messages.length, 4);
368+
369+
assertEquals(
370+
messages[0].id.href,
371+
"https://example.com/ap/note/01941f29-7c00-7fe8-ab0a-7b593990a3c0",
372+
);
373+
assertEquals(
374+
messages[0].actor.id?.href,
375+
"https://example.com/ap/actor/bot",
376+
);
377+
assertEquals(messages[0].visibility, "unlisted");
378+
assertEquals(messages[0].text, "Hello, world!");
379+
assertEquals(
380+
messages[0].published,
381+
Temporal.Instant.from("2025-01-01T00:00:00Z"),
382+
);
383+
384+
assertEquals(
385+
messages[1].id.href,
386+
"https://example.com/ap/note/0194244f-d800-7873-8993-ef71ccd47306",
387+
);
388+
assertEquals(
389+
messages[1].actor.id?.href,
390+
"https://example.com/ap/actor/bot",
391+
);
392+
assertEquals(messages[1].visibility, "unlisted");
393+
assertEquals(messages[1].text, "Hello, world!");
394+
assertEquals(
395+
messages[1].published,
396+
Temporal.Instant.from("2025-01-02T00:00:00Z"),
397+
);
398+
399+
assertEquals(
400+
messages[2].id.href,
401+
"https://example.com/ap/note/01942976-3400-7f34-872e-2cbf0f9eeac4",
402+
);
403+
assertEquals(
404+
messages[2].actor.id?.href,
405+
"https://example.com/ap/actor/bot",
406+
);
407+
assertEquals(messages[2].visibility, "unlisted");
408+
assertEquals(messages[2].text, "Hello, world!");
409+
assertEquals(
410+
messages[2].published,
411+
Temporal.Instant.from("2025-01-03T00:00:00Z"),
412+
);
413+
414+
assertEquals(
415+
messages[3].id.href,
416+
"https://example.com/ap/note/01942e9c-9000-7480-a553-7a6ce737ce14",
417+
);
418+
assertEquals(
419+
messages[3].actor.id?.href,
420+
"https://example.com/ap/actor/bot",
421+
);
422+
assertEquals(messages[3].visibility, "unlisted");
423+
assertEquals(messages[3].text, "Hello, world!");
424+
assertEquals(
425+
messages[3].published,
426+
Temporal.Instant.from("2025-01-04T00:00:00Z"),
427+
);
428+
});
429+
430+
await t.step("order: 'oldest'", async () => {
431+
const outbox = session.getOutbox({ order: "oldest" });
432+
const messages = await Array.fromAsync(outbox);
433+
const messageIds = messages.map((msg) => msg.id.href);
434+
assertEquals(messageIds, [
435+
"https://example.com/ap/note/01941f29-7c00-7fe8-ab0a-7b593990a3c0",
436+
"https://example.com/ap/note/0194244f-d800-7873-8993-ef71ccd47306",
437+
"https://example.com/ap/note/01942976-3400-7f34-872e-2cbf0f9eeac4",
438+
"https://example.com/ap/note/01942e9c-9000-7480-a553-7a6ce737ce14",
439+
]);
440+
});
441+
442+
await t.step("order: 'newest'", async () => {
443+
const outbox = session.getOutbox({ order: "newest" });
444+
const messages = await Array.fromAsync(outbox);
445+
const messageIds = messages.map((msg) => msg.id.href);
446+
assertEquals(messageIds, [
447+
"https://example.com/ap/note/01942e9c-9000-7480-a553-7a6ce737ce14",
448+
"https://example.com/ap/note/01942976-3400-7f34-872e-2cbf0f9eeac4",
449+
"https://example.com/ap/note/0194244f-d800-7873-8993-ef71ccd47306",
450+
"https://example.com/ap/note/01941f29-7c00-7fe8-ab0a-7b593990a3c0",
451+
]);
452+
});
453+
454+
await t.step("since", async () => {
455+
const outbox = session.getOutbox({
456+
since: Temporal.Instant.from("2025-01-03T00:00:00Z"),
457+
});
458+
const messages = await Array.fromAsync(outbox);
459+
const messageIds = messages.map((msg) => msg.id.href);
460+
assertEquals(messageIds, [
461+
"https://example.com/ap/note/01942e9c-9000-7480-a553-7a6ce737ce14",
462+
"https://example.com/ap/note/01942976-3400-7f34-872e-2cbf0f9eeac4",
463+
]);
464+
});
465+
466+
await t.step("until", async () => {
467+
const outbox = session.getOutbox({
468+
until: Temporal.Instant.from("2025-01-02T00:00:00Z"),
469+
});
470+
const messages = await Array.fromAsync(outbox);
471+
const messageIds = messages.map((msg) => msg.id.href);
472+
assertEquals(messageIds, [
473+
"https://example.com/ap/note/0194244f-d800-7873-8993-ef71ccd47306",
474+
"https://example.com/ap/note/01941f29-7c00-7fe8-ab0a-7b593990a3c0",
475+
]);
476+
});
477+
});
478+
264479
export interface SentActivity {
265480
recipients: "followers" | Recipient[];
266481
activity: Activity;

src/session-impl.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ import {
2727
} from "@fedify/fedify";
2828
import { Follow, Undo } from "@fedify/fedify/vocab";
2929
import { getLogger } from "@logtape/logtape";
30-
import { generate as uuidv7 } from "@std/uuid/unstable-v7";
30+
import { extractTimestamp, generate as uuidv7 } from "@std/uuid/unstable-v7";
3131
import type { BotImpl } from "./bot-impl.ts";
32-
import { createMessage } from "./message-impl.ts";
32+
import { createMessage, isMessageObject } from "./message-impl.ts";
3333
import type { AuthorizedMessage, Message, MessageClass } from "./message.ts";
3434
import type {
3535
Session,
36+
SessionGetOutboxOptions,
3637
SessionPublishOptions,
3738
SessionPublishOptionsWithClass,
3839
} from "./session.ts";
@@ -290,4 +291,43 @@ export class SessionImpl<TContextData> implements Session<TContextData> {
290291
}
291292
return await createMessage(msg, this, options.replyTarget, true);
292293
}
294+
295+
async *getOutbox(
296+
options: SessionGetOutboxOptions = {},
297+
): AsyncIterable<AuthorizedMessage<MessageClass, TContextData>> {
298+
const { order, until, since } = options;
299+
const { kv, kvPrefixes } = this.bot;
300+
const untilTs = until == null ? null : until.epochMilliseconds;
301+
const sinceTs = since == null ? null : since.epochMilliseconds;
302+
let messageIds = await kv.get<string[]>(kvPrefixes.messages) ?? [];
303+
if (sinceTs != null) {
304+
const offset = messageIds.findIndex((id) =>
305+
extractTimestamp(id) >= sinceTs
306+
);
307+
messageIds = messageIds.slice(offset);
308+
}
309+
if (untilTs != null) {
310+
const offset = messageIds.findLastIndex((id) =>
311+
extractTimestamp(id) <= untilTs
312+
);
313+
messageIds = messageIds.slice(0, offset + 1);
314+
}
315+
if (order == null || order === "newest") {
316+
messageIds = messageIds.toReversed();
317+
}
318+
for (const id of messageIds) {
319+
const messageJson = await kv.get([...kvPrefixes.messages, id]);
320+
if (messageJson == null) continue;
321+
let object: Object | null;
322+
try {
323+
const activity = await Create.fromJsonLd(messageJson, this.context);
324+
object = await activity.getObject(this.context);
325+
} catch {
326+
continue;
327+
}
328+
if (object == null || !isMessageObject(object)) continue;
329+
const message = await createMessage(object, this);
330+
yield message;
331+
}
332+
}
293333
}

0 commit comments

Comments
 (0)