@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,13 @@
|
|
|
1
|
+
// Story routes for Yurucommu backend
|
|
2
|
+
// v2: 1 Story = 1 Media (Instagram style)
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import type { Env, Variables } from "../types.ts";
|
|
5
|
+
import baseRoutes from "./stories/routes.ts";
|
|
6
|
+
import interactionRoutes from "./stories/interactions.ts";
|
|
7
|
+
|
|
8
|
+
const stories = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
9
|
+
|
|
10
|
+
stories.route("/", baseRoutes);
|
|
11
|
+
stories.route("/", interactionRoutes);
|
|
12
|
+
|
|
13
|
+
export default stories;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Takos Tools - DM handlers
|
|
3
|
+
*
|
|
4
|
+
* Handles: yurucommu_send_dm, yurucommu_get_dm_threads,
|
|
5
|
+
* yurucommu_get_dm_messages
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { and, desc, eq, isNotNull } from "drizzle-orm";
|
|
9
|
+
import {
|
|
10
|
+
activities,
|
|
11
|
+
actors,
|
|
12
|
+
blocks,
|
|
13
|
+
inbox,
|
|
14
|
+
objectRecipients,
|
|
15
|
+
objects,
|
|
16
|
+
} from "../../../db/index.ts";
|
|
17
|
+
import {
|
|
18
|
+
activityApId,
|
|
19
|
+
generateId,
|
|
20
|
+
objectApId,
|
|
21
|
+
safeJsonParse,
|
|
22
|
+
} from "../../federation-helpers.ts";
|
|
23
|
+
import {
|
|
24
|
+
MAX_DM_CONTENT_LENGTH,
|
|
25
|
+
resolveConversationId,
|
|
26
|
+
} from "../dm/query-helpers.ts";
|
|
27
|
+
import {
|
|
28
|
+
errAuth,
|
|
29
|
+
errNotFound,
|
|
30
|
+
errRequired,
|
|
31
|
+
ok,
|
|
32
|
+
requireString,
|
|
33
|
+
resolveDmPartner,
|
|
34
|
+
toolLimit,
|
|
35
|
+
type ToolResponse,
|
|
36
|
+
} from "../takos-tools-response.ts";
|
|
37
|
+
import type { Input, ToolContext } from "./types.ts";
|
|
38
|
+
|
|
39
|
+
// `.batch` lives only on the concrete D1/libsql subclasses; reach it through a
|
|
40
|
+
// narrow structural cast so the Note + recipient + activity + inbox commit
|
|
41
|
+
// atomically (no orphan-recipient DM on a partial write).
|
|
42
|
+
type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
|
|
43
|
+
|
|
44
|
+
export async function handleSendDm(
|
|
45
|
+
c: ToolContext,
|
|
46
|
+
input: Input,
|
|
47
|
+
actor: { ap_id: string } | null,
|
|
48
|
+
) {
|
|
49
|
+
if (!actor) return c.json(errAuth(), 401);
|
|
50
|
+
|
|
51
|
+
const db = c.get("db");
|
|
52
|
+
const recipient = requireString(input, "recipient");
|
|
53
|
+
const content = requireString(input, "content");
|
|
54
|
+
if (!recipient || !content) {
|
|
55
|
+
return c.json(errRequired("Recipient and content"), 400);
|
|
56
|
+
}
|
|
57
|
+
// Same content cap as the canonical DM route.
|
|
58
|
+
if (content.length > MAX_DM_CONTENT_LENGTH) {
|
|
59
|
+
return c.json(
|
|
60
|
+
{ success: false, error: "Content too long" } as ToolResponse,
|
|
61
|
+
400,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const target = await db
|
|
66
|
+
.select({ apId: actors.apId })
|
|
67
|
+
.from(actors)
|
|
68
|
+
.where(eq(actors.preferredUsername, recipient))
|
|
69
|
+
.get();
|
|
70
|
+
if (!target) return c.json(errNotFound("Recipient"), 404);
|
|
71
|
+
|
|
72
|
+
// SECURITY (#23, broken access control / block bypass): mirror the canonical
|
|
73
|
+
// DM send path (routes/dm/messages.ts) — reject when the recipient has blocked
|
|
74
|
+
// the sender. Respond with the same not-found shape as a missing recipient so
|
|
75
|
+
// the sender cannot distinguish a block from a non-existent user (no block leak).
|
|
76
|
+
const blockedBy = await db
|
|
77
|
+
.select({ blockerApId: blocks.blockerApId })
|
|
78
|
+
.from(blocks)
|
|
79
|
+
.where(
|
|
80
|
+
and(
|
|
81
|
+
eq(blocks.blockerApId, target.apId),
|
|
82
|
+
eq(blocks.blockedApId, actor.ap_id),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
.get();
|
|
86
|
+
if (blockedBy) return c.json(errNotFound("Recipient"), 404);
|
|
87
|
+
|
|
88
|
+
const baseUrl = c.env.APP_URL;
|
|
89
|
+
const now = new Date().toISOString();
|
|
90
|
+
const messageId = generateId();
|
|
91
|
+
const apId = objectApId(baseUrl, messageId);
|
|
92
|
+
// Resolve to the STORED conversation id of an existing thread (incl. legacy-
|
|
93
|
+
// scheme) so a tool-sent DM joins the same thread the web/API client sees,
|
|
94
|
+
// instead of forking a new current-scheme conversation. Matches dm/messages.ts.
|
|
95
|
+
const conversationId = await resolveConversationId(
|
|
96
|
+
db,
|
|
97
|
+
baseUrl,
|
|
98
|
+
actor.ap_id,
|
|
99
|
+
target.apId,
|
|
100
|
+
);
|
|
101
|
+
const toJson = JSON.stringify([target.apId]);
|
|
102
|
+
const ccJson = JSON.stringify([]);
|
|
103
|
+
const actId = activityApId(baseUrl, generateId());
|
|
104
|
+
|
|
105
|
+
// Co-commit the Note + recipient row + activity + inbox notification in one
|
|
106
|
+
// atomic batch. D1 has no interactive transactions, and the recipient's DM
|
|
107
|
+
// reader resolves membership ONLY via object_recipients — so a partial write
|
|
108
|
+
// (Note without its recipient row) would be permanently invisible to them.
|
|
109
|
+
await (db as unknown as Batchable).batch([
|
|
110
|
+
db.insert(objects).values({
|
|
111
|
+
apId,
|
|
112
|
+
type: "Note",
|
|
113
|
+
attributedTo: actor.ap_id,
|
|
114
|
+
content,
|
|
115
|
+
summary: null,
|
|
116
|
+
attachmentsJson: "[]",
|
|
117
|
+
inReplyTo: null,
|
|
118
|
+
conversation: conversationId,
|
|
119
|
+
visibility: "direct",
|
|
120
|
+
toJson,
|
|
121
|
+
ccJson,
|
|
122
|
+
published: now,
|
|
123
|
+
isLocal: 1,
|
|
124
|
+
}),
|
|
125
|
+
db
|
|
126
|
+
.insert(objectRecipients)
|
|
127
|
+
.values({ objectApId: apId, recipientApId: target.apId, type: "to" })
|
|
128
|
+
.onConflictDoNothing(),
|
|
129
|
+
db.insert(activities).values({
|
|
130
|
+
apId: actId,
|
|
131
|
+
type: "Create",
|
|
132
|
+
actorApId: actor.ap_id,
|
|
133
|
+
objectApId: apId,
|
|
134
|
+
rawJson: JSON.stringify({
|
|
135
|
+
type: "Create",
|
|
136
|
+
actor: actor.ap_id,
|
|
137
|
+
object: apId,
|
|
138
|
+
}),
|
|
139
|
+
direction: "inbound",
|
|
140
|
+
}),
|
|
141
|
+
db.insert(inbox).values({
|
|
142
|
+
actorApId: target.apId,
|
|
143
|
+
activityApId: actId,
|
|
144
|
+
}),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
return c.json(ok({ message_id: apId, conversation_id: conversationId }));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function handleGetDmThreads(
|
|
151
|
+
c: ToolContext,
|
|
152
|
+
input: Input,
|
|
153
|
+
actor: { ap_id: string } | null,
|
|
154
|
+
) {
|
|
155
|
+
if (!actor) return c.json(errAuth(), 401);
|
|
156
|
+
|
|
157
|
+
const db = c.get("db");
|
|
158
|
+
const limit = toolLimit(input.limit, 20, 50);
|
|
159
|
+
|
|
160
|
+
const dms = await db
|
|
161
|
+
.select({
|
|
162
|
+
attributedTo: objects.attributedTo,
|
|
163
|
+
toJson: objects.toJson,
|
|
164
|
+
published: objects.published,
|
|
165
|
+
content: objects.content,
|
|
166
|
+
})
|
|
167
|
+
.from(objects)
|
|
168
|
+
.where(
|
|
169
|
+
and(
|
|
170
|
+
eq(objects.visibility, "direct"),
|
|
171
|
+
eq(objects.type, "Note"),
|
|
172
|
+
isNotNull(objects.conversation),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
.orderBy(desc(objects.published))
|
|
176
|
+
.limit(2000);
|
|
177
|
+
|
|
178
|
+
// Filter to only messages where actor is sender or recipient, then group by partner
|
|
179
|
+
const threads: Record<
|
|
180
|
+
string,
|
|
181
|
+
{ partner: string; lastMessage: string; lastDate: string }
|
|
182
|
+
> = {};
|
|
183
|
+
|
|
184
|
+
for (const dm of dms) {
|
|
185
|
+
const partner = resolveDmPartner(dm, actor.ap_id);
|
|
186
|
+
if (partner && !threads[partner]) {
|
|
187
|
+
threads[partner] = {
|
|
188
|
+
partner,
|
|
189
|
+
lastMessage: dm.content || "",
|
|
190
|
+
lastDate: dm.published,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return c.json(ok({ threads: Object.values(threads).slice(0, limit) }));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function handleGetDmMessages(
|
|
199
|
+
c: ToolContext,
|
|
200
|
+
input: Input,
|
|
201
|
+
actor: { ap_id: string } | null,
|
|
202
|
+
) {
|
|
203
|
+
if (!actor) return c.json(errAuth(), 401);
|
|
204
|
+
|
|
205
|
+
const db = c.get("db");
|
|
206
|
+
const threadId = requireString(input, "thread_id");
|
|
207
|
+
const limit = toolLimit(input.limit, 50, 100);
|
|
208
|
+
if (!threadId) return c.json(errRequired("Thread ID"), 400);
|
|
209
|
+
|
|
210
|
+
const baseUrl = c.env.APP_URL;
|
|
211
|
+
// Read from the STORED conversation id (incl. a legacy-scheme thread) so the
|
|
212
|
+
// agent sees the full history, not just messages under the current-scheme id.
|
|
213
|
+
const conversationId = await resolveConversationId(
|
|
214
|
+
db,
|
|
215
|
+
baseUrl,
|
|
216
|
+
actor.ap_id,
|
|
217
|
+
threadId,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const messages = await db
|
|
221
|
+
.select()
|
|
222
|
+
.from(objects)
|
|
223
|
+
.where(
|
|
224
|
+
and(
|
|
225
|
+
eq(objects.visibility, "direct"),
|
|
226
|
+
eq(objects.type, "Note"),
|
|
227
|
+
eq(objects.conversation, conversationId),
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
.orderBy(desc(objects.published))
|
|
231
|
+
.limit(limit);
|
|
232
|
+
|
|
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
|
+
return c.json(
|
|
239
|
+
ok({
|
|
240
|
+
messages: filtered.map((m) => ({
|
|
241
|
+
ap_id: m.apId,
|
|
242
|
+
content: m.content,
|
|
243
|
+
from: m.attributedTo,
|
|
244
|
+
published: m.published,
|
|
245
|
+
})),
|
|
246
|
+
conversation_id: conversationId,
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Takos Tools - Follow handlers
|
|
3
|
+
*
|
|
4
|
+
* Handles: yurucommu_follow_user, yurucommu_unfollow_user,
|
|
5
|
+
* yurucommu_get_followers, yurucommu_get_following
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { and, eq, gt } from "drizzle-orm";
|
|
9
|
+
import { sql } from "drizzle-orm";
|
|
10
|
+
import { actors, follows } from "../../../db/index.ts";
|
|
11
|
+
import { actorIsBlockedBy } from "../../lib/post-visibility.ts";
|
|
12
|
+
import { isUniqueConstraintError } from "../../lib/parse-helpers.ts";
|
|
13
|
+
import {
|
|
14
|
+
errAuth,
|
|
15
|
+
errNotFound,
|
|
16
|
+
errRequired,
|
|
17
|
+
fetchFollowList,
|
|
18
|
+
formatActorSummary,
|
|
19
|
+
ok,
|
|
20
|
+
requireString,
|
|
21
|
+
toolLimit,
|
|
22
|
+
type ToolResponse,
|
|
23
|
+
} from "../takos-tools-response.ts";
|
|
24
|
+
import type { Input, ToolContext } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
// `.batch` lives only on the concrete D1/libsql subclasses; reach it through a
|
|
27
|
+
// narrow structural cast so the edge + counter updates commit atomically.
|
|
28
|
+
type Batchable = { batch: (stmts: unknown[]) => Promise<unknown> };
|
|
29
|
+
|
|
30
|
+
export async function handleFollowUser(
|
|
31
|
+
c: ToolContext,
|
|
32
|
+
input: Input,
|
|
33
|
+
actor: { ap_id: string } | null,
|
|
34
|
+
) {
|
|
35
|
+
if (!actor) return c.json(errAuth(), 401);
|
|
36
|
+
|
|
37
|
+
const db = c.get("db");
|
|
38
|
+
const username = requireString(input, "username");
|
|
39
|
+
if (!username) return c.json(errRequired("Username"), 400);
|
|
40
|
+
|
|
41
|
+
const target = await db
|
|
42
|
+
.select()
|
|
43
|
+
.from(actors)
|
|
44
|
+
.where(eq(actors.preferredUsername, username))
|
|
45
|
+
.get();
|
|
46
|
+
if (!target) return c.json(errNotFound("User"), 404);
|
|
47
|
+
|
|
48
|
+
if (target.apId === actor.ap_id) {
|
|
49
|
+
return c.json(
|
|
50
|
+
{ success: false, error: "Cannot follow yourself" } as ToolResponse,
|
|
51
|
+
400,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A blocked actor must not (re-)establish a follow edge to the blocker — the
|
|
56
|
+
// block-sever invariant (block removes follows + gates inbound Follow) would be
|
|
57
|
+
// bypassed by this local edge insert. Mirrors the canonical follow route
|
|
58
|
+
// (follow-helpers.ts) and the DM tool's own block guard.
|
|
59
|
+
if (await actorIsBlockedBy(db, target.apId, actor.ap_id)) {
|
|
60
|
+
return c.json(errNotFound("User"), 404);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const existingFollow = await db
|
|
64
|
+
.select()
|
|
65
|
+
.from(follows)
|
|
66
|
+
.where(
|
|
67
|
+
and(
|
|
68
|
+
eq(follows.followerApId, actor.ap_id),
|
|
69
|
+
eq(follows.followingApId, target.apId),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
.get();
|
|
73
|
+
|
|
74
|
+
if (!existingFollow) {
|
|
75
|
+
const status = target.isPrivate ? "pending" : "accepted";
|
|
76
|
+
|
|
77
|
+
// Co-commit the edge + counter bumps in one atomic batch so a mid-write
|
|
78
|
+
// failure can't leave the edge with un-incremented counts (permanent drift).
|
|
79
|
+
const ops: unknown[] = [
|
|
80
|
+
db.insert(follows).values({
|
|
81
|
+
followerApId: actor.ap_id,
|
|
82
|
+
followingApId: target.apId,
|
|
83
|
+
status,
|
|
84
|
+
}),
|
|
85
|
+
];
|
|
86
|
+
if (status === "accepted") {
|
|
87
|
+
ops.push(
|
|
88
|
+
db
|
|
89
|
+
.update(actors)
|
|
90
|
+
.set({ followingCount: sql`${actors.followingCount} + 1` })
|
|
91
|
+
.where(eq(actors.apId, actor.ap_id)),
|
|
92
|
+
db
|
|
93
|
+
.update(actors)
|
|
94
|
+
.set({ followerCount: sql`${actors.followerCount} + 1` })
|
|
95
|
+
.where(eq(actors.apId, target.apId)),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
await (db as unknown as Batchable).batch(ops);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// A concurrent follow won the race past the existing-check: the unique
|
|
102
|
+
// (follower, following) edge now exists and its atomic batch already
|
|
103
|
+
// applied the +1s. Treat as idempotent success instead of a 500.
|
|
104
|
+
if (!isUniqueConstraintError(e)) throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return c.json(
|
|
109
|
+
ok({
|
|
110
|
+
following: true,
|
|
111
|
+
status: target.isPrivate ? "pending" : "accepted",
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function handleUnfollowUser(
|
|
117
|
+
c: ToolContext,
|
|
118
|
+
input: Input,
|
|
119
|
+
actor: { ap_id: string } | null,
|
|
120
|
+
) {
|
|
121
|
+
if (!actor) return c.json(errAuth(), 401);
|
|
122
|
+
|
|
123
|
+
const db = c.get("db");
|
|
124
|
+
const username = requireString(input, "username");
|
|
125
|
+
if (!username) return c.json(errRequired("Username"), 400);
|
|
126
|
+
|
|
127
|
+
const target = await db
|
|
128
|
+
.select()
|
|
129
|
+
.from(actors)
|
|
130
|
+
.where(eq(actors.preferredUsername, username))
|
|
131
|
+
.get();
|
|
132
|
+
if (!target) return c.json(errNotFound("User"), 404);
|
|
133
|
+
|
|
134
|
+
const follow = await db
|
|
135
|
+
.select()
|
|
136
|
+
.from(follows)
|
|
137
|
+
.where(
|
|
138
|
+
and(
|
|
139
|
+
eq(follows.followerApId, actor.ap_id),
|
|
140
|
+
eq(follows.followingApId, target.apId),
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
.get();
|
|
144
|
+
|
|
145
|
+
if (follow) {
|
|
146
|
+
// #COUNTER-SYM: the decrements run BEFORE the delete and are each gated on
|
|
147
|
+
// the accepted edge STILL existing (correlated EXISTS), so two concurrent /
|
|
148
|
+
// retried unfollows can't double-decrement — the second batch's EXISTS is
|
|
149
|
+
// false (the first already deleted the edge) → its -1s match 0 rows. gt(>0)
|
|
150
|
+
// additionally guards underflow. Mirrors the canonical web/federated paths.
|
|
151
|
+
const acceptedEdgeExists = sql`EXISTS (SELECT 1 FROM ${follows} WHERE ${follows.followerApId} = ${actor.ap_id} AND ${follows.followingApId} = ${target.apId} AND ${follows.status} = 'accepted')`;
|
|
152
|
+
const ops: unknown[] = [];
|
|
153
|
+
if (follow.status === "accepted") {
|
|
154
|
+
ops.push(
|
|
155
|
+
db
|
|
156
|
+
.update(actors)
|
|
157
|
+
.set({ followingCount: sql`${actors.followingCount} - 1` })
|
|
158
|
+
.where(
|
|
159
|
+
and(
|
|
160
|
+
eq(actors.apId, actor.ap_id),
|
|
161
|
+
gt(actors.followingCount, 0),
|
|
162
|
+
acceptedEdgeExists,
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
db
|
|
166
|
+
.update(actors)
|
|
167
|
+
.set({ followerCount: sql`${actors.followerCount} - 1` })
|
|
168
|
+
.where(
|
|
169
|
+
and(
|
|
170
|
+
eq(actors.apId, target.apId),
|
|
171
|
+
gt(actors.followerCount, 0),
|
|
172
|
+
acceptedEdgeExists,
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
ops.push(
|
|
178
|
+
db
|
|
179
|
+
.delete(follows)
|
|
180
|
+
.where(
|
|
181
|
+
and(
|
|
182
|
+
eq(follows.followerApId, actor.ap_id),
|
|
183
|
+
eq(follows.followingApId, target.apId),
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
);
|
|
187
|
+
await (db as unknown as Batchable).batch(ops);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return c.json(ok({ unfollowed: true }));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function handleGetFollowList(
|
|
194
|
+
c: ToolContext,
|
|
195
|
+
input: Input,
|
|
196
|
+
actor: { ap_id: string } | null,
|
|
197
|
+
direction: "followers" | "following",
|
|
198
|
+
) {
|
|
199
|
+
const db = c.get("db");
|
|
200
|
+
const username = requireString(input, "username");
|
|
201
|
+
const limit = toolLimit(input.limit, 20, 50);
|
|
202
|
+
|
|
203
|
+
if (!username) return c.json(errRequired("Username"), 400);
|
|
204
|
+
|
|
205
|
+
const target = await db
|
|
206
|
+
.select()
|
|
207
|
+
.from(actors)
|
|
208
|
+
.where(eq(actors.preferredUsername, username))
|
|
209
|
+
.get();
|
|
210
|
+
if (!target) return c.json(errNotFound("User"), 404);
|
|
211
|
+
|
|
212
|
+
// A locked (private) account's follower/following list is withheld from
|
|
213
|
+
// everyone but the account owner — count only — matching the web route
|
|
214
|
+
// listFollowRelation. Without this the MCP surface would be a graph-
|
|
215
|
+
// enumeration bypass of the locked-account gate.
|
|
216
|
+
if (target.isPrivate && actor?.ap_id !== target.apId) {
|
|
217
|
+
const count =
|
|
218
|
+
direction === "followers" ? target.followerCount : target.followingCount;
|
|
219
|
+
return c.json(ok({ [direction]: [], count }));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const actorList = await fetchFollowList(db, target.apId, direction, limit);
|
|
223
|
+
|
|
224
|
+
return c.json(ok({ [direction]: actorList.map(formatActorSummary) }));
|
|
225
|
+
}
|