@a-t-h-i/bot-lobby 0.6.44 → 0.6.45
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/README.md +15 -4
- package/package.json +1 -6
- package/src/execution/agent-runner.ts +0 -2
- package/src/execution/delegation.ts +1 -2
- package/src/execution/pi-runner.ts +2 -6
- package/src/index.ts +0 -3
- package/src/lobby/host.ts +1 -12
- package/src/lobby/planner.ts +1 -6
- package/src/lobby/prompts.ts +4 -6
- package/src/lobby/quickfix.ts +0 -2
- package/src/lobby/runtime.ts +1 -5
- package/src/lobby/service.ts +0 -6
- package/src/lobby/topics.ts +0 -1
- package/src/pi/tools.ts +2 -2
- package/src/webui/api/index.ts +0 -39
- package/src/webui/dev/fake-service.ts +0 -57
- package/src/webui/dev/fixtures.ts +0 -6
- package/src/webui/protocol.ts +0 -29
- package/src/workflow/workflow.ts +1 -17
- package/webui/dist/assets/index-ClwTDiyo.css +2 -0
- package/webui/dist/assets/index-Ct47Kp7Q.js +110 -0
- package/webui/dist/build.json +18 -58
- package/webui/dist/index.html +2 -2
- package/src/excalidraw/check.ts +0 -31
- package/src/excalidraw/client.ts +0 -334
- package/src/excalidraw/room.ts +0 -118
- package/src/excalidraw/scene.ts +0 -746
- package/src/excalidraw/sessions.ts +0 -302
- package/src/excalidraw/tools.ts +0 -223
- package/src/webui/api/excalidraw.ts +0 -105
- package/webui/dist/assets/index-C0T8aK16.js +0 -110
- package/webui/dist/assets/index-XtSiJAAR.css +0 -2
package/webui/dist/build.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
|
-
"hash": "
|
|
3
|
-
"builtAt": "2026-10-
|
|
2
|
+
"hash": "0df0d32db3ef3e2e8ea322e11ee37a28e36a5d6bafcb8879f15511b3e650f751",
|
|
3
|
+
"builtAt": "2026-10-13T20:25:30.590Z",
|
|
4
4
|
"files": [
|
|
5
5
|
{
|
|
6
6
|
"path": "src/lobby/prompts.ts",
|
|
7
|
-
"sha256": "
|
|
7
|
+
"sha256": "3a8cce283a49b576e134d03bbc706f17daf00e5398869b402b590b44706e3fa0"
|
|
8
8
|
},
|
|
9
9
|
{
|
|
10
10
|
"path": "src/webui/protocol.ts",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "9268a55d2be4bce71d06d11ac5ed51f023d2202cf2c52f122498c6d9e573f62c"
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"path": "webui/components.json",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"path": "webui/src/app/AltH.tsx",
|
|
27
|
-
"sha256": "
|
|
27
|
+
"sha256": "8da7cb184294a27afe68834da391882feb55f1248ffc228c91debd44e24920b4"
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
30
|
"path": "webui/src/app/AppTools.tsx",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
"path": "webui/src/app/Composer.tsx",
|
|
35
|
-
"sha256": "
|
|
35
|
+
"sha256": "5e268afa7d5cfdf650d55dbb9a9a477bbc0bdca53ad3cf18adb36b4e71b4c79c"
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"path": "webui/src/app/Header.tsx",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"path": "webui/src/app/Shell.tsx",
|
|
63
|
-
"sha256": "
|
|
63
|
+
"sha256": "4dd0dd60639f56850a17ccbfdc5e5051ae938d706999afe341d1c93c7057b3b5"
|
|
64
64
|
},
|
|
65
65
|
{
|
|
66
66
|
"path": "webui/src/app/Sidebar.tsx",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
78
|
"path": "webui/src/app/TabIcons.tsx",
|
|
79
|
-
"sha256": "
|
|
79
|
+
"sha256": "37cafc982a38b556749b28a72414b70ed56f3341d5f63a1d66ca697cd97d3d35"
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
"path": "webui/src/app/TabStrip.tsx",
|
|
@@ -120,7 +120,7 @@
|
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
122
|
"path": "webui/src/app/phoneCarousel.ts",
|
|
123
|
-
"sha256": "
|
|
123
|
+
"sha256": "690e01f70f04cfdb1432d561b75e9b8a16fc8ad0558ed54bb5296dce0a1deaf5"
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
126
|
"path": "webui/src/app/readTopic.ts",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
},
|
|
133
133
|
{
|
|
134
134
|
"path": "webui/src/app/router.ts",
|
|
135
|
-
"sha256": "
|
|
135
|
+
"sha256": "c800b5a5ecd3e03d5d9b278a085b15e2f6acf6c4b5d6d8f89dbf77e2d7fcdbcb"
|
|
136
136
|
},
|
|
137
137
|
{
|
|
138
138
|
"path": "webui/src/app/sound.ts",
|
|
@@ -260,7 +260,7 @@
|
|
|
260
260
|
},
|
|
261
261
|
{
|
|
262
262
|
"path": "webui/src/index.css",
|
|
263
|
-
"sha256": "
|
|
263
|
+
"sha256": "560ccd154241f7ede5806fbf7c17b7919a044542cc4c965d35fc7f2ea636d185"
|
|
264
264
|
},
|
|
265
265
|
{
|
|
266
266
|
"path": "webui/src/lib/act.ts",
|
|
@@ -386,25 +386,9 @@
|
|
|
386
386
|
"path": "webui/src/prompts/payload.ts",
|
|
387
387
|
"sha256": "00aff317aa805303ffb8c6a451bf7b30841cc8a19cf079187cf61a535fe0619a"
|
|
388
388
|
},
|
|
389
|
-
{
|
|
390
|
-
"path": "webui/src/tabs/excalidraw/ExcalidrawTab.tsx",
|
|
391
|
-
"sha256": "e255e3579b7f6c929c05c2437519182e0a429255c94658ff1fe12ac4d356d13f"
|
|
392
|
-
},
|
|
393
|
-
{
|
|
394
|
-
"path": "webui/src/tabs/excalidraw/SessionDetail.tsx",
|
|
395
|
-
"sha256": "97835ddcd29b89d4768d081ae0c73021fceb7aa174827c8cfb5c68ec1e6e40f0"
|
|
396
|
-
},
|
|
397
|
-
{
|
|
398
|
-
"path": "webui/src/tabs/excalidraw/SessionList.tsx",
|
|
399
|
-
"sha256": "8d2ea9741214c30c85e8940e95724f71d3d33ab1cfe57f8889f8743285a7f954"
|
|
400
|
-
},
|
|
401
|
-
{
|
|
402
|
-
"path": "webui/src/tabs/excalidraw/words.ts",
|
|
403
|
-
"sha256": "134b86797a1911abb7d5c396d0603de8a35ed66a8419b98e2d1fef0e154ee5ae"
|
|
404
|
-
},
|
|
405
389
|
{
|
|
406
390
|
"path": "webui/src/tabs/git/GitTab.tsx",
|
|
407
|
-
"sha256": "
|
|
391
|
+
"sha256": "3514bdf364cc4d20612a86857aa19b795716ca1c8aec9695eef478fd3457c98a"
|
|
408
392
|
},
|
|
409
393
|
{
|
|
410
394
|
"path": "webui/src/tabs/git/PullDetail.tsx",
|
|
@@ -428,7 +412,7 @@
|
|
|
428
412
|
},
|
|
429
413
|
{
|
|
430
414
|
"path": "webui/src/tabs/issues/IssuesTab.tsx",
|
|
431
|
-
"sha256": "
|
|
415
|
+
"sha256": "6c9ba8911c22fea81c77935acfc65fa0b029aba707e38f2caf64f2bd806ea140"
|
|
432
416
|
},
|
|
433
417
|
{
|
|
434
418
|
"path": "webui/src/tabs/issues/words.ts",
|
|
@@ -580,31 +564,7 @@
|
|
|
580
564
|
},
|
|
581
565
|
{
|
|
582
566
|
"path": "webui/src/tabs/registry.tsx",
|
|
583
|
-
"sha256": "
|
|
584
|
-
},
|
|
585
|
-
{
|
|
586
|
-
"path": "webui/src/tabs/sessions/ChatView.tsx",
|
|
587
|
-
"sha256": "a6d7be372fddb100816304d1b0f8ba2462fa57b05b39394470f432fac5074062"
|
|
588
|
-
},
|
|
589
|
-
{
|
|
590
|
-
"path": "webui/src/tabs/sessions/Dialogs.tsx",
|
|
591
|
-
"sha256": "b356f5220f0bdbcc8bbc65328033d4ecee5cd19a398ef0980835ba8ff617be21"
|
|
592
|
-
},
|
|
593
|
-
{
|
|
594
|
-
"path": "webui/src/tabs/sessions/SessionDetail.tsx",
|
|
595
|
-
"sha256": "57a6772eae6441ebe7fc12e43d15cf7ab6e4a5833adc80f67f1467ab68d2bb4e"
|
|
596
|
-
},
|
|
597
|
-
{
|
|
598
|
-
"path": "webui/src/tabs/sessions/SessionsTab.tsx",
|
|
599
|
-
"sha256": "06b786f615e62fb74aa48de1f8e0e3b850db5796a2782a0371832c0690f5bb7e"
|
|
600
|
-
},
|
|
601
|
-
{
|
|
602
|
-
"path": "webui/src/tabs/sessions/WhereIcon.tsx",
|
|
603
|
-
"sha256": "4233416cf305ea409bfcc3efd72fff09c57ceaf6e60a1230ec3bb77d150fc21f"
|
|
604
|
-
},
|
|
605
|
-
{
|
|
606
|
-
"path": "webui/src/tabs/sessions/words.ts",
|
|
607
|
-
"sha256": "4754645dbbb727d1278283f3afdd634b58e4b01e61ddd3f84d6aa84d01f1e8ba"
|
|
567
|
+
"sha256": "76ceae508932bbe884d0c83d67c45cd3e82786c74314d8827162994e20bd840d"
|
|
608
568
|
},
|
|
609
569
|
{
|
|
610
570
|
"path": "webui/src/tabs/settings/SettingsForm.tsx",
|
|
@@ -624,7 +584,7 @@
|
|
|
624
584
|
},
|
|
625
585
|
{
|
|
626
586
|
"path": "webui/src/tabs/settings/words.ts",
|
|
627
|
-
"sha256": "
|
|
587
|
+
"sha256": "a4a0ba4cf548fcb63d4bacb2c8b7f6c7c20487bf5951d58a7fb04325441f6519"
|
|
628
588
|
},
|
|
629
589
|
{
|
|
630
590
|
"path": "webui/src/tabs/tasks/Comments.tsx",
|
|
@@ -652,7 +612,7 @@
|
|
|
652
612
|
},
|
|
653
613
|
{
|
|
654
614
|
"path": "webui/src/tabs/tasks/ResumeTask.tsx",
|
|
655
|
-
"sha256": "
|
|
615
|
+
"sha256": "3bbaa2342151be92262f350a7aee0c832a3609a02e1737c356070a17fad67837"
|
|
656
616
|
},
|
|
657
617
|
{
|
|
658
618
|
"path": "webui/src/tabs/tasks/TaskBoard.tsx",
|
|
@@ -660,7 +620,7 @@
|
|
|
660
620
|
},
|
|
661
621
|
{
|
|
662
622
|
"path": "webui/src/tabs/tasks/TaskDetail.tsx",
|
|
663
|
-
"sha256": "
|
|
623
|
+
"sha256": "91a957d2125347465759276008146e66a8a85e701c6babc7ba4721083d11cebe"
|
|
664
624
|
},
|
|
665
625
|
{
|
|
666
626
|
"path": "webui/src/tabs/tasks/TaskList.tsx",
|
|
@@ -720,7 +680,7 @@
|
|
|
720
680
|
},
|
|
721
681
|
{
|
|
722
682
|
"path": "webui/src/ui/SplitPane.tsx",
|
|
723
|
-
"sha256": "
|
|
683
|
+
"sha256": "fd8742f29f219fd257e1b682e1bfe032f20d599c8313f829f981ccc1d155dcb9"
|
|
724
684
|
},
|
|
725
685
|
{
|
|
726
686
|
"path": "webui/src/ui/WorkingMark.tsx",
|
package/webui/dist/index.html
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
14
14
|
<title>bot-lobby</title>
|
|
15
15
|
<meta property="csp-nonce" nonce="__CSP_NONCE__">
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-Ct47Kp7Q.js" nonce="__CSP_NONCE__"></script>
|
|
17
|
+
<link rel="stylesheet" crossorigin href="/assets/index-ClwTDiyo.css" nonce="__CSP_NONCE__">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
|
20
20
|
<div id="root"></div>
|
package/src/excalidraw/check.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* "Does this session work?": join the room for a moment, as a seat named
|
|
3
|
-
* after the lobby, and say what is there — the server reached, who is in the
|
|
4
|
-
* room, how much is on the board, whether the link's key fits. The seat leaves
|
|
5
|
-
* again at once, so a check never leaves an extra collaborator in the room.
|
|
6
|
-
*/
|
|
7
|
-
import { ExcalidrawRoom, type RoomOptions } from "./client.ts";
|
|
8
|
-
import { parseRoomLink } from "./room.ts";
|
|
9
|
-
|
|
10
|
-
export interface CheckResult {
|
|
11
|
-
ok: boolean;
|
|
12
|
-
text: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function checkSession(link: string, name: string, options: Pick<RoomOptions, "server" | "sceneWaitMs" | "connectTimeoutMs" | "connect"> = {}): Promise<CheckResult> {
|
|
16
|
-
const parsed = parseRoomLink(link);
|
|
17
|
-
if (!parsed) return { ok: false, text: "not a valid room link" };
|
|
18
|
-
const room = new ExcalidrawRoom({ link: parsed, username: "bot-lobby · check", name, ...options });
|
|
19
|
-
try {
|
|
20
|
-
const status = await room.ready();
|
|
21
|
-
// Give an answer to the newcomer's arrival a moment more when someone is there but the board has not come yet.
|
|
22
|
-
if (status.undecryptable > 0 && status.elements === 0) return { ok: false, text: "reached the room, but its messages would not open: the link's key does not match, so it may have been copied incompletely" };
|
|
23
|
-
if (status.peers === 0) return { ok: true, text: "reached the server; nobody has this session open yet — open the link in Excalidraw, and agents can read and draw" };
|
|
24
|
-
const who = status.people.length > 0 ? status.people.join(", ") : `${status.peers} other${status.peers === 1 ? "" : "s"}`;
|
|
25
|
-
return { ok: true, text: `reached the room with ${who}; ${status.synced ? `${status.elements} element${status.elements === 1 ? "" : "s"} on the board` : "the board has not been sent yet (it comes when someone in the room answers)"}` };
|
|
26
|
-
} catch (error) {
|
|
27
|
-
return { ok: false, text: (error as Error).message };
|
|
28
|
-
} finally {
|
|
29
|
-
room.close();
|
|
30
|
-
}
|
|
31
|
-
}
|
package/src/excalidraw/client.ts
DELETED
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One agent's seat in an Excalidraw shared session. It joins the room the way
|
|
3
|
-
* a second browser tab would: over Excalidraw's collaboration socket, sending
|
|
4
|
-
* and receiving the encrypted scene messages every client in the room sends.
|
|
5
|
-
* Others in the room answer a newcomer with the whole scene, so a seat learns
|
|
6
|
-
* the board from whoever is there; what the agent draws goes out as an update
|
|
7
|
-
* that every browser merges in place, under the agent's name.
|
|
8
|
-
*
|
|
9
|
-
* A seat never answers a newcomer with a scene it does not have: a browser
|
|
10
|
-
* that took an empty scene from an agent would skip loading the board it saved
|
|
11
|
-
* itself. And it does not draw while nobody else is in the room, because
|
|
12
|
-
* nothing keeps a scene for a room but the browsers in it.
|
|
13
|
-
*/
|
|
14
|
-
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
15
|
-
import { io, type Socket } from "socket.io-client";
|
|
16
|
-
import { DEFAULT_ORIGIN, DEFAULT_SERVER, seal, unseal, type RoomLink } from "./room.ts";
|
|
17
|
-
import { describeScene, mergeElements, plainText, planDraw, sceneBounds, visible, type DrawOutcome, type DrawRequest, type Scene, type SceneElement } from "./scene.ts";
|
|
18
|
-
|
|
19
|
-
/** The part of a socket.io client a seat uses; tests and other transports can stand in for it. */
|
|
20
|
-
export type SocketLike = Pick<Socket, "on" | "emit" | "close" | "connected"> & { id?: string | undefined };
|
|
21
|
-
|
|
22
|
-
export interface RoomOptions {
|
|
23
|
-
link: Pick<RoomLink, "roomId" | "roomKey">;
|
|
24
|
-
/** The name other people see next to this seat's cursor. */
|
|
25
|
-
username: string;
|
|
26
|
-
/** The session's name in the lobby, for readings and messages. */
|
|
27
|
-
name: string;
|
|
28
|
-
server?: string;
|
|
29
|
-
connect?: (server: string) => SocketLike;
|
|
30
|
-
/** Give up on reaching the server after this long. */
|
|
31
|
-
connectTimeoutMs?: number;
|
|
32
|
-
/** How long to wait for a collaborator to send the scene (Excalidraw's own client waits five seconds). */
|
|
33
|
-
sceneWaitMs?: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface RoomStatus {
|
|
37
|
-
connected: boolean;
|
|
38
|
-
/** Others in the room, people and agents, not counting this seat. */
|
|
39
|
-
peers: number;
|
|
40
|
-
/** A collaborator has sent the whole scene. */
|
|
41
|
-
synced: boolean;
|
|
42
|
-
elements: number;
|
|
43
|
-
/** Messages that would not open: the link's key does not match the room's. */
|
|
44
|
-
undecryptable: number;
|
|
45
|
-
/** Names the collaborators have given themselves. */
|
|
46
|
-
people: string[];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const CONNECT_TIMEOUT_MS = 10_000;
|
|
50
|
-
const SCENE_WAIT_MS = 5000;
|
|
51
|
-
/** The collaboration server relays at most a megabyte a message; a scene near that is not sent whole. */
|
|
52
|
-
const MAX_MESSAGE_CHARS = 900_000;
|
|
53
|
-
|
|
54
|
-
function defaultConnect(server: string): SocketLike {
|
|
55
|
-
const proxy = proxyFor(server);
|
|
56
|
-
// Where a network lets plain HTTPS through but not a websocket, the seat falls back to long-polling, as a browser does.
|
|
57
|
-
// socket.io types `agent` for browsers (string | boolean); in Node it is passed on to the http and ws requests as an http.Agent.
|
|
58
|
-
const agent = proxy ? { agent: new HttpsProxyAgent(proxy) as unknown as string } : {};
|
|
59
|
-
// Excalidraw's collaboration server refuses handshakes whose Origin is not on its allowlist, so a seat presents the site a browser would come from.
|
|
60
|
-
let site = DEFAULT_ORIGIN;
|
|
61
|
-
if (server !== DEFAULT_SERVER) {
|
|
62
|
-
try {
|
|
63
|
-
site = new URL(server).origin;
|
|
64
|
-
} catch {
|
|
65
|
-
/* fall back to DEFAULT_ORIGIN */
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return io(server, { transports: ["websocket", "polling"], tryAllTransports: true, autoUnref: true, timeout: 8000, extraHeaders: { Origin: site }, ...agent });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The proxy the environment names for this server (`HTTPS_PROXY`, `HTTP_PROXY`,
|
|
73
|
-
* `ALL_PROXY`, in either case), unless `NO_PROXY` exempts its host. Node's own
|
|
74
|
-
* sockets ignore these variables, so without this a seat on a network that only
|
|
75
|
-
* lets traffic out through a proxy never reaches the server.
|
|
76
|
-
*/
|
|
77
|
-
export function proxyFor(server: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
78
|
-
let url: URL;
|
|
79
|
-
try {
|
|
80
|
-
url = new URL(server);
|
|
81
|
-
} catch {
|
|
82
|
-
return undefined;
|
|
83
|
-
}
|
|
84
|
-
const pick = (...names: string[]) => names.map((name) => env[name]?.trim()).find((value) => value);
|
|
85
|
-
const proxy = url.protocol === "http:" ? pick("http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY") : pick("https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY");
|
|
86
|
-
if (!proxy) return undefined;
|
|
87
|
-
const host = url.hostname.toLowerCase();
|
|
88
|
-
const exempt = (pick("no_proxy", "NO_PROXY") ?? "").split(/[\s,]+/).filter(Boolean);
|
|
89
|
-
for (const entry of exempt) {
|
|
90
|
-
if (entry === "*") return undefined;
|
|
91
|
-
const name = entry.toLowerCase().replace(/:\d+$/, "").replace(/^\*?\./, "");
|
|
92
|
-
if (host === name || host.endsWith(`.${name}`)) return undefined;
|
|
93
|
-
}
|
|
94
|
-
return /^[a-z][a-z0-9+.-]*:\/\//i.test(proxy) ? proxy : `http://${proxy}`;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Why a connection failed, in words a person can act on. socket.io reports
|
|
99
|
-
* every transport failure as "websocket error" or "xhr poll error" and keeps
|
|
100
|
-
* the cause (a refused upgrade, an unknown host, a certificate) underneath.
|
|
101
|
-
*/
|
|
102
|
-
export function connectFailure(error: unknown): string {
|
|
103
|
-
const outer = error as { message?: string; description?: unknown; context?: { responseText?: unknown } } | undefined;
|
|
104
|
-
const description = outer?.description as { message?: unknown } | number | string | undefined;
|
|
105
|
-
// A websocket keeps its cause in `description`; long-polling keeps the HTTP status there (0 when no response came) and the cause in the request's text.
|
|
106
|
-
const polled = typeof outer?.context?.responseText === "string" ? outer.context.responseText.split("\n")[0]!.replace(/^Error: /, "") : "";
|
|
107
|
-
const text =
|
|
108
|
-
typeof description === "object" && typeof description?.message === "string" && description.message
|
|
109
|
-
? description.message
|
|
110
|
-
: typeof description === "number" && description > 0
|
|
111
|
-
? `Unexpected server response: ${description}`
|
|
112
|
-
: typeof description === "string" && description
|
|
113
|
-
? description
|
|
114
|
-
: polled || outer?.message || "";
|
|
115
|
-
const status = /Unexpected server response: (\d{3})/.exec(text)?.[1];
|
|
116
|
-
if (status) return `the connection was refused with HTTP ${status}`;
|
|
117
|
-
if (/ENOTFOUND|EAI_AGAIN/.test(text)) return `${text}: the server's name did not resolve, so check the network or DNS`;
|
|
118
|
-
if (/ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/.test(text)) return `${text}: check the network, or set HTTPS_PROXY if it only lets traffic out through a proxy`;
|
|
119
|
-
if (/certificate|CERT_|SSL|TLS/i.test(text)) return `${text}: a proxy or firewall may be intercepting HTTPS; NODE_EXTRA_CA_CERTS can point at its certificate`;
|
|
120
|
-
return text;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export function collaborationServer(env: NodeJS.ProcessEnv = process.env): string {
|
|
124
|
-
return env.BOT_LOBBY_EXCALIDRAW_SERVER?.trim() || DEFAULT_SERVER;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export class ExcalidrawRoom {
|
|
128
|
-
readonly scene: Scene = new Map();
|
|
129
|
-
private readonly options: RoomOptions;
|
|
130
|
-
private socket: SocketLike | undefined;
|
|
131
|
-
private opening: Promise<RoomStatus> | undefined;
|
|
132
|
-
private peerIds: string[] = [];
|
|
133
|
-
private readonly names = new Map<string, string>();
|
|
134
|
-
private synced = false;
|
|
135
|
-
private undecryptable = 0;
|
|
136
|
-
private closed = false;
|
|
137
|
-
private timers: Array<ReturnType<typeof setTimeout>> = [];
|
|
138
|
-
|
|
139
|
-
constructor(options: RoomOptions) {
|
|
140
|
-
this.options = options;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
get name(): string {
|
|
144
|
-
return this.options.name;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Which room this seat is in; a seat is replaced when its session's link changes. */
|
|
148
|
-
get linkKey(): string {
|
|
149
|
-
return `${this.options.link.roomId},${this.options.link.roomKey}`;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
status(): RoomStatus {
|
|
153
|
-
return {
|
|
154
|
-
connected: this.socket?.connected ?? false,
|
|
155
|
-
peers: this.peerIds.length,
|
|
156
|
-
synced: this.synced,
|
|
157
|
-
elements: visible(this.scene).length,
|
|
158
|
-
undecryptable: this.undecryptable,
|
|
159
|
-
people: [...new Set(this.peerIds.map((id) => this.names.get(id)).filter((name): name is string => Boolean(name)))],
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** Join the room (once) and wait until a collaborator has sent the scene, or the wait for it is over. */
|
|
164
|
-
ready(): Promise<RoomStatus> {
|
|
165
|
-
this.opening ??= this.open();
|
|
166
|
-
return this.opening;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private open(): Promise<RoomStatus> {
|
|
170
|
-
return new Promise((resolve, reject) => {
|
|
171
|
-
// A seat that failed to connect once may try again.
|
|
172
|
-
this.closed = false;
|
|
173
|
-
const { roomId } = this.options.link;
|
|
174
|
-
const server = this.options.server ?? collaborationServer();
|
|
175
|
-
const socket = (this.options.connect ?? defaultConnect)(server);
|
|
176
|
-
this.socket = socket;
|
|
177
|
-
let done = false;
|
|
178
|
-
let lastError = "";
|
|
179
|
-
const settle = () => {
|
|
180
|
-
if (done) return;
|
|
181
|
-
done = true;
|
|
182
|
-
resolve(this.status());
|
|
183
|
-
this.announce();
|
|
184
|
-
};
|
|
185
|
-
this.timers.push(
|
|
186
|
-
setTimeout(() => {
|
|
187
|
-
if (done) return;
|
|
188
|
-
done = true;
|
|
189
|
-
this.opening = undefined;
|
|
190
|
-
this.close();
|
|
191
|
-
// Only the proxy's host is named: its URL may carry a password.
|
|
192
|
-
const via = this.options.connect ? undefined : proxyFor(server);
|
|
193
|
-
reject(new Error(`could not reach the Excalidraw collaboration server ${server}${via ? ` through the proxy ${new URL(via).host}` : ""}${lastError ? ` (${lastError})` : ""}`));
|
|
194
|
-
}, this.options.connectTimeoutMs ?? CONNECT_TIMEOUT_MS),
|
|
195
|
-
);
|
|
196
|
-
socket.on("connect_error", (error: Error) => {
|
|
197
|
-
lastError = connectFailure(error) || lastError;
|
|
198
|
-
});
|
|
199
|
-
// Every (re)connection is greeted with init-room; the seat answers by joining.
|
|
200
|
-
socket.on("init-room", () => {
|
|
201
|
-
socket.emit("join-room", roomId);
|
|
202
|
-
this.timers.push(setTimeout(settle, this.options.sceneWaitMs ?? SCENE_WAIT_MS));
|
|
203
|
-
});
|
|
204
|
-
socket.on("first-in-room", () => settle());
|
|
205
|
-
socket.on("room-user-change", (clients: string[]) => {
|
|
206
|
-
this.peerIds = clients.filter((id) => id !== socket.id);
|
|
207
|
-
for (const id of [...this.names.keys()]) if (!clients.includes(id)) this.names.delete(id);
|
|
208
|
-
});
|
|
209
|
-
socket.on("new-user", () => {
|
|
210
|
-
// What this seat does not know, it does not claim to.
|
|
211
|
-
if (this.synced || visible(this.scene).length > 0) void this.send("SCENE_INIT", [...this.scene.values()]);
|
|
212
|
-
});
|
|
213
|
-
socket.on("client-broadcast", (data: unknown, iv: unknown) => {
|
|
214
|
-
void this.receive(data, iv).then((initial) => {
|
|
215
|
-
if (initial) settle();
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** A received message; true when it was the scene a newcomer is waiting for. */
|
|
222
|
-
private async receive(data: unknown, iv: unknown): Promise<boolean> {
|
|
223
|
-
const message = (await unseal(this.options.link.roomKey, data, iv)) as { type?: string; payload?: Record<string, unknown> } | undefined;
|
|
224
|
-
if (!message || typeof message !== "object") {
|
|
225
|
-
this.undecryptable += 1;
|
|
226
|
-
return false;
|
|
227
|
-
}
|
|
228
|
-
const payload = message.payload ?? {};
|
|
229
|
-
switch (message.type) {
|
|
230
|
-
case "SCENE_INIT":
|
|
231
|
-
case "SCENE_UPDATE":
|
|
232
|
-
if (Array.isArray(payload.elements)) mergeElements(this.scene, payload.elements);
|
|
233
|
-
if (message.type === "SCENE_INIT") this.synced = true;
|
|
234
|
-
return message.type === "SCENE_INIT";
|
|
235
|
-
case "MOUSE_LOCATION":
|
|
236
|
-
case "IDLE_STATUS":
|
|
237
|
-
if (typeof payload.socketId === "string" && typeof payload.username === "string" && plainText(payload.username)) this.names.set(payload.socketId, plainText(payload.username).slice(0, 40));
|
|
238
|
-
return false;
|
|
239
|
-
default:
|
|
240
|
-
return false;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Send a scene message to the room; false when the seat is not connected. */
|
|
245
|
-
private async send(type: "SCENE_INIT" | "SCENE_UPDATE" | "IDLE_STATUS" | "MOUSE_LOCATION", body: unknown): Promise<boolean> {
|
|
246
|
-
const socket = this.socket;
|
|
247
|
-
if (!socket?.connected || this.closed) return false;
|
|
248
|
-
const payload = type === "SCENE_INIT" || type === "SCENE_UPDATE" ? { elements: body } : body;
|
|
249
|
-
if (JSON.stringify(payload).length > MAX_MESSAGE_CHARS) return false;
|
|
250
|
-
const sealed = await seal(this.options.link.roomKey, { type, payload });
|
|
251
|
-
socket.emit("server-broadcast", this.options.link.roomId, sealed.data, sealed.iv);
|
|
252
|
-
return true;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Tell the room who this seat is (its collaborator name), and where it just
|
|
257
|
-
* drew. Excalidraw sends these as volatile messages because it sends them
|
|
258
|
-
* constantly; a seat says it once, so it does not let the server drop it.
|
|
259
|
-
*/
|
|
260
|
-
private announce(near?: { x: number; y: number }): void {
|
|
261
|
-
const socketId = this.socket?.id;
|
|
262
|
-
if (!socketId) return;
|
|
263
|
-
void this.send("IDLE_STATUS", { socketId, userState: "active", username: this.options.username });
|
|
264
|
-
if (near) void this.send("MOUSE_LOCATION", { socketId, pointer: { x: near.x, y: near.y, tool: "pointer" }, button: "up", selectedElementIds: {}, username: this.options.username });
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Why nothing may be drawn right now, or undefined when it may. */
|
|
268
|
-
cannotDraw(): string | undefined {
|
|
269
|
-
if (!this.socket?.connected) return "this seat is not connected to the room";
|
|
270
|
-
if (this.peerIds.length === 0) return "nobody else is in the room, and only a browser keeps a room's board; ask the user to open the session's link in Excalidraw, then try again";
|
|
271
|
-
return undefined;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Draw: the elements go out to the room and into this seat's own scene. */
|
|
275
|
-
async draw(request: DrawRequest): Promise<DrawOutcome> {
|
|
276
|
-
const outcome = planDraw(this.scene, request);
|
|
277
|
-
if (outcome.changed.length === 0) return outcome;
|
|
278
|
-
const reason = this.cannotDraw();
|
|
279
|
-
if (reason) return { ...outcome, changed: [], drawn: [], removed: [], problems: [...outcome.problems, reason] };
|
|
280
|
-
for (const element of outcome.changed) this.scene.set(element.id, element);
|
|
281
|
-
await this.send("SCENE_UPDATE", outcome.changed);
|
|
282
|
-
const around = sceneBounds(outcome.changed.filter((element) => !element.isDeleted));
|
|
283
|
-
this.announce(around ? { x: around.x1, y: around.y1 } : undefined);
|
|
284
|
-
return outcome;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** The board in words, for a model, with what is known about the room around it. */
|
|
288
|
-
read(): string {
|
|
289
|
-
const status = this.status();
|
|
290
|
-
const notes: string[] = [];
|
|
291
|
-
if (!status.connected) notes.push("Not connected to the room right now; this is the board as last seen.");
|
|
292
|
-
else if (status.undecryptable > 0 && status.elements === 0) notes.push("Messages in this room would not open: the session link's key does not match the room's, so it may have been copied incompletely.");
|
|
293
|
-
else if (status.peers === 0) notes.push("Nobody else is in the room, so the board above is not known: ask the user to open the session's link in Excalidraw, then read again.");
|
|
294
|
-
else if (!status.synced) notes.push("No collaborator has sent the whole board yet, so this may be incomplete; read again in a few seconds.");
|
|
295
|
-
if (status.peers > 0) notes.push(`In the room with you: ${status.people.length > 0 ? status.people.join(", ") : `${status.peers} other${status.peers === 1 ? "" : "s"}`}.`);
|
|
296
|
-
return [describeScene(this.scene, this.options.name), ...notes].join("\n");
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
elements(): SceneElement[] {
|
|
300
|
-
return visible(this.scene);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
close(): void {
|
|
304
|
-
this.closed = true;
|
|
305
|
-
for (const timer of this.timers) clearTimeout(timer);
|
|
306
|
-
this.timers = [];
|
|
307
|
-
this.socket?.close();
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** The seats an agent has open, one per session, kept for the length of its run. */
|
|
312
|
-
export class RoomPool {
|
|
313
|
-
private readonly rooms = new Map<string, ExcalidrawRoom>();
|
|
314
|
-
private readonly make: (options: RoomOptions) => ExcalidrawRoom;
|
|
315
|
-
|
|
316
|
-
constructor(make: (options: RoomOptions) => ExcalidrawRoom = (options) => new ExcalidrawRoom(options)) {
|
|
317
|
-
this.make = make;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** The seat for a session, opened on first use; a session whose link changed gets a new seat. */
|
|
321
|
-
room(key: string, options: RoomOptions): ExcalidrawRoom {
|
|
322
|
-
const existing = this.rooms.get(key);
|
|
323
|
-
if (existing && existing.linkKey === `${options.link.roomId},${options.link.roomKey}`) return existing;
|
|
324
|
-
existing?.close();
|
|
325
|
-
const room = this.make(options);
|
|
326
|
-
this.rooms.set(key, room);
|
|
327
|
-
return room;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
closeAll(): void {
|
|
331
|
-
for (const room of this.rooms.values()) room.close();
|
|
332
|
-
this.rooms.clear();
|
|
333
|
-
}
|
|
334
|
-
}
|