bothouse-dsh 0.1.0 → 0.1.41
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/client/client.js +18267 -648
- package/collab/approvals.mjs +177 -0
- package/collab/attachments.mjs +144 -0
- package/collab/avatar-uploads.mjs +53 -0
- package/collab/avatars.mjs +1 -1
- package/collab/cli-member.mjs +117 -32
- package/collab/cloudweb.mjs +173 -114
- package/collab/dsh-member.mjs +588 -44
- package/collab/gate-context.mjs +80 -0
- package/collab/handoff-rules.mjs +122 -0
- package/collab/hub.mjs +23 -4
- package/collab/later.mjs +146 -0
- package/collab/memory-index.mjs +61 -0
- package/collab/mentions.mjs +8 -0
- package/collab/page.html +24 -3
- package/collab/personas.mjs +4 -0
- package/collab/plugin.mjs +385 -32
- package/collab/quota-cooling.mjs +169 -0
- package/collab/roster.mjs +81 -16
- package/collab/store.mjs +220 -45
- package/collab/tasks.mjs +646 -38
- package/collab/team-exit.mjs +189 -11
- package/collab/verify-multichannel.mjs +42 -0
- package/package.json +13 -3
package/collab/plugin.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { personaLibrary } from './personas.mjs';
|
|
1
2
|
// CoNest collab: Discord-style channels inside a DSH Web host. Load it by
|
|
2
3
|
// absolute path from a --patch overlay (see dsh/README.md):
|
|
3
4
|
// - insert:
|
|
@@ -5,7 +6,7 @@
|
|
|
5
6
|
// name: /abs/path/dsh/collab/plugin.mjs
|
|
6
7
|
// config: { dataDir, workspacePath, leadFile?, fallbackLead?, botTokenEnv?, ownerName?, rooms?, dshMembers?, maxRelay? }
|
|
7
8
|
import { createRequire } from 'node:module';
|
|
8
|
-
import { readFileSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
9
10
|
import { isAbsolute, join } from 'node:path';
|
|
10
11
|
import { pathToFileURL } from 'node:url';
|
|
11
12
|
import { Store, CollabError, publicMessage } from './store.mjs';
|
|
@@ -14,19 +15,28 @@ import { TeamExit } from './team-exit.mjs';
|
|
|
14
15
|
import { CloudWebGateway, WS_PATH } from './cloudweb.mjs';
|
|
15
16
|
import { DshMembers } from './dsh-member.mjs';
|
|
16
17
|
import { Roster } from './roster.mjs';
|
|
17
|
-
import { Tasks } from './tasks.mjs';
|
|
18
|
+
import { Tasks, WORKING } from './tasks.mjs';
|
|
18
19
|
import { createCliMember } from './cli-member.mjs';
|
|
19
20
|
import { avatarImage, checkAvatar } from './avatars.mjs';
|
|
21
|
+
import { AvatarUploads } from './avatar-uploads.mjs';
|
|
20
22
|
import { handler as clisHandler } from './clis.mjs';
|
|
21
23
|
import { LeadState } from './lead-state.mjs';
|
|
24
|
+
import { Attachments, INLINE_IMAGE } from './attachments.mjs';
|
|
25
|
+
import { Later, registerLater } from './later.mjs';
|
|
26
|
+
import { QuotaCooling, quotaDirectory } from './quota-cooling.mjs';
|
|
22
27
|
|
|
23
28
|
export const name = 'conest-collab';
|
|
24
29
|
export const inject = [
|
|
25
|
-
'webServer', 'connection', 'llm', 'agents', 'agentDefaultModel', 'agentPresets', 'permissionPresets', 'sessionTitle', 'workspaceRegistry',
|
|
30
|
+
'attachments', 'webServer', 'connection', 'llm', 'agents', 'agentDefaultModel', 'agentPresets', 'permissionPresets', 'sessionTitle', 'workspaceRegistry',
|
|
31
|
+
'sessionPersistence',
|
|
26
32
|
];
|
|
27
33
|
|
|
28
34
|
const PAGE = readFileSync(new URL('./page.html', import.meta.url), 'utf8');
|
|
29
35
|
const MAX_BODY = 256 * 1024;
|
|
36
|
+
// #144: a message POST may carry base64 attachments. Same cap as the bot WebSocket
|
|
37
|
+
// maxPayload below (8 MiB), so a message that is accepted here still fits in the frame
|
|
38
|
+
// that forwards it to a bot.
|
|
39
|
+
const MAX_MESSAGE_BODY = 8 * 1024 * 1024;
|
|
30
40
|
const OWNER = 'owner';
|
|
31
41
|
|
|
32
42
|
/** Validate the overlay config; misconfiguration fails at load. */
|
|
@@ -39,6 +49,11 @@ export function resolveConfig(config, env = process.env) {
|
|
|
39
49
|
if (!Array.isArray(rooms) || rooms.length === 0 || rooms.some(r => !Number.isSafeInteger(r?.id) || r.id <= 0 || typeof r.name !== 'string' || !r.name.trim())) {
|
|
40
50
|
throw Error('conest-collab: rooms must be [{ id: positive integer, name }]');
|
|
41
51
|
}
|
|
52
|
+
for (const room of rooms) {
|
|
53
|
+
if (room.workspacePath != null && room.workspacePath !== '' && (typeof room.workspacePath !== 'string' || !isAbsolute(room.workspacePath))) {
|
|
54
|
+
throw Error('conest-collab: rooms[].workspacePath must be an absolute path or empty');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
42
57
|
const members = c.dshMembers ?? [];
|
|
43
58
|
const ids = new Set();
|
|
44
59
|
for (const m of members) {
|
|
@@ -49,6 +64,9 @@ export function resolveConfig(config, env = process.env) {
|
|
|
49
64
|
if (m.model !== undefined && (typeof m.model?.provider !== 'string' || typeof m.model?.model !== 'string')) {
|
|
50
65
|
throw Error(`conest-collab: dshMembers ${m.id} model must be { provider, model }`);
|
|
51
66
|
}
|
|
67
|
+
if (m.rooms !== undefined && (!Array.isArray(m.rooms) || m.rooms.some(id => !rooms.some(r => r.id === id)))) {
|
|
68
|
+
throw Error(`conest-collab: dshMembers ${m.id} rooms must contain configured chat channel ids`);
|
|
69
|
+
}
|
|
52
70
|
if (m.persona !== undefined && typeof m.persona !== 'string') throw Error(`conest-collab: dshMembers ${m.id} persona must be text`);
|
|
53
71
|
try { checkAvatar(m.avatar); } catch (error) { throw Error(`conest-collab: dshMembers ${m.id} ${error.message}`); }
|
|
54
72
|
ids.add(m.id);
|
|
@@ -68,6 +86,14 @@ function dshRequire() {
|
|
|
68
86
|
return createRequire(realpathSync(process.argv[1]));
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
/**
|
|
90
|
+
* #557: a channel-started turn asks in its room. Prepended so a DSH Web UI tab
|
|
91
|
+
* or another plugin's answerer (e.g. auto review) never takes a hidden Session's ask.
|
|
92
|
+
*/
|
|
93
|
+
export function installApprovalAnswerer(ctx, dsh) {
|
|
94
|
+
ctx.on('approval/request', (request, next) => dsh.onApprovalRequest(request, next), { prepend: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
71
97
|
export async function apply(ctx, rawConfig) {
|
|
72
98
|
const config = resolveConfig(rawConfig);
|
|
73
99
|
const require = dshRequire();
|
|
@@ -78,28 +104,35 @@ export async function apply(ctx, rawConfig) {
|
|
|
78
104
|
|
|
79
105
|
const store = new Store(join(config.dataDir, 'collab.sqlite3'));
|
|
80
106
|
for (const room of config.rooms) {
|
|
81
|
-
store.ensureRoom(room.id, room.name.trim());
|
|
107
|
+
store.ensureRoom(room.id, store.room(room.id)?.name ?? room.name.trim(), room.workspacePath);
|
|
82
108
|
store.ensureRoomSettings(room.id);
|
|
83
109
|
}
|
|
110
|
+
store.migrateMemberRooms();
|
|
84
111
|
const leadState = new LeadState(config.leadFile, ctx.logger);
|
|
85
112
|
const hub = new Hub(store, ctx.logger, leadState, config.fallbackLead);
|
|
113
|
+
hub.workspacePath = config.workspacePath;
|
|
86
114
|
hub.teamExit = new TeamExit(hub);
|
|
87
115
|
hub.setMember({ id: OWNER, name: config.ownerName, kind: 'human', online: true });
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
116
|
+
const files = new Attachments(join(config.dataDir, 'attachments'));
|
|
117
|
+
const gateway = new CloudWebGateway(hub, { token: config.botToken, logger: ctx.logger, files });
|
|
118
|
+
const later = new Later(hub, join(config.dataDir, 'collab.sqlite3'), (id, msg) => dsh.nudge(id, msg) || gateway.nudge(id, msg), { start: false });
|
|
119
|
+
gateway.later = later;
|
|
120
|
+
const dsh = new DshMembers(hub, { files, later, ctx, createUserMessage, createCliMember, workspacePath: config.workspacePath, logger: ctx.logger }, [], { maxRelay: config.maxRelay });
|
|
121
|
+
const avatarUploads = new AvatarUploads(join(config.dataDir, 'avatars'));
|
|
122
|
+
const roster = new Roster(hub, dsh, ctx.llm, avatarUploads);
|
|
91
123
|
// #139 B: dispatched tasks; a reminder wakes its owner whichever kind of member it is.
|
|
92
124
|
// Started after dsh.recover(): a reminder due during the downtime must not look like a leftover job.
|
|
93
125
|
const tasks = new Tasks(hub, { start: false });
|
|
94
126
|
hub.tasks = tasks;
|
|
95
127
|
gateway.tasks = tasks;
|
|
128
|
+
const quota = new QuotaCooling(hub, tasks, (id, msg) => dsh.nudge(id,msg) || gateway.nudge(id,msg), quotaDirectory(config.leadFile));
|
|
129
|
+
hub.quota = quota;
|
|
96
130
|
tasks.onWake((memberId, message) => dsh.nudge(memberId, message) || gateway.nudge(memberId, message));
|
|
97
|
-
// A workspace released after its last task
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
});
|
|
131
|
+
// A workspace released after its last task shows up free in the room list. Its sessions are
|
|
132
|
+
// cleared when a new claim takes it (store.resetSessions: DSH members rebind from the store; local
|
|
133
|
+
// CLI members drop their ACP session), not on release, so a rework goes on in them (#597 G35).
|
|
134
|
+
tasks.onRelease(() => hub.emit({ type: 'rooms', rooms: hub.store.roomsView() }));
|
|
135
|
+
hub.store.onSessionsReset(roomId => dsh.resetRoom(roomId));
|
|
103
136
|
// Config members are seeds; UI-created members persist in the store. Both must be routable before recovery.
|
|
104
137
|
roster.load(config.dshMembers);
|
|
105
138
|
const wizard = await roster.importWizard(join(config.dataDir, 'wizard-members.json'), ctx.logger);
|
|
@@ -107,13 +140,16 @@ export async function apply(ctx, rawConfig) {
|
|
|
107
140
|
ctx.on('session/event', (session, event) => dsh.onSessionEvent(session, event));
|
|
108
141
|
ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => dsh.onClaimed(agent.session.id, message.id, turn));
|
|
109
142
|
ctx.on('agent/inbox/discarded', ({ agent, message }) => dsh.onDiscarded(agent.session.id, message.id));
|
|
143
|
+
installApprovalAnswerer(ctx, dsh);
|
|
110
144
|
// Queue leftover jobs before any route can add new ones, so both share one order.
|
|
111
145
|
dsh.recover();
|
|
112
146
|
tasks.start();
|
|
147
|
+
later.start();
|
|
148
|
+
quota.start();
|
|
113
149
|
if (!config.botToken) ctx.logger.warn(`conest-collab: ${config.botTokenEnv} is unset; cc-connect bots cannot connect`);
|
|
114
150
|
|
|
115
151
|
const owner = req => ctx.connection.requestRejection(req) === undefined;
|
|
116
|
-
const wss = new WebSocketServer({ noServer: true, maxPayload:
|
|
152
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: BOT_WS_MAX_PAYLOAD });
|
|
117
153
|
const streams = new Set();
|
|
118
154
|
|
|
119
155
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/collab', handler: (req, res) => browserRoute(req, res) }), 'conest-collab: /collab');
|
|
@@ -122,6 +158,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
122
158
|
path: WS_PATH,
|
|
123
159
|
handler: (req, socket, head) => {
|
|
124
160
|
const url = new URL(req.url ?? '/', 'http://collab.invalid');
|
|
161
|
+
later.origin ??= localOrigin(req);
|
|
125
162
|
if (!gateway.authorized(req, url)) {
|
|
126
163
|
socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
|
|
127
164
|
return;
|
|
@@ -130,6 +167,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
130
167
|
},
|
|
131
168
|
}), 'conest-collab: cloud-web upgrade');
|
|
132
169
|
ctx.effect(() => async () => {
|
|
170
|
+
quota.close();
|
|
171
|
+
later.close();
|
|
133
172
|
tasks.close();
|
|
134
173
|
gateway.closeAll();
|
|
135
174
|
for (const end of streams) end();
|
|
@@ -144,6 +183,7 @@ export async function apply(ctx, rawConfig) {
|
|
|
144
183
|
/** cc-connect ChannelHistory: GET /api/rooms/{id}/messages?before=&limit= (bot token or owner). */
|
|
145
184
|
function historyRoute(req, res) {
|
|
146
185
|
const url = new URL(req.url ?? '/', 'http://collab.invalid');
|
|
186
|
+
later.origin ??= localOrigin(req);
|
|
147
187
|
const m = /^\/api\/rooms\/(\d+)\/messages$/.exec(url.pathname);
|
|
148
188
|
if (req.method !== 'GET' || m === null) return json(res, 404, { error: 'not found' });
|
|
149
189
|
if (!gateway.authorized(req, url) && !owner(req)) return json(res, 401, { error: 'unauthorized' });
|
|
@@ -156,19 +196,24 @@ export async function apply(ctx, rawConfig) {
|
|
|
156
196
|
async function browserRoute(req, res) {
|
|
157
197
|
const url = new URL(req.url ?? '/', 'http://collab.invalid');
|
|
158
198
|
const path = url.pathname.replace(/\/+$/, '');
|
|
199
|
+
later.origin ??= localOrigin(req);
|
|
200
|
+
const scheduled = laterRoute(req, res, url, { hub, gateway, owner, later });
|
|
201
|
+
if (scheduled !== undefined) return scheduled;
|
|
159
202
|
const routed = tasksRoute(req, res, url, { tasks, gateway, owner });
|
|
160
203
|
if (routed !== undefined) return routed;
|
|
161
|
-
const opened = workspacesRoute(req, res, url, { store, hub, gateway, owner });
|
|
204
|
+
const opened = workspacesRoute(req, res, url, { store, hub, gateway, owner, later, tasks, dsh });
|
|
162
205
|
if (opened !== undefined) return opened;
|
|
163
206
|
if (!owner(req)) {
|
|
164
207
|
if (path === '/collab' && req.method === 'GET') return text(res, 401, 'text/plain; charset=utf-8', '请先在同一个浏览器打开 DSH 网页登录,再回到 /collab。');
|
|
165
|
-
return
|
|
208
|
+
return refuse(req, res);
|
|
166
209
|
}
|
|
167
210
|
if (path === '/collab' && req.method === 'GET') return text(res, 200, 'text/html; charset=utf-8', PAGE);
|
|
211
|
+
if (path === '/collab/api/personas' && req.method === 'GET') return json(res, 200, personaLibrary(url.searchParams.get('lang') ?? 'zh'));
|
|
168
212
|
if (path === '/collab/api/state' && req.method === 'GET') {
|
|
169
213
|
return json(res, 200, { me: OWNER, lead_id: hub.selectedPrimaryMember()?.id ?? null,
|
|
170
214
|
effective_lead_id: hub.primaryMember()?.id ?? null,
|
|
171
|
-
rooms: store.roomsView().map(r => ({ ...r,
|
|
215
|
+
rooms: store.roomsView().map(r => ({ ...r, lead_id: store.leadId(r.id), effective_lead_id: hub.primaryIn(r.id)?.id ?? null,
|
|
216
|
+
workspace_path: store.workspacePath(r.id, config.workspacePath) })), members: hub.memberView(),
|
|
172
217
|
thinking: Object.fromEntries([...hub.thinking].map(([k, v]) => [k, [...v]])) });
|
|
173
218
|
}
|
|
174
219
|
if (path === '/collab/api/events' && req.method === 'GET') return events(req, res);
|
|
@@ -186,6 +231,20 @@ export async function apply(ctx, rawConfig) {
|
|
|
186
231
|
return { lead_id: id, effective_lead_id: hub.primaryMember()?.id ?? null };
|
|
187
232
|
});
|
|
188
233
|
}
|
|
234
|
+
const att = /^\/collab\/api\/attachments\/([0-9a-f-]{36})$/.exec(path);
|
|
235
|
+
if (att !== null && req.method === 'GET') {
|
|
236
|
+
const row = store.attachmentMeta(att[1]);
|
|
237
|
+
const bytes = row === undefined ? undefined : files.read(att[1]);
|
|
238
|
+
if (bytes === undefined) return json(res, 404, { error: 'attachment not found' });
|
|
239
|
+
return sendAttachment(req, res, row, bytes);
|
|
240
|
+
}
|
|
241
|
+
const uploaded = /^\/collab\/avatars\/uploads\/([a-z0-9-]+\.png)$/.exec(path);
|
|
242
|
+
if (uploaded !== null && req.method === 'GET') {
|
|
243
|
+
const png = avatarUploads.read(uploaded[1]);
|
|
244
|
+
if (png === undefined) return json(res, 404, { error: 'avatar not found' });
|
|
245
|
+
res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store' });
|
|
246
|
+
return res.end(png);
|
|
247
|
+
}
|
|
189
248
|
const av = /^\/collab\/avatars\/([a-z0-9_-]+)\.png$/.exec(path);
|
|
190
249
|
if (av !== null && req.method === 'GET') {
|
|
191
250
|
const png = avatarImage(av[1]);
|
|
@@ -203,6 +262,10 @@ export async function apply(ctx, rawConfig) {
|
|
|
203
262
|
: store.page(roomId, before === null ? undefined : Number(before), url.searchParams.get('limit'));
|
|
204
263
|
return json(res, 200, { messages: rows.map(r => publicMessage(r, store)) });
|
|
205
264
|
}
|
|
265
|
+
const settings = roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePath: config.workspacePath });
|
|
266
|
+
if (settings !== undefined) return settings;
|
|
267
|
+
const renamed = renameRoomRoute(req, res, path, { store, hub, dsh });
|
|
268
|
+
if (renamed !== undefined) return renamed;
|
|
206
269
|
const mode = /^\/collab\/api\/rooms\/(\d+)\/mode$/.exec(path);
|
|
207
270
|
if (mode !== null && req.method === 'GET') {
|
|
208
271
|
const roomId = Number(mode[1]);
|
|
@@ -221,15 +284,77 @@ export async function apply(ctx, rawConfig) {
|
|
|
221
284
|
if (m !== null && req.method === 'POST') {
|
|
222
285
|
return withBody(req, res, (body) => {
|
|
223
286
|
const clientKey = typeof body?.client_key === 'string' && body.client_key ? `web:${body.client_key.slice(0, 64)}` : undefined;
|
|
224
|
-
const
|
|
225
|
-
|
|
287
|
+
const roomId = Number(m[1]);
|
|
288
|
+
if (store.room(roomId) === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
|
|
289
|
+
const attachments = files.save(body?.attachments);
|
|
290
|
+
const { message } = hub.post(roomId, { authorKind: 'user', author: OWNER, name: config.ownerName, text: String(body?.text ?? ''), clientKey,
|
|
291
|
+
replyTo: Number.isInteger(body?.reply_to) ? body.reply_to : undefined, attachments });
|
|
226
292
|
return { message: publicMessage(message, store) };
|
|
227
|
-
});
|
|
293
|
+
}, true, MAX_MESSAGE_BODY);
|
|
228
294
|
}
|
|
229
|
-
|
|
295
|
+
const edited = ownMessageRoute(req, res, path, { store, hub, gateway, dsh });
|
|
296
|
+
if (edited !== undefined) return edited;
|
|
297
|
+
// #120/#557: owner-only (checked above) decision on a permission card (cloud-web bot or DSH member).
|
|
230
298
|
const pm = /^\/collab\/api\/messages\/(\d+)\/permission$/.exec(path);
|
|
231
299
|
if (pm !== null && req.method === 'POST') {
|
|
232
|
-
return withBody(req, res, body => ({ message:
|
|
300
|
+
return withBody(req, res, body => ({ message: hub.approvals.decide(Number(pm[1]), String(body?.decision ?? '')) }));
|
|
301
|
+
}
|
|
302
|
+
// #146: owner-only (checked above) retry on a failed message or error card.
|
|
303
|
+
const rm = /^\/collab\/api\/messages\/(\d+)\/retry$/.exec(path);
|
|
304
|
+
if (rm !== null && req.method === 'POST') {
|
|
305
|
+
return withBody(req, res, () => {
|
|
306
|
+
const messageId = Number(rm[1]);
|
|
307
|
+
const message = store.message(messageId);
|
|
308
|
+
if (message === undefined) throw new CollabError('not-found', 'message not found');
|
|
309
|
+
let card;
|
|
310
|
+
if (message.card) {
|
|
311
|
+
try { card = JSON.parse(message.card); } catch {}
|
|
312
|
+
}
|
|
313
|
+
let memberId = card?.member_id;
|
|
314
|
+
let triggerId = card?.trigger_id;
|
|
315
|
+
if (!memberId && typeof message.client_key === 'string') {
|
|
316
|
+
const jm = /^dsh-job-(\d+)/.exec(message.client_key);
|
|
317
|
+
if (jm !== null) {
|
|
318
|
+
const job = store.job(Number(jm[1]));
|
|
319
|
+
if (job !== undefined) {
|
|
320
|
+
memberId = job.member_id;
|
|
321
|
+
triggerId = job.trigger_id;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!memberId && message.author_kind === 'role') {
|
|
326
|
+
memberId = message.author;
|
|
327
|
+
triggerId = message.from_turn ?? store.lastUserMessage(message.room_id, message.id)?.id;
|
|
328
|
+
}
|
|
329
|
+
if (!memberId || triggerId == null) {
|
|
330
|
+
throw new CollabError('invalid', 'cannot determine member or trigger message for retry');
|
|
331
|
+
}
|
|
332
|
+
const trigger = store.message(triggerId);
|
|
333
|
+
if (trigger === undefined) {
|
|
334
|
+
throw new CollabError('invalid', 'trigger message not found');
|
|
335
|
+
}
|
|
336
|
+
const member = hub.members.get(memberId);
|
|
337
|
+
if (member === undefined) {
|
|
338
|
+
throw new CollabError('invalid', `member ${memberId} not found`);
|
|
339
|
+
}
|
|
340
|
+
if (member.online === false) {
|
|
341
|
+
throw new CollabError('offline', `${member.name} 当前不在线,无法重试`);
|
|
342
|
+
}
|
|
343
|
+
hub.setError(memberId, undefined);
|
|
344
|
+
hub.setThinking(message.room_id, memberId, true);
|
|
345
|
+
const retried = dsh.retry(memberId, trigger) || gateway.retry(memberId, trigger);
|
|
346
|
+
if (!retried) {
|
|
347
|
+
hub.setThinking(message.room_id, memberId, false);
|
|
348
|
+
throw new CollabError('failed', 'failed to dispatch retry');
|
|
349
|
+
}
|
|
350
|
+
// The card is settled once the retry is dispatched: the outcome arrives as the
|
|
351
|
+
// member's reply or as a new error card with its own retry button. A transient
|
|
352
|
+
// 'retrying' state here was never cleared, so the card stayed "retrying" forever.
|
|
353
|
+
if (card && (card.state === 'failed' || card.action === 'configure-model')) {
|
|
354
|
+
hub.edit(message.id, message.author_kind, message.author, message.text, JSON.stringify({ ...card, state: 'retried' }));
|
|
355
|
+
}
|
|
356
|
+
return { ok: true, member_id: memberId, trigger_id: triggerId };
|
|
357
|
+
}, false);
|
|
233
358
|
}
|
|
234
359
|
if (path === '/collab/api/clis') return clisHandler(req, res);
|
|
235
360
|
if (path === '/collab/api/models' && req.method === 'GET') return json(res, 200, { routes: await roster.models() });
|
|
@@ -245,6 +370,8 @@ export async function apply(ctx, rawConfig) {
|
|
|
245
370
|
if (path === '/collab/api/members' && req.method === 'POST') {
|
|
246
371
|
return withBody(req, res, async body => ({ member: await roster.create(body) }));
|
|
247
372
|
}
|
|
373
|
+
const avatarResult = memberAvatarRoute(req, res, path, { roster, owner });
|
|
374
|
+
if (avatarResult !== undefined) return avatarResult;
|
|
248
375
|
const mm = /^\/collab\/api\/members\/([^/]+)$/.exec(path);
|
|
249
376
|
if (mm !== null && req.method === 'PATCH') {
|
|
250
377
|
return withBody(req, res, async body => ({ member: await roster.update(decodeURIComponent(mm[1]), body) }));
|
|
@@ -266,17 +393,122 @@ export async function apply(ctx, rawConfig) {
|
|
|
266
393
|
}
|
|
267
394
|
}
|
|
268
395
|
|
|
396
|
+
/** Owner-only channel settings; workspaces inherit their parent channel. */
|
|
397
|
+
export function roomSettingsRoute(req, res, path, { store, hub, dsh, workspacePath }) {
|
|
398
|
+
const match = /^\/collab\/api\/rooms\/(\d+)\/(settings|renew)$/.exec(path);
|
|
399
|
+
if (match === null) return undefined;
|
|
400
|
+
const roomId = Number(match[1]);
|
|
401
|
+
if (store.room(roomId) === undefined) return Promise.resolve(json(res, 404, { error: '频道不存在' }));
|
|
402
|
+
const view = () => ({ lead_id: store.leadId(roomId), effective_lead_id: hub.primaryIn(roomId)?.id ?? null,
|
|
403
|
+
workspace_path: store.workspacePath(roomId, workspacePath) });
|
|
404
|
+
if (match[2] === 'renew' && req.method === 'POST') {
|
|
405
|
+
return withBody(req, res, async () => ({ members: await dsh.renewRoom(roomId) }), false);
|
|
406
|
+
}
|
|
407
|
+
if (match[2] !== 'settings') return undefined;
|
|
408
|
+
if (req.method === 'GET') return Promise.resolve(json(res, 200, view()));
|
|
409
|
+
if (req.method !== 'PATCH') return undefined;
|
|
410
|
+
return withBody(req, res, body => {
|
|
411
|
+
if (body?.lead_id !== undefined && body.lead_id !== null && !hub.routableIn(roomId).some(m => m.id === body.lead_id)) {
|
|
412
|
+
throw new CollabError('invalid', '请选择本频道的主角色');
|
|
413
|
+
}
|
|
414
|
+
if (body?.workspace_path !== undefined) {
|
|
415
|
+
const path = body.workspace_path;
|
|
416
|
+
if (typeof path !== 'string' || !isAbsolute(path)) throw new CollabError('invalid', '工作目录必须是绝对路径');
|
|
417
|
+
let stat;
|
|
418
|
+
try { stat = statSync(path); } catch { throw new CollabError('invalid', '工作目录不存在或无法访问'); }
|
|
419
|
+
if (!stat.isDirectory()) throw new CollabError('invalid', '工作目录必须是目录,不能是文件');
|
|
420
|
+
}
|
|
421
|
+
store.transaction(() => {
|
|
422
|
+
if (body?.lead_id !== undefined) store.setLead(roomId, body.lead_id);
|
|
423
|
+
if (body?.workspace_path !== undefined) store.setWorkspacePath(roomId, body.workspace_path, workspacePath);
|
|
424
|
+
});
|
|
425
|
+
hub.emit({ type: 'room-settings', room_id: store.settingsRoom(roomId), ...view() });
|
|
426
|
+
return view();
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Owner-only upload endpoint, also callable in isolated HTTP regressions. */
|
|
431
|
+
export function memberAvatarRoute(req, res, path, { roster, owner }) {
|
|
432
|
+
const match = /^\/collab\/api\/members\/([^/]+)\/avatar$/.exec(path);
|
|
433
|
+
if (match === null || !['POST', 'DELETE'].includes(req.method)) return undefined;
|
|
434
|
+
if (!owner(req)) { json(res, 401, { error: 'unauthorized' }); return Promise.resolve(); }
|
|
435
|
+
return withBody(req, res, body => {
|
|
436
|
+
if (req.method === 'POST' && typeof body?.png !== 'string') throw new CollabError('invalid', '请提供 PNG base64 头像数据');
|
|
437
|
+
return { member: roster.setAvatar(decodeURIComponent(match[1]), req.method === 'POST' ? body.png : undefined) };
|
|
438
|
+
}, req.method === 'POST', 512 * 1024);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Owner authentication is checked by browserRoute before this handler, like ownMessageRoute. */
|
|
442
|
+
export function renameRoomRoute(req, res, path, { store, hub, dsh }) {
|
|
443
|
+
const match = /^\/collab\/api\/rooms\/(\d+)$/.exec(path);
|
|
444
|
+
if (match === null || req.method !== 'PATCH') return undefined;
|
|
445
|
+
const roomId = Number(match[1]);
|
|
446
|
+
if (store.room(roomId) === undefined) return json(res, 404, { error: 'room not found' });
|
|
447
|
+
return withBody(req, res, async body => {
|
|
448
|
+
const room = store.renameRoom(roomId, body?.name);
|
|
449
|
+
hub.emit({ type: 'rooms', rooms: store.roomsView() });
|
|
450
|
+
await dsh.refreshRoomTitles(roomId);
|
|
451
|
+
return { room };
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* #148: the owner edits or withdraws a message they wrote (callers check the
|
|
457
|
+
* owner). An edit changes the text only; members that already read it are not
|
|
458
|
+
* asked again. Withdrawing also stops the work it started: cloud-web bots get a
|
|
459
|
+
* recall (their bridge stops that turn or drops it from its queue), DSH jobs end
|
|
460
|
+
* and a DSH turn already running is cancelled.
|
|
461
|
+
* PATCH /collab/api/messages/{id} { text }
|
|
462
|
+
* DELETE /collab/api/messages/{id} -> { recalled: [bot ids], stopped: [names], still_running: [names] }
|
|
463
|
+
*/
|
|
464
|
+
export function ownMessageRoute(req, res, path, { store, hub, gateway, dsh }) {
|
|
465
|
+
const m = /^\/collab\/api\/messages\/(\d+)$/.exec(path);
|
|
466
|
+
if (m === null) return undefined;
|
|
467
|
+
const id = Number(m[1]);
|
|
468
|
+
if (req.method === 'PATCH') {
|
|
469
|
+
return withBody(req, res, (body) => {
|
|
470
|
+
const row = store.editUser(id, OWNER, body?.text);
|
|
471
|
+
if (row === undefined) throw new CollabError('forbidden', 'only your own plain messages can be edited');
|
|
472
|
+
const message = publicMessage(row, store); // with the store, so a reply keeps its quote
|
|
473
|
+
hub.emit({ type: 'message', message });
|
|
474
|
+
return { message };
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (req.method === 'DELETE') {
|
|
478
|
+
return withBody(req, res, () => {
|
|
479
|
+
const row = hub.remove(id, 'user', OWNER);
|
|
480
|
+
if (row === undefined) throw new CollabError('forbidden', 'only your own messages can be withdrawn');
|
|
481
|
+
const recalled = gateway.recall(row.id);
|
|
482
|
+
const name = who => hub.members.get(who)?.name ?? who;
|
|
483
|
+
const { stopped, running } = dsh.recall(row.id);
|
|
484
|
+
return { ok: true, recalled, stopped: stopped.map(name), still_running: running.map(name) };
|
|
485
|
+
}, false);
|
|
486
|
+
}
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* #597 G26: a later ticket only opens workspaces (and later). Sent anywhere else, say where to go
|
|
492
|
+
* instead of a bare 401, so a member does not give up; the route's own auth is unchanged.
|
|
493
|
+
*/
|
|
494
|
+
export const TICKET_WRONG_ROUTE = '这个接口不认 X-Later-Ticket 票据。建工作区请用 POST /collab/api/rooms/{id}/workspaces(同一个票据头,JSON: title, owner, reason),任务和十分钟看门会自动开,不用再调 /tasks。';
|
|
495
|
+
function refuse(req, res) {
|
|
496
|
+
if (req.headers?.['x-later-ticket'] !== undefined) return json(res, 403, { error: TICKET_WRONG_ROUTE });
|
|
497
|
+
return json(res, 401, { error: 'unauthorized' });
|
|
498
|
+
}
|
|
499
|
+
|
|
269
500
|
/**
|
|
270
501
|
* #139 B task API, for the owner's browser and for cloud-web bots. The browser
|
|
271
502
|
* always acts as the owner. Under the shared bot token a bot names itself in
|
|
272
503
|
* body.actor, which is unverified (Tasks.perform: no owner, no review); a bot
|
|
273
504
|
* acts as itself verified with a `task` frame on its cloud-web socket.
|
|
274
505
|
* GET /collab/api/rooms/{id}/tasks board of the room's chat channel and its workspaces
|
|
275
|
-
* POST /collab/api/rooms/{id}/tasks { title, owner, parent_task?, message_id?, reason?, force? } dispatch
|
|
506
|
+
* POST /collab/api/rooms/{id}/tasks { title, owner, ref, base?, parent_task?, message_id?, reason?, force? } dispatch
|
|
507
|
+
* (ref: file:line or 无(新建); base required when the title says 共改文件/多人改)
|
|
276
508
|
* GET /collab/api/tasks/{id}
|
|
277
509
|
* POST /collab/api/tasks/{id}/stance { stance: 接|问|退|升, note? } (task owner)
|
|
278
510
|
* POST /collab/api/tasks/{id}/return { message_id } (task owner)
|
|
279
|
-
* POST /collab/api/tasks/{id}/review { verdict: done|rework, note
|
|
511
|
+
* POST /collab/api/tasks/{id}/review { verdict: done|rework, note } (the dispatcher; note required for rework)
|
|
280
512
|
* Returns undefined when the request is not for this route, else a Promise of the reply.
|
|
281
513
|
*/
|
|
282
514
|
export function tasksRoute(req, res, url, { tasks, gateway, owner }) {
|
|
@@ -285,7 +517,7 @@ export function tasksRoute(req, res, url, { tasks, gateway, owner }) {
|
|
|
285
517
|
const one = /^\/collab\/api\/tasks\/(\d+)(?:\/(stance|return|review))?$/.exec(path);
|
|
286
518
|
if (board === null && one === null) return undefined;
|
|
287
519
|
const human = owner(req);
|
|
288
|
-
if (!human && !gateway.authorized(req, url)) return Promise.resolve(
|
|
520
|
+
if (!human && !gateway.authorized(req, url)) return Promise.resolve(refuse(req, res));
|
|
289
521
|
// The browser is the owner; the shared bot token says nothing about which bot, so body.actor is unverified.
|
|
290
522
|
const sender = body => (human ? { actor: OWNER, verified: true } : { actor: body?.actor, verified: false });
|
|
291
523
|
const id = Number((board ?? one)[1]);
|
|
@@ -308,10 +540,13 @@ export function tasksRoute(req, res, url, { tasks, gateway, owner }) {
|
|
|
308
540
|
* opens the next 工作区<n+1> under that chat channel. Anyone else gets 401.
|
|
309
541
|
* Returns undefined when the request is not for this route, else a Promise of the reply.
|
|
310
542
|
*/
|
|
311
|
-
export function workspacesRoute(req, res, url, { store, hub, gateway, owner }) {
|
|
543
|
+
export function workspacesRoute(req, res, url, { store, hub, gateway, owner, later, tasks, dsh }) {
|
|
312
544
|
const m = /^\/collab\/api\/rooms\/(\d+)\/workspaces\/?$/.exec(url.pathname);
|
|
313
545
|
if (m === null || req.method !== 'POST') return undefined;
|
|
314
|
-
if (!owner(req) && !gateway.authorized(req, url))
|
|
546
|
+
if (!owner(req) && !gateway.authorized(req, url)) {
|
|
547
|
+
if (req.headers['x-later-ticket'] === undefined || !later || !tasks) return Promise.resolve(json(res, 401, { error: 'unauthorized' }));
|
|
548
|
+
return ticketWorkspace(req, res, Number(m[1]), { store, hub, gateway, later, tasks, dsh });
|
|
549
|
+
}
|
|
315
550
|
return withBody(req, res, () => {
|
|
316
551
|
const workspace = store.createWorkspace(Number(m[1]));
|
|
317
552
|
hub.emit({ type: 'rooms', rooms: store.roomsView() });
|
|
@@ -319,13 +554,103 @@ export function workspacesRoute(req, res, url, { store, hub, gateway, owner }) {
|
|
|
319
554
|
}, false);
|
|
320
555
|
}
|
|
321
556
|
|
|
557
|
+
/**
|
|
558
|
+
* #597 G24: a DSH member's (member, room) later ticket opens a workspace and hands the job over in
|
|
559
|
+
* one step. Only the primary role at request time may do it (tickets never expire, so a ticket of a
|
|
560
|
+
* former primary is refused here). The task is opened directly (owner defaults to the primary itself),
|
|
561
|
+
* the handoff line is a system message (no chat parsing, so an earlier @ dispatch is not opened twice)
|
|
562
|
+
* and the owner is woken explicitly. A ticket pasted into the channel is useless to anyone but the
|
|
563
|
+
* primary. An open chat-side task of the same owner from the primary is handed over and closed.
|
|
564
|
+
*/
|
|
565
|
+
function ticketWorkspace(req, res, roomId, { store, hub, gateway, later, tasks, dsh }) {
|
|
566
|
+
const claims = later.verify(req.headers['x-later-ticket']);
|
|
567
|
+
if (!claims) return Promise.resolve(json(res, 401, { error: 'unauthorized' }));
|
|
568
|
+
if (claims.room !== roomId) return Promise.resolve(json(res, 403, { error: 'ticket is for another room' }));
|
|
569
|
+
if (hub.primaryIn(roomId)?.id !== claims.member) return Promise.resolve(json(res, 403, { error: 'only the primary role opens workspaces' }));
|
|
570
|
+
return withBody(req, res, body => {
|
|
571
|
+
const lead = claims.member;
|
|
572
|
+
const taskOwner = body?.owner === undefined || body.owner === '' ? lead : String(body.owner);
|
|
573
|
+
if (!hub.routable().some(r => r.id === taskOwner)) throw new CollabError('member-missing', 'unknown owner');
|
|
574
|
+
const title = String(body?.title ?? '').trim();
|
|
575
|
+
if (!title) throw new CollabError('invalid', 'workspace handoff requires a title');
|
|
576
|
+
if (store.room(roomId) === undefined) throw new CollabError('room-missing', `room ${roomId} does not exist`);
|
|
577
|
+
const workspace = store.createWorkspace(roomId);
|
|
578
|
+
hub.emit({ type: 'rooms', rooms: store.roomsView() });
|
|
579
|
+
const name = hub.members.get(taskOwner)?.name ?? taskOwner;
|
|
580
|
+
const reason = typeof body?.reason === 'string' ? body.reason : undefined;
|
|
581
|
+
const { message } = hub.post(workspace.id, { authorKind: 'system', author: 'system', name: '交接',
|
|
582
|
+
text: `【交接】${name}:${title}${reason ? `\n${reason}` : ''}` });
|
|
583
|
+
const task = tasks.assign({ roomId: workspace.id, owner: taskOwner, actor: lead, title, reason, messageId: message.id, force: true });
|
|
584
|
+
tasks.q.setSource.run('workspace', task.id);
|
|
585
|
+
const chat = workspace.parent_id ?? roomId;
|
|
586
|
+
const open = tasks.q.openChat.get(chat, taskOwner, lead, ...WORKING);
|
|
587
|
+
if (open !== undefined) {
|
|
588
|
+
tasks.handBack(open.id, { actor: open.owner, messageId: message.id });
|
|
589
|
+
tasks.review(open.id, { actor: lead, verdict: 'done', note: `转到${workspace.name} #${task.id}` });
|
|
590
|
+
}
|
|
591
|
+
if (!dsh?.nudge(taskOwner, message)) gateway.nudge?.(taskOwner, message);
|
|
592
|
+
return { workspace, task: tasks.task(task.id) };
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
322
596
|
const ERROR_STATUS = { 'room-missing': 404, 'member-missing': 404, 'task-missing': 404, conflict: 409, 'read-only': 403, forbidden: 403, offline: 503, 'member-busy': 409 };
|
|
323
597
|
|
|
324
598
|
/** Parse a JSON body (unless `parse` is false), run fn, and map CollabError codes to HTTP statuses. */
|
|
325
|
-
|
|
599
|
+
class BodyTooLarge extends Error {}
|
|
600
|
+
|
|
601
|
+
// cc-connect send accepts attachments up to 50 MiB by default (DefaultMaxAttachmentSize in
|
|
602
|
+
// bridge/core/api.go). Bot frames carry them base64-encoded (x4/3) plus JSON fields; 1 MiB of
|
|
603
|
+
// headroom covers those. A smaller cap makes ws close the bot with 1009 after the sender
|
|
604
|
+
// already reported success.
|
|
605
|
+
const MAX_BOT_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
606
|
+
export const BOT_WS_MAX_PAYLOAD = Math.ceil(MAX_BOT_ATTACHMENT_BYTES / 3) * 4 + 1024 * 1024;
|
|
607
|
+
|
|
608
|
+
/** Parses a single `bytes=` range against size: undefined = serve whole body, null = unsatisfiable. */
|
|
609
|
+
export function parseRange(header, size) {
|
|
610
|
+
if (typeof header !== 'string') return undefined;
|
|
611
|
+
const m = /^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/.exec(header);
|
|
612
|
+
if (m === null || (m[1] === '' && m[2] === '')) return undefined;
|
|
613
|
+
if (m[1] === '') {
|
|
614
|
+
const n = Number(m[2]);
|
|
615
|
+
if (n === 0 || size === 0) return null;
|
|
616
|
+
return { start: Math.max(0, size - n), end: size - 1 };
|
|
617
|
+
}
|
|
618
|
+
const start = Number(m[1]);
|
|
619
|
+
if (m[2] !== '' && Number(m[2]) < start) return undefined;
|
|
620
|
+
if (start >= size) return null;
|
|
621
|
+
return { start, end: m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1) };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Serves a stored attachment; honours single byte ranges so iOS Safari can play audio. */
|
|
625
|
+
export function sendAttachment(req, res, row, bytes) {
|
|
626
|
+
const headers = { 'content-type': row.mime, 'accept-ranges': 'bytes',
|
|
627
|
+
'content-disposition': `${INLINE_IMAGE.test(row.mime) ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.name)}`,
|
|
628
|
+
'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=86400' };
|
|
629
|
+
const range = parseRange(req.headers?.range, bytes.length);
|
|
630
|
+
if (range === null) {
|
|
631
|
+
res.writeHead(416, { ...headers, 'content-range': `bytes */${bytes.length}`, 'content-length': 0 });
|
|
632
|
+
return res.end();
|
|
633
|
+
}
|
|
634
|
+
if (range === undefined) {
|
|
635
|
+
res.writeHead(200, { ...headers, 'content-length': bytes.length });
|
|
636
|
+
return res.end(bytes);
|
|
637
|
+
}
|
|
638
|
+
const part = bytes.subarray(range.start, range.end + 1);
|
|
639
|
+
res.writeHead(206, { ...headers, 'content-length': part.length,
|
|
640
|
+
'content-range': `bytes ${range.start}-${range.end}/${bytes.length}` });
|
|
641
|
+
return res.end(part);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export async function withBody(req, res, fn, parse = true, limit = MAX_BODY) {
|
|
326
645
|
let body;
|
|
327
646
|
if (parse) {
|
|
328
|
-
|
|
647
|
+
let raw;
|
|
648
|
+
try { raw = await readBody(req, limit); } catch (error) {
|
|
649
|
+
if (!(error instanceof BodyTooLarge)) return json(res, 400, { error: 'request body aborted' });
|
|
650
|
+
res.setHeader('connection', 'close');
|
|
651
|
+
return json(res, 413, { error: `request body exceeds ${limit / 1024 / 1024} MiB`, code: 'too-large' });
|
|
652
|
+
}
|
|
653
|
+
try { body = JSON.parse(raw || '{}'); } catch { return json(res, 400, { error: 'invalid JSON body' }); }
|
|
329
654
|
}
|
|
330
655
|
try {
|
|
331
656
|
return json(res, 200, await fn(body));
|
|
@@ -335,6 +660,11 @@ async function withBody(req, res, fn, parse = true) {
|
|
|
335
660
|
}
|
|
336
661
|
}
|
|
337
662
|
|
|
663
|
+
/** Loopback origin of the server that accepted req, for the local later command. */
|
|
664
|
+
function localOrigin(req) {
|
|
665
|
+
const port = req.socket?.localPort;
|
|
666
|
+
return Number.isInteger(port) ? `http://127.0.0.1:${port}` : undefined;
|
|
667
|
+
}
|
|
338
668
|
function json(res, status, value) { text(res, status, 'application/json; charset=utf-8', JSON.stringify(value)); }
|
|
339
669
|
|
|
340
670
|
function text(res, status, type, body) {
|
|
@@ -342,16 +672,39 @@ function text(res, status, type, body) {
|
|
|
342
672
|
res.end(body);
|
|
343
673
|
}
|
|
344
674
|
|
|
345
|
-
function readBody(req) {
|
|
675
|
+
function readBody(req, limit = MAX_BODY) {
|
|
346
676
|
return new Promise((resolve, reject) => {
|
|
347
677
|
const chunks = [];
|
|
348
678
|
let size = 0;
|
|
679
|
+
// Past the limit, keep reading but drop the bytes: destroying the socket here
|
|
680
|
+
// makes the browser see a network error ("Failed to fetch") instead of the 413.
|
|
349
681
|
req.on('data', (chunk) => {
|
|
350
682
|
size += chunk.length;
|
|
351
|
-
if (size >
|
|
683
|
+
if (size > limit) { chunks.length = 0; return; }
|
|
352
684
|
chunks.push(chunk);
|
|
353
685
|
});
|
|
354
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
686
|
+
req.on('end', () => (size > limit ? reject(new BodyTooLarge()) : resolve(Buffer.concat(chunks).toString('utf8'))));
|
|
355
687
|
req.on('error', reject);
|
|
356
688
|
});
|
|
357
689
|
}
|
|
690
|
+
|
|
691
|
+
/** Authenticated one-shot scheduling for local and external channel members. */
|
|
692
|
+
export function laterRoute(req, res, url, { hub, gateway, owner, later }) {
|
|
693
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
694
|
+
const laterMatch = /^\/collab\/api\/rooms\/(\d+)\/later$/.exec(path);
|
|
695
|
+
if (laterMatch && req.method === 'POST') {
|
|
696
|
+
// Only a (member, room) ticket from later.instructions() registers; owner and the shared bot token do not.
|
|
697
|
+
const claims = later.verify(req.headers['x-later-ticket']);
|
|
698
|
+
if (!claims) return json(res, 401, { error: 'unauthorized' });
|
|
699
|
+
if (claims.room !== Number(laterMatch[1])) return json(res, 403, { error: 'ticket is for another room' });
|
|
700
|
+
return withBody(req, res, body => {
|
|
701
|
+
if (body?.member !== undefined && body.member !== claims.member) throw new CollabError('forbidden', 'ticket is for another member');
|
|
702
|
+
if (body?.room !== undefined && Number(body.room) !== claims.room) throw new CollabError('forbidden', 'ticket is for another room');
|
|
703
|
+
if (!hub.routable().some(m => m.id === claims.member)) throw new CollabError('member-missing', 'unknown member');
|
|
704
|
+
const reminder = registerLater(hub.store.db, { ...body, member: claims.member, room: claims.room });
|
|
705
|
+
later.tick();
|
|
706
|
+
return { reminder };
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
return undefined;
|
|
710
|
+
}
|