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/cloudweb.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
133
|
-
this.
|
|
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) ? [
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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)
|
|
356
|
-
|
|
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
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
|
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
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
/**
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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) {
|