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.
@@ -6,7 +6,10 @@ import { exitTarget } from './team-exit.mjs';
6
6
  // Rooms are addressed as "room-<id>", messages as "hub-<id>".
7
7
  import { forDisplay, forMember, mentions, nameCalled } from './mentions.mjs';
8
8
  import { checkAvatar } from './avatars.mjs';
9
- import { CollabError, MAX_TEXT, publicMessage, quotePrefix } from './store.mjs';
9
+ import { CollabError, MAX_TEXT, messageAttachments, publicMessage, quotePrefix } from './store.mjs';
10
+ import { ChannelApprovals, parseCard, textDecision } from './approvals.mjs';
11
+
12
+ export { textDecision };
10
13
 
11
14
  export const WS_PATH = '/cloud-web/ws';
12
15
  // Declared to cc-connect. 'buttons' is declared so permission prompts arrive as
@@ -24,21 +27,27 @@ export const CAPABILITIES = ['text', 'reconstruct_reply', 'typing', 'preview', '
24
27
  */
25
28
  export const CAP_TEAM_WORKSPACE = 'team_workspace';
26
29
  export function supportsWorkspaces(store) { return typeof store?.createWorkspace === 'function'; }
30
+ /**
31
+ * Bot -> channel images, files and voice clips (bridge platform/cloud-web SendImage /
32
+ * SendFile / SendAudio). Advertised only when the gateway has an attachment store; the
33
+ * bytes are saved there and shown in the Channel page like an uploaded attachment.
34
+ */
35
+ export const MEDIA_CAPABILITIES = ['image', 'file', 'audio'];
36
+ // `format` of an audio frame (cc-connect audioFormatHint: file extension or mime subtype)
37
+ // -> mime type the page's <audio> element can play.
38
+ const AUDIO_MIME = { mp3: 'audio/mpeg', mpeg: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', opus: 'audio/ogg', oga: 'audio/ogg',
39
+ m4a: 'audio/mp4', mp4: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', webm: 'audio/webm', amr: 'audio/amr', silk: 'audio/silk' };
40
+ export function audioMime(format) {
41
+ const f = String(format ?? '').toLowerCase().replace(/^\./, '').replace(/^audio\//, '');
42
+ return AUDIO_MIME[f] ?? (f ? `audio/${f.replace(/[^a-z0-9.+-]/g, '')}` : 'audio/mpeg');
43
+ }
27
44
 
28
45
  export const PERM_DECISIONS = { allow: 'perm:allow', deny: 'perm:deny', allow_all: 'perm:allow_all' };
29
- const PERM_STATES = { allow: 'allowed', deny: 'denied', allow_all: 'allowed_all' };
30
- // Same vocabularies and order as bridge core/engine.go approveAllPhrases /
31
- // approveAllSingleTokens / allowKeywords / denyKeywords, so a text reply and a
32
- // button click resolve to the same decision.
33
- const APPROVE_ALL_PHRASES = ['allow all', 'approve all', 'yes all'];
34
- const APPROVE_ALL_TOKENS = ['allowall', '允许所有', '允许全部', '全部允许', '所有允许', '都允许', '全部同意'];
35
- const ALLOW_TOKENS = ['allow', 'yes', 'y', 'ok', 'approve', '允许', '同意', '可以', '好', '好的', '是', '确认'];
36
- const DENY_TOKENS = ['deny', 'no', 'n', 'reject', 'cancel', '拒绝', '不允许', '不行', '不', '否', '取消'];
37
46
  const REGISTER_TIMEOUT_MS = 10_000;
38
47
  const WS_BAD_REGISTER = 4002;
39
48
  const WS_REPLACED = 4003;
40
49
  // cc-connect status notices that teammates never see (discord.go isBotNotice).
41
- const NOTICE_PREFIXES = ['📬', '✅', '↪', '⏳', '🔄', '⏹', '⏸'];
50
+ export const NOTICE_PREFIXES = ['📬', '✅', '↪', '⏳', '🔄', '⏹', '⏸'];
42
51
  // "model · usage" footer cc-connect appends (discord.go botFooter).
43
52
  const FOOTER = /(?:^|\n+)\*[^*\n]*·[^*]*\*\s*$/s;
44
53
  const BARE_MENTION = '(只 @ 了你,没写内容:看上面的频道消息判断要你做什么;看不出来就问)';
@@ -67,6 +76,14 @@ export function progressCard(content) {
67
76
  let p;
68
77
  try { p = JSON.parse(content.slice(PROGRESS_PREFIX.length)); } catch { return undefined; }
69
78
  if (p === null || typeof p !== 'object') return undefined;
79
+ return progressCardOf(p);
80
+ }
81
+
82
+ /**
83
+ * Progress card payload object ({ agent, state, steps, elapsed_sec, items }) → { text, card }.
84
+ * Shared by bridge cards above and DSH/CLI member turns (dsh-member.mjs), so both render alike.
85
+ */
86
+ export function progressCardOf(p) {
70
87
  const state = p.state in CARD_STATES ? p.state : 'running';
71
88
  const steps = Number.isInteger(p.steps) ? p.steps : 0;
72
89
  const sec = Number.isInteger(p.elapsed_sec) ? p.elapsed_sec : 0;
@@ -80,7 +97,9 @@ export function progressCard(content) {
80
97
  }
81
98
  const agent = String(p.agent ?? 'Agent').trim() || 'Agent';
82
99
  const brief = [lines[0], ...briefSteps(items, state)].join('\n');
83
- return { text: lines.join('\n'), card: JSON.stringify({ title: `${agent} · ${CARD_STATES[state]}`, state, steps, elapsed_sec: sec, brief }) };
100
+ const card = { text: lines.join('\n'), card: JSON.stringify({ title: `${agent} · ${CARD_STATES[state]}`, state, steps, elapsed_sec: sec, brief }) };
101
+ // Bridge task card (cloud-web public_progress.go): its members feed the task board (tasks.mjs fromCard).
102
+ return Array.isArray(p.task_members) ? { ...card, members: p.task_members } : card;
84
103
  }
85
104
 
86
105
  /**
@@ -118,19 +137,28 @@ function briefSteps(items, state) {
118
137
  });
119
138
  }
120
139
 
140
+ /** reply_to_* frame fields for a reply (store.quote result); {} for a plain message. */
141
+ function replyToFields(quoted, store) {
142
+ if (quoted === undefined) return {};
143
+ if (quoted.deleted) return { reply_to_id: String(quoted.id) };
144
+ return { reply_to_id: String(quoted.id), reply_to_author: quoted.name, reply_to_text: store.message(quoted.id)?.text ?? quoted.text };
145
+ }
146
+
121
147
  export class CloudWebGateway {
122
148
  /**
123
149
  * @param hub - Hub instance.
124
150
  * @param options - { token: shared bot token (empty disables the gateway), logger }.
125
151
  */
126
- constructor(hub, { token, logger }) {
152
+ constructor(hub, { token, logger, files }) {
127
153
  this.hub = hub;
154
+ /** #144: Attachments store; its wire() adds images/files to a forwarded message. */
155
+ this.files = files;
128
156
  this.token = token;
129
157
  this.logger = logger;
130
158
  /** member id -> { ws, member } */
131
159
  this.bots = new Map();
132
- /** permission prompt message id -> { bot, room, sessionKey, replyCtx } while undecided */
133
- this.pendingPerms = new Map();
160
+ /** #120: permission cards, shared with DSH members (approvals.mjs); meta = { gateway, bot, sessionKey } */
161
+ this.approvals = ChannelApprovals.of(hub);
134
162
  /** Tasks (#139 B), set by the plugin; `task` frames act through it. */
135
163
  this.tasks = undefined;
136
164
  hub.onMessage(message => this.onNew(message));
@@ -184,9 +212,17 @@ export class CloudWebGateway {
184
212
  ws.close(WS_BAD_REGISTER, 'bad register');
185
213
  return undefined;
186
214
  }
215
+ const roomParam = url.searchParams.get('room');
216
+ const roomId = roomParam === null ? undefined : Number(roomParam.replace(/^room-/, ''));
217
+ if (roomId !== undefined && (!Number.isSafeInteger(roomId) || this.hub.store.room(roomId) === undefined)) {
218
+ send(ws, { type: 'register_ack', ok: false, error: 'unknown room' });
219
+ ws.close(WS_BAD_REGISTER, 'unknown room');
220
+ return undefined;
221
+ }
222
+ const rooms = roomId === undefined ? [] : [this.hub.store.settingsRoom(roomId)];
187
223
  const name = clean(url.searchParams.get('name') ?? '').slice(0, MAX_NAME) || existing?.name || id;
188
224
  // @-mentions resolve by name, so a bot may not reuse another member's name.
189
- if (this.hub.memberList().some(m => m.id !== id && m.name.toLowerCase() === name.toLowerCase())) {
225
+ if (this.hub.memberList().some(m => m.id !== id && (!rooms.length || !m.rooms?.length || rooms.some(r => m.rooms.includes(r))) && m.name.toLowerCase() === name.toLowerCase())) {
190
226
  send(ws, { type: 'register_ack', ok: false, error: `name ${name} is already used by another member` });
191
227
  ws.close(WS_BAD_REGISTER, 'name taken');
192
228
  return undefined;
@@ -195,12 +231,12 @@ export class CloudWebGateway {
195
231
  // avatar=<built-in slug> or an http(s) image URL (cc-connect's own library); anything else falls back to the initial badge.
196
232
  let avatar = '';
197
233
  try { avatar = checkAvatar(url.searchParams.get('avatar'), { allowUrl: true }); } catch { avatar = ''; }
198
- const member = { id, name, kind: 'bot', mentionId: /^\d+$/.test(mentionId) ? mentionId : '', online: true, ...(avatar ? { avatar } : {}) };
234
+ const member = { id, name, rooms, kind: 'bot', mentionId: /^\d+$/.test(mentionId) ? mentionId : '', online: true, ...(avatar ? { avatar } : {}) };
199
235
  const bot = { ws, member };
200
236
  const old = this.bots.get(id);
201
237
  this.bots.set(id, bot);
202
238
  if (old !== undefined) old.ws.close(WS_REPLACED, 'replaced by new connection');
203
- const capabilities = supportsWorkspaces(this.hub.store) ? [...CAPABILITIES, CAP_TEAM_WORKSPACE] : CAPABILITIES;
239
+ const capabilities = [...CAPABILITIES, ...(this.files ? MEDIA_CAPABILITIES : []), ...(supportsWorkspaces(this.hub.store) ? [CAP_TEAM_WORKSPACE] : [])];
204
240
  send(ws, { type: 'register_ack', ok: true, capabilities, server: { name: 'conest-dsh-collab' } });
205
241
  this.hub.setMember(member);
206
242
  // A reconnect means a restarted cc-connect; its last typing_stop may be lost.
@@ -208,7 +244,7 @@ export class CloudWebGateway {
208
244
  // ...and its half-streamed text previews (also those left by a hub restart) will never be committed.
209
245
  this.hub.dropDrafts('role', id);
210
246
  // ...and its pending permission requests are gone with the old process.
211
- for (const [msgId, p] of this.pendingPerms) if (p.bot === id) this.closePerm(msgId, 'expired');
247
+ this.approvals.expireWhere(p => p.meta?.gateway === this && p.meta.bot === id);
212
248
  return bot;
213
249
  }
214
250
 
@@ -218,20 +254,27 @@ export class CloudWebGateway {
218
254
  const target = exitTarget(msg);
219
255
  const fromAgent = msg.author_kind === 'role';
220
256
  // The owner's text reply to a pending permission prompt goes the same way as a button click.
221
- const answered = msg.author_kind === 'user' ? this.answerByText(msg) : new Set();
257
+ const answered = this.approvals.answerByText(msg);
222
258
  let text = msg.text;
223
259
  if (fromAgent) {
224
260
  if (NOTICE_PREFIXES.some(p => text.trimStart().startsWith(p))) return;
225
261
  text = text.replace(FOOTER, '').trim();
226
262
  if (!text) return;
227
263
  }
228
- const members = this.hub.routable();
264
+ const members = this.hub.routableIn(msg.room_id);
229
265
  const mentioned = mentions(text, members);
230
266
  const room = this.hub.store.room(msg.room_id);
231
267
  const author = this.hub.members.get(msg.author);
232
268
  const only = this.hub.replyRoute(msg);
233
- const quote = quotePrefix(this.hub.store.quote(msg));
269
+ const metas = messageAttachments(msg);
270
+ const attached = metas.length && this.files ? this.files.wire(metas) : {};
271
+ const quoted = this.hub.store.quote(msg);
272
+ const quote = quotePrefix(quoted);
273
+ // The replied-to row's id (same id space as a task card's preview_handle and a reply_ack),
274
+ // so the bridge can find the work it belongs to (core.Message.Reference, reply_task.go).
275
+ const replyTo = replyToFields(quoted, this.hub.store);
234
276
  for (const { ws, member } of this.bots.values()) {
277
+ if (!members.some(m => m.id === member.id)) continue;
235
278
  if ((target && target !== member.id) || (fromAgent && msg.author === member.id)) continue;
236
279
  if (answered.has(member.id)) continue;
237
280
  // A reply addressed to other bots is not this bot's to take (reply_routing.go accept=false).
@@ -244,8 +287,8 @@ export class CloudWebGateway {
244
287
  else content = quote + content;
245
288
  const frame = {
246
289
  type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: 'group',
247
- user_id: msg.author, user_name: msg.name, content, reply_ctx: roomKey(msg.room_id),
248
- mentioned: hit, from_bot: fromAgent,
290
+ user_id: msg.author, user_name: msg.name, content: content + (hit ? (this.later?.instructions(member.id, msg.room_id) ?? '') : ''), reply_ctx: roomKey(msg.room_id),
291
+ mentioned: hit, from_bot: fromAgent, ...attached, ...replyTo,
249
292
  };
250
293
  if (room !== undefined) frame.chat_name = room.name.replace(/\s+/g, ' ');
251
294
  if (author?.mentionId) frame.user_mention_id = author.mentionId;
@@ -258,9 +301,10 @@ export class CloudWebGateway {
258
301
  * reminder); onNew never forwards system rows. False when the bot is offline.
259
302
  */
260
303
  nudge(memberId, msg) {
304
+ if (this.hub.quota?.cooling(memberId)) return false;
261
305
  const bot = this.bots.get(memberId);
262
- if (bot === undefined) return false;
263
- const members = this.hub.routable();
306
+ if (bot === undefined || !this.hub.routableIn(msg.room_id).some(m => m.id === memberId)) return false;
307
+ const members = this.hub.routableIn(msg.room_id);
264
308
  const room = this.hub.store.room(msg.room_id);
265
309
  const frame = {
266
310
  type: 'message', msg_id: `hub-${msg.id}`, chat_id: roomKey(msg.room_id), chat_type: 'group',
@@ -292,6 +336,7 @@ export class CloudWebGateway {
292
336
  ...view(r), parent_id: r.parent_id == null ? '' : roomKey(r.parent_id),
293
337
  owner: owner == null ? '' : roomKey(owner),
294
338
  active: this.tasks?.countActive(r.id) ?? 0,
339
+ paused: this.tasks?.countPaused?.(r.id) ?? 0,
295
340
  };
296
341
  });
297
342
  return { ok: true, self: view(self), rooms };
@@ -312,7 +357,9 @@ export class CloudWebGateway {
312
357
  // stale-claim) when d.claim names an earlier claim than the current one.
313
358
  if (self.kind !== 'workspace') return { ok: false, error: 'not-workspace' };
314
359
  const claim = d.claim == null || d.claim === '' ? null : String(d.claim);
315
- const released = this.tasks ? this.tasks.release(id, claim) : store.releaseWorkspace(id, claim);
360
+ // park (core ParkTeamWorkspace): its tasks are paused, not finished; they stop counting first.
361
+ const released = !this.tasks ? store.releaseWorkspace(id, claim)
362
+ : d.park ? this.tasks.park(id, claim) : this.tasks.release(id, claim);
316
363
  if (released) this.hub.emit({ type: 'rooms', rooms: store.roomsView() });
317
364
  return { ok: true, room: view(self) };
318
365
  }
@@ -328,16 +375,42 @@ export class CloudWebGateway {
328
375
  return { ok: false, error: 'bad-op' };
329
376
  }
330
377
 
378
+ /**
379
+ * Retry a previous trigger for a connected bot (#146). Clears error and delivers the message.
380
+ */
381
+ retry(memberId, msg) {
382
+ const bot = this.bots.get(memberId);
383
+ if (bot === undefined) return false;
384
+ this.hub.setError(memberId, undefined);
385
+ return this.nudge(memberId, msg);
386
+ }
387
+
388
+ /**
389
+ * #148: the owner withdrew a message. Every connected bot gets a recall for
390
+ * it; a bridge stops the turn that message started, or drops it from its
391
+ * queue, and ignores it otherwise (core/engine.go handleMessageRecall).
392
+ * Returns the ids of the bots told.
393
+ */
394
+ recall(msgId) {
395
+ const told = [];
396
+ for (const [id, { ws }] of this.bots) {
397
+ send(ws, { type: 'message_recall', msg_id: `hub-${msgId}` });
398
+ told.push(id);
399
+ }
400
+ return told;
401
+ }
402
+
331
403
  async fromBot(bot, d) {
332
404
  const { member } = bot;
333
405
  switch (d.type) {
334
406
  case 'ping': send(bot.ws, { type: 'pong' }); return;
335
407
  case 'task': {
336
408
  // #139 B: the socket says which bot this is, so the action is verified as it.
409
+ // The frame's `ref` is the ack correlation id, so its task field ref (#574) travels as task_ref.
337
410
  const ack = r => send(bot.ws, { type: 'task_ack', ref: d.ref, ...r });
338
411
  if (this.tasks === undefined) { ack({ ok: false, error: 'tasks are not enabled' }); return; }
339
412
  try {
340
- const task = this.tasks.perform(String(d.action ?? ''), { id: Number(d.task_id), roomId: roomOf(d, this.hub.store), body: d }, { actor: member.id, verified: true });
413
+ const task = this.tasks.perform(String(d.action ?? ''), { id: Number(d.task_id), roomId: roomOf(d, this.hub.store), body: { ...d, ref: d.task_ref } }, { actor: member.id, verified: true });
341
414
  ack({ ok: true, task });
342
415
  } catch (error) {
343
416
  ack({ ok: false, error: error?.message ?? String(error), code: error?.code });
@@ -351,9 +424,15 @@ export class CloudWebGateway {
351
424
  const text = forDisplay(content, this.hub.routable()).trim();
352
425
  if (!text || text.length > MAX_TEXT) return;
353
426
  // reply_in_place (#121): the streamed preview becomes this reply; fall back to a new row if it is gone.
427
+ // A frame with ref_id asks for the posted row's id back (bridge SentMessageRecorder).
428
+ const ack = id => { if (typeof d.ref_id === 'string' && d.ref_id) send(bot.ws, { type: 'preview_ack', ref_id: d.ref_id, preview_handle: String(id) }); };
354
429
  const h = d.preview_handle;
355
- if (typeof h === 'string' && /^\d+$/.test(h) && this.hub.commit(Number(h), 'role', member.id, text, d.agent === true) !== undefined) return;
356
- this.hub.post(room, { authorKind: 'role', author: member.id, name: member.name, text, fromTurn: d.agent === true });
430
+ if (typeof h === 'string' && /^\d+$/.test(h)) {
431
+ const row = this.hub.commit(Number(h), 'role', member.id, text, d.agent === true);
432
+ if (row !== undefined) { ack(row.id); return; }
433
+ }
434
+ const { message } = this.hub.post(room, { authorKind: 'role', author: member.id, name: member.name, text, fromTurn: d.agent === true });
435
+ ack(message.id);
357
436
  return;
358
437
  }
359
438
  case 'buttons': {
@@ -371,13 +450,40 @@ export class CloudWebGateway {
371
450
  return;
372
451
  }
373
452
  // cc-connect keeps one pending permission per session: a new prompt supersedes older ones.
374
- for (const [msgId, p] of this.pendingPerms) if (p.bot === member.id && p.sessionKey === d.session_key) this.closePerm(msgId, 'expired');
375
- const card = {
376
- kind: 'permission', title: '权限请求', state: 'pending',
453
+ this.approvals.expireWhere(p => p.meta?.gateway === this && p.meta.bot === member.id && p.meta.sessionKey === d.session_key);
454
+ const sessionKey = d.session_key;
455
+ const replyCtx = typeof d.reply_ctx === 'string' ? d.reply_ctx : roomKey(room);
456
+ this.approvals.open({
457
+ room, author: member, text,
377
458
  actions: decisions.map(([decision, data]) => ({ decision, label: buttons.find(b => b.data === data).label })),
378
- };
379
- const { message } = this.hub.post(room, { authorKind: 'role', author: member.id, name: member.name, text, card: JSON.stringify(card) });
380
- this.pendingPerms.set(message.id, { bot: member.id, room, sessionKey: d.session_key, replyCtx: typeof d.reply_ctx === 'string' ? d.reply_ctx : roomKey(room) });
459
+ meta: { gateway: this, bot: member.id, sessionKey },
460
+ check: () => {
461
+ if (!this.bots.has(member.id)) throw new CollabError('offline', `${member.name} 已断线,权限请求还在等待,重连后会失效`);
462
+ },
463
+ onDecide: (decision) => {
464
+ const bot = this.bots.get(member.id);
465
+ if (bot !== undefined) send(bot.ws, { type: 'card_action', session_key: sessionKey, reply_ctx: replyCtx, action: PERM_DECISIONS[decision] });
466
+ },
467
+ });
468
+ return;
469
+ }
470
+ case 'image':
471
+ case 'file':
472
+ case 'audio': {
473
+ const room = roomOf(d, this.hub.store);
474
+ if (room === undefined || this.files === undefined || typeof d.data !== 'string' || !d.data) return;
475
+ const ext = String(d.format ?? '').toLowerCase().replace(/^\./, '').replace(/^audio\//, '').replace(/[^a-z0-9]/g, '');
476
+ const mime = d.type === 'audio' ? audioMime(d.format) : (typeof d.mime_type === 'string' && d.mime_type) || 'application/octet-stream';
477
+ const name = typeof d.file_name === 'string' && d.file_name ? d.file_name
478
+ : d.type === 'audio' ? `voice.${ext || 'mp3'}` : d.type === 'image' ? `image.${mime.split('/')[1] ?? 'png'}` : 'file';
479
+ let attachments;
480
+ try {
481
+ attachments = this.files.save([{ name, mime, data: d.data }]);
482
+ } catch (error) {
483
+ this.logger.warn('collab: cloud-web %s frame from %s dropped: %s', d.type, member.id, error?.message ?? error);
484
+ return;
485
+ }
486
+ this.hub.post(room, { authorKind: 'role', author: member.id, name: member.name, text: '', attachments });
381
487
  return;
382
488
  }
383
489
  case 'typing_start':
@@ -389,14 +495,21 @@ export class CloudWebGateway {
389
495
  case 'preview_start': {
390
496
  if (typeof d.ref_id !== 'string') return;
391
497
  const room = roomOf(d, this.hub.store);
392
- const body = this.body(d.content);
498
+ const seq = cardSeq(d);
499
+ const body = withSeq(this.body(d.content), seq);
393
500
  let handle = '';
394
501
  if (room !== undefined && body.text) {
395
- const { message } = this.hub.draft(room, {
502
+ const { message, created } = this.hub.draft(room, {
396
503
  authorKind: 'role', author: member.id, name: member.name, text: body.text, card: body.card,
397
504
  clientKey: `preview:${member.id}:${d.ref_id}`,
398
505
  });
506
+ // #119: a task card create retried after a lost ack finds the row it
507
+ // made before; show the newer content instead of the first one.
508
+ const fresh = created || newerSeq(message, seq);
509
+ if (!created && fresh) this.hub.edit(message.id, 'role', member.id, body.text, body.card);
399
510
  handle = String(message.id);
511
+ // A late older frame must not reopen tasks the newer card already handed back.
512
+ if (fresh && body.members) this.tasks?.fromCard(room, member.id, message.id, body.members);
400
513
  }
401
514
  // Always ack, or cc-connect waits out its 10 s timeout.
402
515
  send(bot.ws, { type: 'preview_ack', ref_id: d.ref_id, preview_handle: handle });
@@ -412,75 +525,22 @@ export class CloudWebGateway {
412
525
  const h = d.preview_handle;
413
526
  if (typeof h !== 'string' || !/^\d+$/.test(h)) return;
414
527
  if (d.type === 'delete_message') { this.hub.remove(Number(h), 'role', member.id); return; }
415
- const body = this.body(d.content);
416
- if (body.text) this.hub.edit(Number(h), 'role', member.id, body.text, body.card);
528
+ const seq = cardSeq(d);
529
+ // #119: a delayed older task card frame must not roll the card back.
530
+ if (seq !== undefined && !newerSeq(this.hub.store.message(Number(h)), seq)) return;
531
+ const body = withSeq(this.body(d.content), seq);
532
+ if (!body.text) return;
533
+ const row = this.hub.edit(Number(h), 'role', member.id, body.text, body.card);
534
+ if (row !== undefined && body.members) this.tasks?.fromCard(row.room_id, member.id, row.id, body.members);
417
535
  return;
418
536
  }
419
537
  default:
420
538
  }
421
539
  }
422
540
 
423
- /**
424
- * Resolve permission prompt `msgId` with `decision` (a PERM_DECISIONS key) on
425
- * behalf of the Channel owner (callers check that). Throws CollabError when the
426
- * prompt is not a permission request, already decided, expired, or its bot is offline.
427
- */
541
+ /** #120: the owner's decision on permission card `msgId` (approvals.mjs decide). */
428
542
  decidePermission(msgId, decision, by = 'button') {
429
- if (!Object.hasOwn(PERM_DECISIONS, decision)) throw new CollabError('invalid', `unknown decision ${decision}`);
430
- const row = this.hub.store.message(msgId);
431
- const card = row === undefined ? undefined : parseCard(row.card);
432
- if (card?.kind !== 'permission') throw new CollabError('not-permission', '这条消息不是权限请求');
433
- if (card.state !== 'pending') throw new CollabError('conflict', card.state === 'expired' ? '这个权限请求已过期' : '这个权限请求已经处理过了');
434
- const p = this.pendingPerms.get(msgId);
435
- if (p === undefined) {
436
- // Pending in the store but not in memory: collab restarted, cc-connect's side is unknown.
437
- this.closePerm(msgId, 'expired', row);
438
- throw new CollabError('conflict', '这个权限请求已过期');
439
- }
440
- const bot = this.bots.get(p.bot);
441
- if (bot === undefined) throw new CollabError('offline', `${row.name} 已断线,权限请求还在等待,重连后会失效`);
442
- this.closePerm(msgId, PERM_STATES[decision], row, by);
443
- send(bot.ws, { type: 'card_action', session_key: p.sessionKey, reply_ctx: p.replyCtx, action: PERM_DECISIONS[decision] });
444
- return publicMessage(this.hub.store.message(msgId), this.hub.store);
445
- }
446
-
447
- /** Mark a permission prompt decided/expired and update it for browsers. */
448
- closePerm(msgId, state, row = this.hub.store.message(msgId), by) {
449
- this.pendingPerms.delete(msgId);
450
- const card = row === undefined ? undefined : parseCard(row.card);
451
- if (card?.kind !== 'permission' || card.state !== 'pending') return;
452
- const next = { ...card, state, ...(by ? { decided_by: by } : {}), decided_at: Math.floor(Date.now() / 1000) };
453
- this.hub.edit(row.id, 'role', row.author, row.text, JSON.stringify(next));
454
- }
455
-
456
- /**
457
- * Owner text reply → decision on the room's pending prompts; returns the ids of the
458
- * bots that must not also get the text (prompt answered, or withheld from them).
459
- *
460
- * cc-connect checks a pending permission before its gate and before any mention rule
461
- * (bridge core/engine.go handleMessage → handlePendingPermission, ahead of applyGate),
462
- * and hub bots run with group_reply_all (core/setup_wizard.go), so every bot with a
463
- * pending prompt that receives an allow/deny word acts on it. Hence: no @ → the
464
- * decision applies to every pending prompt in the room; an @ → only the @'d bots', and
465
- * the other pending bots are not sent the text so their prompts stay open.
466
- */
467
- answerByText(msg) {
468
- const answered = new Set();
469
- const members = this.hub.routable();
470
- const inRoom = [...this.pendingPerms].filter(([, p]) => p.room === msg.room_id && this.bots.has(p.bot));
471
- if (inRoom.length === 0) return answered;
472
- const mentioned = mentions(msg.text, members);
473
- for (const [msgId, p] of inRoom) {
474
- const decision = textDecision(forMember(msg.text, this.bots.get(p.bot).member, members));
475
- if (decision === undefined) continue;
476
- if (mentioned.size > 0 && !mentioned.has(p.bot)) {
477
- answered.add(p.bot); // addressed to someone else: withhold it so this bot's bridge doesn't approve
478
- continue;
479
- }
480
- try { this.decidePermission(msgId, decision, 'text'); } catch { continue; }
481
- answered.add(p.bot);
482
- }
483
- return answered;
543
+ return this.approvals.decide(msgId, decision, by);
484
544
  }
485
545
 
486
546
  body(content) {
@@ -495,24 +555,23 @@ export class CloudWebGateway {
495
555
  }
496
556
  }
497
557
 
498
- function parseCard(raw) {
499
- if (typeof raw !== 'string') return undefined;
500
- try { return JSON.parse(raw); } catch { return undefined; }
558
+ /** The task card sequence a frame carries (bridge cloud-web EnsureTaskCard), or undefined. */
559
+ function cardSeq(d) {
560
+ return Number.isSafeInteger(d.seq) && d.seq >= 0 ? d.seq : undefined;
501
561
  }
502
562
 
503
- // Same rune set as bridge core/engine.go permissionTokenSeparator (whitespace plus these).
504
- const PERM_TOKEN_SEPARATORS = /[\s@@,,.。!!??::;;()()[\]【】"'“”‘’、·]+/u;
563
+ /** Record seq in the stored card so later frames can be compared with it. */
564
+ function withSeq(body, seq) {
565
+ if (seq === undefined || body.card == null) return body;
566
+ const card = parseCard(body.card);
567
+ return card === undefined ? body : { ...body, card: JSON.stringify({ ...card, seq }) };
568
+ }
505
569
 
506
- /** Port of bridge core/engine.go isApproveAllResponse / isAllowResponse / isDenyResponse (checked in that order). */
507
- export function textDecision(text) {
508
- const tokens = String(text).toLowerCase().trim().split(PERM_TOKEN_SEPARATORS).filter(Boolean);
509
- if (tokens.length === 0) return undefined;
510
- const joined = tokens.join(' ');
511
- if (APPROVE_ALL_PHRASES.some(ph => joined === ph || ` ${joined} `.includes(` ${ph} `))) return 'allow_all';
512
- if (tokens.some(t => APPROVE_ALL_TOKENS.includes(t))) return 'allow_all';
513
- if (tokens.some(t => ALLOW_TOKENS.includes(t))) return 'allow';
514
- if (tokens.some(t => DENY_TOKENS.includes(t))) return 'deny';
515
- return undefined;
570
+ /** True when seq is newer than what row's card was last written with (rows without one count as older). */
571
+ function newerSeq(row, seq) {
572
+ if (row === undefined || seq === undefined) return false;
573
+ const stored = parseCard(row.card)?.seq;
574
+ return !Number.isSafeInteger(stored) || seq > stored;
516
575
  }
517
576
 
518
577
  function send(ws, frame) {