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

The prompts settings page and its forms leave settings.ts for src/web/pages/prompts.ts #6319

Merged opened this

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

Merged

mason/forge:mason/6308-the-prompts-settings-page-and-its-forms → main 3 commits fork

  • Merged. mason/6308-the-prompts-settings-page-and-its-forms was merged into main as 2d2b864.

Review

Sign in to review this change.
3 files changed +265 −242
src/http/server.ts +6 −4
@@ -376,10 +376,6 @@ import {
376376 crewRoleUpdate,
377377 crewSettingsPage,
378378 dangerPage,
379 promptSourceAdd,
380 promptSourceDelete,
381 promptSourceRun,
382 promptsSettingsPage,
383379 repoDelete,
384380 settingsPage,
385381 settingsUpdate,
@@ -389,6 +385,12 @@ import {
389385 mirrorStop,
390386 } from "../web/pages/settings.ts";
391387 import { hooksPage, webhookCreate, webhookDelete } from "../web/pages/hooks.ts";
388 import {
389 promptSourceAdd,
390 promptSourceDelete,
391 promptSourceRun,
392 promptsSettingsPage,
393 } from "../web/pages/prompts.ts";
392394 import {
393395 labelCreate,
394396 labelDelete,
src/web/pages/prompts.ts added +259
@@ -0,0 +1,259 @@
1 /**
2 * Settings › Prompts, admin-only: the Instagram account whose top comment on
3 * the latest Reel becomes the crew's next tell, and the three forms that
4 * connect it, fetch it now and remove it. The frame, the tab bar and the
5 * admin gate are the settings page's, imported from it.
6 */
7
8 import {
9 addPromptSource,
10 listPromptSources,
11 removePromptSource,
12 runPromptSource,
13 type PromptSourceView,
14 } from "../../actions/prompts.ts";
15 import { REFRESH_AFTER_DAYS } from "../../crew/prompts.ts";
16 import { timePhase } from "../../core/route_timing.ts";
17 import type { Ctx } from "../../http/context.ts";
18 import { repoBase, repoNotFound } from "../chrome.ts";
19 import { field } from "../form.ts";
20 import { html, type Html } from "../html.ts";
21 import { icon } from "../icons.ts";
22 import { flashTo } from "../layout.ts";
23 import {
24 badge,
25 btn,
26 card,
27 confirmAction,
28 field as formField,
29 time,
30 timeAgo,
31 } from "../ui.ts";
32 import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts";
33
34 const PROMPT_ERRORS: Record<string, string> = {
35 invalid_at: "The time is hours and minutes in UTC, like 15:00.",
36 invalid_token:
37 "Paste the long-lived token as Instagram gave it: one line, no spaces.",
38 invalid_kind: "Only Instagram is known here.",
39 already_connected:
40 "An Instagram account is already connected here. Remove it first.",
41 no_such_source: "No account by that id.",
42 not_running: "The prompt routine is not running on this forge.",
43 };
44
45 /** The day the forge will refresh a token pasted or refreshed at `iso`. */
46 function refreshDay(iso: string): string {
47 return new Date(Date.parse(iso) + REFRESH_AFTER_DAYS * 24 * 3_600_000)
48 .toISOString()
49 .slice(0, 10);
50 }
51
52 /**
53 * The Instagram card: the account and what the last run said when one is
54 * connected, the form when none is. Beneath it, the one-time steps at Meta,
55 * because the forge cannot take them for anyone. The token is a password
56 * field on the way in and nowhere on the way out.
57 */
58 function promptsView(base: string, sources: PromptSourceView[]): Html {
59 const source = sources.find((s) => s.kind === "instagram");
60 const at = (id: string) =>
61 `${base}/settings/prompts/${encodeURIComponent(id)}`;
62 const body =
63 source === undefined
64 ? html`<div class="card-body">
65 <p class="muted small">
66 Once a day, the top comment on your latest Reel becomes the crew's
67 next tell.
68 </p>
69 </div>
70 <form
71 class="card-body"
72 method="post"
73 action="${base}/settings/prompts"
74 >
75 ${formField({
76 label: "Token",
77 name: "token",
78 type: "password",
79 hint: "A long-lived Instagram token. The forge seals it, refreshes it before it expires, and never shows it again.",
80 required: true,
81 autocomplete: "off",
82 mono: true,
83 })}
84 ${formField({
85 label: "Every day at … UTC",
86 name: "at",
87 type: "time",
88 value: "15:00",
89 required: true,
90 })}
91 <div class="form-actions">
92 ${btn("Add", { variant: "primary", icon: "plus" })}
93 </div>
94 </form>`
95 : html`<ul class="list">
96 <li>
97 <div class="row">
98 <span class="row-lead">${icon("comment")}</span>
99 <span class="row-main">
100 <span class="row-title"
101 >${source.account === ""
102 ? html`<span class="muted">not read yet</span>`
103 : `@${source.account}`}
104 ${badge(`every day at ${source.atUtc} UTC`)}</span
105 >
106 <span class="row-meta"
107 >${source.lastRanAt === null
108 ? "Has not run yet."
109 : html`Last ran ${time(source.lastRanAt)}.
110 ${source.lastSaid}`}</span
111 >
112 <span class="row-meta"
113 >Token refreshed ${timeAgo(source.tokenRefreshedAt)}; the
114 forge refreshes it again around
115 ${refreshDay(source.tokenRefreshedAt)}.</span
116 >
117 </span>
118 <span class="row-trail">
119 <form method="post" action="${at(source.id)}/run">
120 ${btn("Fetch now", { size: "sm", icon: "sync" })}
121 </form>
122 ${confirmAction({
123 action: `${at(source.id)}/delete`,
124 verb: "Remove",
125 size: "sm",
126 })}
127 </span>
128 </div>
129 </li>
130 </ul>`;
131 return html`${card(body, {
132 head: html`${icon("comment")}
133 <h2>Instagram</h2>`,
134 pad: false,
135 })}
136 ${card(
137 html`<p class="muted small">
138 The one-time steps at Meta, for a professional account of your own. None
139 of them needs App Review.
140 </p>
141 <ol class="muted">
142 <li>
143 Make the Instagram account a professional one — Business or Creator —
144 in the Instagram app's settings.
145 </li>
146 <li>
147 At developers.facebook.com, make a Meta app and add the Instagram
148 product with <b>Business Login for Instagram</b>. No Facebook Page is
149 needed.
150 </li>
151 <li>
152 Get a token for the account with the scopes
153 <span class="mono">instagram_business_basic</span> and
154 <span class="mono">instagram_business_manage_comments</span>.
155 </li>
156 <li>
157 Exchange it for a long-lived token, the one that lives sixty days; the
158 product's docs show the one call.
159 </li>
160 <li>
161 Paste the long-lived token above. The forge refreshes it before it
162 expires, and the paste is never shown again.
163 </li>
164 </ol>`,
165 { head: html`<h2>Getting a token</h2>` },
166 )}`;
167 }
168
169 // GET /:owner/:name/settings/prompts
170 export function promptsSettingsPage(ctx: Ctx): Response {
171 const repo = timedAdminRepo(ctx);
172 if (!repo) return repoNotFound(ctx);
173 const listed = timePhase(ctx.request, "sources", () => listPromptSources(ctx.app, ctx.auth.user, repo));
174 return timePhase(ctx.request, "render", () =>
175 settingsPageResponse(
176 ctx,
177 repo,
178 "prompts",
179 "Prompts",
180 promptsView(repoBase(repo), listed.ok ? listed.value : []),
181 ),
182 );
183 }
184
185 // POST /:owner/:name/settings/prompts — connect an account.
186 export async function promptSourceAdd(ctx: Ctx): Promise<Response> {
187 const gate = await adminForm(ctx);
188 if (gate instanceof Response) return gate;
189 const { repo, form } = gate;
190 const added = addPromptSource(ctx.app, ctx.auth.user, repo, {
191 kind: "instagram",
192 token: field(form, "token"),
193 at: field(form, "at"),
194 });
195 if (!added.ok && added.error === "not_found") return repoNotFound(ctx);
196 return flashTo(
197 ctx,
198 `${repoBase(repo)}/settings/prompts`,
199 added.ok
200 ? {
201 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.`,
202 }
203 : {
204 tone: "danger",
205 message:
206 PROMPT_ERRORS[added.error] ?? "The account could not be connected.",
207 },
208 );
209 }
210
211 // POST /:owner/:name/settings/prompts/:id/run — look at Instagram now.
212 export async function promptSourceRun(ctx: Ctx): Promise<Response> {
213 const gate = await adminForm(ctx);
214 if (gate instanceof Response) return gate;
215 const { repo } = gate;
216 const ran = await runPromptSource(
217 ctx.app,
218 ctx.auth.user,
219 repo,
220 ctx.params.id ?? "",
221 );
222 if (!ran.ok && ran.error === "not_found") return repoNotFound(ctx);
223 return flashTo(
224 ctx,
225 `${repoBase(repo)}/settings/prompts`,
226 ran.ok
227 ? {
228 ...(ran.value.lastSaid.startsWith("Told the crew")
229 ? {}
230 : { tone: "attn" as const }),
231 message: ran.value.lastSaid,
232 }
233 : {
234 tone: "danger",
235 message: PROMPT_ERRORS[ran.error] ?? "No account by that id.",
236 },
237 );
238 }
239
240 // POST /:owner/:name/settings/prompts/:id/delete
241 export async function promptSourceDelete(ctx: Ctx): Promise<Response> {
242 const gate = await adminForm(ctx);
243 if (gate instanceof Response) return gate;
244 const { repo } = gate;
245 const removed = removePromptSource(
246 ctx.app,
247 ctx.auth.user,
248 repo,
249 ctx.params.id ?? "",
250 );
251 if (!removed.ok && removed.error === "not_found") return repoNotFound(ctx);
252 return flashTo(
253 ctx,
254 `${repoBase(repo)}/settings/prompts`,
255 removed.ok
256 ? { message: "Instagram is disconnected. The token is forgotten." }
257 : { tone: "danger", message: "No account by that id." },
258 );
259 }
src/web/pages/settings.ts −238
@@ -31,15 +31,7 @@ import {
3131 revokeAppEgress,
3232 type EgressView,
3333 } from "../../actions/egress_grants.ts";
34 import {
35 addPromptSource,
36 listPromptSources,
37 removePromptSource,
38 runPromptSource,
39 type PromptSourceView,
40 } from "../../actions/prompts.ts";
4134 import type { DnsRecord } from "../../apps/domains.ts";
42 import { REFRESH_AFTER_DAYS } from "../../crew/prompts.ts";
4335 import {
4436 listAppEnvNames,
4537 NAME_MAX,
@@ -103,7 +95,6 @@ import {
10395 row,
10496 selectField,
10597 time,
106 timeAgo,
10798 tlEvent,
10899 } from "../ui.ts";
109100 import { cutWaveFor } from "../../actions/cut_wave.ts";
@@ -1438,235 +1429,6 @@ function redeploying(ctx: Ctx, how: string): string {
14381429 : ` Production and every preview are starting again ${how}.`;
14391430 }
14401431
1441 // ---- prompts ----
1442
1443 const PROMPT_ERRORS: Record<string, string> = {
1444 invalid_at: "The time is hours and minutes in UTC, like 15:00.",
1445 invalid_token:
1446 "Paste the long-lived token as Instagram gave it: one line, no spaces.",
1447 invalid_kind: "Only Instagram is known here.",
1448 already_connected:
1449 "An Instagram account is already connected here. Remove it first.",
1450 no_such_source: "No account by that id.",
1451 not_running: "The prompt routine is not running on this forge.",
1452 };
1453
1454 /** The day the forge will refresh a token pasted or refreshed at `iso`. */
1455 function refreshDay(iso: string): string {
1456 return new Date(Date.parse(iso) + REFRESH_AFTER_DAYS * 24 * 3_600_000)
1457 .toISOString()
1458 .slice(0, 10);
1459 }
1460
1461 /**
1462 * The Instagram card: the account and what the last run said when one is
1463 * connected, the form when none is. Beneath it, the one-time steps at Meta,
1464 * because the forge cannot take them for anyone. The token is a password
1465 * field on the way in and nowhere on the way out.
1466 */
1467 function promptsView(base: string, sources: PromptSourceView[]): Html {
1468 const source = sources.find((s) => s.kind === "instagram");
1469 const at = (id: string) =>
1470 `${base}/settings/prompts/${encodeURIComponent(id)}`;
1471 const body =
1472 source === undefined
1473 ? html`<div class="card-body">
1474 <p class="muted small">
1475 Once a day, the top comment on your latest Reel becomes the crew's
1476 next tell.
1477 </p>
1478 </div>
1479 <form
1480 class="card-body"
1481 method="post"
1482 action="${base}/settings/prompts"
1483 >
1484 ${formField({
1485 label: "Token",
1486 name: "token",
1487 type: "password",
1488 hint: "A long-lived Instagram token. The forge seals it, refreshes it before it expires, and never shows it again.",
1489 required: true,
1490 autocomplete: "off",
1491 mono: true,
1492 })}
1493 ${formField({
1494 label: "Every day at … UTC",
1495 name: "at",
1496 type: "time",
1497 value: "15:00",
1498 required: true,
1499 })}
1500 <div class="form-actions">
1501 ${btn("Add", { variant: "primary", icon: "plus" })}
1502 </div>
1503 </form>`
1504 : html`<ul class="list">
1505 <li>
1506 <div class="row">
1507 <span class="row-lead">${icon("comment")}</span>
1508 <span class="row-main">
1509 <span class="row-title"
1510 >${source.account === ""
1511 ? html`<span class="muted">not read yet</span>`
1512 : `@${source.account}`}
1513 ${badge(`every day at ${source.atUtc} UTC`)}</span
1514 >
1515 <span class="row-meta"
1516 >${source.lastRanAt === null
1517 ? "Has not run yet."
1518 : html`Last ran ${time(source.lastRanAt)}.
1519 ${source.lastSaid}`}</span
1520 >
1521 <span class="row-meta"
1522 >Token refreshed ${timeAgo(source.tokenRefreshedAt)}; the
1523 forge refreshes it again around
1524 ${refreshDay(source.tokenRefreshedAt)}.</span
1525 >
1526 </span>
1527 <span class="row-trail">
1528 <form method="post" action="${at(source.id)}/run">
1529 ${btn("Fetch now", { size: "sm", icon: "sync" })}
1530 </form>
1531 ${confirmAction({
1532 action: `${at(source.id)}/delete`,
1533 verb: "Remove",
1534 size: "sm",
1535 })}
1536 </span>
1537 </div>
1538 </li>
1539 </ul>`;
1540 return html`${card(body, {
1541 head: html`${icon("comment")}
1542 <h2>Instagram</h2>`,
1543 pad: false,
1544 })}
1545 ${card(
1546 html`<p class="muted small">
1547 The one-time steps at Meta, for a professional account of your own. None
1548 of them needs App Review.
1549 </p>
1550 <ol class="muted">
1551 <li>
1552 Make the Instagram account a professional one — Business or Creator —
1553 in the Instagram app's settings.
1554 </li>
1555 <li>
1556 At developers.facebook.com, make a Meta app and add the Instagram
1557 product with <b>Business Login for Instagram</b>. No Facebook Page is
1558 needed.
1559 </li>
1560 <li>
1561 Get a token for the account with the scopes
1562 <span class="mono">instagram_business_basic</span> and
1563 <span class="mono">instagram_business_manage_comments</span>.
1564 </li>
1565 <li>
1566 Exchange it for a long-lived token, the one that lives sixty days; the
1567 product's docs show the one call.
1568 </li>
1569 <li>
1570 Paste the long-lived token above. The forge refreshes it before it
1571 expires, and the paste is never shown again.
1572 </li>
1573 </ol>`,
1574 { head: html`<h2>Getting a token</h2>` },
1575 )}`;
1576 }
1577
1578 // GET /:owner/:name/settings/prompts
1579 export function promptsSettingsPage(ctx: Ctx): Response {
1580 const repo = timedAdminRepo(ctx);
1581 if (!repo) return repoNotFound(ctx);
1582 const listed = timePhase(ctx.request, "sources", () => listPromptSources(ctx.app, ctx.auth.user, repo));
1583 return timePhase(ctx.request, "render", () =>
1584 settingsPageResponse(
1585 ctx,
1586 repo,
1587 "prompts",
1588 "Prompts",
1589 promptsView(repoBase(repo), listed.ok ? listed.value : []),
1590 ),
1591 );
1592 }
1593
1594 // POST /:owner/:name/settings/prompts — connect an account.
1595 export async function promptSourceAdd(ctx: Ctx): Promise<Response> {
1596 const gate = await adminForm(ctx);
1597 if (gate instanceof Response) return gate;
1598 const { repo, form } = gate;
1599 const added = addPromptSource(ctx.app, ctx.auth.user, repo, {
1600 kind: "instagram",
1601 token: field(form, "token"),
1602 at: field(form, "at"),
1603 });
1604 if (!added.ok && added.error === "not_found") return repoNotFound(ctx);
1605 return flashTo(
1606 ctx,
1607 `${repoBase(repo)}/settings/prompts`,
1608 added.ok
1609 ? {
1610 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.`,
1611 }
1612 : {
1613 tone: "danger",
1614 message:
1615 PROMPT_ERRORS[added.error] ?? "The account could not be connected.",
1616 },
1617 );
1618 }
1619
1620 // POST /:owner/:name/settings/prompts/:id/run — look at Instagram now.
1621 export async function promptSourceRun(ctx: Ctx): Promise<Response> {
1622 const gate = await adminForm(ctx);
1623 if (gate instanceof Response) return gate;
1624 const { repo } = gate;
1625 const ran = await runPromptSource(
1626 ctx.app,
1627 ctx.auth.user,
1628 repo,
1629 ctx.params.id ?? "",
1630 );
1631 if (!ran.ok && ran.error === "not_found") return repoNotFound(ctx);
1632 return flashTo(
1633 ctx,
1634 `${repoBase(repo)}/settings/prompts`,
1635 ran.ok
1636 ? {
1637 ...(ran.value.lastSaid.startsWith("Told the crew")
1638 ? {}
1639 : { tone: "attn" as const }),
1640 message: ran.value.lastSaid,
1641 }
1642 : {
1643 tone: "danger",
1644 message: PROMPT_ERRORS[ran.error] ?? "No account by that id.",
1645 },
1646 );
1647 }
1648
1649 // POST /:owner/:name/settings/prompts/:id/delete
1650 export async function promptSourceDelete(ctx: Ctx): Promise<Response> {
1651 const gate = await adminForm(ctx);
1652 if (gate instanceof Response) return gate;
1653 const { repo } = gate;
1654 const removed = removePromptSource(
1655 ctx.app,
1656 ctx.auth.user,
1657 repo,
1658 ctx.params.id ?? "",
1659 );
1660 if (!removed.ok && removed.error === "not_found") return repoNotFound(ctx);
1661 return flashTo(
1662 ctx,
1663 `${repoBase(repo)}/settings/prompts`,
1664 removed.ok
1665 ? { message: "Instagram is disconnected. The token is forgotten." }
1666 : { tone: "danger", message: "No account by that id." },
1667 );
1668 }
1669
16701432 // ---- crew ----
16711433
16721434 // GET /:owner/:name/settings/crew

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs