@takosjp/yurucommu-core 3.0.0

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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Takos Tools - Post handlers
3
+ *
4
+ * Handles: yurucommu_create_post, yurucommu_delete_post,
5
+ * yurucommu_like_post, yurucommu_bookmark_post
6
+ */
7
+
8
+ import { and, count, eq, gt } from "drizzle-orm";
9
+ import { sql } from "drizzle-orm";
10
+ import { actors, bookmarks, likes, objects } from "../../../db/index.ts";
11
+ import {
12
+ actorIsBlockedBy,
13
+ canViewerReadObjectFull,
14
+ } from "../../lib/post-visibility.ts";
15
+ import {
16
+ MAX_POST_CONTENT_LENGTH,
17
+ normalizeVisibility,
18
+ } from "../posts/transformers.ts";
19
+ import {
20
+ errAuth,
21
+ errNotFound,
22
+ errRequired,
23
+ ok,
24
+ requireString,
25
+ togglePostRelation,
26
+ toolLimit,
27
+ type ToolResponse,
28
+ } from "../takos-tools-response.ts";
29
+ import type { Input, ToolContext } from "./types.ts";
30
+
31
+ // `.batch` lives only on the concrete D1/libsql subclasses; reach it through a
32
+ // narrow structural cast so the object write + postCount update commit together.
33
+ type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
34
+
35
+ export async function handleCreatePost(
36
+ c: ToolContext,
37
+ input: Input,
38
+ actor: { ap_id: string } | null,
39
+ ) {
40
+ if (!actor) return c.json(errAuth(), 401);
41
+
42
+ const db = c.get("db");
43
+ const content = requireString(input, "content");
44
+ // Constrain visibility to the canonical enum (unknown → "public"), matching
45
+ // the web post route; a raw value would be invisible to every feed filter.
46
+ const visibility = normalizeVisibility(String(input.visibility || "public"));
47
+ const inReplyTo = input.in_reply_to ? String(input.in_reply_to) : null;
48
+
49
+ if (!content) return c.json(errRequired("Content"), 400);
50
+ // Enforce the same content cap as the canonical post route so this MCP path
51
+ // can't store an oversized Note that then federates + renders everywhere.
52
+ if (content.length > MAX_POST_CONTENT_LENGTH) {
53
+ return c.json(
54
+ { success: false, error: "Content too long" } as ToolResponse,
55
+ 400,
56
+ );
57
+ }
58
+
59
+ // Gate a reply on the SAME read-gate + block-check the canonical web reply
60
+ // route enforces (routes/posts/routes.ts) — not a mere existence check. Without
61
+ // it the agent could reply to a parent it cannot read (followers-only / direct /
62
+ // private-community), bumping that restricted parent's reply_count and exposing
63
+ // its existence via the stored in_reply_to (a privacy oracle), or reply to a
64
+ // parent whose author blocked the actor (block bypass + notification).
65
+ if (inReplyTo) {
66
+ const parent = await db
67
+ .select({
68
+ attributedTo: objects.attributedTo,
69
+ visibility: objects.visibility,
70
+ toJson: objects.toJson,
71
+ ccJson: objects.ccJson,
72
+ audienceJson: objects.audienceJson,
73
+ communityApId: objects.communityApId,
74
+ type: objects.type,
75
+ endTime: objects.endTime,
76
+ })
77
+ .from(objects)
78
+ .where(eq(objects.apId, inReplyTo))
79
+ .get();
80
+ if (
81
+ !parent ||
82
+ !(await canViewerReadObjectFull(db, parent, actor.ap_id)) ||
83
+ (await actorIsBlockedBy(db, parent.attributedTo, actor.ap_id))
84
+ ) {
85
+ return c.json(
86
+ { success: false, error: "Reply target not found" } as ToolResponse,
87
+ 404,
88
+ );
89
+ }
90
+ }
91
+
92
+ const postId = crypto.randomUUID();
93
+ const now = new Date().toISOString();
94
+ const apId = `${c.env.APP_URL}/ap/notes/${postId}`;
95
+
96
+ // Co-commit the Note + author postCount bump (+ parent replyCount recompute for
97
+ // a reply) atomically (no drift on a mid-write failure). Direct posts do NOT
98
+ // count toward postCount (mirror the canonical create/delete + DM paths).
99
+ const stmts: unknown[] = [
100
+ db.insert(objects).values({
101
+ apId,
102
+ type: "Note",
103
+ attributedTo: actor.ap_id,
104
+ content,
105
+ summary: null,
106
+ attachmentsJson: "[]",
107
+ inReplyTo,
108
+ visibility,
109
+ likeCount: 0,
110
+ replyCount: 0,
111
+ announceCount: 0,
112
+ shareCount: 0,
113
+ published: now,
114
+ isLocal: 1,
115
+ }),
116
+ ];
117
+ if (visibility !== "direct") {
118
+ stmts.push(
119
+ db
120
+ .update(actors)
121
+ .set({ postCount: sql`${actors.postCount} + 1` })
122
+ .where(eq(actors.apId, actor.ap_id)),
123
+ );
124
+ }
125
+ if (inReplyTo) {
126
+ stmts.push(
127
+ db
128
+ .update(objects)
129
+ .set({
130
+ replyCount: sql`(SELECT COUNT(*) FROM ${objects} WHERE ${objects.inReplyTo} = ${inReplyTo})`,
131
+ })
132
+ .where(eq(objects.apId, inReplyTo)),
133
+ );
134
+ }
135
+ await (db as unknown as Batchable).batch(stmts);
136
+
137
+ return c.json(ok({ post_id: postId, ap_id: apId }));
138
+ }
139
+
140
+ export async function handleDeletePost(
141
+ c: ToolContext,
142
+ input: Input,
143
+ actor: { ap_id: string } | null,
144
+ ) {
145
+ if (!actor) return c.json(errAuth(), 401);
146
+
147
+ const db = c.get("db");
148
+ const postId = requireString(input, "post_id");
149
+ if (!postId) return c.json(errRequired("Post ID"), 400);
150
+
151
+ const post = await db
152
+ .select({ apId: objects.apId })
153
+ .from(objects)
154
+ .where(and(eq(objects.apId, postId), eq(objects.attributedTo, actor.ap_id)))
155
+ .get();
156
+ if (!post) {
157
+ return c.json(
158
+ {
159
+ success: false,
160
+ error: "Post not found or not authorized",
161
+ } as ToolResponse,
162
+ 404,
163
+ );
164
+ }
165
+
166
+ // #COUNTER-SYM: gate the decrement on the object STILL existing (correlated
167
+ // EXISTS) and run it BEFORE the delete, so two concurrent/retried deletes of
168
+ // the same post can't double-decrement postCount — the second batch's EXISTS
169
+ // is false (the first already deleted the row) → its -1 matches 0 rows. gt(>0)
170
+ // guards underflow. Mirrors the canonical web delete path (posts/routes.ts).
171
+ const objectExists = sql`EXISTS (SELECT 1 FROM ${objects} WHERE ${objects.apId} = ${postId})`;
172
+ await (db as unknown as Batchable).batch([
173
+ db
174
+ .update(actors)
175
+ .set({ postCount: sql`${actors.postCount} - 1` })
176
+ .where(
177
+ and(
178
+ eq(actors.apId, actor.ap_id),
179
+ gt(actors.postCount, 0),
180
+ objectExists,
181
+ ),
182
+ ),
183
+ db.delete(objects).where(eq(objects.apId, postId)),
184
+ ]);
185
+
186
+ return c.json(ok({ deleted: true }));
187
+ }
188
+
189
+ export async function handleLikePost(
190
+ c: ToolContext,
191
+ input: Input,
192
+ actor: { ap_id: string } | null,
193
+ ) {
194
+ if (!actor) return c.json(errAuth(), 401);
195
+
196
+ const db = c.get("db");
197
+ const postId = requireString(input, "post_id");
198
+ const likeActive = Boolean(input.like);
199
+
200
+ if (!postId) return c.json(errRequired("Post ID"), 400);
201
+
202
+ // Read-gate the like exactly as the web route does (interactions.ts): an
203
+ // unentitled actor who merely learns a followers-only / direct / private-
204
+ // community post's apId must not be able to like it (which would bump
205
+ // like_count and leak the post's existence). 404 when not readable.
206
+ const post = await db
207
+ .select({
208
+ apId: objects.apId,
209
+ visibility: objects.visibility,
210
+ attributedTo: objects.attributedTo,
211
+ toJson: objects.toJson,
212
+ ccJson: objects.ccJson,
213
+ audienceJson: objects.audienceJson,
214
+ communityApId: objects.communityApId,
215
+ type: objects.type,
216
+ endTime: objects.endTime,
217
+ })
218
+ .from(objects)
219
+ .where(eq(objects.apId, postId))
220
+ .get();
221
+ // Block-gate too (the read-gate passes for any public post, so it alone does
222
+ // not stop a blocked actor): an actor the author blocked must not bump the
223
+ // author's like_count, mirroring the canonical like route (interactions.ts).
224
+ if (
225
+ !post ||
226
+ !(await canViewerReadObjectFull(db, post, actor.ap_id)) ||
227
+ (await actorIsBlockedBy(db, post.attributedTo, actor.ap_id))
228
+ ) {
229
+ return c.json(errNotFound("Post"), 404);
230
+ }
231
+
232
+ // Co-commit the like-edge toggle AND the likeCount recompute in ONE atomic
233
+ // batch (the canonical route does the same): a mid-request failure between the
234
+ // toggle and the count UPDATE would otherwise leave likeCount diverged from the
235
+ // likes table. The recompute is a COUNT(*) subquery so it is idempotent.
236
+ const toggleStmt = likeActive
237
+ ? db
238
+ .insert(likes)
239
+ .values({ actorApId: actor.ap_id, objectApId: post.apId })
240
+ .onConflictDoNothing()
241
+ : db
242
+ .delete(likes)
243
+ .where(
244
+ and(
245
+ eq(likes.actorApId, actor.ap_id),
246
+ eq(likes.objectApId, post.apId),
247
+ ),
248
+ );
249
+ await (db as unknown as Batchable).batch([
250
+ toggleStmt,
251
+ db
252
+ .update(objects)
253
+ .set({
254
+ likeCount: sql`(SELECT COUNT(*) FROM ${likes} WHERE ${likes.objectApId} = ${post.apId})`,
255
+ })
256
+ .where(eq(objects.apId, post.apId)),
257
+ ]);
258
+
259
+ const likeCountResult = await db
260
+ .select({ count: count() })
261
+ .from(likes)
262
+ .where(eq(likes.objectApId, post.apId))
263
+ .get();
264
+ const likeCount = likeCountResult?.count ?? 0;
265
+
266
+ return c.json(ok({ liked: likeActive, like_count: likeCount }));
267
+ }
268
+
269
+ export async function handleBookmarkPost(
270
+ c: ToolContext,
271
+ input: Input,
272
+ actor: { ap_id: string } | null,
273
+ ) {
274
+ if (!actor) return c.json(errAuth(), 401);
275
+
276
+ const db = c.get("db");
277
+ const postId = requireString(input, "post_id");
278
+ const bookmark = Boolean(input.bookmark);
279
+
280
+ if (!postId) return c.json(errRequired("Post ID"), 400);
281
+
282
+ const post = await db
283
+ .select({ apId: objects.apId })
284
+ .from(objects)
285
+ .where(eq(objects.apId, postId))
286
+ .get();
287
+ if (!post) return c.json(errNotFound("Post"), 404);
288
+
289
+ await togglePostRelation(db, bookmarks, actor.ap_id, post.apId, bookmark);
290
+
291
+ return c.json(ok({ bookmarked: bookmark }));
292
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Takos Tools - Search handlers
3
+ *
4
+ * Handles: yurucommu_search_users, yurucommu_search_posts,
5
+ * yurucommu_get_trending, yurucommu_get_user_profile
6
+ */
7
+
8
+ import { and, desc, eq, isNull, or } from "drizzle-orm";
9
+ import { sql } from "drizzle-orm";
10
+ import { actors, objects } from "../../../db/index.ts";
11
+
12
+ import { NO_AUDIENCE_PREDICATE } from "../../lib/community-visibility.ts";
13
+ import { excludeBlockedMutedAuthors } from "../../lib/feed-exclude.ts";
14
+ import { extractHashtags } from "../posts/transformers.ts";
15
+ import { postContentSearchPredicate } from "../search.ts";
16
+ import {
17
+ ACTOR_SUMMARY_COLUMNS,
18
+ errNotFound,
19
+ errRequired,
20
+ formatActorSummary,
21
+ ok,
22
+ requireString,
23
+ toolLimit,
24
+ } from "../takos-tools-response.ts";
25
+ import type { Input, ToolContext } from "./types.ts";
26
+
27
+ export function handleSearchUsers(
28
+ c: ToolContext,
29
+ input: Input,
30
+ _actor: { ap_id: string } | null,
31
+ ) {
32
+ return searchUsers(c, input);
33
+ }
34
+
35
+ export function handleSearchPosts(
36
+ c: ToolContext,
37
+ input: Input,
38
+ actor: { ap_id: string } | null,
39
+ ) {
40
+ return searchPosts(c, input, actor);
41
+ }
42
+
43
+ export function handleGetTrending(
44
+ c: ToolContext,
45
+ input: Input,
46
+ _actor: { ap_id: string } | null,
47
+ ) {
48
+ return getTrending(c, input);
49
+ }
50
+
51
+ export function handleGetUserProfile(
52
+ c: ToolContext,
53
+ input: Input,
54
+ actor: { ap_id: string } | null,
55
+ ) {
56
+ return getUserProfile(c, input, actor);
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+
61
+ async function searchUsers(c: ToolContext, input: Input) {
62
+ const db = c.get("db");
63
+ const query = requireString(input, "query");
64
+ const limit = toolLimit(input.limit, 20, 50);
65
+
66
+ if (!query) return c.json(ok({ actors: [] }));
67
+
68
+ const results = await db
69
+ .select({
70
+ ...ACTOR_SUMMARY_COLUMNS,
71
+ summary: actors.summary,
72
+ followerCount: actors.followerCount,
73
+ })
74
+ .from(actors)
75
+ .where(
76
+ and(
77
+ // Exclude soft-deleted tombstones (matches the web /search/actors
78
+ // notDeleted filter + the sibling searchPosts/getTrending handlers);
79
+ // otherwise a scrubbed `deleted-<id>` tombstone can surface here.
80
+ isNull(actors.deletedAt),
81
+ eq(actors.isPrivate, 0),
82
+ or(
83
+ sql`instr(lower(${actors.preferredUsername}), lower(${query})) > 0`,
84
+ sql`instr(lower(${actors.name}), lower(${query})) > 0`,
85
+ ),
86
+ ),
87
+ )
88
+ .orderBy(desc(actors.followerCount))
89
+ .limit(limit);
90
+
91
+ return c.json(
92
+ ok({
93
+ actors: results.map((a) => ({
94
+ ...formatActorSummary(a),
95
+ summary: a.summary,
96
+ follower_count: a.followerCount,
97
+ })),
98
+ }),
99
+ );
100
+ }
101
+
102
+ async function searchPosts(
103
+ c: ToolContext,
104
+ input: Input,
105
+ actor: { ap_id: string } | null,
106
+ ) {
107
+ const db = c.get("db");
108
+ const query = requireString(input, "query");
109
+ const limit = toolLimit(input.limit, 20, 50);
110
+
111
+ if (!query) return c.json(ok({ posts: [] }));
112
+
113
+ const posts = await db
114
+ .select()
115
+ .from(objects)
116
+ .where(
117
+ and(
118
+ // Match via the SAME predicate as the web GET /search/posts (FTS trigram
119
+ // for >=3 codepoints, instr() fallback for shorter) so the agent and web
120
+ // surfaces return identical results and the agent path uses the FTS index
121
+ // instead of a full-table substring scan.
122
+ postContentSearchPredicate(query),
123
+ eq(objects.visibility, "public"),
124
+ NO_AUDIENCE_PREDICATE,
125
+ isNull(objects.deletedAt),
126
+ // Honor the caller's block/mute filter, matching the web search +
127
+ // feed surfaces (undefined for an anonymous caller → and() drops it).
128
+ excludeBlockedMutedAuthors(db, actor?.ap_id ?? ""),
129
+ ),
130
+ )
131
+ .orderBy(desc(objects.published), desc(objects.apId))
132
+ .limit(limit);
133
+
134
+ return c.json(
135
+ ok({
136
+ posts: posts.map((p) => ({
137
+ ap_id: p.apId,
138
+ content: p.content,
139
+ published: p.published,
140
+ like_count: p.likeCount,
141
+ })),
142
+ }),
143
+ );
144
+ }
145
+
146
+ async function getTrending(c: ToolContext, input: Input) {
147
+ const db = c.get("db");
148
+ const limit = toolLimit(input.limit, 10, 50);
149
+ const sinceDate = new Date(
150
+ Date.now() - 7 * 24 * 60 * 60 * 1000,
151
+ ).toISOString();
152
+
153
+ const posts = await db
154
+ .select({ content: objects.content })
155
+ .from(objects)
156
+ .where(
157
+ and(
158
+ eq(objects.visibility, "public"),
159
+ NO_AUDIENCE_PREDICATE,
160
+ isNull(objects.deletedAt),
161
+ sql`${objects.published} > ${sinceDate}`,
162
+ ),
163
+ )
164
+ .orderBy(desc(objects.published))
165
+ .limit(1000);
166
+
167
+ const hashtagCounts: Record<string, number> = {};
168
+
169
+ for (const post of posts) {
170
+ // Use the SHARED full-Unicode tokenizer (extractHashtags) so the agent
171
+ // trending list tokenizes hashtags identically to storage + web search/
172
+ // trending. The old inline ASCII+Hiragana+Katakana+BMP-CJK class silently
173
+ // dropped Korean/Cyrillic/Greek/accented-Latin/CJK-ext tags and mis-segmented
174
+ // #caf\u00E9 \u2192 #caf. (extractHashtags de-dupes within a post, so a tag spammed in
175
+ // one post counts once for that post \u2014 desirable for trending.)
176
+ for (const tag of extractHashtags(post.content || "")) {
177
+ const tagName = tag.toLowerCase();
178
+ hashtagCounts[tagName] = (hashtagCounts[tagName] || 0) + 1;
179
+ }
180
+ }
181
+
182
+ const trending = Object.entries(hashtagCounts)
183
+ .sort((a, b) => b[1] - a[1])
184
+ .slice(0, limit)
185
+ .map(([tag, count]) => ({ tag, count }));
186
+
187
+ return c.json(ok({ trending }));
188
+ }
189
+
190
+ async function getUserProfile(
191
+ c: ToolContext,
192
+ input: Input,
193
+ actor: { ap_id: string } | null,
194
+ ) {
195
+ const db = c.get("db");
196
+ const username = requireString(input, "username");
197
+ if (!username) return c.json(errRequired("Username"), 400);
198
+
199
+ const actorRecord = await db
200
+ .select({
201
+ ...ACTOR_SUMMARY_COLUMNS,
202
+ summary: actors.summary,
203
+ followerCount: actors.followerCount,
204
+ followingCount: actors.followingCount,
205
+ postCount: actors.postCount,
206
+ isPrivate: actors.isPrivate,
207
+ })
208
+ .from(actors)
209
+ .where(eq(actors.preferredUsername, username))
210
+ .get();
211
+
212
+ if (!actorRecord) return c.json(errNotFound("User"), 404);
213
+
214
+ // Fail-close for private accounts (allow self lookup only).
215
+ if (actorRecord.isPrivate && actor?.ap_id !== actorRecord.apId) {
216
+ return c.json(errNotFound("User"), 404);
217
+ }
218
+
219
+ return c.json(
220
+ ok({
221
+ ...formatActorSummary(actorRecord),
222
+ summary: actorRecord.summary,
223
+ follower_count: actorRecord.followerCount,
224
+ following_count: actorRecord.followingCount,
225
+ post_count: actorRecord.postCount,
226
+ }),
227
+ );
228
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Takos Tools - Timeline & notification handlers
3
+ *
4
+ * Handles: yurucommu_get_timeline, yurucommu_get_notifications
5
+ */
6
+
7
+ import { and, desc, eq, inArray, isNull } from "drizzle-orm";
8
+ import { actors, inbox, objects } from "../../../db/index.ts";
9
+ import { NO_AUDIENCE_PREDICATE } from "../../lib/community-visibility.ts";
10
+ import { encodeFeedCursor, feedCursorWhere } from "../../lib/feed-cursor.ts";
11
+ import {
12
+ ACTOR_SUMMARY_COLUMNS,
13
+ errAuth,
14
+ formatActorSummary,
15
+ ok,
16
+ toolLimit,
17
+ } from "../takos-tools-response.ts";
18
+ import type { Input, ToolContext } from "./types.ts";
19
+
20
+ export async function handleGetTimeline(
21
+ c: ToolContext,
22
+ input: Input,
23
+ _actor: { ap_id: string } | null,
24
+ ) {
25
+ const db = c.get("db");
26
+ const limit = toolLimit(input.limit, 20, 50);
27
+ const before = input.before ? String(input.before) : null;
28
+
29
+ const whereConditions = [
30
+ eq(objects.visibility, "public"),
31
+ NO_AUDIENCE_PREDICATE,
32
+ isNull(objects.deletedAt),
33
+ ];
34
+ // Composite (published, apId) cursor so agent pagination doesn't skip posts
35
+ // sharing a published millisecond (see lib/feed-cursor.ts).
36
+ const toolCursor = feedCursorWhere(objects.published, objects.apId, before);
37
+ if (toolCursor) whereConditions.push(toolCursor);
38
+
39
+ // Fetch one extra row as a has-more probe so the final/full page does NOT
40
+ // advertise a next_cursor (the bare `.limit(limit)` returned a cursor on every
41
+ // non-empty page, forcing the agent into one extra empty fetch and never a
42
+ // clean end-of-feed). Mirrors the client feed/notifications limit+1 pattern.
43
+ const scanned = await db
44
+ .select()
45
+ .from(objects)
46
+ .where(and(...whereConditions))
47
+ .orderBy(desc(objects.published), desc(objects.apId))
48
+ .limit(limit + 1);
49
+ const hasMore = scanned.length > limit;
50
+ const posts = hasMore ? scanned.slice(0, limit) : scanned;
51
+
52
+ const authorIds = [...new Set(posts.map((p) => p.attributedTo))];
53
+ const authorRows =
54
+ authorIds.length > 0
55
+ ? await db
56
+ .select(ACTOR_SUMMARY_COLUMNS)
57
+ .from(actors)
58
+ .where(inArray(actors.apId, authorIds))
59
+ : [];
60
+
61
+ const authorMap = new Map(authorRows.map((a) => [a.apId, a]));
62
+
63
+ return c.json(
64
+ ok({
65
+ posts: posts.map((p) => {
66
+ const author = authorMap.get(p.attributedTo);
67
+ return {
68
+ ap_id: p.apId,
69
+ content: p.content,
70
+ published: p.published,
71
+ like_count: p.likeCount,
72
+ author: author ? formatActorSummary(author) : null,
73
+ };
74
+ }),
75
+ has_more: hasMore,
76
+ next_cursor:
77
+ hasMore && posts.length > 0
78
+ ? encodeFeedCursor(
79
+ posts[posts.length - 1].published,
80
+ posts[posts.length - 1].apId,
81
+ )
82
+ : null,
83
+ }),
84
+ );
85
+ }
86
+
87
+ export async function handleGetNotifications(
88
+ c: ToolContext,
89
+ input: Input,
90
+ actor: { ap_id: string } | null,
91
+ ) {
92
+ if (!actor) return c.json(errAuth(), 401);
93
+
94
+ const db = c.get("db");
95
+ const limit = toolLimit(input.limit, 20, 50);
96
+ const unreadOnly = Boolean(input.unread_only);
97
+
98
+ // Use query API with relations for inbox + activity join
99
+ const inboxEntries = await db.query.inbox.findMany({
100
+ where: and(
101
+ eq(inbox.actorApId, actor.ap_id),
102
+ ...(unreadOnly ? [eq(inbox.read, 0)] : []),
103
+ ),
104
+ with: {
105
+ activity: true,
106
+ },
107
+ orderBy: desc(inbox.createdAt),
108
+ limit,
109
+ });
110
+
111
+ // Filter: activity must not be from self and must be one of the expected types
112
+ const allowedTypes = new Set(["Follow", "Like", "Announce", "Create"]);
113
+ const filtered = inboxEntries.filter(
114
+ (entry) =>
115
+ entry.activity &&
116
+ entry.activity.actorApId !== actor.ap_id &&
117
+ allowedTypes.has(entry.activity.type),
118
+ );
119
+
120
+ return c.json(
121
+ ok({
122
+ notifications: filtered.map((entry) => ({
123
+ id: entry.activityApId,
124
+ type: entry.activity.type.toLowerCase(),
125
+ from_actor: entry.activity.actorApId,
126
+ object: entry.activity.objectApId,
127
+ read: !!entry.read,
128
+ created_at: entry.createdAt,
129
+ })),
130
+ }),
131
+ );
132
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared types for takos-tools handler modules.
3
+ */
4
+
5
+ import type { Context } from "hono";
6
+ import type { Env, Variables } from "../../types.ts";
7
+
8
+ export type HonoEnv = { Bindings: Env; Variables: Variables };
9
+ export type ToolContext = Context<HonoEnv>;
10
+ export type Input = Record<string, unknown>;