Public: anyone can read this; the crew can change it.

The crew's next prompt comes from Instagram, once a day #330

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

instagram-prompts → main 6 commits

  • Merged. instagram-prompts was merged into main as 8528a01.

Review

Sign in to review this change.
15 files changed +2449 −1
docs/APPS.md +68
@@ -713,6 +713,74 @@ is resting and why, with a button to use it again sooner. Nothing granted
713713 anywhere means the forge's own token, as before. Every run remembers the
714714 account it spent.
715715
716 ### Prompts from Instagram
717
718 A repository's next tell can come from its audience. Under Settings →
719 Prompts an admin connects the Instagram account of the app — a game with
720 an account of its own, say — and once a day the forge reads the account's
721 latest Reel, takes its most-liked top-level comment, and tells the crew
722 exactly as a person would from the studio box: an issue is opened and
723 routed to a builder. The comment is the tell; the forge invents nothing.
724
725 What happens at the hour, every day, in UTC:
726
727 1. The token is refreshed when it is more than fifty days old (below).
728 2. The account's username is read once, the first time, and shown.
729 3. The latest Reel is found — the newest item Instagram marks `REELS`, or
730 the newest video where it marks nothing.
731 4. Its top-level comments are read, at most four pages of fifty. The top
732 comment is the one with the most likes; a tie goes to the earliest. A
733 comment with no text does not count.
734 5. The crew is told, and the Reel is remembered as told.
735
736 The tell has one shape. Its first line is the issue's title, as with any
737 tell, and the rest is its body:
738
739 ```
740 <the comment, as written>
741
742 From Instagram: the top comment (<n> likes) by @<username> on the Reel of <YYYY-MM-DD>. <permalink>
743 ```
744
745 Nothing is told twice, and nothing is told early. A Reel already told is
746 skipped ("No new Reel since <date>; the last one was told as #<n>."), a
747 Reel with no comments yet is left for tomorrow and _not_ remembered as told
748 ("The latest Reel has no comments yet."), and an account with no Reel says
749 so ("No Reel yet."). Every run leaves its sentence on the settings page, and
750 **Fetch now** runs the same routine at once, whatever the hour. A run that
751 meets an error ends in a sentence too, never a throw: Instagram's own words
752 in one line, or — for a token Instagram no longer accepts — "connect the
753 account again". A repository whose routine ran today does not run again
754 until tomorrow, and one whose forge was down at the hour catches up on the
755 next tick.
756
757 The token is a long-lived Instagram user token, which lives sixty days. It
758 is sealed under the forge's key, bound to its row, opened only for the run,
759 and never listed or shown again; the API's listing carries the account, the
760 hour, and what the last run said, and nothing else. When it is more than
761 fifty days old the forge refreshes it — Instagram allows a refresh once a
762 token is a day old — stores the new one sealed, and notes the time, so a
763 token pasted once stays good for as long as the account does. A token the
764 forge cannot open (a data directory restored under another key) asks to be
765 connected again, like everything else about a lost key.
766
767 The one-time steps at Meta, none of which needs App Review for an account
768 of your own:
769
770 1. Make the Instagram account a professional one (Business or Creator).
771 2. At developers.facebook.com, make a Meta app and add the **Instagram**
772 product with Business Login for Instagram. No Facebook Page is needed.
773 3. Get a token for the account with the scopes `instagram_business_basic`
774 and `instagram_business_manage_comments`.
775 4. Exchange it for a long-lived token, the one that lives sixty days.
776 5. Paste it under Settings → Prompts, choose the hour, and press Add — or
777 `POST /api/v1/repos/:owner/:name/prompts` with
778 `{ "kind": "instagram", "token": "…", "at": "15:00" }`.
779
780 `prompt.told` on the outbox names the source, the Reel, the comment and the
781 issue; it is for the repository's admins, like everything else about its
782 settings.
783
716784 ## Configuration
717785
718786 | variable | default | meaning |
src/actions/prompts.ts added +152
@@ -0,0 +1,152 @@
1 /**
2 * Prompt-source actions: connect the Instagram account whose Reels prompt
3 * the crew, look at it now, and disconnect it. Admin over the repository,
4 * like every other setting that spends the crew's time. The daily run
5 * itself is the routine's (`crew/prompts.ts`); `Fetch now` borrows it.
6 */
7
8 import type { App } from "../app.ts";
9 import {
10 addPromptSource as insertSource,
11 getPromptSource,
12 listPromptSources as querySources,
13 removePromptSource as deleteSource,
14 type AddPromptError,
15 type PromptKind,
16 type PromptSource,
17 } from "../core/prompts.ts";
18 import type { Repo } from "../core/repos.ts";
19 import type { User } from "../core/users.ts";
20 import { promptsFor } from "../crew/prompts.ts";
21 import { err, ok, type Result } from "../kernel/result.ts";
22 import { requireRepoAdmin } from "./access.ts";
23
24 /** A source as a page or the API shows it: everything but the token. */
25 export interface PromptSourceView {
26 readonly id: string;
27 readonly kind: PromptKind;
28 /** The account's username, or empty until the first run has asked. */
29 readonly account: string;
30 readonly atUtc: string;
31 readonly lastRanAt: string | null;
32 readonly lastSaid: string;
33 readonly lastIssueNumber: number | null;
34 readonly tokenRefreshedAt: string;
35 }
36
37 export const viewOf = (source: PromptSource): PromptSourceView => ({
38 id: source.id,
39 kind: source.kind,
40 account: source.account,
41 atUtc: source.atUtc,
42 lastRanAt: source.lastRanAt,
43 lastSaid: source.lastSaid,
44 lastIssueNumber: source.lastIssueNumber,
45 tokenRefreshedAt: source.tokenRefreshedAt,
46 });
47
48 export type PromptError = "not_found" | "no_such_source";
49
50 /** One of this repository's sources, by id — never another's. */
51 function ownedSource(
52 app: App,
53 repo: Repo,
54 sourceId: string,
55 ): PromptSource | undefined {
56 const source = getPromptSource(app.db, sourceId);
57 return source?.repoId === repo.id ? source : undefined;
58 }
59
60 export function listPromptSources(
61 app: App,
62 actor: User | undefined,
63 repo: Repo,
64 ): Result<PromptSourceView[], "not_found"> {
65 const admin = requireRepoAdmin(app, actor, repo);
66 if (!admin.ok) return admin;
67 return ok(querySources(app.db, repo.id).map(viewOf));
68 }
69
70 export interface AddPromptInput {
71 readonly kind: string;
72 readonly token: string;
73 readonly at: string;
74 }
75
76 /**
77 * Connect an account. One of each kind per repository: a second Instagram
78 * account would mean two tells a day from two places, and the page shows
79 * one. The token is sealed on the way in and not shown again.
80 */
81 export function addPromptSource(
82 app: App,
83 actor: User | undefined,
84 repo: Repo,
85 input: AddPromptInput,
86 ): Result<
87 PromptSourceView,
88 "not_found" | "invalid_kind" | "already_connected" | AddPromptError
89 > {
90 const admin = requireRepoAdmin(app, actor, repo);
91 if (!admin.ok) return admin;
92 if (input.kind !== "instagram") return err("invalid_kind");
93 if (querySources(app.db, repo.id).some((s) => s.kind === input.kind))
94 return err("already_connected");
95 const added = insertSource(app.db, app.config.dataDir, {
96 repoId: repo.id,
97 kind: input.kind,
98 token: input.token,
99 atUtc: input.at,
100 createdBy: admin.value.id,
101 });
102 if (!added.ok) return added;
103 app.bus.append({
104 kind: "prompt.source_added",
105 repoId: repo.id,
106 actorId: admin.value.id,
107 payload: {
108 source: added.value.id,
109 kind: added.value.kind,
110 at: added.value.atUtc,
111 },
112 });
113 return ok(viewOf(added.value));
114 }
115
116 /** Look at Instagram now, whatever the hour; the source as the run left it. */
117 export async function runPromptSource(
118 app: App,
119 actor: User | undefined,
120 repo: Repo,
121 sourceId: string,
122 ): Promise<Result<PromptSourceView, PromptError | "not_running">> {
123 const admin = requireRepoAdmin(app, actor, repo);
124 if (!admin.ok) return admin;
125 if (ownedSource(app, repo, sourceId) === undefined)
126 return err("no_such_source");
127 const routine = promptsFor(app);
128 if (routine === undefined) return err("not_running");
129 const ran = await routine.runNow(sourceId);
130 return ran === undefined ? err("no_such_source") : ok(viewOf(ran));
131 }
132
133 /** Disconnect. The token is forgotten; the issues already told stay. */
134 export function removePromptSource(
135 app: App,
136 actor: User | undefined,
137 repo: Repo,
138 sourceId: string,
139 ): Result<void, PromptError> {
140 const admin = requireRepoAdmin(app, actor, repo);
141 if (!admin.ok) return admin;
142 const source = ownedSource(app, repo, sourceId);
143 if (source === undefined || !deleteSource(app.db, source.id))
144 return err("no_such_source");
145 app.bus.append({
146 kind: "prompt.source_removed",
147 repoId: repo.id,
148 actorId: admin.value.id,
149 payload: { source: source.id, kind: source.kind, account: source.account },
150 });
151 return ok();
152 }
src/core/event_audience.ts +1
@@ -26,6 +26,7 @@ export function eventAudience(kind: string): EventAudience {
2626 kind.startsWith("webhook.") ||
2727 kind.startsWith("app.env_") ||
2828 kind.startsWith("app.domain.") ||
29 kind.startsWith("prompt.") ||
2930 kind === "repo.settings_updated"
3031 )
3132 return "admins";
src/core/prompts.ts added +271
@@ -0,0 +1,271 @@
1 /**
2 * Prompt sources: where a repository's next tell comes from when nobody is
3 * typing. One kind so far — an Instagram account whose latest Reel's top
4 * comment becomes the crew's next tell, once a day (docs/APPS.md, "Prompts
5 * from Instagram").
6 *
7 * The token is sealed at rest under the forge's key, bound to the row it
8 * belongs to, and opened only for the run; nothing here ever returns it in
9 * a listing. Pure data: no Instagram, no authz, no events — the routine in
10 * `crew/prompts.ts` does the asking, and `actions/prompts.ts` the deciding.
11 */
12
13 import type { Database } from "bun:sqlite";
14 import { id } from "../kernel/id.ts";
15 import { err, ok, type Result } from "../kernel/result.ts";
16 import { open, seal, type OpenError } from "../kernel/secret.ts";
17
18 export type PromptKind = "instagram";
19
20 export interface PromptSource {
21 readonly id: string;
22 readonly repoId: string;
23 readonly kind: PromptKind;
24 /** The account's username, learnt on the first run; empty until then. */
25 readonly account: string;
26 /** When the routine runs each day, `HH:MM` in UTC. */
27 readonly atUtc: string;
28 /** When the sealed token was pasted or last refreshed. */
29 readonly tokenRefreshedAt: string;
30 readonly createdBy: string | null;
31 readonly createdAt: string;
32 readonly lastRanAt: string | null;
33 /** What the last run said, one sentence for a person. */
34 readonly lastSaid: string;
35 /** The Reel already told about, so the same one is never told twice. */
36 readonly lastMediaId: string | null;
37 readonly lastIssueNumber: number | null;
38 }
39
40 interface Row {
41 id: string;
42 repo_id: string;
43 kind: string;
44 account: string;
45 at_utc: string;
46 token_refreshed_at: string;
47 created_by: string | null;
48 created_at: string;
49 last_ran_at: string | null;
50 last_said: string;
51 last_media_id: string | null;
52 last_issue_number: number | null;
53 }
54
55 /** Every column but the sealed token: what a listing may carry. */
56 const COLUMNS = `id, repo_id, kind, account, at_utc, token_refreshed_at, created_by,
57 created_at, last_ran_at, last_said, last_media_id, last_issue_number`;
58
59 const toSource = (r: Row): PromptSource => ({
60 id: r.id,
61 repoId: r.repo_id,
62 kind: "instagram",
63 account: r.account,
64 atUtc: r.at_utc,
65 tokenRefreshedAt: r.token_refreshed_at,
66 createdBy: r.created_by,
67 createdAt: r.created_at,
68 lastRanAt: r.last_ran_at,
69 lastSaid: r.last_said,
70 lastMediaId: r.last_media_id,
71 lastIssueNumber: r.last_issue_number,
72 });
73
74 const aad = (sourceId: string): string => `prompt-source:${sourceId}`;
75
76 const TOKEN_MAX = 1024;
77
78 export type AddPromptError = "invalid_at" | "invalid_token";
79
80 /** `HH:MM`, twenty-four hours, both parts zero-padded. */
81 export function isDailyTime(at: string): boolean {
82 return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(at);
83 }
84
85 /**
86 * A token as Instagram hands one out: printable, no spaces, not absurdly
87 * long. Its shape is otherwise Instagram's business; the first run finds
88 * out whether it is accepted, and says so.
89 */
90 export function isPromptToken(token: string): boolean {
91 return (
92 token.length > 0 &&
93 token.length <= TOKEN_MAX &&
94 /^[\x21-\x7e]+$/.test(token)
95 );
96 }
97
98 export function addPromptSource(
99 db: Database,
100 dataDir: string,
101 input: {
102 repoId: string;
103 kind: PromptKind;
104 token: string;
105 atUtc: string;
106 createdBy?: string | undefined;
107 now?: Date | undefined;
108 },
109 ): Result<PromptSource, AddPromptError> {
110 const atUtc = input.atUtc.trim();
111 if (!isDailyTime(atUtc)) return err("invalid_at");
112 const token = input.token.trim();
113 if (!isPromptToken(token)) return err("invalid_token");
114 const sourceId = id();
115 const at = (input.now ?? new Date()).toISOString();
116 db.query(
117 `INSERT INTO prompt_source (id, repo_id, kind, token_enc, token_refreshed_at, at_utc, created_by, created_at)
118 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?5)`,
119 ).run(
120 sourceId,
121 input.repoId,
122 input.kind,
123 seal(dataDir, token, aad(sourceId)),
124 at,
125 atUtc,
126 input.createdBy ?? null,
127 );
128 return ok(getPromptSource(db, sourceId)!);
129 }
130
131 export function getPromptSource(
132 db: Database,
133 sourceId: string,
134 ): PromptSource | undefined {
135 const row = db
136 .query<Row, [string]>(`SELECT ${COLUMNS} FROM prompt_source WHERE id = ?1`)
137 .get(sourceId);
138 return row ? toSource(row) : undefined;
139 }
140
141 /** A repository's sources, oldest first. */
142 export function listPromptSources(
143 db: Database,
144 repoId: string,
145 ): PromptSource[] {
146 return db
147 .query<
148 Row,
149 [string]
150 >(`SELECT ${COLUMNS} FROM prompt_source WHERE repo_id = ?1 ORDER BY created_at, id`)
151 .all(repoId)
152 .map(toSource);
153 }
154
155 /**
156 * The sources whose time has come today: `at_utc` at or before the clock's
157 * `HH:MM`, and no run yet on the clock's date. A forge that was down at the
158 * hour catches up on its next tick; one that ran already waits for tomorrow.
159 */
160 export function duePromptSources(db: Database, now: Date): PromptSource[] {
161 const iso = now.toISOString();
162 return db
163 .query<Row, [string, string]>(
164 `SELECT ${COLUMNS} FROM prompt_source
165 WHERE at_utc <= ?1 AND (last_ran_at IS NULL OR substr(last_ran_at, 1, 10) <> ?2)
166 ORDER BY at_utc, created_at, id`,
167 )
168 .all(iso.slice(11, 16), iso.slice(0, 10))
169 .map(toSource);
170 }
171
172 /**
173 * Claim a source for a run, atomically: the row's `last_ran_at` moves to now
174 * only if it has not run today — or unconditionally for a person's "Fetch
175 * now". False means another run has it; the caller does nothing more.
176 */
177 export function claimPromptRun(
178 db: Database,
179 sourceId: string,
180 now: Date,
181 o: { force?: boolean | undefined } = {},
182 ): boolean {
183 const iso = now.toISOString();
184 const result = db
185 .query(
186 `UPDATE prompt_source SET last_ran_at = ?2
187 WHERE id = ?1
188 AND (?3 = 1 OR last_ran_at IS NULL OR substr(last_ran_at, 1, 10) <> substr(?2, 1, 10))`,
189 )
190 .run(sourceId, iso, o.force ? 1 : 0);
191 return result.changes === 1;
192 }
193
194 /** The token, for a run. A value that will not open says so, never a throw. */
195 export function openPromptToken(
196 db: Database,
197 dataDir: string,
198 sourceId: string,
199 ): Result<string, "not_found" | OpenError> {
200 const row = db
201 .query<
202 { token_enc: string },
203 [string]
204 >("SELECT token_enc FROM prompt_source WHERE id = ?1")
205 .get(sourceId);
206 if (!row) return err("not_found");
207 return open(dataDir, row.token_enc, aad(sourceId));
208 }
209
210 /** After a refresh: the new token sealed, and the moment noted. */
211 export function storePromptToken(
212 db: Database,
213 dataDir: string,
214 sourceId: string,
215 token: string,
216 now: Date,
217 ): void {
218 db.query(
219 "UPDATE prompt_source SET token_enc = ?2, token_refreshed_at = ?3 WHERE id = ?1",
220 ).run(sourceId, seal(dataDir, token, aad(sourceId)), now.toISOString());
221 }
222
223 export function setPromptAccount(
224 db: Database,
225 sourceId: string,
226 username: string,
227 ): void {
228 db.query("UPDATE prompt_source SET account = ?2 WHERE id = ?1").run(
229 sourceId,
230 username,
231 );
232 }
233
234 /**
235 * What a run came to. The media id and the issue number are written only
236 * when a Reel was actually told — a Reel with no comments yet leaves them
237 * alone, so tomorrow's run looks at the same Reel again.
238 */
239 export function recordPromptRun(
240 db: Database,
241 sourceId: string,
242 run: {
243 said: string;
244 mediaId?: string | undefined;
245 issueNumber?: number | undefined;
246 now: Date;
247 },
248 ): PromptSource | undefined {
249 db.query(
250 `UPDATE prompt_source
251 SET last_ran_at = ?2,
252 last_said = ?3,
253 last_media_id = COALESCE(?4, last_media_id),
254 last_issue_number = COALESCE(?5, last_issue_number)
255 WHERE id = ?1`,
256 ).run(
257 sourceId,
258 run.now.toISOString(),
259 run.said.slice(0, 400),
260 run.mediaId ?? null,
261 run.issueNumber ?? null,
262 );
263 return getPromptSource(db, sourceId);
264 }
265
266 export function removePromptSource(db: Database, sourceId: string): boolean {
267 return (
268 db.query("DELETE FROM prompt_source WHERE id = ?1").run(sourceId).changes >
269 0
270 );
271 }
src/core/schema.ts +24
@@ -898,4 +898,28 @@ export const migrations: readonly string[] = [
898898 );
899899 CREATE INDEX app_domain_app ON app_domain(app_id);
900900 `,
901
902 // 33 — where a repository's next tell comes from when nobody is typing:
903 // an Instagram account whose latest Reel's top comment becomes the crew's
904 // next tell, once a day at `at_utc`. The token is sealed under the forge's
905 // key, bound to the row; `last_media_id` is the Reel already told, so the
906 // same one is never told twice; `last_said` is the last run's sentence.
907 `
908 CREATE TABLE prompt_source (
909 id TEXT PRIMARY KEY,
910 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
911 kind TEXT NOT NULL,
912 account TEXT NOT NULL DEFAULT '',
913 token_enc TEXT NOT NULL,
914 token_refreshed_at TEXT NOT NULL,
915 at_utc TEXT NOT NULL,
916 created_by TEXT REFERENCES user(id) ON DELETE SET NULL,
917 created_at TEXT NOT NULL,
918 last_ran_at TEXT,
919 last_said TEXT NOT NULL DEFAULT '',
920 last_media_id TEXT,
921 last_issue_number INTEGER
922 );
923 CREATE INDEX prompt_source_repo ON prompt_source(repo_id);
924 `,
901925 ];
src/crew/instagram.ts added +244
@@ -0,0 +1,244 @@
1 /**
2 * Instagram, as the prompt routine sees it: who the token belongs to, the
3 * newest Reel, its top-level comments, and a fresh token. The Instagram API
4 * with Instagram Login (Meta's "Business Login for Instagram"): a
5 * professional account, a long-lived user token, no Facebook Page.
6 *
7 * The interface is what the routine is written against; `graphInstagram`
8 * is the one that talks to Meta, and a test hands the routine a fake. The
9 * token travels only in the query string of a request to Meta's host, as
10 * the API asks, and is never logged or put in a sentence.
11 */
12
13 import { redactSecrets } from "../kernel/log.ts";
14
15 /** A Reel, or the newest video when the API does not say which are Reels. */
16 export interface Reel {
17 readonly id: string;
18 readonly permalink: string;
19 /** As Instagram gives it, ISO 8601. */
20 readonly timestamp: string;
21 readonly caption: string;
22 readonly commentsCount: number;
23 }
24
25 /** A top-level comment. */
26 export interface Comment {
27 readonly id: string;
28 readonly text: string;
29 readonly username: string;
30 readonly timestamp: string;
31 readonly likeCount: number;
32 }
33
34 export interface InstagramClient {
35 me(token: string): Promise<{ id: string; username: string }>;
36 latestReel(token: string): Promise<Reel | undefined>;
37 comments(token: string, mediaId: string): Promise<Comment[]>;
38 refresh(token: string): Promise<{ token: string }>;
39 }
40
41 /**
42 * What Meta answered with instead of data. `code` is Meta's: 190 is a
43 * token that is invalid or has expired, the one a person must act on.
44 */
45 export class InstagramError extends Error {
46 constructor(
47 readonly code: number,
48 message: string,
49 readonly type = "",
50 ) {
51 super(message);
52 this.name = "InstagramError";
53 }
54 }
55
56 /** The token is no good: the person who pasted it must make another. */
57 export const TOKEN_REFUSED = 190;
58
59 /**
60 * One sentence for a run that met an error, for the source's `last_said`.
61 * Never the token, never a stack.
62 */
63 export function instagramSaid(error: unknown): string {
64 if (error instanceof InstagramError) {
65 if (error.code === TOKEN_REFUSED)
66 return "Instagram no longer accepts the token; connect the account again.";
67 return `Instagram answered: ${oneSentence(redactSecrets(error.message))}`;
68 }
69 if (error instanceof Error && error.name === "TimeoutError")
70 return "Instagram did not answer within ten seconds.";
71 return `The run failed: ${oneSentence(redactSecrets(error instanceof Error ? error.message : String(error)))}`;
72 }
73
74 function oneSentence(text: string): string {
75 const line = text.replace(/\s+/g, " ").trim();
76 const cut = line.length > 200 ? `${line.slice(0, 199).trimEnd()}…` : line;
77 return /[.!?…]$/.test(cut) ? cut : `${cut}.`;
78 }
79
80 /**
81 * The comment that becomes the tell: the most liked, and of those the
82 * earliest. A comment with nothing in it is nobody's prompt.
83 */
84 export function topComment(comments: readonly Comment[]): Comment | undefined {
85 let top: Comment | undefined;
86 for (const comment of comments) {
87 if (comment.text.trim() === "") continue;
88 if (
89 top === undefined ||
90 comment.likeCount > top.likeCount ||
91 (comment.likeCount === top.likeCount && comment.timestamp < top.timestamp)
92 )
93 top = comment;
94 }
95 return top;
96 }
97
98 // ---- the real one ----
99
100 const HOST = "https://graph.instagram.com";
101 const VERSION = "v25.0";
102 const TIMEOUT_MS = 10_000;
103 /** How many pages of comments are read before the top is taken as found. */
104 export const COMMENT_PAGES = 4;
105 const COMMENTS_PER_PAGE = 50;
106 const MEDIA_PER_PAGE = 50;
107
108 type Json = Record<string, unknown>;
109
110 interface MediaItem {
111 id?: unknown;
112 media_type?: unknown;
113 media_product_type?: unknown;
114 permalink?: unknown;
115 timestamp?: unknown;
116 caption?: unknown;
117 comments_count?: unknown;
118 }
119
120 interface CommentItem {
121 id?: unknown;
122 text?: unknown;
123 username?: unknown;
124 timestamp?: unknown;
125 like_count?: unknown;
126 }
127
128 const str = (v: unknown): string => (typeof v === "string" ? v : "");
129 const num = (v: unknown): number => (typeof v === "number" ? v : 0);
130
131 export function graphInstagram(fetchFn: typeof fetch = fetch): InstagramClient {
132 const get = async (
133 path: string,
134 params: Record<string, string>,
135 ): Promise<Json> => {
136 const url = new URL(path, HOST);
137 for (const [key, value] of Object.entries(params))
138 url.searchParams.set(key, value);
139 const response = await fetchFn(url, {
140 signal: AbortSignal.timeout(TIMEOUT_MS),
141 headers: { accept: "application/json" },
142 });
143 const body = (await response.json().catch(() => ({}))) as Json;
144 const failure = body["error"];
145 if (typeof failure === "object" && failure !== null) {
146 const e = failure as Json;
147 throw new InstagramError(
148 num(e["code"]),
149 str(e["message"]) || `Instagram answered ${response.status}`,
150 str(e["type"]),
151 );
152 }
153 if (!response.ok)
154 throw new InstagramError(
155 response.status,
156 `Instagram answered ${response.status}`,
157 );
158 return body;
159 };
160
161 const items = <T>(body: Json): T[] =>
162 Array.isArray(body["data"]) ? (body["data"] as T[]) : [];
163 const after = (body: Json): string | undefined => {
164 const paging = body["paging"];
165 if (typeof paging !== "object" || paging === null) return undefined;
166 const cursors = (paging as Json)["cursors"];
167 if (typeof cursors !== "object" || cursors === null) return undefined;
168 const cursor = str((cursors as Json)["after"]);
169 return cursor === "" ? undefined : cursor;
170 };
171
172 return {
173 async me(token) {
174 const body = await get(`/${VERSION}/me`, {
175 fields: "id,username",
176 access_token: token,
177 });
178 return { id: str(body["id"]), username: str(body["username"]) };
179 },
180
181 async latestReel(token) {
182 const body = await get(`/${VERSION}/me/media`, {
183 fields:
184 "id,media_type,media_product_type,permalink,timestamp,caption,comments_count",
185 limit: String(MEDIA_PER_PAGE),
186 access_token: token,
187 });
188 const media = items<MediaItem>(body);
189 // Newest first. Where the API says which items are Reels, that is
190 // the answer; where it does not (Instagram Login may leave the field
191 // out), the newest video stands in.
192 const says = media.some((m) => typeof m.media_product_type === "string");
193 const found = says
194 ? media.find((m) => m.media_product_type === "REELS")
195 : media.find((m) => m.media_type === "VIDEO");
196 if (found === undefined || str(found.id) === "") return undefined;
197 return {
198 id: str(found.id),
199 permalink: str(found.permalink),
200 timestamp: str(found.timestamp),
201 caption: str(found.caption),
202 commentsCount: num(found.comments_count),
203 };
204 },
205
206 async comments(token, mediaId) {
207 const out: Comment[] = [];
208 let cursor: string | undefined;
209 for (let page = 0; page < COMMENT_PAGES; page++) {
210 const body = await get(
211 `/${VERSION}/${encodeURIComponent(mediaId)}/comments`,
212 {
213 fields: "id,text,username,timestamp,like_count",
214 limit: String(COMMENTS_PER_PAGE),
215 access_token: token,
216 ...(cursor === undefined ? {} : { after: cursor }),
217 },
218 );
219 for (const c of items<CommentItem>(body))
220 out.push({
221 id: str(c.id),
222 text: str(c.text),
223 username: str(c.username),
224 timestamp: str(c.timestamp),
225 likeCount: num(c.like_count),
226 });
227 cursor = after(body);
228 if (cursor === undefined) break;
229 }
230 return out;
231 },
232
233 async refresh(token) {
234 const body = await get("/refresh_access_token", {
235 grant_type: "ig_refresh_token",
236 access_token: token,
237 });
238 const fresh = str(body["access_token"]);
239 if (fresh === "")
240 throw new InstagramError(0, "the refresh answered with no token");
241 return { token: fresh };
242 },
243 };
244 }
src/crew/prompts.ts added +265
@@ -0,0 +1,265 @@
1 /**
2 * The prompt routine: once a day, for every repository that asked, the top
3 * comment on the account's latest Reel becomes the crew's next tell
4 * (docs/APPS.md, "Prompts from Instagram").
5 *
6 * Every minute the clock is compared with each source's hour; a source
7 * whose hour has passed and that has not run today runs. A run reads the
8 * latest Reel, takes its most-liked comment, and tells the crew as a
9 * person would from the studio box — `tellCrew` opens the issue and routes
10 * it. Nothing is invented: no Reel, no new Reel, or no comment yet is a
11 * sentence on the settings page and another look tomorrow. One source runs
12 * at a time, `Fetch now` included, and a run that fails is a sentence too,
13 * never a throw into the timer.
14 */
15
16 import type { App } from "../app.ts";
17 import { tellCrew } from "../actions/crew.ts";
18 import {
19 duePromptSources,
20 getPromptSource,
21 openPromptToken,
22 recordPromptRun,
23 setPromptAccount,
24 storePromptToken,
25 type PromptSource,
26 claimPromptRun,
27 } from "../core/prompts.ts";
28 import { getRepoById } from "../core/repos.ts";
29 import { getUserById } from "../core/users.ts";
30 import { createLog, redactSecrets } from "../kernel/log.ts";
31 import {
32 instagramSaid,
33 topComment,
34 type Comment,
35 type InstagramClient,
36 type Reel,
37 } from "./instagram.ts";
38
39 const log = createLog("prompts");
40
41 /** How often the clock is looked at. */
42 /** How long a shutdown waits for a run in flight before going on without it. */
43 const STOP_DEADLINE_MS = 15_000;
44 const TICK_MS = 60_000;
45
46 /**
47 * A long-lived token lives sixty days and may be refreshed once it is a day
48 * old; at fifty the forge refreshes it, leaving a ten-day margin for a
49 * forge that is down.
50 */
51 export const REFRESH_AFTER_DAYS = 50;
52 const DAY_MS = 24 * 3_600_000;
53
54 export interface PromptRoutineOptions {
55 readonly client: InstagramClient;
56 /** The clock, for tests. @default the wall clock */
57 readonly now?: (() => Date) | undefined;
58 /** @default a minute */
59 readonly everyMs?: number | undefined;
60 /** How long `stop()` waits for a run in flight. */
61 readonly stopDeadlineMs?: number | undefined;
62 }
63
64 export interface PromptRoutineHandle {
65 /** Stop the clock; a run in flight finishes first. */
66 readonly stop: () => Promise<void>;
67 /** Run every source whose hour has come and that has not run today. */
68 readonly runDue: (now?: Date) => Promise<void>;
69 /** Run one source now, whatever the hour; the source after, or nothing for an unknown id. */
70 readonly runNow: (sourceId: string) => Promise<PromptSource | undefined>;
71 }
72
73 const handles = new WeakMap<App, PromptRoutineHandle>();
74
75 /** The routine of this forge, if one was started. */
76 export const promptsFor = (app: App): PromptRoutineHandle | undefined =>
77 handles.get(app);
78
79 export function startPromptRoutine(
80 app: App,
81 options: PromptRoutineOptions,
82 ): PromptRoutineHandle {
83 const now = options.now ?? (() => new Date());
84 let stopped = false;
85 // One source at a time: the due sweep and a `Fetch now` join the same
86 // line, so a run never overlaps another on the same row.
87 let line: Promise<unknown> = Promise.resolve();
88 const enqueue = <T>(work: () => Promise<T>): Promise<T> => {
89 const next = line.then(work);
90 line = next.catch(() => undefined);
91 return next;
92 };
93
94 const runDue = (at = now()): Promise<void> =>
95 enqueue(async () => {
96 for (const source of duePromptSources(app.db, at)) {
97 if (stopped) return;
98 await runSource(app, options.client, source, at);
99 }
100 });
101
102 const timer = setInterval(() => {
103 if (stopped) return;
104 runDue().catch((error) =>
105 log.error("prompt sweep failed", { error: String(error) }),
106 );
107 }, options.everyMs ?? TICK_MS);
108
109 const handle: PromptRoutineHandle = {
110 runDue,
111 runNow: (sourceId) =>
112 enqueue(async () => {
113 const source = getPromptSource(app.db, sourceId);
114 if (source === undefined) return undefined;
115 return runSource(app, options.client, source, now(), true);
116 }),
117 stop: async () => {
118 stopped = true;
119 clearInterval(timer);
120 // A run in flight is waited for, but not forever: shutdown has its
121 // own patience, and Instagram is not allowed to spend it.
122 await Promise.race([
123 line,
124 Bun.sleep(options.stopDeadlineMs ?? STOP_DEADLINE_MS),
125 ]);
126 if (handles.get(app) === handle) handles.delete(app);
127 },
128 };
129 handles.set(app, handle);
130 return handle;
131 }
132
133 /** The likes, in words. */
134 const likes = (n: number): string => (n === 1 ? "1 like" : `${n} likes`);
135
136 /** The Reel's day, as `YYYY-MM-DD`, from Instagram's timestamp. */
137 const dayOf = (reel: Reel): string => reel.timestamp.slice(0, 10);
138
139 /** What the settings page shows of a told comment: its first line, shortly. */
140 function shortly(text: string): string {
141 const line = text.trim().split(/\n/)[0]!.trim();
142 return line.length <= 60 ? line : `${line.slice(0, 60).trimEnd()}…`;
143 }
144
145 /**
146 * The tell, as the crew reads it: the comment itself first — `tellCrew`
147 * makes its first line the title — and where it came from beneath.
148 */
149 export function tellText(reel: Reel, comment: Comment): string {
150 return `${comment.text.trim()}\n\nFrom Instagram: the top comment (${likes(comment.likeCount)}) by @${comment.username} on the Reel of ${dayOf(reel)}. ${reel.permalink}`.trim();
151 }
152
153 /**
154 * One run of one source. Every exit is a sentence recorded on the row; the
155 * media id is recorded only when the Reel was told, so a Reel that has no
156 * comments yet is looked at again tomorrow.
157 */
158 async function runSource(
159 app: App,
160 client: InstagramClient,
161 source: PromptSource,
162 now: Date,
163 /** A person's "Fetch now" runs whatever the day; the sweep does not. */
164 force = false,
165 ): Promise<PromptSource | undefined> {
166 // Every token this run has held. A sentence about an error may quote a
167 // request, and a request carries the token: nothing recorded — and so
168 // nothing listed or shown — may hold it, or anything shaped like one.
169 const tokens: string[] = [];
170 const clean = (said: string): string =>
171 redactSecrets(
172 tokens.reduce((text, t) => (t === "" ? text : text.split(t).join("[token]")), said),
173 );
174 const record = (run: {
175 said: string;
176 mediaId?: string;
177 issueNumber?: number;
178 }) => recordPromptRun(app.db, source.id, { ...run, said: clean(run.said), now });
179 // The row is claimed before any network work: two forges over one data
180 // directory, or two ticks over one row, must not tell the crew twice.
181 if (!claimPromptRun(app.db, source.id, now, { force })) return undefined;
182 try {
183 const opened = openPromptToken(app.db, app.config.dataDir, source.id);
184 if (!opened.ok)
185 return record({
186 said:
187 opened.error === "not_found"
188 ? "The source is gone."
189 : "The token would not open; connect the account again.",
190 });
191 let token = opened.value;
192 tokens.push(token);
193
194 if (
195 now.getTime() - Date.parse(source.tokenRefreshedAt) >
196 REFRESH_AFTER_DAYS * DAY_MS
197 ) {
198 const fresh = await client.refresh(token);
199 storePromptToken(app.db, app.config.dataDir, source.id, fresh.token, now);
200 token = fresh.token;
201 tokens.push(token);
202 }
203
204 if (source.account === "") {
205 const me = await client.me(token);
206 if (me.username !== "") setPromptAccount(app.db, source.id, me.username);
207 }
208
209 const reel = await client.latestReel(token);
210 if (reel === undefined) return record({ said: "No Reel yet." });
211 if (reel.id === source.lastMediaId)
212 return record({
213 said:
214 source.lastIssueNumber === null
215 ? `No new Reel since ${dayOf(reel)}.`
216 : `No new Reel since ${dayOf(reel)}; the last one was told as #${source.lastIssueNumber}.`,
217 });
218
219 const top = topComment(await client.comments(token, reel.id));
220 if (top === undefined)
221 return record({ said: "The latest Reel has no comments yet." });
222
223 const repo = getRepoById(app.db, source.repoId);
224 if (repo === undefined) return record({ said: "The repository is gone." });
225 const creator =
226 source.createdBy === null
227 ? undefined
228 : getUserById(app.db, source.createdBy);
229 const told = await tellCrew(app, creator, repo, tellText(reel, top));
230 if (!told.ok)
231 return record({
232 said:
233 told.error === "unauthorized"
234 ? "The person who connected the account is gone; connect it again."
235 : `The crew could not be told (${told.error}).`,
236 });
237
238 const number = told.value.issue.number;
239 app.bus.append({
240 kind: "prompt.told",
241 repoId: repo.id,
242 payload: {
243 source: source.id,
244 kind: source.kind,
245 mediaId: reel.id,
246 commentId: top.id,
247 issue: number,
248 },
249 });
250 const who = `"${shortly(top.text)}" — @${top.username}, ${likes(top.likeCount)}`;
251 return record({
252 // Honest about who heard it: an issue opens with nobody to route it
253 // to when the crew is empty, and that is not "told the crew".
254 said:
255 told.value.routed === undefined
256 ? `Opened #${number}: ${who}; nobody on the crew to take it yet.`
257 : `Told the crew #${number}: ${who}.`,
258 mediaId: reel.id,
259 issueNumber: number,
260 });
261 } catch (error) {
262 log.warn("prompt run failed", { source: source.id, error: String(error) });
263 return record({ said: instagramSaid(error) });
264 }
265 }
src/design/activity.ts +11 −1
@@ -69,6 +69,12 @@ export function eventVerb(event: ForgeEvent): string {
6969 if (kind === "app.domain.verified") return `verified the domain ${which}`;
7070 return `removed the domain ${which}`;
7171 }
72 if (kind === "prompt.told") {
73 const issue = payload["issue"];
74 return typeof issue === "number"
75 ? `told the crew the top Instagram comment as #${issue}`
76 : "told the crew the top Instagram comment";
77 }
7278 return WORDS[kind] ?? "acted on this repository";
7379 }
7480
@@ -78,7 +84,9 @@ export function eventActor(
7884 actorName: string | undefined,
7985 ): string {
8086 if (actorName !== undefined) return actorName;
81 return event.kind.startsWith("run.") || event.kind.startsWith("app.")
87 return event.kind.startsWith("run.") ||
88 event.kind.startsWith("app.") ||
89 event.kind.startsWith("prompt.")
8290 ? "the forge"
8391 : "someone";
8492 }
@@ -120,4 +128,6 @@ const WORDS: Record<string, string> = {
120128 "app.created": "made it an app",
121129 "app.updated": "changed the app's settings",
122130 "app.deleted": "stopped serving it as an app",
131 "prompt.source_added": "connected an Instagram account to prompt the crew",
132 "prompt.source_removed": "disconnected the Instagram account",
123133 };
src/http/api.ts +68
@@ -159,6 +159,12 @@ import {
159159 listAppDomains,
160160 removeAppDomain,
161161 } from "../actions/domains.ts";
162 import {
163 addPromptSource,
164 listPromptSources,
165 removePromptSource,
166 runPromptSource,
167 } from "../actions/prompts.ts";
162168 import { listDomains } from "../core/domains.ts";
163169 import type { Database } from "bun:sqlite";
164170 import {
@@ -2912,6 +2918,68 @@ export function apiRoutes(router: Router<Handler>): void {
29122918 },
29132919 );
29142920
2921 // Where the crew's next tell comes from when nobody is typing
2922 // (docs/APPS.md, "Prompts from Instagram"). Admin scope and admin over
2923 // the repository; the token goes in once and never comes back out.
2924 router.add("GET", "/api/v1/repos/:owner/:name/prompts", (ctx) => {
2925 const denied = requireScope(ctx, "admin");
2926 if (denied) return denied;
2927 const repo = loadRepo(ctx);
2928 if (!repo) return notFound();
2929 const listed = listPromptSources(ctx.app, ctx.auth.user, repo);
2930 if (!listed.ok) return respondAccess(listed.error, 422);
2931 return json({ items: listed.value, next_cursor: null });
2932 });
2933
2934 router.add("POST", "/api/v1/repos/:owner/:name/prompts", async (ctx) => {
2935 const denied = requireScope(ctx, "admin");
2936 if (denied) return denied;
2937 const repo = loadRepo(ctx);
2938 if (!repo) return notFound();
2939 const input = await body(ctx.request);
2940 const added = addPromptSource(ctx.app, ctx.auth.user, repo, {
2941 kind: str(input.kind),
2942 token: str(input.token),
2943 at: str(input.at),
2944 });
2945 if (!added.ok) return respondAccess(added.error, 422);
2946 return json(added.value, 201);
2947 });
2948
2949 router.add(
2950 "POST",
2951 "/api/v1/repos/:owner/:name/prompts/:id/run",
2952 async (ctx) => {
2953 const denied = requireScope(ctx, "admin");
2954 if (denied) return denied;
2955 const repo = loadRepo(ctx);
2956 if (!repo) return notFound();
2957 const ran = await runPromptSource(
2958 ctx.app,
2959 ctx.auth.user,
2960 repo,
2961 ctx.params.id ?? "",
2962 );
2963 if (!ran.ok) return respondAccess(ran.error, 422);
2964 return json(ran.value);
2965 },
2966 );
2967
2968 router.add("DELETE", "/api/v1/repos/:owner/:name/prompts/:id", (ctx) => {
2969 const denied = requireScope(ctx, "admin");
2970 if (denied) return denied;
2971 const repo = loadRepo(ctx);
2972 if (!repo) return notFound();
2973 const removed = removePromptSource(
2974 ctx.app,
2975 ctx.auth.user,
2976 repo,
2977 ctx.params.id ?? "",
2978 );
2979 if (!removed.ok) return respondAccess(removed.error, 422);
2980 return json({}, 200);
2981 });
2982
29152983 // The app's environment: names in, names out, and never a value back.
29162984 // `:var`, not `:name` — `:name` is the repository's.
29172985 router.add("GET", "/api/v1/repos/:owner/:name/app/env", (ctx) => {
src/http/server.ts +12
@@ -180,6 +180,10 @@ import {
180180 labelDelete,
181181 labelUpdate,
182182 labelsPage,
183 promptSourceAdd,
184 promptSourceDelete,
185 promptSourceRun,
186 promptsSettingsPage,
183187 repoDelete,
184188 settingsPage,
185189 settingsUpdate,
@@ -809,6 +813,14 @@ export function buildRouter(): Router<Handler> {
809813 "/:owner/:name/settings/app/domains/:host/delete",
810814 appDomainDelete,
811815 );
816 router.add("GET", "/:owner/:name/settings/prompts", promptsSettingsPage);
817 router.add("POST", "/:owner/:name/settings/prompts", promptSourceAdd);
818 router.add("POST", "/:owner/:name/settings/prompts/:id/run", promptSourceRun);
819 router.add(
820 "POST",
821 "/:owner/:name/settings/prompts/:id/delete",
822 promptSourceDelete,
823 );
812824 router.add("GET", "/:owner/:name/settings/crew", crewSettingsPage);
813825 router.add("POST", "/:owner/:name/settings/crew", crewAdd);
814826 router.add(
src/main.ts +7
@@ -24,6 +24,8 @@ import { health } from "./http/health.ts";
2424 import { STALE_AFTER_MS } from "./runs/lease.ts";
2525 import { startCrew } from "./crew/dispatch.ts";
2626 import { typeSafeDecider } from "./crew/decide.ts";
27 import { graphInstagram } from "./crew/instagram.ts";
28 import { startPromptRoutine } from "./crew/prompts.ts";
2729 import { pruneTokens } from "./core/tokens.ts";
2830
2931 // `forge hook <name>` re-enters here from a git hook stub. It relays to the
@@ -180,6 +182,10 @@ const crew = appsOn
180182 : { configDir: process.env.FORGE_CLAUDE_CONFIG_DIR }),
181183 })
182184 : undefined;
185 // Prompts from Instagram (docs/APPS.md): once a day, for each repository
186 // that connected an account, the top comment on its latest Reel is told to
187 // the crew. A minute's clock; nothing leaves the box until a source exists.
188 const prompts = startPromptRoutine(app, { client: graphInstagram() });
183189
184190 // The webhook delivery worker: poll the outbox for due deliveries. The event
185191 // row is the source of truth, so a missed tick only delays delivery, never
@@ -265,6 +271,7 @@ function shutdown(): void {
265271 clearInterval(syncTimer);
266272 clearInterval(leaseTimer);
267273 Promise.all([
274 prompts.stop(),
268275 crew?.stop({ drain: true }),
269276 apps?.stop(),
270277 checks?.stop(),
src/web/pages/search.ts +9
@@ -411,6 +411,15 @@ const DESTRUCTIVE: {
411411 phrase: "",
412412 }),
413413 },
414 {
415 pattern: /^\/([^/]+)\/([^/]+)\/settings\/prompts\/([^/]+)\/delete$/,
416 describe: (m) => ({
417 verb: "Disconnect",
418 title: "Disconnect Instagram?",
419 detail: `The daily tell from Instagram stops for ${m[1]}/${m[2]}, and the token is forgotten. The issues it already told stay.`,
420 phrase: "",
421 }),
422 },
414423 {
415424 pattern: /^\/([^/]+)\/([^/]+)\/settings\/labels\/([^/]+)\/delete$/,
416425 describe: (m) => ({
src/web/pages/settings.ts +243
@@ -22,7 +22,15 @@ import {
2222 removeAppDomain,
2323 type AppDomainView,
2424 } from "../../actions/domains.ts";
25 import {
26 addPromptSource,
27 listPromptSources,
28 removePromptSource,
29 runPromptSource,
30 type PromptSourceView,
31 } from "../../actions/prompts.ts";
2532 import type { DnsRecord } from "../../apps/domains.ts";
33 import { REFRESH_AFTER_DAYS } from "../../crew/prompts.ts";
2634 import {
2735 listAppEnvNames,
2836 NAME_MAX,
@@ -97,6 +105,7 @@ import {
97105 row,
98106 selectField,
99107 time,
108 timeAgo,
100109 userLink,
101110 } from "../ui.ts";
102111 import { crewReadiness } from "./crew.ts";
@@ -106,6 +115,7 @@ type Section =
106115 | "general"
107116 | "app"
108117 | "crew"
118 | "prompts"
109119 | "autonomy"
110120 | "labels"
111121 | "hooks"
@@ -139,6 +149,12 @@ function sections(base: string, active: Section) {
139149 icon: "bot" as const,
140150 current: active === "crew",
141151 },
152 {
153 label: "Prompts",
154 href: `${base}/settings/prompts`,
155 icon: "comment" as const,
156 current: active === "prompts",
157 },
142158 {
143159 label: "Autonomy",
144160 href: `${base}/settings/autonomy`,
@@ -921,6 +937,233 @@ function redeploying(ctx: Ctx, how: string): string {
921937 : ` Production and every preview are starting again ${how}.`;
922938 }
923939
940 // ---- prompts ----
941
942 const PROMPT_ERRORS: Record<string, string> = {
943 invalid_at: "The time is hours and minutes in UTC, like 15:00.",
944 invalid_token:
945 "Paste the long-lived token as Instagram gave it: one line, no spaces.",
946 invalid_kind: "Only Instagram is known here.",
947 already_connected:
948 "An Instagram account is already connected here. Remove it first.",
949 no_such_source: "No account by that id.",
950 not_running: "The prompt routine is not running on this forge.",
951 };
952
953 /** The day the forge will refresh a token pasted or refreshed at `iso`. */
954 function refreshDay(iso: string): string {
955 return new Date(Date.parse(iso) + REFRESH_AFTER_DAYS * 24 * 3_600_000)
956 .toISOString()
957 .slice(0, 10);
958 }
959
960 /**
961 * The Instagram card: the account and what the last run said when one is
962 * connected, the form when none is. Beneath it, the one-time steps at Meta,
963 * because the forge cannot take them for anyone. The token is a password
964 * field on the way in and nowhere on the way out.
965 */
966 function promptsView(base: string, sources: PromptSourceView[]): Html {
967 const source = sources.find((s) => s.kind === "instagram");
968 const at = (id: string) =>
969 `${base}/settings/prompts/${encodeURIComponent(id)}`;
970 const body =
971 source === undefined
972 ? html`<div class="card-body">
973 <p class="muted small">
974 Once a day, the top comment on your latest Reel becomes the crew's
975 next tell.
976 </p>
977 </div>
978 <form
979 class="card-body"
980 method="post"
981 action="${base}/settings/prompts"
982 >
983 ${formField({
984 label: "Token",
985 name: "token",
986 type: "password",
987 hint: "A long-lived Instagram token. The forge seals it, refreshes it before it expires, and never shows it again.",
988 required: true,
989 autocomplete: "off",
990 mono: true,
991 })}
992 ${formField({
993 label: "Every day at … UTC",
994 name: "at",
995 type: "time",
996 value: "15:00",
997 required: true,
998 })}
999 <div class="form-actions">
1000 ${btn("Add", { variant: "primary", icon: "plus" })}
1001 </div>
1002 </form>`
1003 : html`<ul class="list">
1004 <li>
1005 <div class="row">
1006 <span class="row-lead">${icon("comment")}</span>
1007 <span class="row-main">
1008 <span class="row-title"
1009 >${source.account === ""
1010 ? html`<span class="muted">not read yet</span>`
1011 : `@${source.account}`}
1012 ${badge(`every day at ${source.atUtc} UTC`)}</span
1013 >
1014 <span class="row-meta"
1015 >${source.lastRanAt === null
1016 ? "Has not run yet."
1017 : html`Last ran ${time(source.lastRanAt)}.
1018 ${source.lastSaid}`}</span
1019 >
1020 <span class="row-meta"
1021 >Token refreshed ${timeAgo(source.tokenRefreshedAt)}; the
1022 forge refreshes it again around
1023 ${refreshDay(source.tokenRefreshedAt)}.</span
1024 >
1025 </span>
1026 <span class="row-trail">
1027 <form method="post" action="${at(source.id)}/run">
1028 ${btn("Fetch now", { size: "sm", icon: "sync" })}
1029 </form>
1030 ${confirmAction({
1031 action: `${at(source.id)}/delete`,
1032 verb: "Remove",
1033 size: "sm",
1034 })}
1035 </span>
1036 </div>
1037 </li>
1038 </ul>`;
1039 return html`${card(body, {
1040 head: html`${icon("comment")}
1041 <h2>Instagram</h2>`,
1042 pad: false,
1043 })}
1044 ${card(
1045 html`<p class="muted small">
1046 The one-time steps at Meta, for a professional account of your own. None
1047 of them needs App Review.
1048 </p>
1049 <ol class="steps">
1050 <li>
1051 Make the Instagram account a professional one — Business or Creator —
1052 in the Instagram app's settings.
1053 </li>
1054 <li>
1055 At developers.facebook.com, make a Meta app and add the Instagram
1056 product with <b>Business Login for Instagram</b>. No Facebook Page is
1057 needed.
1058 </li>
1059 <li>
1060 Get a token for the account with the scopes
1061 <span class="mono">instagram_business_basic</span> and
1062 <span class="mono">instagram_business_manage_comments</span>.
1063 </li>
1064 <li>
1065 Exchange it for a long-lived token, the one that lives sixty days; the
1066 product's docs show the one call.
1067 </li>
1068 <li>
1069 Paste the long-lived token above. The forge refreshes it before it
1070 expires, and the paste is never shown again.
1071 </li>
1072 </ol>`,
1073 { head: html`<h2>Getting a token</h2>` },
1074 )}`;
1075 }
1076
1077 // GET /:owner/:name/settings/prompts
1078 export function promptsSettingsPage(ctx: Ctx): Response {
1079 const repo = loadAdminRepo(ctx);
1080 if (!repo) return repoNotFound(ctx);
1081 const listed = listPromptSources(ctx.app, ctx.auth.user, repo);
1082 return settingsPageResponse(
1083 ctx,
1084 repo,
1085 "prompts",
1086 "Prompts",
1087 promptsView(repoBase(repo), listed.ok ? listed.value : []),
1088 );
1089 }
1090
1091 // POST /:owner/:name/settings/prompts — connect an account.
1092 export async function promptSourceAdd(ctx: Ctx): Promise<Response> {
1093 const gate = await adminForm(ctx);
1094 if (gate instanceof Response) return gate;
1095 const { repo, form } = gate;
1096 const added = addPromptSource(ctx.app, ctx.auth.user, repo, {
1097 kind: "instagram",
1098 token: field(form, "token"),
1099 at: field(form, "at"),
1100 });
1101 if (!added.ok && added.error === "not_found") return repoNotFound(ctx);
1102 return flashTo(
1103 ctx,
1104 `${repoBase(repo)}/settings/prompts`,
1105 added.ok
1106 ? {
1107 message: `Instagram is connected. Every day at ${added.value.atUtc} UTC the top comment on the latest Reel is told to the crew; press Fetch now to try it.`,
1108 }
1109 : {
1110 tone: "danger",
1111 message:
1112 PROMPT_ERRORS[added.error] ?? "The account could not be connected.",
1113 },
1114 );
1115 }
1116
1117 // POST /:owner/:name/settings/prompts/:id/run — look at Instagram now.
1118 export async function promptSourceRun(ctx: Ctx): Promise<Response> {
1119 const gate = await adminForm(ctx);
1120 if (gate instanceof Response) return gate;
1121 const { repo } = gate;
1122 const ran = await runPromptSource(
1123 ctx.app,
1124 ctx.auth.user,
1125 repo,
1126 ctx.params.id ?? "",
1127 );
1128 if (!ran.ok && ran.error === "not_found") return repoNotFound(ctx);
1129 return flashTo(
1130 ctx,
1131 `${repoBase(repo)}/settings/prompts`,
1132 ran.ok
1133 ? {
1134 ...(ran.value.lastSaid.startsWith("Told the crew")
1135 ? {}
1136 : { tone: "attn" as const }),
1137 message: ran.value.lastSaid,
1138 }
1139 : {
1140 tone: "danger",
1141 message: PROMPT_ERRORS[ran.error] ?? "No account by that id.",
1142 },
1143 );
1144 }
1145
1146 // POST /:owner/:name/settings/prompts/:id/delete
1147 export async function promptSourceDelete(ctx: Ctx): Promise<Response> {
1148 const gate = await adminForm(ctx);
1149 if (gate instanceof Response) return gate;
1150 const { repo } = gate;
1151 const removed = removePromptSource(
1152 ctx.app,
1153 ctx.auth.user,
1154 repo,
1155 ctx.params.id ?? "",
1156 );
1157 if (!removed.ok && removed.error === "not_found") return repoNotFound(ctx);
1158 return flashTo(
1159 ctx,
1160 `${repoBase(repo)}/settings/prompts`,
1161 removed.ok
1162 ? { message: "Instagram is disconnected. The token is forgotten." }
1163 : { tone: "danger", message: "No account by that id." },
1164 );
1165 }
1166
9241167 // ---- crew ----
9251168
9261169 // GET /:owner/:name/settings/crew
test/event-audience.test.ts +2
@@ -107,6 +107,8 @@ describe("who an event is for", () => {
107107 expect(eventAudience("app.env_set")).toBe("admins");
108108 expect(eventAudience("app.env_deleted")).toBe("admins");
109109 expect(eventAudience("repo.settings_updated")).toBe("admins");
110 expect(eventAudience("prompt.told")).toBe("admins");
111 expect(eventAudience("prompt.source_added")).toBe("admins");
110112 expect(eventAudience("repo.pinned")).toBe("actor");
111113 expect(eventAudience("push")).toBe("readers");
112114 expect(eventAudience("a.kind.nobody.classified")).toBe("readers");
test/prompts.test.ts added +1072
@@ -0,0 +1,1072 @@
1 /**
2 * Prompts from Instagram (docs/APPS.md): once a day, the top comment on the
3 * account's latest Reel becomes the crew's next tell. Instagram is a fake
4 * the test scripts, the clock is the test's, and nothing here touches the
5 * network — the real client is exercised against a fake `fetch`.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { addToCrew, createAgent, removeFromCrew } from "../src/actions/crew.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { createApp, type App } from "../src/app.ts";
15 import { listEvents } from "../src/core/events.ts";
16 import { getIssue, listIssues } from "../src/core/issues.ts";
17 import {
18 addPromptSource,
19 getPromptSource,
20 isDailyTime,
21 claimPromptRun,
22 listPromptSources,
23 openPromptToken,
24 } from "../src/core/prompts.ts";
25 import type { Repo } from "../src/core/repos.ts";
26 import { createToken } from "../src/core/tokens.ts";
27 import { createUser, type User } from "../src/core/users.ts";
28 import {
29 COMMENT_PAGES,
30 graphInstagram,
31 InstagramError,
32 instagramSaid,
33 topComment,
34 type Comment,
35 type InstagramClient,
36 type Reel,
37 } from "../src/crew/instagram.ts";
38 import {
39 startPromptRoutine,
40 type PromptRoutineHandle,
41 } from "../src/crew/prompts.ts";
42 import { serve, type Servers } from "../src/http/server.ts";
43 import { quietLogs } from "./helpers/globals.ts";
44
45 quietLogs();
46
47 /** Distinctive enough that its presence anywhere is unmistakable. */
48 const TOKEN = "IGAAQzx7k9p2m4-long-lived-token-for-the-test-QQ";
49 const FRESH = "IGAAQfresh-token-after-the-refresh-QQ";
50
51 const at = (iso: string): Comment["timestamp"] => iso;
52 const comment = (
53 id: string,
54 text: string,
55 likeCount: number,
56 timestamp: string,
57 username = "someone",
58 ): Comment => ({ id, text, username, timestamp, likeCount });
59 const reelOf = (id: string, day: string): Reel => ({
60 id,
61 permalink: `https://www.instagram.com/reel/${id}/`,
62 timestamp: `${day}T18:00:00+0000`,
63 caption: "new level",
64 commentsCount: 3,
65 });
66
67 /** A page with its whitespace collapsed, for asserting on a sentence. */
68 const words = (html: string): string => html.replace(/\s+/g, " ");
69
70 describe("topComment", () => {
71 test("picks the most liked, ties to the earliest, and ignores empty text", () => {
72 const a = comment(
73 "a",
74 "later, same likes",
75 5,
76 at("2026-09-27T18:10:00+0000"),
77 );
78 const b = comment(
79 "b",
80 "earlier, same likes",
81 5,
82 at("2026-09-27T18:01:00+0000"),
83 );
84 const c = comment("c", "fewer likes", 2, at("2026-09-27T18:00:00+0000"));
85 const blank = comment("d", " ", 99, at("2026-09-27T17:00:00+0000"));
86 expect(topComment([c, a, b, blank])?.id).toBe("b");
87 expect(topComment([c])?.id).toBe("c");
88 expect(topComment([blank])).toBeUndefined();
89 expect(topComment([])).toBeUndefined();
90 });
91 });
92
93 describe("the Instagram client", () => {
94 /** A fetch that answers from a script and remembers what it was asked. */
95 const scripted = (
96 answer: (url: URL) => { status?: number; body: unknown },
97 ) => {
98 const asked: { url: URL; signal: AbortSignal | null | undefined }[] = [];
99 const fetchFn = ((input: URL | Request | string, init?: RequestInit) => {
100 const url =
101 input instanceof URL
102 ? input
103 : new URL(typeof input === "string" ? input : input.url);
104 asked.push({ url, signal: init?.signal });
105 const { status, body } = answer(url);
106 return Promise.resolve(
107 new Response(JSON.stringify(body), {
108 status: status ?? 200,
109 headers: { "content-type": "application/json" },
110 }),
111 );
112 }) as unknown as typeof fetch;
113 return { asked, client: graphInstagram(fetchFn) };
114 };
115
116 test("asks Meta's host with the token in the query, and a timeout on every call", async () => {
117 const { asked, client } = scripted(() => ({
118 body: { id: "17841400000000000", username: "gamehandle" },
119 }));
120 expect(await client.me(TOKEN)).toEqual({
121 id: "17841400000000000",
122 username: "gamehandle",
123 });
124 const [first] = asked;
125 expect(first?.url.origin).toBe("https://graph.instagram.com");
126 expect(first?.url.pathname).toBe("/v25.0/me");
127 expect(first?.url.searchParams.get("fields")).toBe("id,username");
128 expect(first?.url.searchParams.get("access_token")).toBe(TOKEN);
129 expect(first?.signal).toBeInstanceOf(AbortSignal);
130 });
131
132 test("the latest Reel: the newest REELS item, or the newest video where nothing is marked", async () => {
133 const marked = scripted(() => ({
134 body: {
135 data: [
136 {
137 id: "p1",
138 media_type: "IMAGE",
139 media_product_type: "FEED",
140 permalink: "https://www.instagram.com/p/p1/",
141 timestamp: "2026-09-28T10:00:00+0000",
142 },
143 {
144 id: "v0",
145 media_type: "VIDEO",
146 media_product_type: "FEED",
147 permalink: "https://www.instagram.com/p/v0/",
148 timestamp: "2026-09-27T10:00:00+0000",
149 },
150 {
151 id: "r1",
152 media_type: "VIDEO",
153 media_product_type: "REELS",
154 permalink: "https://www.instagram.com/reel/r1/",
155 timestamp: "2026-09-26T10:00:00+0000",
156 caption: "boss",
157 comments_count: 4,
158 },
159 ],
160 },
161 }));
162 expect(await marked.client.latestReel(TOKEN)).toEqual({
163 id: "r1",
164 permalink: "https://www.instagram.com/reel/r1/",
165 timestamp: "2026-09-26T10:00:00+0000",
166 caption: "boss",
167 commentsCount: 4,
168 });
169 expect(marked.asked[0]?.url.pathname).toBe("/v25.0/me/media");
170
171 const unmarked = scripted(() => ({
172 body: {
173 data: [
174 {
175 id: "p1",
176 media_type: "IMAGE",
177 timestamp: "2026-09-28T10:00:00+0000",
178 },
179 {
180 id: "v1",
181 media_type: "VIDEO",
182 permalink: "https://www.instagram.com/reel/v1/",
183 timestamp: "2026-09-27T10:00:00+0000",
184 },
185 {
186 id: "v2",
187 media_type: "VIDEO",
188 timestamp: "2026-09-26T10:00:00+0000",
189 },
190 ],
191 },
192 }));
193 expect((await unmarked.client.latestReel(TOKEN))?.id).toBe("v1");
194
195 const none = scripted(() => ({ body: { data: [] } }));
196 expect(await none.client.latestReel(TOKEN)).toBeUndefined();
197 });
198
199 test("comments follow the cursor, and stop after four pages", async () => {
200 const paged = scripted((url) => {
201 const after = url.searchParams.get("after");
202 return {
203 body: {
204 data: [
205 {
206 id: `c-${after ?? "first"}`,
207 text: "x",
208 username: "u",
209 timestamp: "2026-09-27T18:00:00+0000",
210 like_count: 1,
211 },
212 ],
213 paging: { cursors: { after: `cursor-${after ?? "1"}` } },
214 },
215 };
216 });
217 const all = await paged.client.comments(TOKEN, "r1");
218 expect(all).toHaveLength(COMMENT_PAGES);
219 expect(paged.asked).toHaveLength(COMMENT_PAGES);
220 expect(paged.asked[0]?.url.pathname).toBe("/v25.0/r1/comments");
221 expect(paged.asked[0]?.url.searchParams.get("after")).toBeNull();
222 expect(paged.asked[1]?.url.searchParams.get("after")).toBe("cursor-1");
223 expect(paged.asked[0]?.url.searchParams.get("fields")).toBe(
224 "id,text,username,timestamp,like_count",
225 );
226
227 const short = scripted(() => ({
228 body: {
229 data: [
230 {
231 id: "c1",
232 text: "one",
233 username: "u",
234 timestamp: "t",
235 like_count: 3,
236 },
237 ],
238 },
239 }));
240 expect(await short.client.comments(TOKEN, "r1")).toEqual([
241 { id: "c1", text: "one", username: "u", timestamp: "t", likeCount: 3 },
242 ]);
243 expect(short.asked).toHaveLength(1);
244 });
245
246 test("Meta's errors become InstagramError, and each is a sentence", async () => {
247 const refused = scripted(() => ({
248 status: 400,
249 body: {
250 error: {
251 message: "Invalid OAuth access token - Cannot parse access token",
252 type: "OAuthException",
253 code: 190,
254 },
255 },
256 }));
257 const failure = await refused.client.me(TOKEN).catch((e: unknown) => e);
258 expect(failure).toBeInstanceOf(InstagramError);
259 expect((failure as InstagramError).code).toBe(190);
260 expect(instagramSaid(failure)).toBe(
261 "Instagram no longer accepts the token; connect the account again.",
262 );
263
264 const limited = scripted(() => ({
265 status: 400,
266 body: {
267 error: { message: "Application request limit reached", code: 4 },
268 },
269 }));
270 const other = await limited.client
271 .latestReel(TOKEN)
272 .catch((e: unknown) => e);
273 expect(instagramSaid(other)).toBe(
274 "Instagram answered: Application request limit reached.",
275 );
276
277 const silent = scripted(() => ({ status: 503, body: {} }));
278 const down = await silent.client.me(TOKEN).catch((e: unknown) => e);
279 expect(instagramSaid(down)).toBe(
280 "Instagram answered: Instagram answered 503.",
281 );
282 expect(instagramSaid(new Error("boom"))).toBe("The run failed: boom.");
283 });
284
285 test("a refresh asks the refresh endpoint and answers with the new token", async () => {
286 const { asked, client } = scripted(() => ({
287 body: { access_token: FRESH, token_type: "bearer", expires_in: 5183944 },
288 }));
289 expect(await client.refresh(TOKEN)).toEqual({ token: FRESH });
290 expect(asked[0]?.url.pathname).toBe("/refresh_access_token");
291 expect(asked[0]?.url.searchParams.get("grant_type")).toBe(
292 "ig_refresh_token",
293 );
294 expect(asked[0]?.url.searchParams.get("access_token")).toBe(TOKEN);
295 });
296 });
297
298 /** An Instagram the test scripts, remembering every call and the token it saw. */
299 class FakeInstagram implements InstagramClient {
300 readonly calls: { call: string; token: string }[] = [];
301 username = "gamehandle";
302 reel: Reel | undefined;
303 /** What the Reel's comments are, whichever Reel is asked about. */
304 replies: Comment[] = [];
305 /** Thrown by the next calls while set. */
306 failWith: Error | undefined;
307 fresh = FRESH;
308
309 private see(call: string, token: string): void {
310 this.calls.push({ call, token });
311 if (this.failWith !== undefined) throw this.failWith;
312 }
313 me(token: string): Promise<{ id: string; username: string }> {
314 this.see("me", token);
315 return Promise.resolve({
316 id: "17841400000000000",
317 username: this.username,
318 });
319 }
320 latestReel(token: string): Promise<Reel | undefined> {
321 this.see("reel", token);
322 return Promise.resolve(this.reel);
323 }
324 comments(token: string, mediaId: string): Promise<Comment[]> {
325 this.see(`comments:${mediaId}`, token);
326 return Promise.resolve(this.replies);
327 }
328 refresh(token: string): Promise<{ token: string }> {
329 this.see("refresh", token);
330 return Promise.resolve({ token: this.fresh });
331 }
332 names(): string[] {
333 return this.calls.map((c) => c.call);
334 }
335 }
336
337 describe("prompts from Instagram", () => {
338 let dataDir: string;
339 let app: App;
340 let servers: Servers;
341 let alice: User;
342 let bob: User;
343 let repo: Repo;
344 let routine: PromptRoutineHandle;
345 let aliceCookie = "";
346 let bobCookie = "";
347 let adminToken = "";
348 let rwToken = "";
349 let bobToken = "";
350 let sourceId = "";
351 const instagram = new FakeInstagram();
352 let clock = new Date("2026-09-28T14:59:00Z");
353 const tick = (iso: string) => {
354 clock = new Date(iso);
355 return routine.runDue(clock);
356 };
357
358 const PROMPTS = "/api/v1/repos/alice/game/prompts";
359 const SETTINGS = "/alice/game/settings/prompts";
360
361 /** A browser's request to the forge: a cookie, and a same-origin form. */
362 const web = (
363 cookie: string,
364 path: string,
365 init: RequestInit & { form?: Record<string, string>; origin?: string } = {},
366 ): Promise<Response> => {
367 const headers = new Headers(init.headers);
368 if (cookie !== "") headers.set("cookie", cookie);
369 const method = init.form !== undefined ? "POST" : (init.method ?? "GET");
370 if (init.form !== undefined)
371 headers.set("origin", init.origin ?? new URL(servers.url).origin);
372 return fetch(`${servers.url}${path}`, {
373 ...init,
374 method,
375 headers,
376 redirect: "manual",
377 ...(init.form === undefined
378 ? {}
379 : { body: new URLSearchParams(init.form) }),
380 });
381 };
382 const flashOf = (response: Response): string =>
383 decodeURIComponent(response.headers.get("set-cookie") ?? "");
384
385 const api = (
386 path: string,
387 init: RequestInit & { token?: string | null } = {},
388 ) => {
389 const headers = new Headers(init.headers);
390 if (typeof init.body === "string")
391 headers.set("content-type", "application/json");
392 const token = init.token === undefined ? adminToken : init.token;
393 if (token) headers.set("authorization", `Bearer ${token}`);
394 return fetch(`${servers.url}${path}`, { ...init, headers });
395 };
396
397 const signIn = async (username: string): Promise<string> => {
398 const response = await fetch(`${servers.url}/signin`, {
399 method: "POST",
400 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
401 redirect: "manual",
402 });
403 return `${(response.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=dev`;
404 };
405
406 const source = () => getPromptSource(app.db, sourceId)!;
407 const openIssues = () => listIssues(app.db, repo.id, { limit: 50 });
408
409 beforeAll(async () => {
410 dataDir = await mkdtemp(join(tmpdir(), "forge-prompts-"));
411 app = createApp({ dataDir, port: 0 });
412 servers = await serve(app);
413 routine = startPromptRoutine(app, {
414 client: instagram,
415 now: () => clock,
416 // The test drives the clock; the timer must never run on its own.
417 everyMs: 3_600_000,
418 });
419 const a = await createUser(app.db, {
420 username: "alice",
421 email: "[email protected]",
422 password: "a-good-passphrase",
423 adminIfFirst: true,
424 });
425 if (!a.ok) throw new Error(a.error);
426 alice = a.value;
427 const b = await createUser(app.db, {
428 username: "bob",
429 email: "[email protected]",
430 password: "a-good-passphrase",
431 });
432 if (!b.ok) throw new Error(b.error);
433 bob = b.value;
434 aliceCookie = await signIn("alice");
435 bobCookie = await signIn("bob");
436 adminToken = createToken(app.db, alice.id, "admin", "admin").token;
437 rwToken = createToken(app.db, alice.id, "rw", "rw").token;
438 bobToken = createToken(app.db, bob.id, "admin", "admin").token;
439
440 const made = await createRepo(app, alice, {
441 name: "game",
442 visibility: "public",
443 description: "A game with an audience",
444 });
445 if (!made.ok) throw new Error(String(made.error));
446 repo = made.value;
447 // A builder on the crew, so a tell has somebody to go to.
448 const scout = await createAgent(app, alice, { username: "scout" });
449 if (!scout.ok) throw new Error(String(scout.error));
450 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
451 });
452
453 afterAll(async () => {
454 await routine.stop();
455 servers.stop();
456 app.close();
457 await rm(dataDir, { recursive: true, force: true });
458 });
459
460 test("adding needs a daily HH:MM, and seals the token where nothing shows it again", async () => {
461 for (const good of ["00:00", "09:30", "15:00", "23:59"])
462 expect(`${good}: ${isDailyTime(good)}`).toBe(`${good}: true`);
463 for (const bad of ["24:00", "9:00", "15:60", "15:00:00", "noon", ""])
464 expect(`${bad}: ${isDailyTime(bad)}`).toBe(`${bad}: false`);
465 const insert = (token: string, atUtc: string) =>
466 addPromptSource(app.db, dataDir, {
467 repoId: repo.id,
468 kind: "instagram",
469 token,
470 atUtc,
471 });
472 expect(insert(TOKEN, "25:00")).toEqual({ ok: false, error: "invalid_at" });
473 expect(insert("", "15:00")).toEqual({ ok: false, error: "invalid_token" });
474 expect(insert("has a space", "15:00")).toEqual({
475 ok: false,
476 error: "invalid_token",
477 });
478 expect(listPromptSources(app.db, repo.id)).toEqual([]);
479
480 // Through the API: the codes, then the item — without the token.
481 const badAt = await api(PROMPTS, {
482 method: "POST",
483 body: JSON.stringify({ kind: "instagram", token: TOKEN, at: "24:00" }),
484 });
485 expect(badAt.status).toBe(422);
486 expect(((await badAt.json()) as { error: string }).error).toBe(
487 "invalid_at",
488 );
489 const badKind = await api(PROMPTS, {
490 method: "POST",
491 body: JSON.stringify({ kind: "tiktok", token: TOKEN, at: "15:00" }),
492 });
493 expect(((await badKind.json()) as { error: string }).error).toBe(
494 "invalid_kind",
495 );
496
497 const added = await api(PROMPTS, {
498 method: "POST",
499 body: JSON.stringify({ kind: "instagram", token: TOKEN, at: "15:00" }),
500 });
501 expect(added.status).toBe(201);
502 const text = await added.text();
503 expect(text).not.toContain(TOKEN);
504 const item = JSON.parse(text) as {
505 id: string;
506 kind: string;
507 account: string;
508 atUtc: string;
509 lastRanAt: string | null;
510 lastSaid: string;
511 lastIssueNumber: number | null;
512 tokenRefreshedAt: string;
513 };
514 expect(item).toEqual({
515 id: item.id,
516 kind: "instagram",
517 account: "",
518 atUtc: "15:00",
519 lastRanAt: null,
520 lastSaid: "",
521 lastIssueNumber: null,
522 tokenRefreshedAt: item.tokenRefreshedAt,
523 });
524 expect("token" in item).toBe(false);
525 sourceId = item.id;
526
527 // Sealed in the row, opened only by its own id, absent from every listing.
528 const row = app.db
529 .query<
530 { token_enc: string },
531 [string]
532 >("SELECT token_enc FROM prompt_source WHERE id = ?1")
533 .get(sourceId)!;
534 expect(row.token_enc).not.toContain(TOKEN);
535 expect(row.token_enc.startsWith("v1:")).toBe(true);
536 expect(openPromptToken(app.db, dataDir, sourceId)).toEqual({
537 ok: true,
538 value: TOKEN,
539 });
540 expect(openPromptToken(app.db, dataDir, "nope")).toEqual({
541 ok: false,
542 error: "not_found",
543 });
544 expect(JSON.stringify(listPromptSources(app.db, repo.id))).not.toContain(
545 TOKEN,
546 );
547 expect(await (await api(PROMPTS)).text()).not.toContain(TOKEN);
548 expect(await (await web(aliceCookie, SETTINGS)).text()).not.toContain(
549 TOKEN,
550 );
551 expect(source().createdBy).toBe(alice.id);
552
553 // One account per repository.
554 const again = await api(PROMPTS, {
555 method: "POST",
556 body: JSON.stringify({ kind: "instagram", token: TOKEN, at: "16:00" }),
557 });
558 expect(again.status).toBe(422);
559 expect(((await again.json()) as { error: string }).error).toBe(
560 "already_connected",
561 );
562 });
563
564 test("nothing before the hour; once after it; not again the same day", async () => {
565 instagram.reel = reelOf("reel-1", "2026-09-27");
566 instagram.replies = [
567 comment("c2", "first!!", 2, at("2026-09-27T18:01:00+0000"), "quick_one"),
568 comment(
569 "c1",
570 "Add a boss fight at the end of level 3\nIt feels empty right now.",
571 5,
572 at("2026-09-27T18:05:00+0000"),
573 "fan_one",
574 ),
575 comment("c3", "", 99, at("2026-09-27T18:00:00+0000"), "blank"),
576 ];
577 await tick("2026-09-28T14:59:00Z");
578 expect(instagram.names()).toEqual([]);
579 expect(source().lastRanAt).toBeNull();
580 expect(openIssues()).toEqual([]);
581
582 await tick("2026-09-28T15:00:00Z");
583 expect(instagram.names()).toEqual(["me", "reel", "comments:reel-1"]);
584 expect(instagram.calls.every((c) => c.token === TOKEN)).toBe(true);
585 const ran = source();
586 expect(ran.account).toBe("gamehandle");
587 expect(ran.lastRanAt).toBe("2026-09-28T15:00:00.000Z");
588 expect(ran.lastMediaId).toBe("reel-1");
589 expect(ran.lastIssueNumber).toBe(1);
590 expect(ran.lastSaid).toBe(
591 'Told the crew #1: "Add a boss fight at the end of level 3" — @fan_one, 5 likes.',
592 );
593
594 // The tell: the comment's first line is the title, the rest and the
595 // provenance are the body, and the builder has it.
596 const issue = getIssue(app.db, repo.id, 1)!;
597 expect(issue.title).toBe("Add a boss fight at the end of level 3");
598 expect(issue.body).toBe(
599 "Add a boss fight at the end of level 3\nIt feels empty right now.\n\nFrom Instagram: the top comment (5 likes) by @fan_one on the Reel of 2026-09-27. https://www.instagram.com/reel/reel-1/",
600 );
601 expect(issue.authorName).toBe("alice");
602 expect(issue.assigneeName).toBe("scout");
603 const told = listEvents(app.db, { repoId: repo.id, limit: 50 }).filter(
604 (e) => e.kind === "prompt.told",
605 );
606 expect(told).toHaveLength(1);
607 expect(told[0]?.payload).toEqual({
608 source: sourceId,
609 kind: "instagram",
610 mediaId: "reel-1",
611 commentId: "c1",
612 issue: 1,
613 });
614 expect(told[0]?.actorId).toBeNull();
615
616 // Later the same day: nothing more.
617 await tick("2026-09-28T15:30:00Z");
618 await tick("2026-09-28T23:59:00Z");
619 expect(instagram.names()).toEqual(["me", "reel", "comments:reel-1"]);
620 expect(source().lastRanAt).toBe("2026-09-28T15:00:00.000Z");
621 });
622
623 test("the same Reel is never told twice", async () => {
624 instagram.calls.length = 0;
625 await tick("2026-09-29T15:00:00Z");
626 // The account is known, so `me` is not asked again; the Reel is known,
627 // so its comments are not read.
628 expect(instagram.names()).toEqual(["reel"]);
629 expect(source().lastSaid).toBe(
630 "No new Reel since 2026-09-27; the last one was told as #1.",
631 );
632 expect(source().lastRanAt).toBe("2026-09-29T15:00:00.000Z");
633 expect(openIssues()).toHaveLength(1);
634 });
635
636 test("a Reel with no comments yet is looked at again tomorrow", async () => {
637 instagram.calls.length = 0;
638 instagram.reel = reelOf("reel-2", "2026-09-29");
639 instagram.replies = [];
640 await tick("2026-09-30T15:00:00Z");
641 expect(instagram.names()).toEqual(["reel", "comments:reel-2"]);
642 expect(source().lastSaid).toBe("The latest Reel has no comments yet.");
643 expect(source().lastMediaId).toBe("reel-1");
644 expect(source().lastIssueNumber).toBe(1);
645 expect(openIssues()).toHaveLength(1);
646
647 instagram.calls.length = 0;
648 instagram.replies = [
649 comment(
650 "c9",
651 "Make the jump feel heavier",
652 1,
653 at("2026-09-30T09:00:00+0000"),
654 "ana",
655 ),
656 ];
657 await tick("2026-10-01T15:00:00Z");
658 expect(instagram.names()).toEqual(["reel", "comments:reel-2"]);
659 expect(source().lastMediaId).toBe("reel-2");
660 expect(source().lastIssueNumber).toBe(2);
661 expect(source().lastSaid).toBe(
662 'Told the crew #2: "Make the jump feel heavier" — @ana, 1 like.',
663 );
664 expect(getIssue(app.db, repo.id, 2)?.body).toContain(
665 "the top comment (1 like) by @ana on the Reel of 2026-09-29.",
666 );
667 });
668
669 test("no Reel at all is a sentence", async () => {
670 instagram.reel = undefined;
671 await tick("2026-10-02T15:00:00Z");
672 expect(source().lastSaid).toBe("No Reel yet.");
673 expect(source().lastMediaId).toBe("reel-2");
674 });
675
676 test("Instagram's refusals are sentences, never throws", async () => {
677 instagram.failWith = new InstagramError(
678 190,
679 "Error validating access token: Session has expired",
680 "OAuthException",
681 );
682 await tick("2026-10-03T15:00:00Z");
683 expect(source().lastSaid).toBe(
684 "Instagram no longer accepts the token; connect the account again.",
685 );
686 expect(source().lastRanAt).toBe("2026-10-03T15:00:00.000Z");
687
688 instagram.failWith = new InstagramError(
689 4,
690 "Application request limit reached",
691 );
692 await tick("2026-10-04T15:00:00Z");
693 expect(source().lastSaid).toBe(
694 "Instagram answered: Application request limit reached.",
695 );
696 instagram.failWith = new Error("socket hang up");
697 await tick("2026-10-05T15:00:00Z");
698 expect(source().lastSaid).toBe("The run failed: socket hang up.");
699 instagram.failWith = undefined;
700 // Nothing above told anybody anything.
701 expect(openIssues()).toHaveLength(2);
702 });
703
704 test("a token older than fifty days is refreshed, and the new one is what runs from then on", async () => {
705 const before = source().tokenRefreshedAt;
706 instagram.calls.length = 0;
707 instagram.reel = reelOf("reel-3", "2026-11-19");
708 instagram.replies = [
709 comment("c20", "A hard mode", 8, at("2026-11-19T20:00:00+0000"), "vet"),
710 ];
711 // Pasted 2026-09-28; fifty-three days on.
712 await tick("2026-11-20T15:00:00Z");
713 expect(instagram.calls).toEqual([
714 { call: "refresh", token: TOKEN },
715 { call: "reel", token: FRESH },
716 { call: "comments:reel-3", token: FRESH },
717 ]);
718 expect(source().tokenRefreshedAt).toBe("2026-11-20T15:00:00.000Z");
719 expect(source().tokenRefreshedAt).not.toBe(before);
720 expect(openPromptToken(app.db, dataDir, sourceId)).toEqual({
721 ok: true,
722 value: FRESH,
723 });
724 expect(source().lastIssueNumber).toBe(3);
725
726 // The next day: fresh, no refresh.
727 instagram.calls.length = 0;
728 await tick("2026-11-21T15:00:00Z");
729 expect(instagram.calls).toEqual([{ call: "reel", token: FRESH }]);
730 // And the sealed row never held either token in the clear.
731 const row = app.db
732 .query<
733 { token_enc: string },
734 [string]
735 >("SELECT token_enc FROM prompt_source WHERE id = ?1")
736 .get(sourceId)!;
737 expect(row.token_enc).not.toContain(FRESH);
738 expect(row.token_enc).not.toContain(TOKEN);
739 });
740
741 test("Fetch now runs at once, whatever the hour, through the API", async () => {
742 // Already ran today; a new Reel is up.
743 instagram.calls.length = 0;
744 instagram.reel = reelOf("reel-4", "2026-11-21");
745 instagram.replies = [
746 comment("c30", "Co-op please", 12, at("2026-11-21T12:00:00+0000"), "duo"),
747 ];
748 const ran = await api(`${PROMPTS}/${sourceId}/run`, { method: "POST" });
749 expect(ran.status).toBe(200);
750 const text = await ran.text();
751 expect(text).not.toContain(FRESH);
752 const item = JSON.parse(text) as {
753 lastSaid: string;
754 lastIssueNumber: number;
755 account: string;
756 };
757 expect(item.lastSaid).toBe(
758 'Told the crew #4: "Co-op please" — @duo, 12 likes.',
759 );
760 expect(item.lastIssueNumber).toBe(4);
761 expect(item.account).toBe("gamehandle");
762 expect(instagram.names()).toEqual(["reel", "comments:reel-4"]);
763 expect(getIssue(app.db, repo.id, 4)?.title).toBe("Co-op please");
764
765 const unknown = await api(`${PROMPTS}/nope/run`, { method: "POST" });
766 expect(unknown.status).toBe(422);
767 expect(((await unknown.json()) as { error: string }).error).toBe(
768 "no_such_source",
769 );
770 });
771
772 test("admin only, like the domains: scope, then admin over the repository", async () => {
773 expect((await api(PROMPTS, { token: rwToken })).status).toBe(403);
774 expect((await api(PROMPTS, { token: null })).status).toBe(401);
775 expect((await api(PROMPTS, { token: bobToken })).status).toBe(404);
776 expect(
777 (
778 await api(PROMPTS, {
779 method: "POST",
780 body: JSON.stringify({
781 kind: "instagram",
782 token: "stolen",
783 at: "15:00",
784 }),
785 token: bobToken,
786 })
787 ).status,
788 ).toBe(404);
789 expect(
790 (
791 await api(`${PROMPTS}/${sourceId}/run`, {
792 method: "POST",
793 token: bobToken,
794 })
795 ).status,
796 ).toBe(404);
797 expect(
798 (
799 await api(`${PROMPTS}/${sourceId}/run`, {
800 method: "POST",
801 token: rwToken,
802 })
803 ).status,
804 ).toBe(403);
805 expect(
806 (
807 await api(`${PROMPTS}/${sourceId}`, {
808 method: "DELETE",
809 token: rwToken,
810 })
811 ).status,
812 ).toBe(403);
813 expect(
814 (
815 await api(`${PROMPTS}/${sourceId}`, {
816 method: "DELETE",
817 token: bobToken,
818 })
819 ).status,
820 ).toBe(404);
821 expect(getPromptSource(app.db, sourceId)).toBeDefined();
822
823 // The pages: a non-admin meets the one 404; another origin, 403.
824 expect((await web(bobCookie, SETTINGS)).status).toBe(404);
825 expect((await web("", SETTINGS)).status).toBe(404);
826 expect(
827 (
828 await web(bobCookie, SETTINGS, {
829 form: { token: "stolen", at: "15:00" },
830 })
831 ).status,
832 ).toBe(404);
833 expect(
834 (await web(bobCookie, `${SETTINGS}/${sourceId}/run`, { form: {} }))
835 .status,
836 ).toBe(404);
837 expect(
838 (await web(bobCookie, `${SETTINGS}/${sourceId}/delete`, { form: {} }))
839 .status,
840 ).toBe(404);
841 expect(
842 (
843 await web(aliceCookie, `${SETTINGS}/${sourceId}/delete`, {
844 form: {},
845 origin: "https://evil.example",
846 })
847 ).status,
848 ).toBe(403);
849 expect(getPromptSource(app.db, sourceId)).toBeDefined();
850 expect(listPromptSources(app.db, repo.id)).toHaveLength(1);
851 });
852
853 test("the settings page: the card, the steps, the doors", async () => {
854 const page = words(await (await web(aliceCookie, SETTINGS)).text());
855 expect(page).toContain(">Prompts<");
856 expect(page).toContain("<h2>Instagram</h2>");
857 expect(page).toContain("@gamehandle");
858 expect(page).toContain("every day at 15:00 UTC");
859 expect(page).toContain("Told the crew #4");
860 expect(page).toContain("Token refreshed");
861 expect(page).toContain("refreshes it again around 2027-01-09");
862 expect(page).toContain("Fetch now");
863 expect(page).toContain(`${SETTINGS}/${sourceId}/run`);
864 expect(page).toContain(
865 encodeURIComponent(`${SETTINGS}/${sourceId}/delete`),
866 );
867 expect(page).toContain("instagram_business_basic");
868 expect(page).toContain("instagram_business_manage_comments");
869 expect(page).toContain("Business Login for Instagram");
870 expect(page).toContain("never shown again");
871 expect(page).not.toContain(FRESH);
872 expect(page).not.toContain(TOKEN);
873
874 // The confirm page knows this door.
875 const confirm = await web(
876 aliceCookie,
877 `/-/confirm?to=${encodeURIComponent(`${SETTINGS}/${sourceId}/delete`)}`,
878 );
879 expect(confirm.status).toBe(200);
880 expect(await confirm.text()).toContain("Disconnect Instagram?");
881
882 // Fetch now from the page flashes what the run said.
883 instagram.calls.length = 0;
884 const fetched = await web(aliceCookie, `${SETTINGS}/${sourceId}/run`, {
885 form: {},
886 });
887 expect(fetched.status).toBe(303);
888 expect(fetched.headers.get("location")).toBe(SETTINGS);
889 expect(flashOf(fetched)).toContain(
890 "No new Reel since 2026-11-21; the last one was told as #4.",
891 );
892 expect(instagram.names()).toEqual(["reel"]);
893
894 // Remove, and the form is back.
895 const removed = await web(aliceCookie, `${SETTINGS}/${sourceId}/delete`, {
896 form: {},
897 });
898 expect(removed.status).toBe(303);
899 expect(flashOf(removed)).toContain("Instagram is disconnected");
900 expect(getPromptSource(app.db, sourceId)).toBeUndefined();
901 const empty = words(await (await web(aliceCookie, SETTINGS)).text());
902 expect(empty).toContain(
903 "Once a day, the top comment on your latest Reel becomes the crew's next tell.",
904 );
905 expect(empty).toContain('type="password"');
906 expect(empty).toContain('name="token"');
907 expect(empty).toContain('type="time"');
908 expect(empty).toContain('value="15:00"');
909 expect(empty).not.toContain("Fetch now");
910 const gone = await web(aliceCookie, `${SETTINGS}/${sourceId}/delete`, {
911 form: {},
912 });
913 expect(flashOf(gone)).toContain("No account by that id");
914
915 // Adding from the form, at another hour.
916 const refused = await web(aliceCookie, SETTINGS, {
917 form: { token: TOKEN, at: "9:00" },
918 });
919 expect(flashOf(refused)).toContain("hours and minutes in UTC");
920 const added = await web(aliceCookie, SETTINGS, {
921 form: { token: TOKEN, at: "09:30" },
922 });
923 expect(added.status).toBe(303);
924 expect(flashOf(added)).toContain("Instagram is connected");
925 expect(flashOf(added)).toContain("09:30 UTC");
926 const fresh = words(await (await web(aliceCookie, SETTINGS)).text());
927 expect(fresh).toContain("every day at 09:30 UTC");
928 expect(fresh).toContain("not read yet");
929 expect(fresh).toContain("Has not run yet.");
930 expect(fresh).not.toContain(TOKEN);
931 const events = listEvents(app.db, { repoId: repo.id, limit: 50 }).map(
932 (e) => e.kind,
933 );
934 expect(events.filter((k) => k === "prompt.source_added")).toHaveLength(2);
935 expect(events.filter((k) => k === "prompt.source_removed")).toHaveLength(1);
936 });
937
938 test("the API removes, and says so once", async () => {
939 const listed = (await (await api(PROMPTS)).json()) as {
940 items: { id: string; atUtc: string; account: string }[];
941 };
942 expect(listed.items).toHaveLength(1);
943 expect(listed.items[0]?.atUtc).toBe("09:30");
944 expect(listed.items[0]?.account).toBe("");
945 const id = listed.items[0]!.id;
946 const removed = await api(`${PROMPTS}/${id}`, { method: "DELETE" });
947 expect(removed.status).toBe(200);
948 expect(await removed.json()).toEqual({});
949 expect(
950 ((await (await api(PROMPTS)).json()) as { items: unknown[] }).items,
951 ).toEqual([]);
952 const again = await api(`${PROMPTS}/${id}`, { method: "DELETE" });
953 expect(again.status).toBe(422);
954 expect(((await again.json()) as { error: string }).error).toBe(
955 "no_such_source",
956 );
957 // A run now finds nothing due, and asks Instagram nothing.
958 instagram.calls.length = 0;
959 await tick("2026-11-22T15:00:00Z");
960 expect(instagram.names()).toEqual([]);
961 });
962 test("a tell nobody can take is said honestly: opened, not told", async () => {
963 const made = await api(PROMPTS, {
964 method: "POST",
965 body: JSON.stringify({ kind: "instagram", token: TOKEN, at: "15:00" }),
966 });
967 expect(made.status).toBe(201);
968 sourceId = ((await made.json()) as { id: string }).id;
969 expect(removeFromCrew(app, alice, repo, "scout").ok).toBe(true);
970 instagram.failWith = undefined;
971 instagram.reel = reelOf("reel-7", "2026-10-09");
972 instagram.replies = [
973 comment("c7", "Let us pet the dog", 8, at("2026-10-09T18:00:00+0000"), "dogfan"),
974 ];
975 clock = new Date("2026-10-10T15:00:00Z");
976 const before = openIssues().length;
977 await routine.runNow(sourceId);
978 const issue = openIssues().find((i) => i.title === "Let us pet the dog")!;
979 expect(issue).toBeDefined();
980 expect(issue.assigneeName).toBeNull();
981 expect(openIssues()).toHaveLength(before + 1);
982 expect(source().lastSaid).toBe(
983 `Opened #${issue.number}: "Let us pet the dog" — @dogfan, 8 likes; nobody on the crew to take it yet.`,
984 );
985 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
986 });
987
988 test("a sentence about an error never carries the token, even when Instagram's words quote the request", async () => {
989 instagram.failWith = new InstagramError(
990 400,
991 `Bad request to https://graph.instagram.com/v25.0/me/media?fields=id&access_token=${TOKEN}`,
992 );
993 clock = new Date("2026-10-11T15:00:00Z");
994 await routine.runNow(sourceId);
995 const said = source().lastSaid;
996 expect(said).toContain("Instagram answered:");
997 expect(said).not.toContain(TOKEN);
998 expect(await (await api(PROMPTS)).text()).not.toContain(TOKEN);
999 expect(await (await web(aliceCookie, SETTINGS)).text()).not.toContain(TOKEN);
1000 // A plain failure that quotes it, the same.
1001 instagram.failWith = new Error(`socket closed while sending ${TOKEN}`);
1002 await routine.runNow(sourceId);
1003 expect(source().lastSaid).not.toContain(TOKEN);
1004 instagram.failWith = undefined;
1005 });
1006
1007 test("a run claims its row before any network work: two sweeps over one row tell the crew once", async () => {
1008 // A second routine over the same database — two forges over one data
1009 // directory, or a timer started twice — with a slow Instagram.
1010 let release: (() => void) | undefined;
1011 const slow = new FakeInstagram();
1012 slow.reel = reelOf("reel-8", "2026-10-11");
1013 slow.replies = [
1014 comment("c8", "Add a secret door", 6, at("2026-10-11T18:00:00+0000"), "sneak"),
1015 ];
1016 const slowReel = slow.latestReel.bind(slow);
1017 slow.latestReel = async (token) => {
1018 await new Promise<void>((resolve) => {
1019 release = resolve;
1020 });
1021 return slowReel(token);
1022 };
1023 instagram.reel = slow.reel;
1024 instagram.replies = slow.replies;
1025 const other = startPromptRoutine(app, {
1026 client: slow,
1027 now: () => clock,
1028 everyMs: 3_600_000,
1029 });
1030 clock = new Date("2026-10-12T15:00:00Z");
1031 const before = openIssues().length;
1032 const a = other.runDue(clock);
1033 const b = routine.runDue(clock);
1034 await Bun.sleep(20);
1035 release?.();
1036 await Promise.all([a, b]);
1037 await other.stop();
1038 expect(openIssues()).toHaveLength(before + 1);
1039 expect(source().lastMediaId).toBe("reel-8");
1040 // Only one of the two ever spoke to Instagram.
1041 expect([slow.names().length > 0, instagram.names().some((n) => n === "comments:reel-8")].filter(Boolean)).toHaveLength(1);
1042 // The claim itself, in the open: once a day, unless a person insists.
1043 expect(claimPromptRun(app.db, sourceId, clock)).toBe(false);
1044 expect(claimPromptRun(app.db, sourceId, clock, { force: true })).toBe(true);
1045 });
1046
1047 test("stop waits for a run in flight, but not forever", async () => {
1048 let hang: (() => void) | undefined;
1049 const stuck: InstagramClient = {
1050 me: (t) => instagram.me(t),
1051 refresh: (t) => instagram.refresh(t),
1052 comments: (t, id) => instagram.comments(t, id),
1053 latestReel: () =>
1054 new Promise<Reel | undefined>((resolve) => {
1055 hang = () => resolve(undefined);
1056 }),
1057 };
1058 const other = startPromptRoutine(app, {
1059 client: stuck,
1060 now: () => clock,
1061 everyMs: 3_600_000,
1062 stopDeadlineMs: 50,
1063 });
1064 const running = other.runNow(sourceId);
1065 const began = Date.now();
1066 await other.stop();
1067 expect(Date.now() - began).toBeLessThan(2_000);
1068 hang?.();
1069 await running;
1070 });
1071
1072 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs