pi-roundtable-web 0.28.0 → 0.29.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 +46 -0
- package/README.md +4 -4
- package/dist/chunk-account-66e6ezdq.js +1 -0
- package/dist/{chunk-action-menu-qpp9bdxh.js → chunk-action-menu-ap5xn7bf.js} +1 -1
- package/dist/chunk-agent-sapt5qhn.js +1 -0
- package/dist/chunk-arranged-rooms-n6bk696f.js +1 -0
- package/dist/chunk-attachments-p5v9gqyt.js +102 -0
- package/dist/chunk-bulk-actions-aze4ewc4.js +1 -0
- package/dist/chunk-character-creator-m7tw20ef.js +225 -0
- package/dist/{chunk-codeeditor-jgj32247.js → chunk-codeeditor-yy6pq8mv.js} +2 -2
- package/dist/chunk-confirm-0cajsfhk.js +1 -0
- package/dist/chunk-connectors-tw0tbm21.js +37 -0
- package/dist/chunk-conversations-wkm23m5r.js +1 -0
- package/dist/chunk-council-sheet-mz7s23nq.js +2 -0
- package/dist/chunk-council-view-zgvzxtgn.js +1 -0
- package/dist/{chunk-drag-04qfef58.js → chunk-drag-d1bpy8w6.js} +1 -1
- package/dist/{chunk-entry-mws704mc.js → chunk-entry-7zr5b1fp.js} +1 -1
- package/dist/chunk-folder-list-bs4eh3g9.js +1 -0
- package/dist/chunk-folds-eez1q41c.js +1 -0
- package/dist/{chunk-gate-vxetvn8t.js → chunk-gate-dt91egcd.js} +1 -1
- package/dist/chunk-host-icons-nfe2t8tk.js +1 -0
- package/dist/chunk-host-picture-4rtswxwa.js +1 -0
- package/dist/chunk-host-state-1vxzmmq6.js +1 -0
- package/dist/chunk-host-ui-x38bf0et.js +1 -0
- package/dist/{chunk-index-zj0gjcvr.js → chunk-index-0h99xcs3.js} +2 -2
- package/dist/{chunk-index-e2gy763y.js → chunk-index-1kb8tv4v.js} +2 -2
- package/dist/chunk-index-24cggw6k.js +50 -0
- package/dist/chunk-index-2g8xtwqg.js +2 -0
- package/dist/chunk-index-3n38xe7m.js +2 -0
- package/dist/{chunk-index-n0cmnwd2.js → chunk-index-3tksdmx7.js} +2 -2
- package/dist/{chunk-index-2822z600.js → chunk-index-4a1tdadd.js} +3 -3
- package/dist/{chunk-index-2syyyjnq.js → chunk-index-53fwc7sc.js} +2 -2
- package/dist/chunk-index-55ps4x5n.js +2 -0
- package/dist/{chunk-index-q5fyrtx4.js → chunk-index-5j1v2q7w.js} +1 -1
- package/dist/chunk-index-5w6tb7p0.js +2 -0
- package/dist/chunk-index-6btdp13p.js +2 -0
- package/dist/chunk-index-6m7dwsmv.js +1 -0
- package/dist/{chunk-index-20xgx0k2.js → chunk-index-6rdywten.js} +2 -2
- package/dist/{chunk-index-3gfbhmpb.js → chunk-index-6s2pqwna.js} +2 -2
- package/dist/{chunk-index-fepsbhr6.js → chunk-index-7mrkq793.js} +2 -2
- package/dist/chunk-index-7xcbpeqz.js +2 -0
- package/dist/chunk-index-88wh5eyj.js +2 -0
- package/dist/{chunk-index-6cg8xp1c.js → chunk-index-8pbwr3rs.js} +1 -1
- package/dist/{chunk-index-jc3r4qam.js → chunk-index-8wpvj7k5.js} +2 -2
- package/dist/chunk-index-97dcp1vh.js +1 -0
- package/dist/chunk-index-a7e6rhxy.js +2 -0
- package/dist/{chunk-index-2tr41qht.js → chunk-index-bxf8eq1s.js} +2 -2
- package/dist/chunk-index-c8gm1crh.js +120 -0
- package/dist/{chunk-index-2y8r4s4f.js → chunk-index-ce7hsda9.js} +2 -2
- package/dist/chunk-index-cfesmk1f.js +2 -0
- package/dist/{chunk-index-m81362sc.js → chunk-index-cmzvq3nf.js} +1 -1
- package/dist/{chunk-index-7qd604rq.js → chunk-index-d4mx2vvf.js} +1 -1
- package/dist/chunk-index-d74tpnkd.js +2 -0
- package/dist/chunk-index-d941ae7c.js +2 -0
- package/dist/{chunk-index-e35v554x.js → chunk-index-dr0jr7d3.js} +3 -3
- package/dist/chunk-index-en728705.js +2 -0
- package/dist/chunk-index-fb3kzyyp.js +2 -0
- package/dist/{chunk-index-3yy10a5h.js → chunk-index-ffnd0jcj.js} +2 -2
- package/dist/chunk-index-fn6305vn.js +3 -0
- package/dist/{chunk-index-zcfr8jdv.js → chunk-index-gdh7wsd5.js} +12 -12
- package/dist/{chunk-index-2hvn2kte.js → chunk-index-m76gdwnk.js} +2 -2
- package/dist/chunk-index-mr391t49.js +2 -0
- package/dist/chunk-index-n3khm9fq.js +2 -0
- package/dist/{chunk-index-sm7kfb07.js → chunk-index-nn4w6a04.js} +2 -2
- package/dist/{chunk-index-gwkhs8s3.js → chunk-index-nr275sa7.js} +2 -2
- package/dist/{chunk-index-x60n1me6.js → chunk-index-pgxgq61x.js} +2 -2
- package/dist/chunk-index-prq011b6.js +2 -0
- package/dist/{chunk-index-v9gbt5tz.js → chunk-index-q4k9vk04.js} +19 -3
- package/dist/chunk-index-rgdaec7y.js +2 -0
- package/dist/chunk-index-rjs1can6.js +2 -0
- package/dist/chunk-index-s4a8e4qm.js +2 -0
- package/dist/{chunk-index-s9p27f9b.js → chunk-index-sz381vda.js} +3 -3
- package/dist/{chunk-index-5hbq6g2e.css → chunk-index-t7csmbpy.css} +1 -1
- package/dist/{chunk-index-rgf4rvns.js → chunk-index-v9jqyr1f.js} +3 -3
- package/dist/{chunk-index-rsd8rftk.js → chunk-index-vpq2cj14.js} +2 -2
- package/dist/chunk-index-vsaggfns.js +2 -0
- package/dist/{chunk-index-p7jkr7gv.js → chunk-index-w17wgjby.js} +5 -5
- package/dist/{chunk-index-c7dbvkrq.js → chunk-index-wgrx2ybn.js} +2 -2
- package/dist/chunk-index-wsv46y9p.js +2 -0
- package/dist/{chunk-index-4t6vnwap.js → chunk-index-xbj816p0.js} +2 -2
- package/dist/{chunk-index-s53bxwkk.js → chunk-index-yr758n6t.js} +2 -2
- package/dist/{chunk-index-5t2jkecy.js → chunk-index-ysv36nbk.js} +2 -2
- package/dist/{chunk-index-zt19fdmc.js → chunk-index-ze9p4npm.js} +2 -2
- package/dist/chunk-index-zfdvyrjt.js +2 -0
- package/dist/chunk-lang-css-3h22se37.js +1 -0
- package/dist/{chunk-lang-dockerfile-n4bt8jbt.js → chunk-lang-dockerfile-y24eejnh.js} +1 -1
- package/dist/{chunk-lang-go-9k4y107e.js → chunk-lang-go-qzgv1161.js} +3 -3
- package/dist/chunk-lang-html-9rvrmmpg.js +1 -0
- package/dist/chunk-lang-javascript-4kv8s3tk.js +1 -0
- package/dist/{chunk-lang-json-hr12bzs1.js → chunk-lang-json-99v80v88.js} +1 -1
- package/dist/{chunk-lang-markdown-ttzcpgkg.js → chunk-lang-markdown-gn8he877.js} +4 -4
- package/dist/{chunk-lang-python-ntj8maem.js → chunk-lang-python-prsw4p4k.js} +2 -2
- package/dist/{chunk-lang-rust-gv8y859d.js → chunk-lang-rust-g6xqta16.js} +1 -1
- package/dist/{chunk-lang-shell-5zzs09b7.js → chunk-lang-shell-dmpnzc6w.js} +1 -1
- package/dist/{chunk-lang-sql-6vmze4pg.js → chunk-lang-sql-wz7ca27b.js} +2 -2
- package/dist/{chunk-lang-toml-tt0d4rw9.js → chunk-lang-toml-160prtq4.js} +1 -1
- package/dist/chunk-lang-typescript-mp0yw903.js +1 -0
- package/dist/{chunk-lang-xml-r2sbqnjv.js → chunk-lang-xml-9htkz2dd.js} +1 -1
- package/dist/{chunk-lang-yaml-ex31qbph.js → chunk-lang-yaml-08749wd5.js} +1 -1
- package/dist/chunk-layout-actions-tdx896xf.js +1 -0
- package/dist/chunk-menu-items-trhybv1g.js +1 -0
- package/dist/chunk-mode-toggle-4dtfphq4.js +1 -0
- package/dist/{chunk-note-row-40ypghn9.js → chunk-note-row-28e5x7n8.js} +33 -4
- package/dist/chunk-notes-5nkayhw8.js +1 -0
- package/dist/chunk-notice-bell-xz8a04am.js +1 -0
- package/dist/{chunk-notices-tefx93s6.js → chunk-notices-d0cwvk27.js} +1 -1
- package/dist/chunk-onboarding-zs4rtea9.js +1 -0
- package/dist/{chunk-overview-zrx3bpwf.js → chunk-overview-9sb3ywa8.js} +1 -1
- package/dist/chunk-palette-5sfhrv4x.js +1 -0
- package/dist/chunk-placement-items-e2neja55.js +1 -0
- package/dist/{chunk-profile-tyb48b79.js → chunk-profile-jffhtpqf.js} +1 -1
- package/dist/chunk-prompt-history-1jz2gk3v.js +1 -0
- package/dist/chunk-prompts-0t30ttd0.js +1 -0
- package/dist/chunk-room-confirms-6595921h.js +1 -0
- package/dist/{chunk-room-menu-ha1evv71.js → chunk-room-menu-hwkjyxwe.js} +1 -1
- package/dist/chunk-rooms-menu-button-k0eq1tcr.js +1 -0
- package/dist/{chunk-schedules-a5pewkhd.js → chunk-schedules-e12y8fw2.js} +1 -1
- package/dist/{chunk-seat-menu-eh3ymmks.js → chunk-seat-menu-n2bcw0mf.js} +1 -1
- package/dist/chunk-sheet-hosts-myvm2vhd.js +29 -0
- package/dist/chunk-shortcut-help-434sqnxc.js +26 -0
- package/dist/chunk-shortcuts-mjxph0g7.js +1 -0
- package/dist/chunk-skill-editor-98492yrt.js +8 -0
- package/dist/chunk-skills-mfv8ppfh.js +1 -0
- package/dist/chunk-start-over-line-m526193z.js +16 -0
- package/dist/{chunk-switcher-list-dtfh87zy.js → chunk-switcher-list-5ngjar24.js} +1 -1
- package/dist/{chunk-tab-dot-qd640mjr.js → chunk-tab-dot-tzjkahj5.js} +1 -1
- package/dist/chunk-table-xbtbex2z.js +1 -0
- package/dist/{chunk-table2d-5nrhfwft.js → chunk-table2d-61bvrykm.js} +1 -1
- package/dist/{chunk-table3d-5cb66d99.js → chunk-table3d-5a7vbgyj.js} +172 -172
- package/dist/chunk-thread-actions-bf6vf2mt.js +1 -0
- package/dist/{chunk-thread-cards-kgt22sbp.js → chunk-thread-cards-n0n603vf.js} +1 -1
- package/dist/chunk-thread-list-7bkdn51x.js +227 -0
- package/dist/{chunk-transcript-a0p3vwh6.js → chunk-transcript-8ztrkt6g.js} +1 -1
- package/dist/chunk-waiting-chip-y83c90v3.js +32 -0
- package/dist/chunk-waiting-s273kcgz.js +1 -0
- package/dist/chunk-where-jr791gzj.js +1 -0
- package/dist/chunk-workspace-dw71mshw.js +586 -0
- package/dist/chunk-zh-tw-vm5d0w6j.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +3 -3
- package/src/api-types.ts +51 -0
- package/src/app/app.ts +7 -0
- package/src/app/card-log.ts +10 -2
- package/src/app/protocol.ts +18 -0
- package/src/app/queued-delivery.ts +107 -0
- package/src/app/queued-frames.ts +115 -0
- package/src/app/room-log.ts +39 -7
- package/src/app/rooms.ts +30 -2
- package/src/app/routes.ts +123 -7
- package/src/app/skill-group-routes.ts +126 -0
- package/src/app/socket.ts +12 -2
- package/src/app/surface.ts +37 -6
- package/src/app/zip.ts +124 -0
- package/src/auth/sessions.ts +9 -1
- package/src/authz.ts +1 -0
- package/src/host/cards.ts +10 -3
- package/src/host/stored-json.ts +23 -0
- package/src/i18n/zh-tw.ts +45 -2
- package/src/notices/mount.ts +1 -0
- package/src/notices/store.ts +9 -1
- package/src/push/channel.ts +1 -0
- package/src/web-plugin.ts +5 -1
- package/dist/chunk-account-678dgbww.js +0 -1
- package/dist/chunk-agent-x0yvvdq2.js +0 -1
- package/dist/chunk-arranged-rooms-6rdkk8t1.js +0 -1
- package/dist/chunk-bulk-actions-zbwdaxtc.js +0 -1
- package/dist/chunk-character-creator-215psy17.js +0 -1
- package/dist/chunk-confirm-2pzrt272.js +0 -1
- package/dist/chunk-connectors-zha2d834.js +0 -37
- package/dist/chunk-conversations-fb071rez.js +0 -1
- package/dist/chunk-council-sheet-k5fycefd.js +0 -2
- package/dist/chunk-folder-list-qxhmczwv.js +0 -1
- package/dist/chunk-folds-b7asegfs.js +0 -1
- package/dist/chunk-group-scene-63yhh1mh.js +0 -1
- package/dist/chunk-group-table-zy4hmgk3.js +0 -1
- package/dist/chunk-host-icons-tyktazzz.js +0 -1
- package/dist/chunk-host-picture-ebhejn2k.js +0 -1
- package/dist/chunk-host-state-wajep0yr.js +0 -1
- package/dist/chunk-host-ui-pc9r24e3.js +0 -1
- package/dist/chunk-index-1af8vsyb.js +0 -2
- package/dist/chunk-index-254701sm.js +0 -3
- package/dist/chunk-index-31m1b2f5.js +0 -2
- package/dist/chunk-index-5vatmakn.js +0 -2
- package/dist/chunk-index-6yra9kdq.js +0 -2
- package/dist/chunk-index-9bdstf8t.js +0 -2
- package/dist/chunk-index-acj4xxgq.js +0 -100
- package/dist/chunk-index-aexy023z.js +0 -2
- package/dist/chunk-index-amxa2bn3.js +0 -2
- package/dist/chunk-index-bef0fc2m.js +0 -2
- package/dist/chunk-index-c6tphrjr.js +0 -1
- package/dist/chunk-index-cn49mawx.js +0 -50
- package/dist/chunk-index-gcmmwh8t.js +0 -2
- package/dist/chunk-index-h2vhv4kh.js +0 -2
- package/dist/chunk-index-h9ccz1r3.js +0 -2
- package/dist/chunk-index-n0ejy0wd.js +0 -2
- package/dist/chunk-index-nkdg1dfz.js +0 -7
- package/dist/chunk-index-qxynr1hf.js +0 -2
- package/dist/chunk-index-r7fdgzy1.js +0 -2
- package/dist/chunk-index-sdcpf5dp.js +0 -2
- package/dist/chunk-index-sw9r4aa4.js +0 -2
- package/dist/chunk-index-t5bmsqqd.js +0 -1
- package/dist/chunk-index-v8mma5np.js +0 -2
- package/dist/chunk-index-vsfsv8hd.js +0 -2
- package/dist/chunk-index-wxft0aqb.js +0 -2
- package/dist/chunk-index-zvvwxkev.js +0 -2
- package/dist/chunk-lang-css-7q1dtpgm.js +0 -1
- package/dist/chunk-lang-html-xgvba8rn.js +0 -1
- package/dist/chunk-lang-javascript-vgxmgd0a.js +0 -1
- package/dist/chunk-lang-typescript-2v652sh5.js +0 -1
- package/dist/chunk-layout-actions-bfqtg71d.js +0 -1
- package/dist/chunk-menu-items-rztgekfj.js +0 -1
- package/dist/chunk-mode-toggle-m5kgb7c7.js +0 -1
- package/dist/chunk-notes-a8nqffbf.js +0 -1
- package/dist/chunk-notice-bell-rdnfmtf2.js +0 -1
- package/dist/chunk-onboarding-wvh0s3vg.js +0 -1
- package/dist/chunk-palette-5xtdvy9a.js +0 -1
- package/dist/chunk-placement-items-rgyrktvy.js +0 -1
- package/dist/chunk-prompt-history-wv0vdsjv.js +0 -1
- package/dist/chunk-prompts-75hgb0t0.js +0 -1
- package/dist/chunk-room-confirms-bcvhpcry.js +0 -1
- package/dist/chunk-rooms-menu-button-ej3a2hyc.js +0 -1
- package/dist/chunk-sheet-hosts-d5zbwbbr.js +0 -29
- package/dist/chunk-shortcut-help-bh9ntk9s.js +0 -26
- package/dist/chunk-shortcuts-6em0atk9.js +0 -1
- package/dist/chunk-skill-editor-e43mwz01.js +0 -8
- package/dist/chunk-skills-vp107wrp.js +0 -1
- package/dist/chunk-start-over-line-xd1v2p0g.js +0 -16
- package/dist/chunk-table-svb2xhfd.js +0 -1
- package/dist/chunk-thread-actions-926eab0w.js +0 -1
- package/dist/chunk-thread-list-j2v1pqkz.js +0 -217
- package/dist/chunk-waiting-cdyz7r1g.js +0 -1
- package/dist/chunk-waiting-chip-58ttx9av.js +0 -28
- package/dist/chunk-where-9jp6g029.js +0 -1
- package/dist/chunk-workspace-d34njy4p.js +0 -578
- package/dist/chunk-zh-tw-y3jb20wc.js +0 -1
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ChannelKey, ConversationPort } from "pi-roundtable";
|
|
2
|
+
import {
|
|
3
|
+
type ClientFrame,
|
|
4
|
+
MAX_MESSAGE_CHARS,
|
|
5
|
+
type RefusalCode,
|
|
6
|
+
} from "../api-types.ts";
|
|
7
|
+
import type { Answerer } from "./desk.ts";
|
|
8
|
+
import { coreMessageId } from "./queued-delivery.ts";
|
|
9
|
+
import type { Rooms } from "./rooms.ts";
|
|
10
|
+
import type { AppSurface } from "./surface.ts";
|
|
11
|
+
|
|
12
|
+
export interface QueuedFrameDeps {
|
|
13
|
+
rooms: Pick<Rooms, "resolve">;
|
|
14
|
+
surface: Pick<AppSurface, "delivery" | "edit">;
|
|
15
|
+
conversations: Pick<ConversationPort, "cancelQueued" | "editQueued">;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An owner's request to edit or cancel a message of theirs that waits for its turn. The room must
|
|
20
|
+
* be one of the app's, the message theirs, and the host must still hold it in the queue: once its
|
|
21
|
+
* turn has started the answer is `not_queued`, and nothing changes. An edit shows the new text in
|
|
22
|
+
* the room as an edited message; a cancel keeps the message, marked cancelled.
|
|
23
|
+
*/
|
|
24
|
+
export async function changeQueued(
|
|
25
|
+
frame: Extract<ClientFrame, { type: "edit_queued" | "cancel_queued" }>,
|
|
26
|
+
who: Answerer,
|
|
27
|
+
deps: QueuedFrameDeps,
|
|
28
|
+
): Promise<RefusalCode | undefined> {
|
|
29
|
+
if (who.tier !== "owner") return "forbidden";
|
|
30
|
+
const room = await deps.rooms.resolve(frame.room);
|
|
31
|
+
if (!room) return "unknown_room";
|
|
32
|
+
if (room.readOnly) return "read_only";
|
|
33
|
+
return inTurn(deps.surface, room.key, () =>
|
|
34
|
+
apply(frame, who, deps, room.key),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The changes of one room per surface, each waiting for the one before it. */
|
|
39
|
+
const lines = new WeakMap<
|
|
40
|
+
QueuedFrameDeps["surface"],
|
|
41
|
+
Map<string, Promise<unknown>>
|
|
42
|
+
>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs `change` after every change already asked for in the room has finished. The host takes an
|
|
46
|
+
* edit at once but the room's log is written a moment later; two edits from two sockets would
|
|
47
|
+
* otherwise land in the log in either order, and the room would show a text the turn does not use.
|
|
48
|
+
*/
|
|
49
|
+
function inTurn<T>(
|
|
50
|
+
owner: QueuedFrameDeps["surface"],
|
|
51
|
+
room: string,
|
|
52
|
+
change: () => Promise<T>,
|
|
53
|
+
): Promise<T> {
|
|
54
|
+
let rooms = lines.get(owner);
|
|
55
|
+
if (!rooms) {
|
|
56
|
+
rooms = new Map();
|
|
57
|
+
lines.set(owner, rooms);
|
|
58
|
+
}
|
|
59
|
+
const before = rooms.get(room) ?? Promise.resolve();
|
|
60
|
+
const result = before.then(change, change);
|
|
61
|
+
const tail = result.catch(() => undefined);
|
|
62
|
+
rooms.set(room, tail);
|
|
63
|
+
// A room with nothing waiting leaves no entry behind.
|
|
64
|
+
tail.then(() => {
|
|
65
|
+
if (rooms.get(room) === tail) rooms.delete(room);
|
|
66
|
+
});
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function apply(
|
|
71
|
+
frame: Extract<ClientFrame, { type: "edit_queued" | "cancel_queued" }>,
|
|
72
|
+
who: Answerer,
|
|
73
|
+
deps: QueuedFrameDeps,
|
|
74
|
+
roomKey: ChannelKey,
|
|
75
|
+
): Promise<RefusalCode | undefined> {
|
|
76
|
+
const room = { key: roomKey };
|
|
77
|
+
const stored = await deps.surface.delivery.stored(room.key, frame.message);
|
|
78
|
+
if (stored?.kind !== "owner") return "not_queued";
|
|
79
|
+
if (stored.principalId !== who.principalId) return "forbidden";
|
|
80
|
+
const by = { principalId: who.principalId };
|
|
81
|
+
const messageId = coreMessageId(stored.id);
|
|
82
|
+
if (frame.type === "cancel_queued")
|
|
83
|
+
return refusalOf(
|
|
84
|
+
await deps.surface.delivery.cancel(
|
|
85
|
+
room.key,
|
|
86
|
+
stored.id,
|
|
87
|
+
() =>
|
|
88
|
+
deps.conversations.cancelQueued?.(room.key, messageId, by) ??
|
|
89
|
+
"unknown",
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
const text = frame.text.trim();
|
|
93
|
+
if (text.length > MAX_MESSAGE_CHARS) return "too_long";
|
|
94
|
+
if (text === "" && stored.files.length === 0) return "empty";
|
|
95
|
+
const change =
|
|
96
|
+
deps.conversations.editQueued?.(room.key, messageId, by, text) ?? "unknown";
|
|
97
|
+
if (change === "done")
|
|
98
|
+
await deps.surface.edit(room.key, stored.id, text, true);
|
|
99
|
+
return refusalOf(change);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function refusalOf(
|
|
103
|
+
change: "done" | "unknown" | "forbidden" | "refused",
|
|
104
|
+
): RefusalCode | undefined {
|
|
105
|
+
switch (change) {
|
|
106
|
+
case "done":
|
|
107
|
+
return undefined;
|
|
108
|
+
case "unknown":
|
|
109
|
+
return "not_queued";
|
|
110
|
+
case "forbidden":
|
|
111
|
+
return "forbidden";
|
|
112
|
+
case "refused":
|
|
113
|
+
return "not_accepted";
|
|
114
|
+
}
|
|
115
|
+
}
|
package/src/app/room-log.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type RoomMessage,
|
|
9
9
|
START_OVER_TEXT,
|
|
10
10
|
} from "../api-types.ts";
|
|
11
|
-
import { storedJson } from "../host/stored-json.ts";
|
|
11
|
+
import { jsonValuesMigration, storedJson } from "../host/stored-json.ts";
|
|
12
12
|
|
|
13
13
|
/** A message to store; the log numbers it and stamps its time. */
|
|
14
14
|
export interface NewRoomMessage {
|
|
@@ -49,6 +49,7 @@ interface Row {
|
|
|
49
49
|
edited_at: Date | null;
|
|
50
50
|
note: boolean | null;
|
|
51
51
|
intercom?: RoomIntercom | string | null;
|
|
52
|
+
delivery: "steered" | "cancelled" | null;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/** The most messages one page of history holds. */
|
|
@@ -78,6 +79,7 @@ function messageOf(row: Row): RoomMessage {
|
|
|
78
79
|
...(row.intercom == null
|
|
79
80
|
? {}
|
|
80
81
|
: { intercom: storedJson(row.intercom, "intercom card") }),
|
|
82
|
+
...(row.delivery === null ? {} : { delivery: row.delivery }),
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -94,6 +96,13 @@ export class RoomLog {
|
|
|
94
96
|
this.#sql = sql;
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
/** Its jsonb columns stored as the JSON values themselves, not as the text of them. */
|
|
100
|
+
static readonly jsonValuesMigration: Migration = jsonValuesMigration(
|
|
101
|
+
"web-room-messages-json-values",
|
|
102
|
+
"web_room_messages",
|
|
103
|
+
["files", "picture", "intercom"],
|
|
104
|
+
);
|
|
105
|
+
|
|
97
106
|
/** The log's table. */
|
|
98
107
|
static readonly migration: Migration = {
|
|
99
108
|
name: "web-room-messages",
|
|
@@ -152,6 +161,14 @@ export class RoomLog {
|
|
|
152
161
|
},
|
|
153
162
|
};
|
|
154
163
|
|
|
164
|
+
/** The column that records how an owner message sent during a turn went: steered into it, or cancelled while it waited. */
|
|
165
|
+
static readonly deliveryMigration: Migration = {
|
|
166
|
+
name: "web-room-messages-delivery",
|
|
167
|
+
up: async (sql) => {
|
|
168
|
+
await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS delivery text`;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
155
172
|
async append(message: NewRoomMessage): Promise<RoomMessage> {
|
|
156
173
|
const [row] = await this.#insert(message, false);
|
|
157
174
|
if (!row) throw new Error("the room log stored nothing");
|
|
@@ -184,18 +201,18 @@ export class RoomLog {
|
|
|
184
201
|
INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
|
|
185
202
|
author_kind, author_label, picture, reference, created_at, note, intercom)
|
|
186
203
|
VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
|
|
187
|
-
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
|
|
188
|
-
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
|
|
189
|
-
${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::jsonb)
|
|
204
|
+
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::text::jsonb,
|
|
205
|
+
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::text::jsonb,
|
|
206
|
+
${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::text::jsonb)
|
|
190
207
|
ON CONFLICT (room, reference) WHERE reference IS NOT NULL DO NOTHING
|
|
191
208
|
RETURNING *`
|
|
192
209
|
: await this.#sql`
|
|
193
210
|
INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
|
|
194
211
|
author_kind, author_label, picture, reference, created_at, note, intercom)
|
|
195
212
|
VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
|
|
196
|
-
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
|
|
197
|
-
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
|
|
198
|
-
${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::jsonb)
|
|
213
|
+
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::text::jsonb,
|
|
214
|
+
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::text::jsonb,
|
|
215
|
+
${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::text::jsonb)
|
|
199
216
|
RETURNING *`;
|
|
200
217
|
}
|
|
201
218
|
|
|
@@ -219,6 +236,21 @@ export class RoomLog {
|
|
|
219
236
|
return row && messageOf(row);
|
|
220
237
|
}
|
|
221
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Records how a message sent during a turn went (`steered`, `cancelled`), or clears it; the
|
|
241
|
+
* message as it is now, or undefined when the room holds none.
|
|
242
|
+
*/
|
|
243
|
+
async setDelivery(
|
|
244
|
+
room: string,
|
|
245
|
+
id: number,
|
|
246
|
+
delivery: "steered" | "cancelled" | null,
|
|
247
|
+
): Promise<RoomMessage | undefined> {
|
|
248
|
+
const [row]: Row[] = await this.#sql`
|
|
249
|
+
UPDATE web_room_messages SET delivery = ${delivery}
|
|
250
|
+
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
251
|
+
return row && messageOf(row);
|
|
252
|
+
}
|
|
253
|
+
|
|
222
254
|
/** The message the host's reference names, if the room holds one. */
|
|
223
255
|
async byReference(
|
|
224
256
|
room: string,
|
package/src/app/rooms.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
channelKey,
|
|
14
14
|
characterOf,
|
|
15
15
|
groupRoomKey,
|
|
16
|
+
helperSurface,
|
|
16
17
|
isHelper,
|
|
17
18
|
roomOf,
|
|
18
19
|
} from "pi-roundtable";
|
|
@@ -74,6 +75,8 @@ const THREAD_ID = /^thread\.[0-9a-f-]{36}$/;
|
|
|
74
75
|
*/
|
|
75
76
|
export class Rooms {
|
|
76
77
|
readonly #options: RoomsOptions;
|
|
78
|
+
/** The rooms of temp knights made on Discord that someone opened by their address here. */
|
|
79
|
+
readonly #opened = new Set<string>();
|
|
77
80
|
|
|
78
81
|
constructor(options: RoomsOptions) {
|
|
79
82
|
this.#options = options;
|
|
@@ -204,6 +207,19 @@ export class Rooms {
|
|
|
204
207
|
};
|
|
205
208
|
}
|
|
206
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The view of a temp knight's room that the list leaves out, opened by its address: from then on
|
|
212
|
+
* the list shows it. Undefined for any other room.
|
|
213
|
+
*/
|
|
214
|
+
async openHelper(key: ChannelKey): Promise<RoomView | undefined> {
|
|
215
|
+
const room = await this.resolve(key);
|
|
216
|
+
if (room?.kind !== "agent") return undefined;
|
|
217
|
+
const agent = this.#options.directory.agent(room.name);
|
|
218
|
+
if (helperSurface(agent) !== "discord") return undefined;
|
|
219
|
+
this.#opened.add(room.name);
|
|
220
|
+
return (await this.list()).find((one) => one.key === key);
|
|
221
|
+
}
|
|
222
|
+
|
|
207
223
|
async list(): Promise<RoomView[]> {
|
|
208
224
|
const { surface, directory, modes, mutes, activity, log, threads } =
|
|
209
225
|
this.#options;
|
|
@@ -274,9 +290,21 @@ export class Rooms {
|
|
|
274
290
|
// Home first; the others keep the team's order.
|
|
275
291
|
rooms.sort((a, b) => Number(b.home) - Number(a.home));
|
|
276
292
|
const last = await log.lastOf(rooms.map((room) => room.key));
|
|
277
|
-
|
|
293
|
+
// A temp knight made on Discord works there: its room here shows once it has a message or
|
|
294
|
+
// someone opened it by its address.
|
|
295
|
+
const quiet = new Set<string>(
|
|
296
|
+
agents
|
|
297
|
+
.filter(
|
|
298
|
+
(agent) =>
|
|
299
|
+
helperSurface(agent) === "discord" && !this.#opened.has(agent.name),
|
|
300
|
+
)
|
|
301
|
+
.map((agent) => agentRoomKey(surface, agent.name))
|
|
302
|
+
.filter((key) => !last.has(key)),
|
|
303
|
+
);
|
|
304
|
+
return rooms.flatMap((room) => {
|
|
305
|
+
if (quiet.has(room.key)) return [];
|
|
278
306
|
const message = last.get(room.key);
|
|
279
|
-
return message ? { ...room, last: message } : room;
|
|
307
|
+
return [message ? { ...room, last: message } : room];
|
|
280
308
|
});
|
|
281
309
|
}
|
|
282
310
|
}
|
package/src/app/routes.ts
CHANGED
|
@@ -60,6 +60,7 @@ import { PromptApi, type PromptApiOptions } from "./prompt-routes.ts";
|
|
|
60
60
|
import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
|
|
61
61
|
import { type ResolvedRoom, type Rooms, roomModeOf } from "./rooms.ts";
|
|
62
62
|
import { SkillDocApi } from "./skill-doc-routes.ts";
|
|
63
|
+
import { SkillGroupApi } from "./skill-group-routes.ts";
|
|
63
64
|
import type { AppSurface } from "./surface.ts";
|
|
64
65
|
import { TeamApi } from "./team-routes.ts";
|
|
65
66
|
import type { WebThreads } from "./threads.ts";
|
|
@@ -70,15 +71,40 @@ import {
|
|
|
70
71
|
type WorkspaceFiles,
|
|
71
72
|
WorkspaceRefusal,
|
|
72
73
|
} from "./workspace-files.ts";
|
|
74
|
+
import { uniqueNames, zipStore } from "./zip.ts";
|
|
73
75
|
|
|
74
76
|
/** The most threads one request puts back on the room list: more than the list ever shows. */
|
|
75
77
|
const MAX_RESTORED = 100;
|
|
78
|
+
/** The most bytes a message's files may add up to and still be zipped together: 200 MB. */
|
|
79
|
+
const MAX_ZIP_BYTES = 200 * 1024 * 1024;
|
|
80
|
+
|
|
81
|
+
/** A message's zip, named after when it was sent on the host's clock: `attachments-20261031-2030.zip`. */
|
|
82
|
+
export const zipName = (at: Date, timeZone = "UTC"): string => {
|
|
83
|
+
if (Number.isNaN(at.getTime())) return "attachments.zip";
|
|
84
|
+
const part = Object.fromEntries(
|
|
85
|
+
new Intl.DateTimeFormat("en-CA", {
|
|
86
|
+
timeZone,
|
|
87
|
+
year: "numeric",
|
|
88
|
+
month: "2-digit",
|
|
89
|
+
day: "2-digit",
|
|
90
|
+
hour: "2-digit",
|
|
91
|
+
minute: "2-digit",
|
|
92
|
+
hourCycle: "h23",
|
|
93
|
+
})
|
|
94
|
+
.formatToParts(at)
|
|
95
|
+
.map(({ type, value }) => [type, value]),
|
|
96
|
+
);
|
|
97
|
+
return `attachments-${part.year}${part.month}${part.day}-${part.hour}${part.minute}.zip`;
|
|
98
|
+
};
|
|
99
|
+
|
|
76
100
|
/** The most rooms one bulk request names. */
|
|
77
101
|
const MAX_BULK_ROOMS = MAX_READ_ALL;
|
|
78
102
|
|
|
79
103
|
export interface AppApiOptions {
|
|
80
104
|
/** The host's wording: a refusal reaches a client of the API in this locale when the catalog has it. */
|
|
81
105
|
presentation?: ConsolePresentation;
|
|
106
|
+
/** The host's IANA time zone, for the date in a zip's name; absent = UTC. */
|
|
107
|
+
timeZone?: string;
|
|
82
108
|
surface: AppSurface;
|
|
83
109
|
rooms: Rooms;
|
|
84
110
|
log: Pick<RoomLog, "page" | "lastOf" | "removeRoom" | "get" | "startOver">;
|
|
@@ -142,6 +168,8 @@ export interface AppApiOptions {
|
|
|
142
168
|
| "documentHistory"
|
|
143
169
|
| "delete"
|
|
144
170
|
>;
|
|
171
|
+
/** The registry's skill groups: listed, saved and deleted. */
|
|
172
|
+
groups: Pick<SkillRegistry, "groups" | "setGroup" | "deleteGroup">;
|
|
145
173
|
};
|
|
146
174
|
/** Present when the host has prompt documents (the prompts plugin): the main prompts the owner edits. */
|
|
147
175
|
prompts?: PromptApiOptions["prompts"];
|
|
@@ -285,6 +313,7 @@ export class AppApi {
|
|
|
285
313
|
readonly #helpers: HelperRoutes;
|
|
286
314
|
readonly #prompts: PromptApi | undefined;
|
|
287
315
|
readonly #skillDocs: SkillDocApi | undefined;
|
|
316
|
+
readonly #skillGroups: SkillGroupApi | undefined;
|
|
288
317
|
|
|
289
318
|
constructor(options: AppApiOptions) {
|
|
290
319
|
this.#options = options;
|
|
@@ -311,6 +340,17 @@ export class AppApi {
|
|
|
311
340
|
catalog: () => options.skills?.catalog() ?? [],
|
|
312
341
|
})
|
|
313
342
|
: undefined;
|
|
343
|
+
const skills = options.skills;
|
|
344
|
+
this.#skillGroups = skills
|
|
345
|
+
? new SkillGroupApi({
|
|
346
|
+
groups: skills.groups,
|
|
347
|
+
catalog: () => skills.catalog(),
|
|
348
|
+
attach: (agent, add, remove) => skills.attach(agent, add, remove),
|
|
349
|
+
active: (agent) =>
|
|
350
|
+
options.directory.agent(agent)?.status === "active",
|
|
351
|
+
changed: () => options.changed(),
|
|
352
|
+
})
|
|
353
|
+
: undefined;
|
|
314
354
|
this.#team = new TeamApi({
|
|
315
355
|
surface: options.surface.surface,
|
|
316
356
|
rooms: options.rooms,
|
|
@@ -394,6 +434,7 @@ export class AppApi {
|
|
|
394
434
|
"workspace",
|
|
395
435
|
"prompts",
|
|
396
436
|
"skill-docs",
|
|
437
|
+
"skill-groups",
|
|
397
438
|
].includes(second ?? ""));
|
|
398
439
|
if (!mine) return undefined;
|
|
399
440
|
try {
|
|
@@ -473,6 +514,8 @@ export class AppApi {
|
|
|
473
514
|
throw notFound();
|
|
474
515
|
return json({ ok: true });
|
|
475
516
|
}
|
|
517
|
+
if (area === "files" && method === "GET" && id === "zip" && !action)
|
|
518
|
+
return await this.#messageZip(request, admission);
|
|
476
519
|
if (area === "files" && method === "GET" && id && !action)
|
|
477
520
|
return await this.#file(id, admission);
|
|
478
521
|
if (area === "workspace") return this.#workspace(request, parts, admission);
|
|
@@ -492,6 +535,11 @@ export class AppApi {
|
|
|
492
535
|
throw new HttpError(404, "This host has no skills.");
|
|
493
536
|
return this.#skillDocs.handle(request, parts, owner);
|
|
494
537
|
}
|
|
538
|
+
if (area === "skill-groups") {
|
|
539
|
+
if (!this.#skillGroups)
|
|
540
|
+
throw new HttpError(404, "This host has no skills.");
|
|
541
|
+
return this.#skillGroups.handle(request, parts, owner);
|
|
542
|
+
}
|
|
495
543
|
throw new HttpError(404, "There is no such API.");
|
|
496
544
|
}
|
|
497
545
|
|
|
@@ -565,8 +613,11 @@ export class AppApi {
|
|
|
565
613
|
const room = await rooms.resolve(key);
|
|
566
614
|
if (!room) throw notFound();
|
|
567
615
|
if (!action && method === "GET") {
|
|
568
|
-
// A thread dismissed from the list
|
|
569
|
-
|
|
616
|
+
// A thread dismissed from the list, or a temp knight's room the list leaves out, still opens
|
|
617
|
+
// by its address; the page asks for its view.
|
|
618
|
+
const view =
|
|
619
|
+
(await rooms.threadView(room.key)) ??
|
|
620
|
+
(await rooms.openHelper(room.key));
|
|
570
621
|
if (!view) throw notFound();
|
|
571
622
|
return json({ room: view } satisfies RoomOneView);
|
|
572
623
|
}
|
|
@@ -609,7 +660,10 @@ export class AppApi {
|
|
|
609
660
|
const cursor = await reads?.cursorOf(room.key, self);
|
|
610
661
|
return json({
|
|
611
662
|
...page,
|
|
612
|
-
messages: withExpiredFiles(
|
|
663
|
+
messages: withExpiredFiles(
|
|
664
|
+
page.messages.map((one) => this.#options.surface.delivery.shown(one)),
|
|
665
|
+
this.#options.files,
|
|
666
|
+
),
|
|
613
667
|
cards: await cards.during(room.key, {
|
|
614
668
|
...(first && (page.more || isStartOver(first))
|
|
615
669
|
? { from: first.at }
|
|
@@ -843,6 +897,16 @@ export class AppApi {
|
|
|
843
897
|
|
|
844
898
|
async #file(id: string, admission: Authenticated): Promise<Response> {
|
|
845
899
|
if (!isFileId(id)) throw notFound();
|
|
900
|
+
await this.#mayServe(id, admission);
|
|
901
|
+
const { files } = this.#options;
|
|
902
|
+
const meta = files.meta(id);
|
|
903
|
+
const blob = files.blob(id);
|
|
904
|
+
if (!meta || !blob) throw notFound();
|
|
905
|
+
return fileResponse(meta, blob);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Refuses, as a missing file, a stored file the visitor may not be served. */
|
|
909
|
+
async #mayServe(id: string, admission: Authenticated): Promise<void> {
|
|
846
910
|
const { files, host } = this.#options;
|
|
847
911
|
// A file bound to a host room or page is for the people it is open to, owners too; every other stored file belongs to an agent room, which only owners read.
|
|
848
912
|
const bound = files.binding(id)?.room;
|
|
@@ -853,10 +917,62 @@ export class AppApi {
|
|
|
853
917
|
!(await host.mayRead(viewerOf(admission.visitor), bound))
|
|
854
918
|
)
|
|
855
919
|
throw notFound();
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* `GET api/files/zip?room=<room>&message=<id>`: the message's files still kept, as one zip.
|
|
924
|
+
* The room and every file answer as the single file does; a refusal is a missing message.
|
|
925
|
+
*/
|
|
926
|
+
async #messageZip(
|
|
927
|
+
request: Request,
|
|
928
|
+
admission: Authenticated,
|
|
929
|
+
): Promise<Response> {
|
|
930
|
+
const params = new URL(request.url).searchParams;
|
|
931
|
+
const room = params.get("room") ?? "";
|
|
932
|
+
const id = Number(params.get("message"));
|
|
933
|
+
if (room === "" || !Number.isSafeInteger(id) || id <= 0) throw notFound();
|
|
934
|
+
const { files, host, log } = this.#options;
|
|
935
|
+
const mayReadRoom =
|
|
936
|
+
admission.visitor.tier === "owner" ||
|
|
937
|
+
(host !== undefined &&
|
|
938
|
+
(await host.mayRead(viewerOf(admission.visitor), room)));
|
|
939
|
+
if (!mayReadRoom) throw notFound();
|
|
940
|
+
const message = await log.get(room, id);
|
|
941
|
+
if (!message) throw notFound();
|
|
942
|
+
// Only the files still kept: a swept file has no binding left to check it by.
|
|
943
|
+
const kept = message.files.flatMap((file) => {
|
|
944
|
+
const meta = isFileId(file.id) ? files.meta(file.id) : undefined;
|
|
945
|
+
const blob = meta && files.blob(file.id);
|
|
946
|
+
return meta && blob ? [{ id: file.id, meta, blob }] : [];
|
|
947
|
+
});
|
|
948
|
+
for (const file of kept) await this.#mayServe(file.id, admission);
|
|
949
|
+
if (kept.length === 0)
|
|
950
|
+
throw new HttpError(
|
|
951
|
+
404,
|
|
952
|
+
"None of this message's files are kept any more.",
|
|
953
|
+
);
|
|
954
|
+
const total = kept.reduce((sum, { blob }) => sum + blob.size, 0);
|
|
955
|
+
if (total > MAX_ZIP_BYTES)
|
|
956
|
+
throw new HttpError(
|
|
957
|
+
413,
|
|
958
|
+
"These files are too large to download together; download them one by one.",
|
|
959
|
+
);
|
|
960
|
+
const names = uniqueNames(kept.map(({ meta }) => meta.name));
|
|
961
|
+
const entries = await Promise.all(
|
|
962
|
+
kept.map(async ({ blob }, i) => ({
|
|
963
|
+
name: names[i] ?? "file",
|
|
964
|
+
data: new Uint8Array(await blob.arrayBuffer()),
|
|
965
|
+
})),
|
|
966
|
+
);
|
|
967
|
+
const at = new Date(message.at);
|
|
968
|
+
return new Response(zipStore(entries, at), {
|
|
969
|
+
headers: {
|
|
970
|
+
"content-type": "application/zip",
|
|
971
|
+
"content-disposition": `attachment; filename="${zipName(at, this.#options.timeZone)}"`,
|
|
972
|
+
"cache-control": "private, no-store",
|
|
973
|
+
"x-content-type-options": "nosniff",
|
|
974
|
+
},
|
|
975
|
+
});
|
|
860
976
|
}
|
|
861
977
|
|
|
862
978
|
/** The workspace files pane's API; every call, a read too, is an owner's. */
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { SkillRegistry } from "pi-roundtable";
|
|
2
|
+
import type {
|
|
3
|
+
SkillGroupApplied,
|
|
4
|
+
SkillGroupApply,
|
|
5
|
+
SkillGroupChanged,
|
|
6
|
+
SkillGroupSave,
|
|
7
|
+
SkillGroupsView,
|
|
8
|
+
SkillView,
|
|
9
|
+
} from "../api-types.ts";
|
|
10
|
+
import { json } from "../http.ts";
|
|
11
|
+
import {
|
|
12
|
+
bad,
|
|
13
|
+
bodyOf,
|
|
14
|
+
HttpError,
|
|
15
|
+
optionalList,
|
|
16
|
+
optionalText,
|
|
17
|
+
requiredText,
|
|
18
|
+
} from "./api-http.ts";
|
|
19
|
+
import type { OwnerOf } from "./team-routes.ts";
|
|
20
|
+
|
|
21
|
+
export interface SkillGroupApiOptions {
|
|
22
|
+
groups: Pick<SkillRegistry, "groups" | "setGroup" | "deleteGroup">;
|
|
23
|
+
/** The catalog the page lists: who carries each skill, and which are built in. */
|
|
24
|
+
catalog(): SkillView[];
|
|
25
|
+
/** The same change the per-skill toggle makes. */
|
|
26
|
+
attach(
|
|
27
|
+
agent: string,
|
|
28
|
+
add: readonly string[],
|
|
29
|
+
remove: readonly string[],
|
|
30
|
+
): Promise<string[]>;
|
|
31
|
+
/** Whether a knight answers; an archived or unknown one is a 404. */
|
|
32
|
+
active(agent: string): boolean;
|
|
33
|
+
changed(): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The app's REST for skill groups: listing them, creating or replacing one, deleting one, and
|
|
38
|
+
* applying one to a knight. Applying switches on the group's skills the knight lacks through the
|
|
39
|
+
* per-skill toggle's path; applying it off switches off the group's skills the knight carries, built-in
|
|
40
|
+
* ones excepted. The knight carries skills, not the group.
|
|
41
|
+
* Every call is an owner's; the registry's refusals are a 400 in its words.
|
|
42
|
+
*/
|
|
43
|
+
export class SkillGroupApi {
|
|
44
|
+
readonly #options: SkillGroupApiOptions;
|
|
45
|
+
|
|
46
|
+
constructor(options: SkillGroupApiOptions) {
|
|
47
|
+
this.#options = options;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async handle(
|
|
51
|
+
request: Request,
|
|
52
|
+
parts: string[],
|
|
53
|
+
owner: OwnerOf,
|
|
54
|
+
): Promise<Response> {
|
|
55
|
+
const { method } = request;
|
|
56
|
+
const [, , name, action] = parts;
|
|
57
|
+
const { groups } = this.#options;
|
|
58
|
+
// The tier is read first, so a person below the owner learns of no group.
|
|
59
|
+
await owner();
|
|
60
|
+
if (!name) {
|
|
61
|
+
if (method !== "GET") throw new HttpError(405, "Method not allowed.");
|
|
62
|
+
return json({
|
|
63
|
+
groups: groups.groups().map(({ name, description, skills }) => ({
|
|
64
|
+
name,
|
|
65
|
+
description,
|
|
66
|
+
skills: [...skills],
|
|
67
|
+
})),
|
|
68
|
+
} satisfies SkillGroupsView);
|
|
69
|
+
}
|
|
70
|
+
const group = groups.groups().find((one) => one.name === name);
|
|
71
|
+
if (!action && method === "PUT") {
|
|
72
|
+
const body = await bodyOf(request);
|
|
73
|
+
const from = optionalText(body, "from");
|
|
74
|
+
const save: SkillGroupSave = {
|
|
75
|
+
description: requiredText(body, "description"),
|
|
76
|
+
skills: optionalList(body, "skills") ?? [],
|
|
77
|
+
...(from ? { from } : {}),
|
|
78
|
+
};
|
|
79
|
+
const renamed = save.from !== undefined && save.from !== name;
|
|
80
|
+
if (renamed) {
|
|
81
|
+
if (!groups.groups().some((one) => one.name === save.from))
|
|
82
|
+
throw new HttpError(404, "There is no such skill group.");
|
|
83
|
+
if (group) throw bad(`There is already a skill group "${name}".`);
|
|
84
|
+
}
|
|
85
|
+
let message = await groups.setGroup(name, save.description, save.skills);
|
|
86
|
+
if (renamed && save.from)
|
|
87
|
+
message = `${message} ${await groups.deleteGroup(save.from)}`;
|
|
88
|
+
this.#options.changed();
|
|
89
|
+
return json({ message } satisfies SkillGroupChanged, group ? 200 : 201);
|
|
90
|
+
}
|
|
91
|
+
if (!group) throw new HttpError(404, "There is no such skill group.");
|
|
92
|
+
if (!action && method === "DELETE") {
|
|
93
|
+
const message = await groups.deleteGroup(name);
|
|
94
|
+
this.#options.changed();
|
|
95
|
+
return json({ message } satisfies SkillGroupChanged);
|
|
96
|
+
}
|
|
97
|
+
if (action === "apply" && method === "POST" && parts.length === 4) {
|
|
98
|
+
const body = await bodyOf(request);
|
|
99
|
+
const apply: SkillGroupApply = {
|
|
100
|
+
agent: requiredText(body, "agent"),
|
|
101
|
+
on: body.on !== false,
|
|
102
|
+
};
|
|
103
|
+
if (!this.#options.active(apply.agent))
|
|
104
|
+
throw new HttpError(404, "There is no such room, knight or file.");
|
|
105
|
+
const catalog = new Map(
|
|
106
|
+
this.#options.catalog().map((entry) => [entry.name, entry]),
|
|
107
|
+
);
|
|
108
|
+
// A built-in skill is carried always: it is never added nor switched off.
|
|
109
|
+
const switchable = group.skills.filter(
|
|
110
|
+
(skill) => catalog.get(skill)?.source.kind !== "builtin",
|
|
111
|
+
);
|
|
112
|
+
const carries = (skill: string) =>
|
|
113
|
+
catalog.get(skill)?.carriers.includes(apply.agent) ?? false;
|
|
114
|
+
const added = apply.on
|
|
115
|
+
? switchable.filter((skill) => !carries(skill))
|
|
116
|
+
: [];
|
|
117
|
+
const removed = apply.on ? [] : switchable.filter(carries);
|
|
118
|
+
if (added.length > 0 || removed.length > 0) {
|
|
119
|
+
await this.#options.attach(apply.agent, added, removed);
|
|
120
|
+
this.#options.changed();
|
|
121
|
+
}
|
|
122
|
+
return json({ added, removed } satisfies SkillGroupApplied);
|
|
123
|
+
}
|
|
124
|
+
throw new HttpError(404, "There is no such API.");
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/app/socket.ts
CHANGED
|
@@ -26,6 +26,8 @@ import type { PluggedFrames, Viewer, WebAppRegistry } from "../web-app.ts";
|
|
|
26
26
|
import type { Answerer, OwnerDesk } from "./desk.ts";
|
|
27
27
|
import type { Presence } from "./presence.ts";
|
|
28
28
|
import { parseClientFrame } from "./protocol.ts";
|
|
29
|
+
import { coreMessageId } from "./queued-delivery.ts";
|
|
30
|
+
import { changeQueued } from "./queued-frames.ts";
|
|
29
31
|
import type { Rooms } from "./rooms.ts";
|
|
30
32
|
import type { AppSocket, Audience, Sockets } from "./sockets.ts";
|
|
31
33
|
import type { AppSurface } from "./surface.ts";
|
|
@@ -65,7 +67,7 @@ export interface SocketRouteOptions {
|
|
|
65
67
|
* not: the pieces of the app send what is per person there, such as the notices and the badge.
|
|
66
68
|
*/
|
|
67
69
|
opened?(connection: AppSocket): Promise<void>;
|
|
68
|
-
conversations: Pick<ConversationPort, "stop">;
|
|
70
|
+
conversations: Pick<ConversationPort, "stop" | "cancelQueued" | "editQueued">;
|
|
69
71
|
logger: Logger;
|
|
70
72
|
/**
|
|
71
73
|
* The longest a socket stays open, whatever its credential says. A password session lives for
|
|
@@ -443,6 +445,14 @@ export class SocketRoute {
|
|
|
443
445
|
else conversations.stop(room.key);
|
|
444
446
|
break;
|
|
445
447
|
}
|
|
448
|
+
case "edit_queued":
|
|
449
|
+
case "cancel_queued":
|
|
450
|
+
refused = await changeQueued(frame, who, {
|
|
451
|
+
rooms,
|
|
452
|
+
surface: this.#options.surface,
|
|
453
|
+
conversations,
|
|
454
|
+
});
|
|
455
|
+
break;
|
|
446
456
|
case "approval":
|
|
447
457
|
case "answer":
|
|
448
458
|
case "answers":
|
|
@@ -658,7 +668,7 @@ export class SocketRoute {
|
|
|
658
668
|
stored?.(message);
|
|
659
669
|
const inbound: InboundMessage = {
|
|
660
670
|
channel: room,
|
|
661
|
-
messageId:
|
|
671
|
+
messageId: coreMessageId(message.id),
|
|
662
672
|
actor: who.actor,
|
|
663
673
|
authorId: who.principalId,
|
|
664
674
|
authorName: who.name,
|