@takosjp/yurucommu-core 3.4.1 → 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 (113) 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 +35 -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/public.ts +16 -0
  45. package/src/backend/retention.ts +115 -0
  46. package/src/backend/routes/account-teardown.ts +422 -156
  47. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  48. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  51. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  52. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  53. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  54. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  55. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  56. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  57. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  58. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  59. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  60. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  61. package/src/backend/routes/activitypub/inbox.ts +81 -105
  62. package/src/backend/routes/activitypub/outbox.ts +0 -0
  63. package/src/backend/routes/activitypub.ts +6 -5
  64. package/src/backend/routes/actors-helpers.ts +34 -8
  65. package/src/backend/routes/actors.ts +333 -169
  66. package/src/backend/routes/auth-helpers.ts +57 -9
  67. package/src/backend/routes/auth.ts +10 -2
  68. package/src/backend/routes/communities/membership-invites.ts +4 -1
  69. package/src/backend/routes/communities/membership-members.ts +201 -73
  70. package/src/backend/routes/communities/membership-requests.ts +141 -76
  71. package/src/backend/routes/communities/membership-shared.ts +228 -21
  72. package/src/backend/routes/communities/messages.ts +9 -2
  73. package/src/backend/routes/communities/routes.ts +48 -13
  74. package/src/backend/routes/dm/contacts.ts +29 -41
  75. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  76. package/src/backend/routes/dm/messages.ts +36 -42
  77. package/src/backend/routes/dm/read-archive.ts +4 -2
  78. package/src/backend/routes/dm/requests.ts +62 -54
  79. package/src/backend/routes/follow-helpers.ts +200 -60
  80. package/src/backend/routes/follow.ts +146 -140
  81. package/src/backend/routes/media.ts +20 -94
  82. package/src/backend/routes/moderation.ts +72 -4
  83. package/src/backend/routes/notes.ts +3 -4
  84. package/src/backend/routes/notifications.ts +209 -108
  85. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  86. package/src/backend/routes/posts/federation.ts +373 -0
  87. package/src/backend/routes/posts/interactions.ts +160 -184
  88. package/src/backend/routes/posts/like-mutation.ts +240 -0
  89. package/src/backend/routes/posts/post-helpers.ts +112 -162
  90. package/src/backend/routes/posts/queries.ts +150 -54
  91. package/src/backend/routes/posts/routes.ts +169 -329
  92. package/src/backend/routes/posts/transformers.ts +61 -6
  93. package/src/backend/routes/recommendations.ts +37 -44
  94. package/src/backend/routes/rtc/index.ts +26 -6
  95. package/src/backend/routes/search.ts +39 -55
  96. package/src/backend/routes/stories/interactions.ts +22 -6
  97. package/src/backend/routes/stories/query-helpers.ts +28 -41
  98. package/src/backend/routes/stories/routes.ts +130 -115
  99. package/src/backend/routes/takos-tools/dm.ts +17 -17
  100. package/src/backend/routes/takos-tools/posts.ts +189 -106
  101. package/src/backend/routes/takos-tools/search.ts +13 -4
  102. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  103. package/src/backend/routes/takos-tools-response.ts +6 -2
  104. package/src/backend/routes/timeline.ts +5 -5
  105. package/src/backend/runtime/call-signaling-do.ts +35 -4
  106. package/src/backend/runtime/one-time-ticket.ts +116 -0
  107. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  108. package/src/backend/runtime/signaling-hub.ts +36 -3
  109. package/src/backend/server.ts +95 -80
  110. package/src/db/d1-write.ts +67 -43
  111. package/src/db/schema/federation.ts +42 -1
  112. package/src/db/schema/messaging.ts +110 -2
  113. 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,30 +45,20 @@ 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.
64
56
  //
65
- // This is NOT a substitute for a scheduled job: this Worker has no `scheduled`
66
- // handler / cron trigger, so expiry cleanup is triggered probabilistically on
67
- // the read path. Expired stories are already excluded from every read query
68
- // (the feed/single-story handlers filter on `endTime`), so the only impact of
69
- // a missed sweep is storage growth, not stale data leaking to users. The guard
70
- // below ensures at most one sweep runs at a time per isolate, so a burst of
71
- // feed requests cannot kick off several concurrent full-table delete sweeps.
57
+ // The public Worker also exposes a scheduled retention handler. Keep this
58
+ // probabilistic read-path pass as a self-hosting fallback for runtimes that do
59
+ // not configure a cron trigger. Expired stories are already excluded from every
60
+ // read query, so a missed sweep affects storage only. The guard below ensures
61
+ // at most one fallback pass runs at a time per isolate.
72
62
  let expiredStoryCleanupInFlight = false;
73
63
 
74
64
  function maybeCleanupExpiredStories(
@@ -219,60 +209,70 @@ async function createAndFanoutActivity(
219
209
  actorApIdStr: string,
220
210
  objectApIdStr: string,
221
211
  activity: Record<string, unknown>,
222
- communityApId?: string | null,
212
+ communityApId: string | null | undefined,
213
+ localStatements: readonly [D1Statement, ...D1Statement[]],
223
214
  ): Promise<void> {
224
215
  const id = activity.id as string;
225
- await db.insert(activities).values({
226
- apId: id,
227
- type: activity.type as string,
228
- actorApId: actorApIdStr,
229
- objectApId: objectApIdStr,
230
- rawJson: JSON.stringify(activity),
231
- direction: "outbound",
232
- });
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> {
233
251
  // A community-scoped story has reach == community: fan its activity out to the
234
252
  // community's members/followers (the same audience posts use), NOT the
235
253
  // author's personal follower graph. A personal story keeps author-follower
236
254
  // reach.
237
- if (communityApId) {
238
- await enqueueFanoutToCommunity(env, id, communityApId);
239
- } else {
240
- 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
+ });
241
273
  }
242
274
  }
243
275
 
244
- /**
245
- * Delete all related data for a story, then the story object itself.
246
- *
247
- * Delegates to `deleteObjectCascade` (the SAME teardown the expiry path
248
- * `cleanupExpiredStories` runs) so it also reaps the story's mandatory R2 blob
249
- * and its `media_uploads` row — child-row-only deletion here would orphan the
250
- * image in R2 forever (there is no orphan-key sweep). The cascade covers
251
- * storyViews/Votes/Shares + likes + announces + bookmarks + objectRecipients +
252
- * media; it reads `attachments_json` off the still-present object row, so the
253
- * object row is dropped afterwards.
254
- */
255
- /**
256
- * Returns true when THIS call actually removed the objects row (false if it was
257
- * already gone). Callers gate the author's postCount decrement on this so a
258
- * concurrent duplicate delete — or a race with the opportunistic expiry sweep —
259
- * decrements at most once for the single +1 the story counted at create time.
260
- */
261
- async function deleteStoryAndRelatedData(
262
- db: Database,
263
- apId: string,
264
- media?: IObjectStorage,
265
- ): Promise<boolean> {
266
- const mediaKeys = await deleteObjectCascade(db, apId, media);
267
- const deleted = await db
268
- .delete(objects)
269
- .where(eq(objects.apId, apId))
270
- .returning({ apId: objects.apId });
271
- // Irreversible R2 purge LAST — after the objects row is gone.
272
- await purgeMediaBlobs(media, mediaKeys);
273
- return deleted.length > 0;
274
- }
275
-
276
276
  /**
277
277
  * Resolve the scope of a stories read request.
278
278
  *
@@ -369,7 +369,7 @@ stories.get("/", async (c) => {
369
369
  ),
370
370
  );
371
371
 
372
- const excludeAuthors = excludeBlockedMutedAuthors(db, actor.ap_id);
372
+ const excludeAuthors = excludeModeratedActors(actor.ap_id);
373
373
  if (excludeAuthors) {
374
374
  storiesWhere = and(storiesWhere, excludeAuthors);
375
375
  }
@@ -529,14 +529,11 @@ stories.get("/:actorId", async (c) => {
529
529
  }
530
530
 
531
531
  // Check blocked/muted (if authenticated)
532
- if (actor) {
533
- const { blockedIds, mutedIds } = await fetchBlockedAndMutedIds(
534
- db,
535
- actor.ap_id,
536
- );
537
- if (blockedIds.includes(targetApId) || mutedIds.includes(targetApId)) {
538
- return c.json({ stories: [] });
539
- }
532
+ if (
533
+ actor &&
534
+ (await personalActorIsSuppressedBy(db, actor.ap_id, targetApId))
535
+ ) {
536
+ return c.json({ stories: [] });
540
537
  }
541
538
 
542
539
  // Personal stories are follower-scoped: the home feed (`GET /`) only surfaces
@@ -578,6 +575,7 @@ stories.get("/:actorId", async (c) => {
578
575
  eq(objects.attributedTo, targetApId),
579
576
  gt(objects.endTime, now),
580
577
  scopeCondition,
578
+ excludeModeratedActors(actor?.ap_id ?? ""),
581
579
  ),
582
580
  )
583
581
  .orderBy(desc(objects.published))
@@ -723,12 +721,6 @@ stories.post("/", async (c) => {
723
721
  };
724
722
  const attachmentsJson = JSON.stringify(storyData);
725
723
 
726
- // Insert the story object and bump the author's denormalized postCount in a
727
- // single atomic batch. D1 has no interactive transactions, so doing these as
728
- // two separate writes could drift the counter relative to the stored stories
729
- // on a mid-request failure. The `Database` union type does not surface `batch`
730
- // (it is only on the concrete D1/libsql subclasses), so reach it through a
731
- // narrow structural cast.
732
724
  const storyInsert = db.insert(objects).values({
733
725
  apId,
734
726
  type: "Story",
@@ -739,14 +731,12 @@ stories.post("/", async (c) => {
739
731
  endTime,
740
732
  published: now,
741
733
  isLocal: 1,
742
- });
734
+ }) as D1Statement;
743
735
 
744
736
  const postCountBump = db
745
737
  .update(actors)
746
738
  .set({ postCount: sql`${actors.postCount} + 1` })
747
- .where(eq(actors.apId, actor.ap_id));
748
-
749
- await (db as unknown as Batchable).batch([storyInsert, postCountBump]);
739
+ .where(eq(actors.apId, actor.ap_id)) as D1Statement;
750
740
 
751
741
  const responseData = transformStoryData(attachmentsJson);
752
742
  const authorInfo = buildAuthor(actor.ap_id, {
@@ -805,6 +795,7 @@ stories.post("/", async (c) => {
805
795
  object: storyObject,
806
796
  },
807
797
  communityApIdValue,
798
+ [storyInsert, postCountBump],
808
799
  );
809
800
 
810
801
  return c.json({ story }, 201);
@@ -831,46 +822,70 @@ stories.post("/delete", async (c) => {
831
822
  return c.json({ error: "Forbidden" }, 403);
832
823
  }
833
824
 
834
- // Enqueue Delete(Story) activity before deleting. Outbound delivery MUST NOT
835
- // run in request path; enqueue is the sync boundary. A community-scoped story
836
- // tombstone is addressed to and fanned out to the community (reach ==
837
- // community), mirroring its Create; a personal story keeps author-follower
838
- // 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.
839
828
  const baseUrl = c.env.APP_URL;
840
829
  const deleteTo = story.communityApId
841
830
  ? [`${story.communityApId}/followers`]
842
831
  : ["https://www.w3.org/ns/activitystreams#Public"];
843
- 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(
844
841
  db,
845
- c.env,
846
- actor.ap_id,
847
- apId,
848
- {
849
- "@context": "https://www.w3.org/ns/activitystreams",
850
- id: activityApId(baseUrl, generateId()),
851
- type: "Delete",
852
- actor: actor.ap_id,
853
- to: deleteTo,
854
- object: apId,
855
- },
856
- 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
+ },
857
853
  );
858
854
 
859
- const removed = await deleteStoryAndRelatedData(db, apId, c.env.MEDIA);
860
-
861
- // Decrement the author's postCount ONLY when THIS request actually removed the
862
- // row. The early 404 above guards SEQUENTIAL duplicates, but two concurrent
863
- // deletes (double-click / retry) — or a manual delete racing the opportunistic
864
- // expiry sweep (cleanupExpiredStories) can both pass the SELECT before either
865
- // delete commits, then both reach here; an unconditional decrement would then
866
- // subtract 2 for one +1. Gating on the actual delete keeps the count exact
867
- // (gt > 0 still guards underflow). Mirrors the EXISTS-guarded post-delete path.
868
- if (removed) {
869
- 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
870
863
  .update(actors)
871
864
  .set({ postCount: sql`${actors.postCount} - 1` })
872
- .where(and(eq(actors.apId, actor.ap_id), gt(actors.postCount, 0)));
873
- }
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
+ );
874
889
 
875
890
  return c.json({ success: true });
876
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,