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/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 gateway = new CloudWebGateway(hub, { token: config.botToken, logger: ctx.logger });
89
- const dsh = new DshMembers(hub, { ctx, createUserMessage, createCliMember, workspacePath: config.workspacePath, logger: ctx.logger }, [], { maxRelay: config.maxRelay });
90
- const roster = new Roster(hub, dsh, ctx.llm);
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 clears its sessions (DSH members rebind from the
98
- // store; local CLI members drop their ACP session) and shows up free in the room list.
99
- tasks.onRelease(roomId => {
100
- dsh.resetRoom(roomId);
101
- hub.emit({ type: 'rooms', rooms: hub.store.roomsView() });
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: 8 * 1024 * 1024 });
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 json(res, 401, { error: 'unauthorized' });
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, dispatch_mode: store.dispatchMode(r.id) })), members: hub.memberView(),
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 { message } = hub.post(Number(m[1]), { authorKind: 'user', author: OWNER, name: config.ownerName, text: String(body?.text ?? ''), clientKey,
225
- replyTo: Number.isInteger(body?.reply_to) ? body.reply_to : undefined });
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
- // #120: owner-only (checked above) decision on a cloud-web bot's permission prompt.
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: gateway.decidePermission(Number(pm[1]), String(body?.decision ?? '')) }));
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? } (the dispatcher)
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(json(res, 401, { error: 'unauthorized' }));
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)) return Promise.resolve(json(res, 401, { error: 'unauthorized' }));
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
- async function withBody(req, res, fn, parse = true) {
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
- try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, 400, { error: 'invalid JSON body' }); }
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 > MAX_BODY) { reject(Error('body too large')); req.destroy(); return; }
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
+ }