pi-roundtable-web 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +165 -12
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-ghq105t4.js +1 -0
- package/dist/chunk-action-menu-me0g9qsc.js +1 -0
- package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
- package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
- package/dist/chunk-character-creator-bd426yjw.js +1 -0
- package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
- package/dist/chunk-confirm-p3mts8mx.js +1 -0
- package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
- package/dist/chunk-conversations-0e0vbxwr.js +1 -0
- package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
- package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
- package/dist/chunk-folder-list-era3tksh.js +1 -0
- package/dist/chunk-folds-hvws0bd5.js +1 -0
- package/dist/chunk-gate-hc7dd0fx.js +1 -0
- package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
- package/dist/chunk-group-table-fqmxgdfw.js +1 -0
- package/dist/chunk-host-icons-4xde9ntk.js +1 -0
- package/dist/chunk-host-picture-014yzk20.js +1 -0
- package/dist/chunk-host-state-t56e5sdy.js +1 -0
- package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
- package/dist/chunk-index-0fmmvgee.js +2 -0
- package/dist/chunk-index-0q15ysrb.js +1 -0
- package/dist/chunk-index-0xh24y25.js +2 -0
- package/dist/chunk-index-2yebd9tb.js +426 -0
- package/dist/chunk-index-4t0r3t3b.js +2 -0
- package/dist/chunk-index-5yzfrf7b.js +138 -0
- package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
- package/dist/chunk-index-73gh7kgr.js +920 -0
- package/dist/chunk-index-7xffskfa.js +8 -0
- package/dist/chunk-index-aqw7cy5j.js +1 -0
- package/dist/chunk-index-b6ca02e8.js +2 -0
- package/dist/chunk-index-b8nvhcx5.js +2 -0
- package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
- package/dist/chunk-index-czdketze.js +2 -0
- package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
- package/dist/chunk-index-eg55mqqe.js +1 -0
- package/dist/chunk-index-gr6e0hse.js +2 -0
- package/dist/chunk-index-gyary7np.js +13 -0
- package/dist/chunk-index-j214tczc.js +1 -0
- package/dist/chunk-index-jd6632ef.js +2 -0
- package/dist/chunk-index-jw7dtshr.js +2 -0
- package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
- package/dist/chunk-index-kwcx62wb.js +4 -0
- package/dist/chunk-index-m7fvw92f.js +2 -0
- package/dist/chunk-index-mmg788a1.js +2 -0
- package/dist/chunk-index-mq0dacwr.js +2 -0
- package/dist/chunk-index-p53ak25v.js +2 -0
- package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
- package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
- package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
- package/dist/chunk-index-rnxj3z8k.js +2 -0
- package/dist/chunk-index-snzzfhk3.js +2 -0
- package/dist/chunk-index-v3bmr0nw.js +2 -0
- package/dist/chunk-index-vcyzwtk2.js +2 -0
- package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
- package/dist/chunk-index-w31c4yjv.js +2 -0
- package/dist/chunk-index-wrm8w165.js +2 -0
- package/dist/chunk-index-xh0mg9cw.js +2 -0
- package/dist/chunk-index-ybtvvx85.js +2 -0
- package/dist/chunk-index-yzsgsvnp.js +2 -0
- package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
- package/dist/chunk-index-zrvg23jd.js +2 -0
- package/dist/chunk-index-zynq0b0w.js +50 -0
- package/dist/chunk-lang-css-88v3hp3d.js +1 -0
- package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
- package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
- package/dist/chunk-lang-html-07d47es4.js +1 -0
- package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
- package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
- package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
- package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
- package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
- package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
- package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
- package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
- package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
- package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
- package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
- package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
- package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
- package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
- package/dist/chunk-note-row-kd0fy7ee.js +75 -0
- package/dist/chunk-notes-yb8f057c.js +1 -0
- package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
- package/dist/chunk-notices-x07da5bb.js +1 -0
- package/dist/chunk-onboarding-csrkbh9z.js +1 -0
- package/dist/chunk-overview-avc2d30t.js +1 -0
- package/dist/chunk-palette-9nhp95bz.js +1 -0
- package/dist/chunk-placement-items-q694dpgk.js +1 -0
- package/dist/chunk-profile-6qemf3m7.js +1 -0
- package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
- package/dist/chunk-prompts-nv6mrn0q.js +1 -0
- package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
- package/dist/chunk-room-menu-rr6nzap1.js +1 -0
- package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
- package/dist/chunk-schedules-rw57nr0m.js +1 -0
- package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
- package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
- package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
- package/dist/chunk-skills-tnr1h16h.js +1 -0
- package/dist/chunk-switcher-list-17enhrse.js +1 -0
- package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
- package/dist/chunk-table-r12cja6p.js +1 -0
- package/dist/chunk-table-seats-g3t392zz.js +1 -0
- package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
- package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
- package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
- package/dist/chunk-transcript-n2s6dher.js +1 -0
- package/dist/chunk-where-f3scdydc.js +1 -0
- package/dist/chunk-workspace-xs023bxm.js +1 -0
- package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +5 -4
- package/src/api-types.ts +356 -4
- package/src/app/app.ts +201 -32
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/presence.ts +2 -2
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +153 -14
- package/src/app/rooms.ts +39 -0
- package/src/app/routes.ts +168 -28
- package/src/app/socket.ts +349 -81
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/threads.ts +31 -2
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +77 -8
- package/src/auth/login-page.ts +4 -2
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +62 -0
- package/src/auth/oidc.ts +649 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +173 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +102 -12
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +112 -0
- package/src/host/context.ts +188 -0
- package/src/host/forms.ts +240 -0
- package/src/host/frames.ts +349 -0
- package/src/host/host-app.ts +178 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +396 -0
- package/src/host/pages.ts +266 -0
- package/src/host/reads.ts +155 -0
- package/src/host/retention.ts +41 -0
- package/src/host/room-views.ts +137 -0
- package/src/host/rooms-service.ts +429 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +106 -0
- package/src/i18n/zh-tw.ts +139 -0
- package/src/index.ts +15 -1
- package/src/notices/api.ts +43 -0
- package/src/notices/mount.ts +292 -0
- package/src/notices/service.ts +294 -0
- package/src/notices/store.ts +298 -0
- package/src/notices/urls.ts +38 -0
- package/src/onboarding/api.ts +32 -0
- package/src/onboarding/service.ts +149 -0
- package/src/onboarding/steps.ts +75 -0
- package/src/onboarding/waivers.ts +40 -0
- package/src/options.ts +534 -34
- package/src/page-presentation.ts +47 -2
- package/src/profile/api.ts +175 -0
- package/src/profile/links.ts +162 -0
- package/src/push/api.ts +189 -52
- package/src/push/dispatch.ts +156 -0
- package/src/push/drain.ts +139 -0
- package/src/push/outbox.ts +218 -0
- package/src/push/prefs.ts +118 -0
- package/src/push/sender.ts +7 -2
- package/src/push/triggers.ts +56 -5
- package/src/push/vapid.ts +19 -0
- package/src/push/web-push-transport.ts +106 -0
- package/src/push-sender/index.ts +14 -0
- package/src/push-sender/run.ts +118 -0
- package/src/push-sender/session.ts +54 -0
- package/src/pwa.ts +32 -4
- package/src/visitors.ts +94 -6
- package/src/web-app.ts +718 -0
- package/src/web-plugin.ts +279 -26
- package/dist/chunk-action-menu-qe839g4b.js +0 -1
- package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
- package/dist/chunk-character-creator-6yf29xwx.js +0 -1
- package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
- package/dist/chunk-confirm-xrw7d732.js +0 -1
- package/dist/chunk-conversations-ry228aes.js +0 -1
- package/dist/chunk-folder-list-5cp8stna.js +0 -1
- package/dist/chunk-folds-hff104tk.js +0 -1
- package/dist/chunk-group-scene-h228jdat.js +0 -1
- package/dist/chunk-index-0p5h2kdz.js +0 -2
- package/dist/chunk-index-0pt08yd9.js +0 -1
- package/dist/chunk-index-12bw9yj8.js +0 -2
- package/dist/chunk-index-2ad17eh4.js +0 -2
- package/dist/chunk-index-31wh5twj.js +0 -52
- package/dist/chunk-index-4dy94pbz.js +0 -2
- package/dist/chunk-index-4ecjtdy8.js +0 -13
- package/dist/chunk-index-4v1b7ks9.js +0 -2
- package/dist/chunk-index-5d8yjj4m.js +0 -2
- package/dist/chunk-index-89ajmxh7.js +0 -2
- package/dist/chunk-index-b9ny4623.js +0 -2
- package/dist/chunk-index-e1dr6g10.js +0 -2
- package/dist/chunk-index-gpkzhq1g.js +0 -2
- package/dist/chunk-index-hk3xyy20.js +0 -2
- package/dist/chunk-index-jm23vn03.js +0 -4
- package/dist/chunk-index-k7gh0jvz.js +0 -2
- package/dist/chunk-index-ky42ev0b.js +0 -2
- package/dist/chunk-index-pa2yyqh0.js +0 -2
- package/dist/chunk-index-pcvt668q.js +0 -2
- package/dist/chunk-index-pqw27eye.js +0 -1
- package/dist/chunk-index-wmbmmrd4.js +0 -302
- package/dist/chunk-index-ymbjrsp6.js +0 -2
- package/dist/chunk-lang-css-t85r8rz0.js +0 -1
- package/dist/chunk-lang-html-n6jparge.js +0 -1
- package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
- package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
- package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
- package/dist/chunk-layout-actions-bv0chj41.js +0 -1
- package/dist/chunk-notes-9b7pgqfe.js +0 -1
- package/dist/chunk-overview-8yw71esz.js +0 -1
- package/dist/chunk-palette-frms7axe.js +0 -1
- package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
- package/dist/chunk-prompt-history-yrqehads.js +0 -1
- package/dist/chunk-prompts-g6609tqx.js +0 -1
- package/dist/chunk-room-confirms-0a2y0925.js +0 -1
- package/dist/chunk-room-menu-g11q97br.js +0 -1
- package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
- package/dist/chunk-schedules-ezmacrbj.js +0 -1
- package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
- package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
- package/dist/chunk-skills-gqcfhq56.js +0 -1
- package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
- package/dist/chunk-table-s8cde1xz.js +0 -1
- package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
- package/dist/chunk-transcript-c59c6mpm.js +0 -1
- package/dist/chunk-workspace-0cyspkk4.js +0 -1
- package/dist/chunk-zh-tw-nc93jm10.js +0 -1
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { InboundMessage } from "pi-roundtable";
|
|
2
|
+
import type {
|
|
3
|
+
ChannelContext,
|
|
4
|
+
ChannelContextMessage,
|
|
5
|
+
ChannelContextOptions,
|
|
6
|
+
} from "pi-roundtable/discord";
|
|
7
|
+
import { formatChannelContext } from "pi-roundtable/discord";
|
|
8
|
+
import type { HostAuthorKind, RoomMessage } from "../api-types.ts";
|
|
9
|
+
import { PAGE_LIMIT } from "../app/room-log.ts";
|
|
10
|
+
import type { HostContext } from "./host-context.ts";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_FETCH = 50;
|
|
13
|
+
const DEFAULT_KEEP = 15;
|
|
14
|
+
const DEFAULT_CHARS = 500;
|
|
15
|
+
/** How many delivered message ids are remembered per room, and how many rooms. */
|
|
16
|
+
const SEEN_PER_ROOM = 500;
|
|
17
|
+
const SEEN_ROOMS = 1_000;
|
|
18
|
+
|
|
19
|
+
type Source = Parameters<NonNullable<ChannelContextOptions["authors"]>>[0];
|
|
20
|
+
|
|
21
|
+
const positive = (name: string, value: number, most: number): number => {
|
|
22
|
+
if (!Number.isInteger(value) || value < 1 || value > most)
|
|
23
|
+
throw new RangeError(
|
|
24
|
+
`channel context option ${name} must be a whole number from 1 to ${most}, got ${value}`,
|
|
25
|
+
);
|
|
26
|
+
return value;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Whose a stored message of a host room is; a message without a kind is read from its row's. */
|
|
30
|
+
function authorKindOf(message: RoomMessage): HostAuthorKind {
|
|
31
|
+
if (message.authorKind) return message.authorKind;
|
|
32
|
+
if (message.kind === "agent") return "assistant";
|
|
33
|
+
return message.kind === "system" ? "notice" : "member";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the channel context reads of one stored message of a host room. */
|
|
37
|
+
function sourceOf(message: RoomMessage): Source {
|
|
38
|
+
const kind = authorKindOf(message);
|
|
39
|
+
return {
|
|
40
|
+
id: String(message.id),
|
|
41
|
+
authorId: message.principalId ?? kind,
|
|
42
|
+
authorName: message.author,
|
|
43
|
+
bot: false,
|
|
44
|
+
own: kind === "assistant",
|
|
45
|
+
...(kind === "external" ? { external: true as const } : {}),
|
|
46
|
+
attachments: message.files.map((file) => ({
|
|
47
|
+
name: file.name,
|
|
48
|
+
contentType: file.type,
|
|
49
|
+
size: file.size,
|
|
50
|
+
url: `api/files/${file.id}`,
|
|
51
|
+
})),
|
|
52
|
+
at: Date.parse(message.at),
|
|
53
|
+
text: message.text,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What `WebApp.rooms.context` takes. */
|
|
58
|
+
export interface HostRoomContextOptions {
|
|
59
|
+
/** The most messages to return, newest kept; default 15, at most 100. */
|
|
60
|
+
limit?: number;
|
|
61
|
+
/** The most characters of one message before it is cut with `…`; default 500. */
|
|
62
|
+
messageChars?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The two ways a host room's recent messages are read. */
|
|
66
|
+
export interface HostRoomContext {
|
|
67
|
+
/** For a turn the room's message addressed: marks what it returns as handed over. */
|
|
68
|
+
read(
|
|
69
|
+
message: InboundMessage,
|
|
70
|
+
options?: ChannelContextOptions,
|
|
71
|
+
): Promise<ChannelContext | undefined>;
|
|
72
|
+
/** For a host with no addressing message: the same block, and nothing is marked handed over. */
|
|
73
|
+
peek(
|
|
74
|
+
room: string,
|
|
75
|
+
options?: HostRoomContextOptions,
|
|
76
|
+
): Promise<ChannelContext | undefined>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The app surface's channel context of a host room: what was said in it since the assistant
|
|
81
|
+
* last posted, before the message that addressed it, as the core's block of channel messages. An
|
|
82
|
+
* outsider's words read as `external`, a notice line is not in the channel as far as the context
|
|
83
|
+
* goes, and a message already handed to the conversation is not handed again. Anything but a host
|
|
84
|
+
* room has none: an agent room is one person's. `peek` reads the same block for the end of the
|
|
85
|
+
* room and leaves the handed-over marks alone, so a retry gets the same.
|
|
86
|
+
*/
|
|
87
|
+
export function hostRoomContext(
|
|
88
|
+
ctx: Pick<HostContext, "registry" | "log">,
|
|
89
|
+
): HostRoomContext {
|
|
90
|
+
const seen = new Map<string, Set<string>>();
|
|
91
|
+
const build = async (
|
|
92
|
+
room: string,
|
|
93
|
+
addressed: number | undefined,
|
|
94
|
+
options: ChannelContextOptions,
|
|
95
|
+
consume: boolean,
|
|
96
|
+
): Promise<ChannelContext | undefined> => {
|
|
97
|
+
if (!ctx.registry.isHostRoom(room)) return undefined;
|
|
98
|
+
const fetch = positive("fetch", options.fetch ?? DEFAULT_FETCH, PAGE_LIMIT);
|
|
99
|
+
const keep = positive("keep", options.keep ?? DEFAULT_KEEP, 100);
|
|
100
|
+
const chars = positive(
|
|
101
|
+
"messageChars",
|
|
102
|
+
options.messageChars ?? DEFAULT_CHARS,
|
|
103
|
+
20_000,
|
|
104
|
+
);
|
|
105
|
+
const page = await ctx.log.page(room, addressed, fetch);
|
|
106
|
+
const sources = page.messages.map((one) => ({
|
|
107
|
+
source: sourceOf(one),
|
|
108
|
+
notice: authorKindOf(one) === "notice",
|
|
109
|
+
}));
|
|
110
|
+
type Entry = (typeof sources)[number];
|
|
111
|
+
const verdict = (entry: Entry) => {
|
|
112
|
+
const given = options.authors?.(entry.source);
|
|
113
|
+
if (given === "keep" || given === "skip" || given === "end") return given;
|
|
114
|
+
if (entry.notice) return "skip";
|
|
115
|
+
return entry.source.own ? "end" : "keep";
|
|
116
|
+
};
|
|
117
|
+
const considered = sources.filter((entry) => verdict(entry) !== "skip");
|
|
118
|
+
const lastEnd = considered.findLastIndex(
|
|
119
|
+
(entry) => verdict(entry) === "end",
|
|
120
|
+
);
|
|
121
|
+
const known = consume ? (seen.get(room) ?? new Set<string>()) : undefined;
|
|
122
|
+
const fresh = considered
|
|
123
|
+
.slice(lastEnd + 1)
|
|
124
|
+
.filter(
|
|
125
|
+
(entry) =>
|
|
126
|
+
verdict(entry) === "keep" &&
|
|
127
|
+
!known?.has(entry.source.id) &&
|
|
128
|
+
entry.source.text !== "",
|
|
129
|
+
)
|
|
130
|
+
.slice(-keep);
|
|
131
|
+
if (known) {
|
|
132
|
+
// What was in the window is not handed over again, kept or not.
|
|
133
|
+
for (const entry of considered.slice(lastEnd + 1))
|
|
134
|
+
known.add(entry.source.id);
|
|
135
|
+
seen.delete(room);
|
|
136
|
+
seen.set(room, new Set([...known].slice(-SEEN_PER_ROOM)));
|
|
137
|
+
if (seen.size > SEEN_ROOMS) {
|
|
138
|
+
const oldest = seen.keys().next().value;
|
|
139
|
+
if (oldest !== undefined) seen.delete(oldest);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (fresh.length === 0) return undefined;
|
|
143
|
+
const messages: ChannelContextMessage[] = fresh.map(({ source }) => ({
|
|
144
|
+
id: source.id,
|
|
145
|
+
authorId: source.authorId,
|
|
146
|
+
authorName: source.authorName,
|
|
147
|
+
bot: false,
|
|
148
|
+
owner: false,
|
|
149
|
+
...(source.external ? { external: true } : {}),
|
|
150
|
+
text:
|
|
151
|
+
source.text.length > chars
|
|
152
|
+
? `${source.text.slice(0, chars)}…`
|
|
153
|
+
: source.text,
|
|
154
|
+
at: new Date(source.at),
|
|
155
|
+
attachments: (source.attachments ?? []).map((file) => ({
|
|
156
|
+
...file,
|
|
157
|
+
messageId: source.id,
|
|
158
|
+
})),
|
|
159
|
+
}));
|
|
160
|
+
return { messages, text: formatChannelContext(messages) };
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
read(message, options = {}) {
|
|
164
|
+
const addressed = /^app-(\d+)$/.exec(message.messageId)?.[1];
|
|
165
|
+
return build(
|
|
166
|
+
message.channel,
|
|
167
|
+
addressed === undefined ? undefined : Number(addressed),
|
|
168
|
+
options,
|
|
169
|
+
true,
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
peek(room, options = {}) {
|
|
173
|
+
const limit = options.limit ?? DEFAULT_KEEP;
|
|
174
|
+
return build(
|
|
175
|
+
room,
|
|
176
|
+
undefined,
|
|
177
|
+
{
|
|
178
|
+
keep: limit,
|
|
179
|
+
fetch: Math.min(PAGE_LIMIT, Math.max(DEFAULT_FETCH, limit * 3)),
|
|
180
|
+
...(options.messageChars === undefined
|
|
181
|
+
? {}
|
|
182
|
+
: { messageChars: options.messageChars }),
|
|
183
|
+
},
|
|
184
|
+
false,
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { PluginError } from "pi-roundtable";
|
|
2
|
+
import type {
|
|
3
|
+
FormField,
|
|
4
|
+
FormSpec,
|
|
5
|
+
FormValues,
|
|
6
|
+
HostAction,
|
|
7
|
+
} from "../web-app.ts";
|
|
8
|
+
|
|
9
|
+
/** The limits of what a host puts on a card, a header or a form. */
|
|
10
|
+
const LIMITS = {
|
|
11
|
+
title: 200,
|
|
12
|
+
label: 120,
|
|
13
|
+
body: 20_000,
|
|
14
|
+
links: 10,
|
|
15
|
+
buttons: 10,
|
|
16
|
+
fields: 20,
|
|
17
|
+
options: 1_000,
|
|
18
|
+
textMax: 500,
|
|
19
|
+
textareaMax: 8_000,
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
const ID = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
23
|
+
const FIELD_NAME = /^[A-Za-z0-9_]{1,40}$/;
|
|
24
|
+
const TONES = new Set(["note", "ok", "warn", "risk"]);
|
|
25
|
+
const STATES = new Set(["open", "working", "done", "superseded", "expired"]);
|
|
26
|
+
|
|
27
|
+
const fail = (what: string): never => {
|
|
28
|
+
throw new PluginError(`web app: ${what}`);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Whether a link may be put on a card: `https`, or a path of this origin. */
|
|
32
|
+
export function safeLink(url: string): boolean {
|
|
33
|
+
// Browsers strip tabs and newlines, so "/\t/host" reads as "//host": no control or space.
|
|
34
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: the check is for them
|
|
35
|
+
if (/[\u0000-\u0020\u007f]/.test(url)) return false;
|
|
36
|
+
if (url.startsWith("/")) return !url.startsWith("//") && !url.includes("\\");
|
|
37
|
+
return URL.parse(url)?.protocol === "https:";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function checkField(field: FormField, where: string): void {
|
|
41
|
+
if (!FIELD_NAME.test(field.name))
|
|
42
|
+
fail(
|
|
43
|
+
`${where}: field name ${JSON.stringify(field.name)} must match [A-Za-z0-9_]{1,40}`,
|
|
44
|
+
);
|
|
45
|
+
if (typeof field.label !== "string" || field.label.length > LIMITS.label)
|
|
46
|
+
fail(
|
|
47
|
+
`${where}: field ${field.name} needs a label of at most ${LIMITS.label} characters`,
|
|
48
|
+
);
|
|
49
|
+
if (field.type === "select" || field.type === "multiselect") {
|
|
50
|
+
const { options } = field;
|
|
51
|
+
if (!Array.isArray(options) || options.length > LIMITS.options)
|
|
52
|
+
fail(
|
|
53
|
+
`${where}: field ${field.name} takes at most ${LIMITS.options} options`,
|
|
54
|
+
);
|
|
55
|
+
const values = new Set(options.map((option) => option.value));
|
|
56
|
+
if (values.size !== options.length)
|
|
57
|
+
fail(`${where}: field ${field.name} lists a value twice`);
|
|
58
|
+
if (field.search !== undefined && typeof field.search !== "boolean")
|
|
59
|
+
fail(`${where}: field ${field.name}'s search is true or false`);
|
|
60
|
+
const initial =
|
|
61
|
+
field.initial === undefined
|
|
62
|
+
? []
|
|
63
|
+
: typeof field.initial === "string"
|
|
64
|
+
? [field.initial]
|
|
65
|
+
: field.initial;
|
|
66
|
+
if (field.type === "select" && initial.length > 1)
|
|
67
|
+
fail(`${where}: select ${field.name} takes one initial value`);
|
|
68
|
+
for (const value of initial)
|
|
69
|
+
if (!values.has(value))
|
|
70
|
+
fail(
|
|
71
|
+
`${where}: field ${field.name}'s initial value ${JSON.stringify(value)} is not an option`,
|
|
72
|
+
);
|
|
73
|
+
} else if (!["text", "textarea", "url"].includes(field.type))
|
|
74
|
+
fail(
|
|
75
|
+
`${where}: field ${field.name} has the unknown type ${String(field.type)}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function checkSpec(form: FormSpec, where: string): void {
|
|
80
|
+
if (typeof form.title !== "string" || typeof form.submit !== "string")
|
|
81
|
+
fail(`${where}: a form needs a title and a submit label`);
|
|
82
|
+
if (!Array.isArray(form.fields) || form.fields.length > LIMITS.fields)
|
|
83
|
+
fail(`${where}: a form takes at most ${LIMITS.fields} fields`);
|
|
84
|
+
const names = new Set<string>();
|
|
85
|
+
for (const field of form.fields) {
|
|
86
|
+
checkField(field, where);
|
|
87
|
+
if (names.has(field.name))
|
|
88
|
+
fail(`${where}: field ${field.name} is listed twice`);
|
|
89
|
+
names.add(field.name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Throws a `PluginError` naming what is wrong with a list of actions a host offers; returns them. */
|
|
94
|
+
export function checkActions(
|
|
95
|
+
actions: readonly HostAction[],
|
|
96
|
+
where: string,
|
|
97
|
+
): readonly HostAction[] {
|
|
98
|
+
if (actions.length > LIMITS.buttons)
|
|
99
|
+
fail(`${where}: at most ${LIMITS.buttons} buttons`);
|
|
100
|
+
const ids = new Set<string>();
|
|
101
|
+
for (const action of actions) {
|
|
102
|
+
if (!ID.test(action.id))
|
|
103
|
+
fail(
|
|
104
|
+
`${where}: button id ${JSON.stringify(action.id)} must match [A-Za-z0-9_.:-]{1,64}`,
|
|
105
|
+
);
|
|
106
|
+
if (ids.has(action.id))
|
|
107
|
+
fail(`${where}: button ${action.id} is listed twice`);
|
|
108
|
+
ids.add(action.id);
|
|
109
|
+
if (typeof action.label !== "string" || action.label.length > LIMITS.label)
|
|
110
|
+
fail(
|
|
111
|
+
`${where}: button ${action.id} needs a label of at most ${LIMITS.label} characters`,
|
|
112
|
+
);
|
|
113
|
+
if (action.confirm !== undefined && action.confirm.length > LIMITS.body)
|
|
114
|
+
fail(`${where}: button ${action.id}'s confirmation is too long`);
|
|
115
|
+
if (action.form) checkSpec(action.form, `${where} button ${action.id}`);
|
|
116
|
+
}
|
|
117
|
+
return actions;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Throws a `PluginError` for a card a host may not put in a room. */
|
|
121
|
+
export function checkCard(card: {
|
|
122
|
+
title: string;
|
|
123
|
+
body: string;
|
|
124
|
+
tone?: string | undefined;
|
|
125
|
+
state?: string | undefined;
|
|
126
|
+
links?: readonly { label: string; url: string }[] | undefined;
|
|
127
|
+
buttons?: readonly HostAction[] | undefined;
|
|
128
|
+
keep?: boolean | undefined;
|
|
129
|
+
}): void {
|
|
130
|
+
if (typeof card.title !== "string" || card.title.length > LIMITS.title)
|
|
131
|
+
fail(`a card's title is at most ${LIMITS.title} characters`);
|
|
132
|
+
if (typeof card.body !== "string" || card.body.length > LIMITS.body)
|
|
133
|
+
fail(`a card's body is at most ${LIMITS.body} characters`);
|
|
134
|
+
if (card.tone !== undefined && !TONES.has(card.tone))
|
|
135
|
+
fail(`a card's tone is note, ok, warn or risk`);
|
|
136
|
+
if (card.state !== undefined && !STATES.has(card.state))
|
|
137
|
+
fail(`a card's state is open, working, done, superseded or expired`);
|
|
138
|
+
if (card.keep !== undefined && typeof card.keep !== "boolean")
|
|
139
|
+
fail("a card's keep is true or false");
|
|
140
|
+
const links = card.links ?? [];
|
|
141
|
+
if (links.length > LIMITS.links)
|
|
142
|
+
fail(`a card takes at most ${LIMITS.links} links`);
|
|
143
|
+
for (const link of links)
|
|
144
|
+
if (!safeLink(link.url))
|
|
145
|
+
fail(
|
|
146
|
+
`a card's link ${JSON.stringify(link.url)} must be https or a path of this origin`,
|
|
147
|
+
);
|
|
148
|
+
checkActions(card.buttons ?? [], "a card");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The buttons a card shows: only while it is open (or has no state), since a settled card has nothing left to press. */
|
|
152
|
+
export const pressable = (card: { state?: string | undefined }): boolean =>
|
|
153
|
+
card.state === undefined || card.state === "open";
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What a form's input comes to: the values kept, or the first thing wrong with it and the field it
|
|
157
|
+
* is about. Every field of the form is checked; a name the form does not have is refused, so an
|
|
158
|
+
* action cannot be handed values it never offered.
|
|
159
|
+
*/
|
|
160
|
+
export function checkInput(
|
|
161
|
+
form: FormSpec | undefined,
|
|
162
|
+
input: FormValues,
|
|
163
|
+
):
|
|
164
|
+
| { ok: true; values: FormValues }
|
|
165
|
+
| { ok: false; error: string; field?: string } {
|
|
166
|
+
if (!form) {
|
|
167
|
+
return Object.keys(input).length === 0
|
|
168
|
+
? { ok: true, values: {} }
|
|
169
|
+
: { ok: false, error: "This action takes no form." };
|
|
170
|
+
}
|
|
171
|
+
const known = new Set(form.fields.map((field) => field.name));
|
|
172
|
+
for (const name of Object.keys(input))
|
|
173
|
+
if (!known.has(name))
|
|
174
|
+
return { ok: false, error: "The form has no such field.", field: name };
|
|
175
|
+
const values: Record<string, string | readonly string[]> = {};
|
|
176
|
+
for (const field of form.fields) {
|
|
177
|
+
const given = input[field.name];
|
|
178
|
+
const empty =
|
|
179
|
+
given === undefined ||
|
|
180
|
+
(typeof given === "string" ? given.trim() === "" : given.length === 0);
|
|
181
|
+
if (empty) {
|
|
182
|
+
if (field.required)
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: "This field is required.",
|
|
186
|
+
field: field.name,
|
|
187
|
+
};
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if ("options" in field) {
|
|
191
|
+
const allowed = new Set(field.options.map((option) => option.value));
|
|
192
|
+
if (field.type === "multiselect") {
|
|
193
|
+
if (
|
|
194
|
+
typeof given === "string" ||
|
|
195
|
+
new Set(given).size !== given.length ||
|
|
196
|
+
!given.every((value) => allowed.has(value))
|
|
197
|
+
)
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
error: "Choose from the list.",
|
|
201
|
+
field: field.name,
|
|
202
|
+
};
|
|
203
|
+
values[field.name] = [...given];
|
|
204
|
+
} else {
|
|
205
|
+
if (typeof given !== "string" || !allowed.has(given))
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
error: "Choose from the list.",
|
|
209
|
+
field: field.name,
|
|
210
|
+
};
|
|
211
|
+
values[field.name] = given;
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (typeof given !== "string")
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: "This field takes one value.",
|
|
219
|
+
field: field.name,
|
|
220
|
+
};
|
|
221
|
+
const max =
|
|
222
|
+
field.max ??
|
|
223
|
+
(field.type === "textarea" ? LIMITS.textareaMax : LIMITS.textMax);
|
|
224
|
+
if (given.length > max)
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
error: "This field is too long.",
|
|
228
|
+
field: field.name,
|
|
229
|
+
};
|
|
230
|
+
if (field.type === "url") {
|
|
231
|
+
const protocol = URL.parse(given.trim())?.protocol;
|
|
232
|
+
if (protocol !== "https:" && protocol !== "http:")
|
|
233
|
+
return { ok: false, error: "Give a web address.", field: field.name };
|
|
234
|
+
values[field.name] = given.trim();
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
values[field.name] = given;
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, values };
|
|
240
|
+
}
|