@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.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- 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>;
|