Public: anyone can read this; the crew can change it.
The crew's next prompt comes from Instagram, once a day #330
Check · Review · Land · Live done — Landed and live in production.
instagram-prompts → main 6 commits
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 | ||
| 713 | 713 | anywhere means the forge's own token, as before. Every run remembers the |
| 714 | 714 | account it spent. |
| 715 | 715 | |
| 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 | ||
| 716 | 784 | ## Configuration |
| 717 | 785 | |
| 718 | 786 | | 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 { | ||
| 26 | 26 | kind.startsWith("webhook.") || |
| 27 | 27 | kind.startsWith("app.env_") || |
| 28 | 28 | kind.startsWith("app.domain.") || |
| 29 | kind.startsWith("prompt.") || | |
| 29 | 30 | kind === "repo.settings_updated" |
| 30 | 31 | ) |
| 31 | 32 | 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[] = [ | ||
| 898 | 898 | ); |
| 899 | 899 | CREATE INDEX app_domain_app ON app_domain(app_id); |
| 900 | 900 | `, |
| 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 | `, | |
| 901 | 925 | ]; |
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 { | ||
| 69 | 69 | if (kind === "app.domain.verified") return `verified the domain ${which}`; |
| 70 | 70 | return `removed the domain ${which}`; |
| 71 | 71 | } |
| 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 | } | |
| 72 | 78 | return WORDS[kind] ?? "acted on this repository"; |
| 73 | 79 | } |
| 74 | 80 | |
| @@ -78,7 +84,9 @@ export function eventActor( | ||
| 78 | 84 | actorName: string | undefined, |
| 79 | 85 | ): string { |
| 80 | 86 | 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.") | |
| 82 | 90 | ? "the forge" |
| 83 | 91 | : "someone"; |
| 84 | 92 | } |
| @@ -120,4 +128,6 @@ const WORDS: Record<string, string> = { | ||
| 120 | 128 | "app.created": "made it an app", |
| 121 | 129 | "app.updated": "changed the app's settings", |
| 122 | 130 | "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", | |
| 123 | 133 | }; |
src/http/api.ts +68
| @@ -159,6 +159,12 @@ import { | ||
| 159 | 159 | listAppDomains, |
| 160 | 160 | removeAppDomain, |
| 161 | 161 | } from "../actions/domains.ts"; |
| 162 | import { | |
| 163 | addPromptSource, | |
| 164 | listPromptSources, | |
| 165 | removePromptSource, | |
| 166 | runPromptSource, | |
| 167 | } from "../actions/prompts.ts"; | |
| 162 | 168 | import { listDomains } from "../core/domains.ts"; |
| 163 | 169 | import type { Database } from "bun:sqlite"; |
| 164 | 170 | import { |
| @@ -2912,6 +2918,68 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2912 | 2918 | }, |
| 2913 | 2919 | ); |
| 2914 | 2920 | |
| 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 | ||
| 2915 | 2983 | // The app's environment: names in, names out, and never a value back. |
| 2916 | 2984 | // `:var`, not `:name` — `:name` is the repository's. |
| 2917 | 2985 | router.add("GET", "/api/v1/repos/:owner/:name/app/env", (ctx) => { |
src/http/server.ts +12
| @@ -180,6 +180,10 @@ import { | ||
| 180 | 180 | labelDelete, |
| 181 | 181 | labelUpdate, |
| 182 | 182 | labelsPage, |
| 183 | promptSourceAdd, | |
| 184 | promptSourceDelete, | |
| 185 | promptSourceRun, | |
| 186 | promptsSettingsPage, | |
| 183 | 187 | repoDelete, |
| 184 | 188 | settingsPage, |
| 185 | 189 | settingsUpdate, |
| @@ -809,6 +813,14 @@ export function buildRouter(): Router<Handler> { | ||
| 809 | 813 | "/:owner/:name/settings/app/domains/:host/delete", |
| 810 | 814 | appDomainDelete, |
| 811 | 815 | ); |
| 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 | ); | |
| 812 | 824 | router.add("GET", "/:owner/:name/settings/crew", crewSettingsPage); |
| 813 | 825 | router.add("POST", "/:owner/:name/settings/crew", crewAdd); |
| 814 | 826 | router.add( |
src/main.ts +7
| @@ -24,6 +24,8 @@ import { health } from "./http/health.ts"; | ||
| 24 | 24 | import { STALE_AFTER_MS } from "./runs/lease.ts"; |
| 25 | 25 | import { startCrew } from "./crew/dispatch.ts"; |
| 26 | 26 | import { typeSafeDecider } from "./crew/decide.ts"; |
| 27 | import { graphInstagram } from "./crew/instagram.ts"; | |
| 28 | import { startPromptRoutine } from "./crew/prompts.ts"; | |
| 27 | 29 | import { pruneTokens } from "./core/tokens.ts"; |
| 28 | 30 | |
| 29 | 31 | // `forge hook <name>` re-enters here from a git hook stub. It relays to the |
| @@ -180,6 +182,10 @@ const crew = appsOn | ||
| 180 | 182 | : { configDir: process.env.FORGE_CLAUDE_CONFIG_DIR }), |
| 181 | 183 | }) |
| 182 | 184 | : 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() }); | |
| 183 | 189 | |
| 184 | 190 | // The webhook delivery worker: poll the outbox for due deliveries. The event |
| 185 | 191 | // row is the source of truth, so a missed tick only delays delivery, never |
| @@ -265,6 +271,7 @@ function shutdown(): void { | ||
| 265 | 271 | clearInterval(syncTimer); |
| 266 | 272 | clearInterval(leaseTimer); |
| 267 | 273 | Promise.all([ |
| 274 | prompts.stop(), | |
| 268 | 275 | crew?.stop({ drain: true }), |
| 269 | 276 | apps?.stop(), |
| 270 | 277 | checks?.stop(), |
src/web/pages/search.ts +9
| @@ -411,6 +411,15 @@ const DESTRUCTIVE: { | ||
| 411 | 411 | phrase: "", |
| 412 | 412 | }), |
| 413 | 413 | }, |
| 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 | }, | |
| 414 | 423 | { |
| 415 | 424 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/labels\/([^/]+)\/delete$/, |
| 416 | 425 | describe: (m) => ({ |
src/web/pages/settings.ts +243
| @@ -22,7 +22,15 @@ import { | ||
| 22 | 22 | removeAppDomain, |
| 23 | 23 | type AppDomainView, |
| 24 | 24 | } from "../../actions/domains.ts"; |
| 25 | import { | |
| 26 | addPromptSource, | |
| 27 | listPromptSources, | |
| 28 | removePromptSource, | |
| 29 | runPromptSource, | |
| 30 | type PromptSourceView, | |
| 31 | } from "../../actions/prompts.ts"; | |
| 25 | 32 | import type { DnsRecord } from "../../apps/domains.ts"; |
| 33 | import { REFRESH_AFTER_DAYS } from "../../crew/prompts.ts"; | |
| 26 | 34 | import { |
| 27 | 35 | listAppEnvNames, |
| 28 | 36 | NAME_MAX, |
| @@ -97,6 +105,7 @@ import { | ||
| 97 | 105 | row, |
| 98 | 106 | selectField, |
| 99 | 107 | time, |
| 108 | timeAgo, | |
| 100 | 109 | userLink, |
| 101 | 110 | } from "../ui.ts"; |
| 102 | 111 | import { crewReadiness } from "./crew.ts"; |
| @@ -106,6 +115,7 @@ type Section = | ||
| 106 | 115 | | "general" |
| 107 | 116 | | "app" |
| 108 | 117 | | "crew" |
| 118 | | "prompts" | |
| 109 | 119 | | "autonomy" |
| 110 | 120 | | "labels" |
| 111 | 121 | | "hooks" |
| @@ -139,6 +149,12 @@ function sections(base: string, active: Section) { | ||
| 139 | 149 | icon: "bot" as const, |
| 140 | 150 | current: active === "crew", |
| 141 | 151 | }, |
| 152 | { | |
| 153 | label: "Prompts", | |
| 154 | href: `${base}/settings/prompts`, | |
| 155 | icon: "comment" as const, | |
| 156 | current: active === "prompts", | |
| 157 | }, | |
| 142 | 158 | { |
| 143 | 159 | label: "Autonomy", |
| 144 | 160 | href: `${base}/settings/autonomy`, |
| @@ -921,6 +937,233 @@ function redeploying(ctx: Ctx, how: string): string { | ||
| 921 | 937 | : ` Production and every preview are starting again ${how}.`; |
| 922 | 938 | } |
| 923 | 939 | |
| 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 | ||
| 924 | 1167 | // ---- crew ---- |
| 925 | 1168 | |
| 926 | 1169 | // GET /:owner/:name/settings/crew |
test/event-audience.test.ts +2
| @@ -107,6 +107,8 @@ describe("who an event is for", () => { | ||
| 107 | 107 | expect(eventAudience("app.env_set")).toBe("admins"); |
| 108 | 108 | expect(eventAudience("app.env_deleted")).toBe("admins"); |
| 109 | 109 | expect(eventAudience("repo.settings_updated")).toBe("admins"); |
| 110 | expect(eventAudience("prompt.told")).toBe("admins"); | |
| 111 | expect(eventAudience("prompt.source_added")).toBe("admins"); | |
| 110 | 112 | expect(eventAudience("repo.pinned")).toBe("actor"); |
| 111 | 113 | expect(eventAudience("push")).toBe("readers"); |
| 112 | 114 | 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 | }); |
new above
offline