@takosjp/yurucommu-core 3.0.2 → 3.0.3
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/migrations/README.md +0 -11
- package/package.json +1 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/stories.ts +16 -1
- package/packages/api/src/types/index.ts +12 -1
- package/src/backend/routes/stories/interactions.ts +65 -1
- package/src/backend/routes/stories/query-helpers.ts +48 -1
- package/src/backend/routes/stories/routes.ts +2 -30
package/migrations/README.md
CHANGED
|
@@ -83,17 +83,6 @@ file name (not the numeric version) in `yurucommu_migrations`.
|
|
|
83
83
|
credentials come from the same reviewed Provider Connection used by the
|
|
84
84
|
OpenTofu run.
|
|
85
85
|
|
|
86
|
-
In the fallback path where the Worker script is not managed by OpenTofu,
|
|
87
|
-
`takosumi_release.post_apply` runs `bun run takosumi:release` as an opaque
|
|
88
|
-
operator release command. It reads non-secret outputs from `TAKOSUMI_OUTPUTS_JSON`,
|
|
89
|
-
writes a temporary Wrangler config, runs `bun install --frozen-lockfile`,
|
|
90
|
-
runs `bun run build:takos-worker`, applies D1 migrations through
|
|
91
|
-
`wrangler d1 execute` without explicit SQL transaction wrappers, and deploys
|
|
92
|
-
with `wrangler deploy`.
|
|
93
|
-
Provider credentials and app-specific secrets come from the selected
|
|
94
|
-
operator release activator boundary through
|
|
95
|
-
`TAKOSUMI_RELEASE_COMMAND_ENV_ALLOWLIST`.
|
|
96
|
-
|
|
97
86
|
Common operator env names:
|
|
98
87
|
|
|
99
88
|
```bash
|
package/package.json
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ActorStories,
|
|
3
|
+
Story,
|
|
4
|
+
StoryOverlay,
|
|
5
|
+
StoryViewersResponse,
|
|
6
|
+
} from "../../types/index.ts";
|
|
2
7
|
import { normalizeActorStories, normalizeStory } from "./normalize.ts";
|
|
3
8
|
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
4
9
|
|
|
@@ -78,3 +83,13 @@ export async function shareStory(
|
|
|
78
83
|
await assertOk(res, "Failed to share story");
|
|
79
84
|
return (await res.json()) as { shared: boolean; share_count: number };
|
|
80
85
|
}
|
|
86
|
+
|
|
87
|
+
// Author-only "seen by" list for a story. GETs the same id-encoded path the
|
|
88
|
+
// sibling like/share routes use.
|
|
89
|
+
export async function getStoryViewers(
|
|
90
|
+
apId: string,
|
|
91
|
+
): Promise<StoryViewersResponse> {
|
|
92
|
+
const res = await apiFetch(`/api/stories/${encodeURIComponent(apId)}/views`);
|
|
93
|
+
await assertOk(res, "Failed to fetch story viewers");
|
|
94
|
+
return (await res.json()) as StoryViewersResponse;
|
|
95
|
+
}
|
|
@@ -150,7 +150,6 @@ export interface StoryOverlay {
|
|
|
150
150
|
// Question-specific
|
|
151
151
|
name?: string; // Question text
|
|
152
152
|
oneOf?: Array<{ type: string; name: string }>; // Options
|
|
153
|
-
closed?: string; // Close time
|
|
154
153
|
// Link-specific
|
|
155
154
|
href?: string;
|
|
156
155
|
// Generic
|
|
@@ -184,6 +183,18 @@ export interface ActorStories {
|
|
|
184
183
|
has_unviewed: boolean;
|
|
185
184
|
}
|
|
186
185
|
|
|
186
|
+
// A single viewer in a story's "seen by" list (author-only).
|
|
187
|
+
export interface StoryViewer {
|
|
188
|
+
actor: PostAuthor;
|
|
189
|
+
viewed_at: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Response of GET /api/stories/:id/views (author-only "seen by").
|
|
193
|
+
export interface StoryViewersResponse {
|
|
194
|
+
view_count: number;
|
|
195
|
+
viewers: StoryViewer[];
|
|
196
|
+
}
|
|
197
|
+
|
|
187
198
|
// Short-lived actor status note. This is the Instagram-Notes-style current
|
|
188
199
|
// status surface, not the ActivityPub `Note` object used for normal posts.
|
|
189
200
|
export interface ActorNote {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import { and, eq, gt, sql } from "drizzle-orm";
|
|
2
|
+
import { and, count, desc, eq, gt, sql } from "drizzle-orm";
|
|
3
3
|
import type { Env, Variables } from "../../types.ts";
|
|
4
4
|
import {
|
|
5
5
|
activities,
|
|
@@ -18,12 +18,14 @@ import {
|
|
|
18
18
|
safeJsonParse,
|
|
19
19
|
} from "../../federation-helpers.ts";
|
|
20
20
|
import {
|
|
21
|
+
buildAuthor,
|
|
21
22
|
canViewerReadStory,
|
|
22
23
|
findStory,
|
|
23
24
|
getVoteCounts,
|
|
24
25
|
resolveStoryApId,
|
|
25
26
|
sumVotes,
|
|
26
27
|
} from "./query-helpers.ts";
|
|
28
|
+
import { loadActorInfoMap } from "../actors-helpers.ts";
|
|
27
29
|
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
28
30
|
import { actorIsBlockedBy } from "../../lib/post-visibility.ts";
|
|
29
31
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
@@ -573,4 +575,66 @@ stories.get("/:id/votes", async (c) => {
|
|
|
573
575
|
return c.json({ votes, total: sumVotes(votes), user_vote });
|
|
574
576
|
});
|
|
575
577
|
|
|
578
|
+
// Cap the returned viewer list most-recent-first. `view_count` stays the true
|
|
579
|
+
// total (the author sees the real number even when the list is truncated).
|
|
580
|
+
const STORY_VIEWERS_LIMIT = 200;
|
|
581
|
+
|
|
582
|
+
// Get the "seen by" viewer list for a story (author-only).
|
|
583
|
+
stories.get("/:id/views", async (c) => {
|
|
584
|
+
const db = c.get("db");
|
|
585
|
+
const actor = c.get("actor");
|
|
586
|
+
const baseUrl = c.env.APP_URL;
|
|
587
|
+
// Resolve the id the same way every sibling /:id/* route does.
|
|
588
|
+
const apId = resolveStoryApId(c.req.param("id"), baseUrl);
|
|
589
|
+
|
|
590
|
+
const story = await findStory(db, apId);
|
|
591
|
+
if (!story) return c.json({ error: "Story not found" }, 404);
|
|
592
|
+
|
|
593
|
+
// Author-only: only the story author may see WHO viewed. Anyone else
|
|
594
|
+
// (including anonymous) gets 404 so the viewer list isn't disclosed and the
|
|
595
|
+
// endpoint isn't a story-existence oracle for non-authors.
|
|
596
|
+
if (!actor || actor.ap_id !== story.attributedTo) {
|
|
597
|
+
return c.json({ error: "Story not found" }, 404);
|
|
598
|
+
}
|
|
599
|
+
// Mirror the /:id/votes expiry gate: don't serve the list for an expired
|
|
600
|
+
// story before the reaper runs.
|
|
601
|
+
if (story.endTime && story.endTime < new Date().toISOString()) {
|
|
602
|
+
return c.json({ error: "Story has expired" }, 410);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// True total (uncapped) — stays accurate even when the list below is capped.
|
|
606
|
+
const totalRow = await db
|
|
607
|
+
.select({ value: count() })
|
|
608
|
+
.from(storyViews)
|
|
609
|
+
.where(eq(storyViews.storyApId, apId))
|
|
610
|
+
.get();
|
|
611
|
+
const view_count = totalRow?.value ?? 0;
|
|
612
|
+
|
|
613
|
+
// Most-recent-first page of viewers, capped.
|
|
614
|
+
const rows = await db
|
|
615
|
+
.select({
|
|
616
|
+
actorApId: storyViews.actorApId,
|
|
617
|
+
viewedAt: storyViews.viewedAt,
|
|
618
|
+
})
|
|
619
|
+
.from(storyViews)
|
|
620
|
+
.where(eq(storyViews.storyApId, apId))
|
|
621
|
+
.orderBy(desc(storyViews.viewedAt))
|
|
622
|
+
.limit(STORY_VIEWERS_LIMIT);
|
|
623
|
+
|
|
624
|
+
// Hydrate ap_id → PostAuthor via the same batch loader the follower/story
|
|
625
|
+
// lists use (local `actors` + `actor_cache`, local wins). A remote viewer
|
|
626
|
+
// absent from both degrades to a best-effort author in `buildAuthor`.
|
|
627
|
+
const infoMap = await loadActorInfoMap(
|
|
628
|
+
db,
|
|
629
|
+
rows.map((r) => r.actorApId),
|
|
630
|
+
"author",
|
|
631
|
+
);
|
|
632
|
+
const viewers = rows.map((r) => ({
|
|
633
|
+
actor: buildAuthor(r.actorApId, infoMap.get(r.actorApId)),
|
|
634
|
+
viewed_at: r.viewedAt,
|
|
635
|
+
}));
|
|
636
|
+
|
|
637
|
+
return c.json({ view_count, viewers });
|
|
638
|
+
});
|
|
639
|
+
|
|
576
640
|
export default stories;
|
|
@@ -11,7 +11,11 @@ import {
|
|
|
11
11
|
storyVotes,
|
|
12
12
|
} from "../../../db/index.ts";
|
|
13
13
|
import type { IObjectStorage } from "../../runtime/types.ts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
formatUsername,
|
|
16
|
+
objectApId,
|
|
17
|
+
safeJsonParse,
|
|
18
|
+
} from "../../federation-helpers.ts";
|
|
15
19
|
import {
|
|
16
20
|
deleteObjectCascade,
|
|
17
21
|
purgeMediaBlobs,
|
|
@@ -271,6 +275,49 @@ export async function fetchActorCache(
|
|
|
271
275
|
);
|
|
272
276
|
}
|
|
273
277
|
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Author projection
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Public author projection for a story author / viewer (the `PostAuthor` shape
|
|
284
|
+
* used across the feed / story surfaces). Shared by the story feed and the
|
|
285
|
+
* viewer ("seen by") list so both hydrate an ap_id → author identically.
|
|
286
|
+
*/
|
|
287
|
+
export type StoryAuthor = {
|
|
288
|
+
ap_id: string;
|
|
289
|
+
username: string;
|
|
290
|
+
preferred_username: string | null;
|
|
291
|
+
name: string | null;
|
|
292
|
+
icon_url: string | null;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Build a StoryAuthor from available data sources. A remote actor with no local
|
|
297
|
+
* `actors` / `actor_cache` row (`data` undefined) degrades gracefully to a
|
|
298
|
+
* best-effort `username` derived from the ap_id plus null fields, so a viewer is
|
|
299
|
+
* never dropped just because its profile isn't cached locally.
|
|
300
|
+
*/
|
|
301
|
+
export function buildAuthor(
|
|
302
|
+
apId: string,
|
|
303
|
+
data:
|
|
304
|
+
| {
|
|
305
|
+
preferredUsername?: string | null;
|
|
306
|
+
name?: string | null;
|
|
307
|
+
iconUrl?: string | null;
|
|
308
|
+
}
|
|
309
|
+
| null
|
|
310
|
+
| undefined,
|
|
311
|
+
): StoryAuthor {
|
|
312
|
+
return {
|
|
313
|
+
ap_id: apId,
|
|
314
|
+
username: formatUsername(apId),
|
|
315
|
+
preferred_username: data?.preferredUsername || null,
|
|
316
|
+
name: data?.name || null,
|
|
317
|
+
icon_url: data?.iconUrl || null,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
274
321
|
// ---------------------------------------------------------------------------
|
|
275
322
|
// Story data cleanup & transformation
|
|
276
323
|
// ---------------------------------------------------------------------------
|
|
@@ -21,7 +21,6 @@ import type { IObjectStorage } from "../../runtime/types.ts";
|
|
|
21
21
|
import {
|
|
22
22
|
activityApId,
|
|
23
23
|
actorApId,
|
|
24
|
-
formatUsername,
|
|
25
24
|
generateId,
|
|
26
25
|
objectApId,
|
|
27
26
|
} from "../../federation-helpers.ts";
|
|
@@ -31,10 +30,12 @@ import { maybeReapDrainedTombstones } from "../actors.ts";
|
|
|
31
30
|
import { checkCommunityPostPermission } from "../posts/post-helpers.ts";
|
|
32
31
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
33
32
|
import {
|
|
33
|
+
buildAuthor,
|
|
34
34
|
cleanupExpiredStories,
|
|
35
35
|
fetchActorCache,
|
|
36
36
|
fetchBatchVotes,
|
|
37
37
|
fetchBlockedAndMutedIds,
|
|
38
|
+
type StoryAuthor,
|
|
38
39
|
sumVotes,
|
|
39
40
|
transformStoryData,
|
|
40
41
|
validateOverlays,
|
|
@@ -101,14 +102,6 @@ stories.post("/delete", storyWriteLimiter);
|
|
|
101
102
|
|
|
102
103
|
type VoteResults = Record<number, number>;
|
|
103
104
|
|
|
104
|
-
type StoryAuthor = {
|
|
105
|
-
ap_id: string;
|
|
106
|
-
username: string;
|
|
107
|
-
preferred_username: string | null;
|
|
108
|
-
name: string | null;
|
|
109
|
-
icon_url: string | null;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
105
|
type StoryResponse = {
|
|
113
106
|
ap_id: string;
|
|
114
107
|
author: StoryAuthor;
|
|
@@ -160,27 +153,6 @@ const MAX_STORY_CAPTION_LENGTH = 500;
|
|
|
160
153
|
// feed page; a busy instance simply shows the 90 most recent.
|
|
161
154
|
const MAX_STORY_FEED_ITEMS = 90;
|
|
162
155
|
|
|
163
|
-
/** Build a StoryAuthor from available data sources. */
|
|
164
|
-
function buildAuthor(
|
|
165
|
-
apId: string,
|
|
166
|
-
data:
|
|
167
|
-
| {
|
|
168
|
-
preferredUsername?: string | null;
|
|
169
|
-
name?: string | null;
|
|
170
|
-
iconUrl?: string | null;
|
|
171
|
-
}
|
|
172
|
-
| null
|
|
173
|
-
| undefined,
|
|
174
|
-
): StoryAuthor {
|
|
175
|
-
return {
|
|
176
|
-
ap_id: apId,
|
|
177
|
-
username: formatUsername(apId),
|
|
178
|
-
preferred_username: data?.preferredUsername || null,
|
|
179
|
-
name: data?.name || null,
|
|
180
|
-
icon_url: data?.iconUrl || null,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
|
|
184
156
|
/** Build a StoryResponse from a story object row and pre-fetched data. */
|
|
185
157
|
function buildStoryResponse(
|
|
186
158
|
s: {
|