@takosjp/yurucommu-core 3.4.3 → 3.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -2,7 +2,7 @@
2
2
  // v2: 1 Story = 1 Media (Instagram style)
3
3
  import { Hono } from "hono";
4
4
  import { and, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
5
- import type { Database } from "../../../db/index.ts";
5
+ import type { D1Statement, Database } from "../../../db/index.ts";
6
6
  import {
7
7
  activities,
8
8
  actors,
@@ -10,6 +10,7 @@ import {
10
10
  follows,
11
11
  likes,
12
12
  objects,
13
+ runBatch,
13
14
  storyViews,
14
15
  } from "../../../db/index.ts";
15
16
  import {
@@ -25,7 +26,7 @@ import {
25
26
  objectApId,
26
27
  } from "../../federation-helpers.ts";
27
28
  import { storyToActivityPub } from "../../lib/activitypub-helpers.ts";
28
- import { excludeBlockedMutedAuthors } from "../../lib/feed-exclude.ts";
29
+ import { excludeModeratedActors } from "../../lib/feed-exclude.ts";
29
30
  import { maybeReapDrainedTombstones } from "../actors.ts";
30
31
  import { checkCommunityPostPermission } from "../posts/post-helpers.ts";
31
32
  import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
@@ -34,7 +35,6 @@ import {
34
35
  cleanupExpiredStories,
35
36
  fetchActorCache,
36
37
  fetchBatchVotes,
37
- fetchBlockedAndMutedIds,
38
38
  type StoryAuthor,
39
39
  sumVotes,
40
40
  transformStoryData,
@@ -45,19 +45,11 @@ import {
45
45
  enqueueFanoutToFollowers,
46
46
  } from "../../lib/delivery/queue.ts";
47
47
  import { logger } from "../../lib/logger.ts";
48
+ import { personalActorIsSuppressedBy } from "../../lib/personal-actor-moderation.ts";
49
+ import { prepareDeliveryFanoutJob } from "../../lib/delivery/fanout-outbox.ts";
48
50
 
49
51
  const log = logger.child({ component: "stories.routes" });
50
52
 
51
- /**
52
- * Narrow view over the concrete D1/libsql drizzle client's atomic batch API.
53
- * The shared `Database` union type does not surface `batch` (it lives on the
54
- * concrete subclasses), so we reach it through a structural cast at the call
55
- * site that needs an atomic multi-statement write.
56
- */
57
- type Batchable = {
58
- batch(statements: readonly unknown[]): Promise<unknown>;
59
- };
60
-
61
53
  const stories = new Hono<{ Bindings: Env; Variables: Variables }>();
62
54
 
63
55
  // Best-effort, opportunistic retention of expired stories.
@@ -217,60 +209,70 @@ async function createAndFanoutActivity(
217
209
  actorApIdStr: string,
218
210
  objectApIdStr: string,
219
211
  activity: Record<string, unknown>,
220
- communityApId?: string | null,
212
+ communityApId: string | null | undefined,
213
+ localStatements: readonly [D1Statement, ...D1Statement[]],
221
214
  ): Promise<void> {
222
215
  const id = activity.id as string;
223
- await db.insert(activities).values({
224
- apId: id,
225
- type: activity.type as string,
226
- actorApId: actorApIdStr,
227
- objectApId: objectApIdStr,
228
- rawJson: JSON.stringify(activity),
229
- direction: "outbound",
230
- });
216
+ const preparedFanout = await prepareDeliveryFanoutJob(
217
+ db,
218
+ communityApId
219
+ ? {
220
+ kind: "community",
221
+ activityId: id,
222
+ targetApId: communityApId,
223
+ }
224
+ : {
225
+ kind: "followers",
226
+ activityId: id,
227
+ targetApId: actorApIdStr,
228
+ },
229
+ );
230
+ await runBatch(db, [
231
+ ...localStatements,
232
+ db.insert(activities).values({
233
+ apId: id,
234
+ type: activity.type as string,
235
+ actorApId: actorApIdStr,
236
+ objectApId: objectApIdStr,
237
+ rawJson: JSON.stringify(activity),
238
+ direction: "outbound",
239
+ }) as D1Statement,
240
+ preparedFanout.statement,
241
+ ]);
242
+ await publishDurableStoryFanoutWakeup(env, id, actorApIdStr, communityApId);
243
+ }
244
+
245
+ async function publishDurableStoryFanoutWakeup(
246
+ env: Env,
247
+ activityId: string,
248
+ actorApId: string,
249
+ communityApId?: string | null,
250
+ ): Promise<void> {
231
251
  // A community-scoped story has reach == community: fan its activity out to the
232
252
  // community's members/followers (the same audience posts use), NOT the
233
253
  // author's personal follower graph. A personal story keeps author-follower
234
254
  // reach.
235
- if (communityApId) {
236
- await enqueueFanoutToCommunity(env, id, communityApId);
237
- } else {
238
- await enqueueFanoutToFollowers(env, id, actorApIdStr);
255
+ try {
256
+ if (communityApId) {
257
+ await enqueueFanoutToCommunity(env, activityId, communityApId);
258
+ } else {
259
+ await enqueueFanoutToFollowers(env, activityId, actorApId);
260
+ }
261
+ } catch (error) {
262
+ // Activity and semantic fanout intent were committed atomically above. The
263
+ // Queue RPC is only a wakeup; keep the route moving so Create returns the
264
+ // committed story and Delete completes its local cascade while scheduled
265
+ // recovery republishes the pending row.
266
+ log.error("Failed to publish durable story fanout wakeup", {
267
+ event: "stories.fanout_wakeup_failed",
268
+ activityId,
269
+ actor: actorApId,
270
+ communityApId: communityApId ?? null,
271
+ error,
272
+ });
239
273
  }
240
274
  }
241
275
 
242
- /**
243
- * Delete all related data for a story, then the story object itself.
244
- *
245
- * Delegates to `deleteObjectCascade` (the SAME teardown the expiry path
246
- * `cleanupExpiredStories` runs) so it also reaps the story's mandatory R2 blob
247
- * and its `media_uploads` row — child-row-only deletion here would orphan the
248
- * image in R2 forever (there is no orphan-key sweep). The cascade covers
249
- * storyViews/Votes/Shares + likes + announces + bookmarks + objectRecipients +
250
- * media; it reads `attachments_json` off the still-present object row, so the
251
- * object row is dropped afterwards.
252
- */
253
- /**
254
- * Returns true when THIS call actually removed the objects row (false if it was
255
- * already gone). Callers gate the author's postCount decrement on this so a
256
- * concurrent duplicate delete — or a race with the opportunistic expiry sweep —
257
- * decrements at most once for the single +1 the story counted at create time.
258
- */
259
- async function deleteStoryAndRelatedData(
260
- db: Database,
261
- apId: string,
262
- media?: IObjectStorage,
263
- ): Promise<boolean> {
264
- const mediaKeys = await deleteObjectCascade(db, apId, media);
265
- const deleted = await db
266
- .delete(objects)
267
- .where(eq(objects.apId, apId))
268
- .returning({ apId: objects.apId });
269
- // Irreversible R2 purge LAST — after the objects row is gone.
270
- await purgeMediaBlobs(media, mediaKeys);
271
- return deleted.length > 0;
272
- }
273
-
274
276
  /**
275
277
  * Resolve the scope of a stories read request.
276
278
  *
@@ -367,7 +369,7 @@ stories.get("/", async (c) => {
367
369
  ),
368
370
  );
369
371
 
370
- const excludeAuthors = excludeBlockedMutedAuthors(db, actor.ap_id);
372
+ const excludeAuthors = excludeModeratedActors(actor.ap_id);
371
373
  if (excludeAuthors) {
372
374
  storiesWhere = and(storiesWhere, excludeAuthors);
373
375
  }
@@ -527,14 +529,11 @@ stories.get("/:actorId", async (c) => {
527
529
  }
528
530
 
529
531
  // Check blocked/muted (if authenticated)
530
- if (actor) {
531
- const { blockedIds, mutedIds } = await fetchBlockedAndMutedIds(
532
- db,
533
- actor.ap_id,
534
- );
535
- if (blockedIds.includes(targetApId) || mutedIds.includes(targetApId)) {
536
- return c.json({ stories: [] });
537
- }
532
+ if (
533
+ actor &&
534
+ (await personalActorIsSuppressedBy(db, actor.ap_id, targetApId))
535
+ ) {
536
+ return c.json({ stories: [] });
538
537
  }
539
538
 
540
539
  // Personal stories are follower-scoped: the home feed (`GET /`) only surfaces
@@ -576,6 +575,7 @@ stories.get("/:actorId", async (c) => {
576
575
  eq(objects.attributedTo, targetApId),
577
576
  gt(objects.endTime, now),
578
577
  scopeCondition,
578
+ excludeModeratedActors(actor?.ap_id ?? ""),
579
579
  ),
580
580
  )
581
581
  .orderBy(desc(objects.published))
@@ -721,12 +721,6 @@ stories.post("/", async (c) => {
721
721
  };
722
722
  const attachmentsJson = JSON.stringify(storyData);
723
723
 
724
- // Insert the story object and bump the author's denormalized postCount in a
725
- // single atomic batch. D1 has no interactive transactions, so doing these as
726
- // two separate writes could drift the counter relative to the stored stories
727
- // on a mid-request failure. The `Database` union type does not surface `batch`
728
- // (it is only on the concrete D1/libsql subclasses), so reach it through a
729
- // narrow structural cast.
730
724
  const storyInsert = db.insert(objects).values({
731
725
  apId,
732
726
  type: "Story",
@@ -737,14 +731,12 @@ stories.post("/", async (c) => {
737
731
  endTime,
738
732
  published: now,
739
733
  isLocal: 1,
740
- });
734
+ }) as D1Statement;
741
735
 
742
736
  const postCountBump = db
743
737
  .update(actors)
744
738
  .set({ postCount: sql`${actors.postCount} + 1` })
745
- .where(eq(actors.apId, actor.ap_id));
746
-
747
- await (db as unknown as Batchable).batch([storyInsert, postCountBump]);
739
+ .where(eq(actors.apId, actor.ap_id)) as D1Statement;
748
740
 
749
741
  const responseData = transformStoryData(attachmentsJson);
750
742
  const authorInfo = buildAuthor(actor.ap_id, {
@@ -803,6 +795,7 @@ stories.post("/", async (c) => {
803
795
  object: storyObject,
804
796
  },
805
797
  communityApIdValue,
798
+ [storyInsert, postCountBump],
806
799
  );
807
800
 
808
801
  return c.json({ story }, 201);
@@ -829,46 +822,70 @@ stories.post("/delete", async (c) => {
829
822
  return c.json({ error: "Forbidden" }, 403);
830
823
  }
831
824
 
832
- // Enqueue Delete(Story) activity before deleting. Outbound delivery MUST NOT
833
- // run in request path; enqueue is the sync boundary. A community-scoped story
834
- // tombstone is addressed to and fanned out to the community (reach ==
835
- // community), mirroring its Create; a personal story keeps author-follower
836
- // reach.
825
+ // A community-scoped story tombstone is addressed to and fanned out to the
826
+ // community (reach == community), mirroring its Create; a personal story
827
+ // keeps author-follower reach.
837
828
  const baseUrl = c.env.APP_URL;
838
829
  const deleteTo = story.communityApId
839
830
  ? [`${story.communityApId}/followers`]
840
831
  : ["https://www.w3.org/ns/activitystreams#Public"];
841
- await createAndFanoutActivity(
832
+ const deleteActivity = {
833
+ "@context": "https://www.w3.org/ns/activitystreams",
834
+ id: activityApId(baseUrl, generateId()),
835
+ type: "Delete",
836
+ actor: actor.ap_id,
837
+ to: deleteTo,
838
+ object: apId,
839
+ };
840
+ const preparedFanout = await prepareDeliveryFanoutJob(
842
841
  db,
843
- c.env,
844
- actor.ap_id,
845
- apId,
846
- {
847
- "@context": "https://www.w3.org/ns/activitystreams",
848
- id: activityApId(baseUrl, generateId()),
849
- type: "Delete",
850
- actor: actor.ap_id,
851
- to: deleteTo,
852
- object: apId,
853
- },
854
- story.communityApId,
842
+ story.communityApId
843
+ ? {
844
+ kind: "community",
845
+ activityId: deleteActivity.id,
846
+ targetApId: story.communityApId,
847
+ }
848
+ : {
849
+ kind: "followers",
850
+ activityId: deleteActivity.id,
851
+ targetApId: actor.ap_id,
852
+ },
855
853
  );
856
854
 
857
- const removed = await deleteStoryAndRelatedData(db, apId, c.env.MEDIA);
858
-
859
- // Decrement the author's postCount ONLY when THIS request actually removed the
860
- // row. The early 404 above guards SEQUENTIAL duplicates, but two concurrent
861
- // deletes (double-click / retry) — or a manual delete racing the opportunistic
862
- // expiry sweep (cleanupExpiredStories) can both pass the SELECT before either
863
- // delete commits, then both reach here; an unconditional decrement would then
864
- // subtract 2 for one +1. Gating on the actual delete keeps the count exact
865
- // (gt > 0 still guards underflow). Mirrors the EXISTS-guarded post-delete path.
866
- if (removed) {
867
- await db
855
+ // Reap every projection of the OLD Create/Update activities while the story
856
+ // row still exists. The outbound Delete is deliberately inserted only in the
857
+ // final batch below; inserting it first made this cascade erase its own
858
+ // pending fanout before a Queue consumer could resolve the Activity.
859
+ const mediaKeys = await deleteObjectCascade(db, apId, c.env.MEDIA);
860
+ const storyExists = sql`EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${apId})`;
861
+ await runBatch(db, [
862
+ db
868
863
  .update(actors)
869
864
  .set({ postCount: sql`${actors.postCount} - 1` })
870
- .where(and(eq(actors.apId, actor.ap_id), gt(actors.postCount, 0)));
871
- }
865
+ .where(
866
+ and(eq(actors.apId, actor.ap_id), gt(actors.postCount, 0), storyExists),
867
+ ) as D1Statement,
868
+ db.delete(objects).where(eq(objects.apId, apId)) as D1Statement,
869
+ db.insert(activities).values({
870
+ apId: deleteActivity.id,
871
+ type: deleteActivity.type,
872
+ actorApId: actor.ap_id,
873
+ objectApId: apId,
874
+ rawJson: JSON.stringify(deleteActivity),
875
+ direction: "outbound",
876
+ }) as D1Statement,
877
+ preparedFanout.statement,
878
+ ]);
879
+
880
+ // Irreversible R2 purge only after the story row is gone and its durable
881
+ // federation intent exists.
882
+ await purgeMediaBlobs(c.env.MEDIA, mediaKeys);
883
+ await publishDurableStoryFanoutWakeup(
884
+ c.env,
885
+ deleteActivity.id,
886
+ actor.ap_id,
887
+ story.communityApId,
888
+ );
872
889
 
873
890
  return c.json({ success: true });
874
891
  });
@@ -5,7 +5,7 @@
5
5
  * yurucommu_get_dm_messages
6
6
  */
7
7
 
8
- import { and, desc, eq, isNotNull } from "drizzle-orm";
8
+ import { and, desc, eq, inArray, or } from "drizzle-orm";
9
9
  import {
10
10
  activities,
11
11
  actors,
@@ -18,7 +18,6 @@ import {
18
18
  activityApId,
19
19
  generateId,
20
20
  objectApId,
21
- safeJsonParse,
22
21
  } from "../../federation-helpers.ts";
23
22
  import {
24
23
  MAX_DM_CONTENT_LENGTH,
@@ -30,11 +29,16 @@ import {
30
29
  errRequired,
31
30
  ok,
32
31
  requireString,
33
- resolveDmPartner,
34
32
  toolLimit,
35
33
  type ToolResponse,
36
34
  } from "../takos-tools-response.ts";
35
+ import {
36
+ dmWhereForActor,
37
+ getOtherParticipant,
38
+ recipientObjectIds,
39
+ } from "../dm/conversations-helpers.ts";
37
40
  import type { Input, ToolContext } from "./types.ts";
41
+ import { isActorBlockedStrict } from "../../lib/blocklist.ts";
38
42
 
39
43
  // `.batch` lives only on the concrete D1/libsql subclasses; reach it through a
40
44
  // narrow structural cast so the Note + recipient + activity + inbox commit
@@ -165,13 +169,7 @@ export async function handleGetDmThreads(
165
169
  content: objects.content,
166
170
  })
167
171
  .from(objects)
168
- .where(
169
- and(
170
- eq(objects.visibility, "direct"),
171
- eq(objects.type, "Note"),
172
- isNotNull(objects.conversation),
173
- ),
174
- )
172
+ .where(dmWhereForActor(db, actor.ap_id))
175
173
  .orderBy(desc(objects.published))
176
174
  .limit(2000);
177
175
 
@@ -182,7 +180,7 @@ export async function handleGetDmThreads(
182
180
  > = {};
183
181
 
184
182
  for (const dm of dms) {
185
- const partner = resolveDmPartner(dm, actor.ap_id);
183
+ const partner = getOtherParticipant(dm, actor.ap_id);
186
184
  if (partner && !threads[partner]) {
187
185
  threads[partner] = {
188
186
  partner,
@@ -206,6 +204,9 @@ export async function handleGetDmMessages(
206
204
  const threadId = requireString(input, "thread_id");
207
205
  const limit = toolLimit(input.limit, 50, 100);
208
206
  if (!threadId) return c.json(errRequired("Thread ID"), 400);
207
+ if (await isActorBlockedStrict(db, threadId)) {
208
+ return c.json(errNotFound("Thread"), 404);
209
+ }
209
210
 
210
211
  const baseUrl = c.env.APP_URL;
211
212
  // Read from the STORED conversation id (incl. a legacy-scheme thread) so the
@@ -225,19 +226,18 @@ export async function handleGetDmMessages(
225
226
  eq(objects.visibility, "direct"),
226
227
  eq(objects.type, "Note"),
227
228
  eq(objects.conversation, conversationId),
229
+ or(
230
+ eq(objects.attributedTo, actor.ap_id),
231
+ inArray(objects.apId, recipientObjectIds(db, actor.ap_id)),
232
+ ),
228
233
  ),
229
234
  )
230
235
  .orderBy(desc(objects.published))
231
236
  .limit(limit);
232
237
 
233
- const filtered = messages.filter((m) => {
234
- if (m.attributedTo === actor.ap_id) return true;
235
- return safeJsonParse<string[]>(m.toJson, []).includes(actor.ap_id);
236
- });
237
-
238
238
  return c.json(
239
239
  ok({
240
- messages: filtered.map((m) => ({
240
+ messages: messages.map((m) => ({
241
241
  ap_id: m.apId,
242
242
  content: m.content,
243
243
  from: m.attributedTo,