bothouse-dsh 0.1.0 → 0.1.40
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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Channel approval cards (#120, #557): one permission card in a room, decided by
|
|
2
|
+
// the owner with a button or a text reply. Member-type neutral: cloud-web bots
|
|
3
|
+
// (cloudweb.mjs) and DSH members (dsh-member.mjs) both open their cards here and
|
|
4
|
+
// get the decision back through their own callback, so the card, the pending
|
|
5
|
+
// table, the text vocabulary and the HTTP route are shared.
|
|
6
|
+
//
|
|
7
|
+
// const { messageId, decision } = ChannelApprovals.of(hub).open({
|
|
8
|
+
// room, author: { id, name }, text,
|
|
9
|
+
// actions: [{ decision: 'allow', label: '允许' }, ...], // default: DEFAULT_ACTIONS
|
|
10
|
+
// signal, // abort = request withdrawn, card expires
|
|
11
|
+
// meta, // caller data, matched by expireWhere()
|
|
12
|
+
// check() {}, // throw a CollabError to refuse a decision now (e.g. offline)
|
|
13
|
+
// onDecide(decision, by) {}, // 'allow' | 'deny' | 'allow_all'; by = 'button' | 'text'
|
|
14
|
+
// mapDecision(decision) {}, // optional: the decision actually applied (card state, decision, onDecide),
|
|
15
|
+
// // e.g. a text 'allow_all' becomes 'allow' when the asker has no allow-all
|
|
16
|
+
// });
|
|
17
|
+
// await decision; // 'allow' | 'deny' | 'allow_all' | 'expired'
|
|
18
|
+
import { forMember, mentions } from './mentions.mjs';
|
|
19
|
+
import { CollabError, publicMessage } from './store.mjs';
|
|
20
|
+
|
|
21
|
+
const STATES = { allow: 'allowed', deny: 'denied', allow_all: 'allowed_all' };
|
|
22
|
+
// Labels and order of cc-connect's permission buttons (bridge core/engine.go), so
|
|
23
|
+
// a DSH member's card reads the same as a cloud-web bot's.
|
|
24
|
+
export const DEFAULT_ACTIONS = [
|
|
25
|
+
{ decision: 'allow', label: '允许' },
|
|
26
|
+
{ decision: 'deny', label: '拒绝' },
|
|
27
|
+
{ decision: 'allow_all', label: '允许所有 (本次会话)' },
|
|
28
|
+
];
|
|
29
|
+
// Same vocabularies and order as bridge core/engine.go approveAllPhrases /
|
|
30
|
+
// approveAllSingleTokens / allowKeywords / denyKeywords, so a text reply and a
|
|
31
|
+
// button click resolve to the same decision.
|
|
32
|
+
const APPROVE_ALL_PHRASES = ['allow all', 'approve all', 'yes all'];
|
|
33
|
+
const APPROVE_ALL_TOKENS = ['allowall', '允许所有', '允许全部', '全部允许', '所有允许', '都允许', '全部同意'];
|
|
34
|
+
const ALLOW_TOKENS = ['allow', 'yes', 'y', 'ok', 'approve', '允许', '同意', '可以', '好', '好的', '是', '确认'];
|
|
35
|
+
const DENY_TOKENS = ['deny', 'no', 'n', 'reject', 'cancel', '拒绝', '不允许', '不行', '不', '否', '取消'];
|
|
36
|
+
|
|
37
|
+
export class ChannelApprovals {
|
|
38
|
+
constructor(hub) {
|
|
39
|
+
this.hub = hub;
|
|
40
|
+
/** card message id -> { room, author, meta, check, onDecide, resolve } while undecided */
|
|
41
|
+
this.pending = new Map();
|
|
42
|
+
/**
|
|
43
|
+
* Owner message row -> Set of member ids its text answered or was withheld from.
|
|
44
|
+
* The DSH planner and the cloud-web listener both ask about the same row
|
|
45
|
+
* object (hub.post hands both the one row), so it is answered once.
|
|
46
|
+
*/
|
|
47
|
+
this.answers = new WeakMap();
|
|
48
|
+
hub.approvals = this;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The hub's shared instance, created on first use. */
|
|
52
|
+
static of(hub) { return hub.approvals ?? new ChannelApprovals(hub); }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Post a pending permission card in `room` as `author` and register it.
|
|
56
|
+
* @returns { messageId, decision } — decision settles with the owner's choice, or 'expired'.
|
|
57
|
+
*/
|
|
58
|
+
open({ room, author, text, actions = DEFAULT_ACTIONS, signal, meta, check, onDecide, mapDecision }) {
|
|
59
|
+
const card = { kind: 'permission', title: '权限请求', state: 'pending', actions: actions.map(a => ({ decision: a.decision, label: a.label })) };
|
|
60
|
+
const { message } = this.hub.post(room, { authorKind: 'role', author: author.id, name: author.name, text, card: JSON.stringify(card) });
|
|
61
|
+
let resolve;
|
|
62
|
+
const decision = new Promise(r => { resolve = r; });
|
|
63
|
+
this.pending.set(message.id, { room, author: author.id, meta, check, onDecide, mapDecision, resolve });
|
|
64
|
+
if (signal !== undefined) {
|
|
65
|
+
const onAbort = () => this.expire(message.id);
|
|
66
|
+
if (signal.aborted) onAbort();
|
|
67
|
+
else {
|
|
68
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
69
|
+
decision.then(() => signal.removeEventListener('abort', onAbort));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { messageId: message.id, decision };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve card `msgId` with `decision` on behalf of the owner (callers check
|
|
77
|
+
* that). Throws CollabError when the message is not a permission card, is
|
|
78
|
+
* already decided or expired, or its asker refuses (check()).
|
|
79
|
+
*/
|
|
80
|
+
decide(msgId, decision, by = 'button') {
|
|
81
|
+
if (!Object.hasOwn(STATES, decision)) throw new CollabError('invalid', `unknown decision ${decision}`);
|
|
82
|
+
const row = this.hub.store.message(msgId);
|
|
83
|
+
const card = row === undefined ? undefined : parseCard(row.card);
|
|
84
|
+
if (card?.kind !== 'permission') throw new CollabError('not-permission', '这条消息不是权限请求');
|
|
85
|
+
if (card.state !== 'pending') throw new CollabError('conflict', card.state === 'expired' ? '这个权限请求已过期' : '这个权限请求已经处理过了');
|
|
86
|
+
const p = this.pending.get(msgId);
|
|
87
|
+
if (p === undefined) {
|
|
88
|
+
// Pending in the store but not in memory: collab restarted, the asker's side is gone.
|
|
89
|
+
this.close(msgId, 'expired', row);
|
|
90
|
+
throw new CollabError('conflict', '这个权限请求已过期');
|
|
91
|
+
}
|
|
92
|
+
p.check?.();
|
|
93
|
+
const applied = p.mapDecision?.(decision) ?? decision;
|
|
94
|
+
this.close(msgId, STATES[applied], row, by);
|
|
95
|
+
p.resolve(applied);
|
|
96
|
+
p.onDecide?.(applied, by);
|
|
97
|
+
return publicMessage(this.hub.store.message(msgId), this.hub.store);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Withdraw card `msgId`: it shows as expired and its decision settles 'expired'. */
|
|
101
|
+
expire(msgId) {
|
|
102
|
+
const p = this.pending.get(msgId);
|
|
103
|
+
this.close(msgId, 'expired');
|
|
104
|
+
p?.resolve('expired');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Expire every pending card whose entry matches pred({ room, author, meta }). */
|
|
108
|
+
expireWhere(pred) {
|
|
109
|
+
for (const [msgId, p] of [...this.pending]) if (pred(p)) this.expire(msgId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Mark a card decided/expired and update it for browsers. */
|
|
113
|
+
close(msgId, state, row = this.hub.store.message(msgId), by) {
|
|
114
|
+
this.pending.delete(msgId);
|
|
115
|
+
const card = row === undefined ? undefined : parseCard(row.card);
|
|
116
|
+
if (card?.kind !== 'permission' || card.state !== 'pending') return;
|
|
117
|
+
const next = { ...card, state, ...(by ? { decided_by: by } : {}), decided_at: Math.floor(Date.now() / 1000) };
|
|
118
|
+
this.hub.edit(row.id, 'role', row.author, row.text, JSON.stringify(next));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Owner text reply → decision on the room's pending cards; returns the ids of
|
|
123
|
+
* the members that must not also get the text as a message (card answered,
|
|
124
|
+
* or withheld from them). Idempotent per message row.
|
|
125
|
+
*
|
|
126
|
+
* cc-connect checks a pending permission before its gate and before any
|
|
127
|
+
* mention rule (bridge core/engine.go handleMessage → handlePendingPermission,
|
|
128
|
+
* ahead of applyGate), and hub bots run with group_reply_all
|
|
129
|
+
* (core/setup_wizard.go), so every bot with a pending prompt that receives an
|
|
130
|
+
* allow/deny word acts on it. Hence: no @ → the decision applies to every
|
|
131
|
+
* pending card in the room; an @ → only the @'d members', and the other
|
|
132
|
+
* pending members are not sent the text so their cards stay open.
|
|
133
|
+
*/
|
|
134
|
+
answerByText(msg) {
|
|
135
|
+
const memo = this.answers.get(msg);
|
|
136
|
+
if (memo !== undefined) return memo;
|
|
137
|
+
const answered = new Set();
|
|
138
|
+
this.answers.set(msg, answered);
|
|
139
|
+
if (msg.author_kind !== 'user') return answered;
|
|
140
|
+
const inRoom = [...this.pending].filter(([, p]) => p.room === msg.room_id);
|
|
141
|
+
if (inRoom.length === 0) return answered;
|
|
142
|
+
const members = this.hub.routable();
|
|
143
|
+
const mentioned = mentions(msg.text, members);
|
|
144
|
+
for (const [msgId, p] of inRoom) {
|
|
145
|
+
const to = this.hub.members.get(p.author) ?? { id: p.author, name: '' };
|
|
146
|
+
const decision = textDecision(forMember(msg.text, to, members));
|
|
147
|
+
if (decision === undefined) continue;
|
|
148
|
+
if (mentioned.size > 0 && !mentioned.has(p.author)) {
|
|
149
|
+
answered.add(p.author); // addressed to someone else: withhold it so this member doesn't approve
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
try { this.decide(msgId, decision, 'text'); } catch { continue; }
|
|
153
|
+
answered.add(p.author);
|
|
154
|
+
}
|
|
155
|
+
return answered;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function parseCard(raw) {
|
|
160
|
+
if (typeof raw !== 'string') return undefined;
|
|
161
|
+
try { return JSON.parse(raw); } catch { return undefined; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Same rune set as bridge core/engine.go permissionTokenSeparator (whitespace plus these).
|
|
165
|
+
const PERM_TOKEN_SEPARATORS = /[\s@@,,.。!!??::;;()()[\]【】"'“”‘’、·]+/u;
|
|
166
|
+
|
|
167
|
+
/** Port of bridge core/engine.go isApproveAllResponse / isAllowResponse / isDenyResponse (checked in that order). */
|
|
168
|
+
export function textDecision(text) {
|
|
169
|
+
const tokens = String(text).toLowerCase().trim().split(PERM_TOKEN_SEPARATORS).filter(Boolean);
|
|
170
|
+
if (tokens.length === 0) return undefined;
|
|
171
|
+
const joined = tokens.join(' ');
|
|
172
|
+
if (APPROVE_ALL_PHRASES.some(ph => joined === ph || ` ${joined} `.includes(` ${ph} `))) return 'allow_all';
|
|
173
|
+
if (tokens.some(t => APPROVE_ALL_TOKENS.includes(t))) return 'allow_all';
|
|
174
|
+
if (tokens.some(t => ALLOW_TOKENS.includes(t))) return 'allow';
|
|
175
|
+
if (tokens.some(t => DENY_TOKENS.includes(t))) return 'deny';
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// #144: files and images the owner attaches to a channel message.
|
|
2
|
+
// Bytes are kept on disk (one file per attachment, named by a random id) and the
|
|
3
|
+
// message row only stores {id, name, mime, size}. Bots receive them through the
|
|
4
|
+
// cloud_web wire format they already understand (bridge/platform/cloud-web/protocol.go
|
|
5
|
+
// wireImage / wireFile: base64 data + mime_type + file_name).
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { CollabError, messageAttachments } from './store.mjs';
|
|
10
|
+
|
|
11
|
+
const ID = /^[0-9a-f-]{36}$/;
|
|
12
|
+
// Raster images are shown in the page and sent to bots as images; anything else
|
|
13
|
+
// (svg, bmp, heic, tiff, html, ...) is served as a download and sent as a file,
|
|
14
|
+
// since model APIs only take png/jpeg/gif/webp images (Anthropic Messages API
|
|
15
|
+
// image block media_type; others reject the request with 400).
|
|
16
|
+
export const INLINE_IMAGE = /^image\/(png|jpeg|gif|webp)$/;
|
|
17
|
+
// Larger images go to bots as files: Anthropic's Messages API rejects an image
|
|
18
|
+
// over 5 MB (docs "Vision" / image size limits).
|
|
19
|
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
export class Attachments {
|
|
22
|
+
constructor(dir) { this.dir = dir; }
|
|
23
|
+
|
|
24
|
+
/** Save [{name, mime, data(base64)}] from a POST body; returns the metadata to store on the message. */
|
|
25
|
+
save(list) {
|
|
26
|
+
if (list == null) return [];
|
|
27
|
+
if (!Array.isArray(list)) throw new CollabError('invalid', 'attachments must be a list');
|
|
28
|
+
if (list.length) mkdirSync(this.dir, { recursive: true });
|
|
29
|
+
return list.map((item) => {
|
|
30
|
+
const data = Buffer.from(String(item?.data ?? ''), 'base64');
|
|
31
|
+
if (data.length === 0) throw new CollabError('invalid', 'attachment is empty');
|
|
32
|
+
const id = randomUUID();
|
|
33
|
+
writeFileSync(join(this.dir, id), data);
|
|
34
|
+
const name = String(item?.name ?? '').replace(/[\\/]/g, '_').slice(0, 200) || id;
|
|
35
|
+
const mime = String(item?.mime ?? '') || 'application/octet-stream';
|
|
36
|
+
return { id, name, mime, size: data.length };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The stored bytes of one attachment, or undefined. */
|
|
41
|
+
read(id) {
|
|
42
|
+
if (!ID.test(String(id))) return undefined;
|
|
43
|
+
try { return readFileSync(join(this.dir, id)); } catch { return undefined; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** cloud_web inbound frame fields (images / files) for a message's attachments. */
|
|
47
|
+
wire(metas) {
|
|
48
|
+
const images = [];
|
|
49
|
+
const files = [];
|
|
50
|
+
for (const a of metas ?? []) {
|
|
51
|
+
const bytes = this.read(a.id);
|
|
52
|
+
if (bytes === undefined) continue;
|
|
53
|
+
const item = { mime_type: a.mime, data: bytes.toString('base64'), file_name: a.name };
|
|
54
|
+
(INLINE_IMAGE.test(a.mime) && bytes.length <= MAX_IMAGE_BYTES ? images : files).push(item);
|
|
55
|
+
}
|
|
56
|
+
const out = {};
|
|
57
|
+
if (images.length) out.images = images;
|
|
58
|
+
if (files.length) out.files = files;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Shared wire admission, projected into the ACP prompt format, each block with its file name. */
|
|
64
|
+
export function namedAttachmentBlocks(files, messages) {
|
|
65
|
+
const wire = files?.wire(messages.flatMap(messageAttachments)) ?? {};
|
|
66
|
+
return [
|
|
67
|
+
...(wire.images ?? []).map(a => ({ name: a.file_name, block: { type: 'image', data: a.data, mimeType: a.mime_type } })),
|
|
68
|
+
...(wire.files ?? []).map(a => ({ name: a.file_name, block: { type: 'resource', resource: {
|
|
69
|
+
uri: `attachment:///${encodeURIComponent(a.file_name)}`, mimeType: a.mime_type, blob: a.data,
|
|
70
|
+
} } })),
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function attachmentBlocks(files, messages) {
|
|
75
|
+
return namedAttachmentBlocks(files, messages).map(b => b.block);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Native DSH requires durable attachment references, not inline ACP blocks.
|
|
80
|
+
* DSH validates the images as one batch (count, total bytes, type, pixels) and its
|
|
81
|
+
* error does not say which image failed, so when the batch is refused each block is
|
|
82
|
+
* re-admitted on top of the ones already accepted: an image DSH refuses on its own or
|
|
83
|
+
* that would push the batch over a limit is dropped, the rest are still delivered.
|
|
84
|
+
* Admission is content-addressed, so re-admitting the same bytes stores nothing new.
|
|
85
|
+
* Returns { parts, dropped: [{ name, reason, code? }] }.
|
|
86
|
+
*/
|
|
87
|
+
export async function nativeAttachmentBlocks(attachments, named) {
|
|
88
|
+
const dropped = [];
|
|
89
|
+
const parts = [];
|
|
90
|
+
const reason = err => String(err?.message ?? err);
|
|
91
|
+
const code = err => (typeof err?.code === 'string' ? err.code : undefined);
|
|
92
|
+
for (const { name, block: b } of named) {
|
|
93
|
+
if (b.type === 'image') { parts.push({ name, part: { type: 'image', mediaType: b.mimeType, data: b.data } }); continue; }
|
|
94
|
+
try {
|
|
95
|
+
parts.push({ name, part: { type: 'file', attachment: await attachments.admitEncodedFile({
|
|
96
|
+
data: b.resource.blob, name: decodeURIComponent(b.resource.uri.slice('attachment:///'.length)),
|
|
97
|
+
}) } });
|
|
98
|
+
} catch (err) { dropped.push({ name, reason: reason(err), code: code(err) }); }
|
|
99
|
+
}
|
|
100
|
+
if (parts.length === 0) return { parts: [], dropped };
|
|
101
|
+
try {
|
|
102
|
+
return { parts: await attachments.admitPromptContent(parts.map(p => p.part)), dropped };
|
|
103
|
+
} catch (batchErr) {
|
|
104
|
+
if (parts.length === 1) return { parts: [], dropped: [...dropped, { name: parts[0].name, reason: reason(batchErr), code: code(batchErr) }] };
|
|
105
|
+
}
|
|
106
|
+
const kept = [];
|
|
107
|
+
let admitted = [];
|
|
108
|
+
for (const p of parts) {
|
|
109
|
+
try {
|
|
110
|
+
admitted = await attachments.admitPromptContent([...kept, p.part]);
|
|
111
|
+
kept.push(p.part);
|
|
112
|
+
} catch (err) { dropped.push({ name: p.name, reason: reason(err), code: code(err) }); }
|
|
113
|
+
}
|
|
114
|
+
return { parts: admitted, dropped };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// DSH's attachment errors (dsh-attachment-local AttachmentError: English message + code).
|
|
118
|
+
// The channel's system notes are Chinese, so the reason shown there is translated by code,
|
|
119
|
+
// falling back to the exact English message, then to the original text.
|
|
120
|
+
const UNDELIVERED_ZH = {
|
|
121
|
+
IMAGE_TOO_MANY_PIXELS: '图片太大,超过像素上限',
|
|
122
|
+
IMAGE_DIMENSION_TOO_LARGE: '图片太长或太宽,超过单边像素上限',
|
|
123
|
+
IMAGE_TOO_LARGE: '图片文件太大,超过大小上限',
|
|
124
|
+
INVALID_IMAGE: '图片格式不支持或已损坏',
|
|
125
|
+
IMAGE_TYPE_MISMATCH: '图片实际格式和扩展名/类型不符',
|
|
126
|
+
ATTACHMENT_NOT_FOUND: '附件文件找不到了',
|
|
127
|
+
ATTACHMENT_CORRUPT: '附件校验失败,可能已损坏',
|
|
128
|
+
ATTACHMENT_READ_FAILED: '附件读取失败',
|
|
129
|
+
ATTACHMENT_WRITE_FAILED: '附件处理失败',
|
|
130
|
+
INVALID_ATTACHMENT_REF: '附件引用无效',
|
|
131
|
+
};
|
|
132
|
+
const UNDELIVERED_MESSAGE_CODE = {
|
|
133
|
+
'Image exceeds the configured decoded-pixel limit.': 'IMAGE_TOO_MANY_PIXELS',
|
|
134
|
+
'Image exceeds the configured per-side pixel limit.': 'IMAGE_DIMENSION_TOO_LARGE',
|
|
135
|
+
'Image exceeds the configured byte limit.': 'IMAGE_TOO_LARGE',
|
|
136
|
+
'Unsupported or malformed image data.': 'INVALID_IMAGE',
|
|
137
|
+
'Image is empty.': 'INVALID_IMAGE',
|
|
138
|
+
'Declared image type does not match its bytes.': 'IMAGE_TYPE_MISMATCH',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Chinese reason for a dropped attachment ({ reason, code? } from nativeAttachmentBlocks). */
|
|
142
|
+
export function undeliveredReasonZh({ reason, code }) {
|
|
143
|
+
return UNDELIVERED_ZH[code] ?? UNDELIVERED_ZH[UNDELIVERED_MESSAGE_CODE[String(reason).trim()]] ?? reason;
|
|
144
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { CollabError } from './store.mjs';
|
|
5
|
+
|
|
6
|
+
export const UPLOAD_FILE = /^[a-z0-9-]+\.png$/;
|
|
7
|
+
export const MAX_AVATAR_BYTES = 256 * 1024;
|
|
8
|
+
const SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');
|
|
9
|
+
|
|
10
|
+
/** Local portraits; only validated filenames ever reach the filesystem. */
|
|
11
|
+
export class AvatarUploads {
|
|
12
|
+
constructor(dir) { this.dir = dir; }
|
|
13
|
+
|
|
14
|
+
filename(id) {
|
|
15
|
+
const file = `${id}.png`;
|
|
16
|
+
if (!UPLOAD_FILE.test(file)) throw new CollabError('invalid', '头像文件名无效');
|
|
17
|
+
return file;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
save(id, png) {
|
|
21
|
+
const file = this.filename(id);
|
|
22
|
+
if (typeof png !== 'string' || !png || (png.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(png))) {
|
|
23
|
+
throw new CollabError('invalid', '头像必须是有效的 PNG base64 数据(不含 data: 前缀)');
|
|
24
|
+
}
|
|
25
|
+
const bytes = Buffer.from(png, 'base64');
|
|
26
|
+
if (bytes.length > MAX_AVATAR_BYTES) throw new CollabError('invalid', '头像不能超过 256KB');
|
|
27
|
+
if (bytes.length < 33 || !bytes.subarray(0, 8).equals(SIGNATURE) || bytes.readUInt32BE(8) !== 13 || bytes.toString('ascii', 12, 16) !== 'IHDR') {
|
|
28
|
+
throw new CollabError('invalid', '头像必须是 PNG 图片');
|
|
29
|
+
}
|
|
30
|
+
if (bytes.readUInt32BE(16) !== 256 || bytes.readUInt32BE(20) !== 256) throw new CollabError('invalid', '头像尺寸必须是 256×256 像素');
|
|
31
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
const temporary = join(this.dir, `${file}.${randomUUID()}.tmp`);
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
|
35
|
+
renameSync(temporary, join(this.dir, file));
|
|
36
|
+
} finally {
|
|
37
|
+
try { unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
38
|
+
}
|
|
39
|
+
return `upload:${file}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
read(file) {
|
|
43
|
+
if (!UPLOAD_FILE.test(file)) return undefined;
|
|
44
|
+
try { return readFileSync(join(this.dir, file)); } catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
remove(id) {
|
|
48
|
+
// Older member ids may contain underscores; those members never had an upload.
|
|
49
|
+
const file = `${id}.png`;
|
|
50
|
+
if (!UPLOAD_FILE.test(file)) return;
|
|
51
|
+
try { unlinkSync(join(this.dir, file)); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
52
|
+
}
|
|
53
|
+
}
|
package/collab/avatars.mjs
CHANGED
|
@@ -10,7 +10,7 @@ export const MAX_AVATAR_URL = 2048;
|
|
|
10
10
|
/** Normalize a member avatar: '' (initial badge), a built-in slug, or — when allowUrl — an http(s) URL. Throws on anything else. */
|
|
11
11
|
export function checkAvatar(value, { allowUrl = false } = {}) {
|
|
12
12
|
const v = String(value ?? '').trim();
|
|
13
|
-
if (v === '' || AVATARS.includes(v)) return v;
|
|
13
|
+
if (v === '' || AVATARS.includes(v) || /^upload:[a-z0-9-]+\.png$/.test(v)) return v;
|
|
14
14
|
if (allowUrl && v.length <= MAX_AVATAR_URL && /^https?:\/\/[^\s"'<>]+$/.test(v)) return v;
|
|
15
15
|
throw new Error(`avatar must be one of ${AVATARS.join(', ')}${allowUrl ? ' or an http(s) URL' : ''}`);
|
|
16
16
|
}
|
package/collab/cli-member.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// CLI 成员:把本机 claude / codex / gemini 通过 ACP 适配器接成群成员。
|
|
2
2
|
// 每个(成员, 房间)一个 ACP session;ask(text) 返回该轮 agent_message_chunk 拼接文本。
|
|
3
3
|
import { spawn } from 'node:child_process'
|
|
4
|
+
import { existsSync } from 'node:fs'
|
|
4
5
|
import { Readable, Writable } from 'node:stream'
|
|
5
6
|
import { createRequire } from 'node:module'
|
|
6
7
|
import path from 'node:path'
|
|
@@ -15,6 +16,26 @@ const ADAPTERS = {
|
|
|
15
16
|
gemini: { cmd: 'gemini', args: ['--acp'] },
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
// DSH 原生权限档位:与 DSH 预设同名(@deepseek-ai/dsh-base cordis.patch.yml:264-276 预设表;
|
|
20
|
+
// UI 名见 @deepseek-ai/dsh-client-ui-permission-presets README.zh.md:32)。默认档 = DSH 默认
|
|
21
|
+
// workspace-write(dsh-base cordis.patch.yml:243-262:DSH_PERMISSION_MODE ?? 'workspace-write')。
|
|
22
|
+
// collab 运行时拿不到 DSH 包里的常量(DSH 由宿主提供),故照抄。
|
|
23
|
+
export const PERMISSION_PRESETS = ['read-only', 'workspace-write', 'danger-full-access']
|
|
24
|
+
export const DEFAULT_PERMISSION_PRESET = 'workspace-write'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 按档位决定一条 ACP 权限请求:返回 { optionId }(直接答)或 'ask'(发审批卡)。
|
|
28
|
+
* danger-full-access = DSH approval never:不弹窗直接放行;workspace-write = approval ask:问用户;
|
|
29
|
+
* read-only:只读档下 CLI 的工具调用(ACP 只在要动手时才来问)一律拒。
|
|
30
|
+
*/
|
|
31
|
+
export function presetDecision(preset, options) {
|
|
32
|
+
const p = PERMISSION_PRESETS.includes(preset) ? preset : DEFAULT_PERMISSION_PRESET
|
|
33
|
+
if (p === 'workspace-write') return 'ask'
|
|
34
|
+
const want = p === 'danger-full-access' ? ['allow_once', 'allow_always'] : ['reject_once', 'reject_always']
|
|
35
|
+
const o = want.map((k) => options.find((x) => x.kind === k)).find(Boolean)
|
|
36
|
+
return o ? { optionId: o.optionId } : null
|
|
37
|
+
}
|
|
38
|
+
|
|
18
39
|
function resolveBin(pkg) {
|
|
19
40
|
try {
|
|
20
41
|
const pj = require.resolve(`${pkg}/package.json`)
|
|
@@ -25,8 +46,11 @@ function resolveBin(pkg) {
|
|
|
25
46
|
return { cmd: 'npx', args: ['-y', pkg] }
|
|
26
47
|
}
|
|
27
48
|
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
// 去掉外层 Claude Code 会话的标记(CLAUDECODE、CLAUDE_CODE_ENTRYPOINT…),成员的 claude 才不会当成嵌套会话;
|
|
50
|
+
// 登录用的 CLAUDE_CODE_OAUTH_TOKEN 留着(ANTHROPIC_API_KEY 本来就不在过滤范围),只靠环境变量登录的用户才登得上。
|
|
51
|
+
const KEEP_ENV = new Set(['CLAUDE_CODE_OAUTH_TOKEN'])
|
|
52
|
+
export function cleanEnv(env = process.env) {
|
|
53
|
+
return Object.fromEntries(Object.entries(env).filter(([k]) => KEEP_ENV.has(k) || (!k.startsWith('CLAUDECODE') && !k.startsWith('CLAUDE_CODE_'))))
|
|
30
54
|
}
|
|
31
55
|
|
|
32
56
|
export class CliMember {
|
|
@@ -36,13 +60,42 @@ export class CliMember {
|
|
|
36
60
|
this.cwd = cwd || process.cwd()
|
|
37
61
|
this.persona = persona
|
|
38
62
|
this.extraEnv = env || {}
|
|
39
|
-
this.sessions = new Map() // room -> { sessionId
|
|
63
|
+
this.sessions = new Map() // room -> { sessionId }(当前 ACP 进程里活着的会话)
|
|
64
|
+
this.saved = new Map() // room -> sessionId:进程退出后留着,下次用 loadSession 续上
|
|
40
65
|
this.buffers = new Map() // sessionId -> string
|
|
66
|
+
this.toolSince = new Set() // sessionId:上一段话之后有过工具调用
|
|
41
67
|
this.onText = new Map() // sessionId -> (textSoFar) => void(#121 流式)
|
|
68
|
+
this.onTool = new Map() // sessionId -> (update) => void:tool_call / tool_call_update / agent_thought_chunk,给进度卡
|
|
69
|
+
this.onPermission = new Map() // sessionId -> async (request) => ACP outcome:本轮的审批处理器
|
|
42
70
|
this.queue = new Map() // room -> Promise(同房间串行)
|
|
43
71
|
this.ready = null
|
|
44
72
|
}
|
|
45
73
|
|
|
74
|
+
/** ACP Client 回调(单独拆出来便于测试)。 */
|
|
75
|
+
_client() {
|
|
76
|
+
return {
|
|
77
|
+
requestPermission: async (r) => {
|
|
78
|
+
// 没有处理器(不在某一轮里)就没有地方问:按 DSH 无应答者的处理当取消(dsh-acp lib/index.js:1202-1228)。
|
|
79
|
+
const h = this.onPermission.get(r.sessionId)
|
|
80
|
+
return { outcome: h ? await h(r) : { outcome: 'cancelled' } }
|
|
81
|
+
},
|
|
82
|
+
sessionUpdate: async (n) => {
|
|
83
|
+
const u = n.update
|
|
84
|
+
if (u?.sessionUpdate === 'agent_message_chunk' && u.content?.type === 'text' && this.buffers.has(n.sessionId)) {
|
|
85
|
+
// 工具调用隔开的两段话是两条消息:另起一段,[report]/[ask] 这类首行标记才落在行首(team-exit.mjs exitMark)。
|
|
86
|
+
const prev = this.buffers.get(n.sessionId)
|
|
87
|
+
const gap = this.toolSince.has(n.sessionId) && prev.trim() && !/\n\s*$/.test(prev) ? '\n\n' : ''
|
|
88
|
+
this.toolSince.delete(n.sessionId)
|
|
89
|
+
const full = prev + gap + u.content.text
|
|
90
|
+
this.buffers.set(n.sessionId, full)
|
|
91
|
+
this.onText.get(n.sessionId)?.(full)
|
|
92
|
+
}
|
|
93
|
+
if (u?.sessionUpdate === 'tool_call' && this.buffers.has(n.sessionId)) this.toolSince.add(n.sessionId)
|
|
94
|
+
if ((u?.sessionUpdate === 'tool_call' || u?.sessionUpdate === 'tool_call_update' || u?.sessionUpdate === 'agent_thought_chunk') && this.buffers.has(n.sessionId)) this.onTool.get(n.sessionId)?.(u)
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
46
99
|
_start() {
|
|
47
100
|
if (this.ready) return this.ready
|
|
48
101
|
const a = ADAPTERS[this.cli]
|
|
@@ -53,74 +106,106 @@ export class CliMember {
|
|
|
53
106
|
env: { ...cleanEnv(), ...this.extraEnv },
|
|
54
107
|
})
|
|
55
108
|
this.proc = proc
|
|
56
|
-
const client =
|
|
57
|
-
async requestPermission(r) {
|
|
58
|
-
const o = r.options.find((x) => x.kind?.startsWith('allow')) || r.options[0]
|
|
59
|
-
return { outcome: { outcome: 'selected', optionId: o.optionId } }
|
|
60
|
-
},
|
|
61
|
-
sessionUpdate: async (n) => {
|
|
62
|
-
const u = n.update
|
|
63
|
-
if (u?.sessionUpdate === 'agent_message_chunk' && u.content?.type === 'text' && this.buffers.has(n.sessionId)) {
|
|
64
|
-
const full = this.buffers.get(n.sessionId) + u.content.text
|
|
65
|
-
this.buffers.set(n.sessionId, full)
|
|
66
|
-
this.onText.get(n.sessionId)?.(full)
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
}
|
|
109
|
+
const client = this._client()
|
|
70
110
|
const stream = acp.ndJsonStream(Writable.toWeb(proc.stdin), Readable.toWeb(proc.stdout))
|
|
71
111
|
this.conn = new acp.ClientSideConnection(() => client, stream)
|
|
72
112
|
const exited = new Promise((_, rej) => {
|
|
73
|
-
proc.once('error',
|
|
74
|
-
|
|
113
|
+
proc.once('error', cause => {
|
|
114
|
+
this.ready = null
|
|
115
|
+
this.exited = null
|
|
116
|
+
if (existsSync(this.cwd)) return rej(cause)
|
|
117
|
+
const error = new Error(`CLI ${this.cli} 在目录 ${this.cwd} 无法启动:${cause.message}`, { cause })
|
|
118
|
+
error.code = 'COLLAB_CLI_START'
|
|
119
|
+
error.cwd = this.cwd
|
|
120
|
+
error.cli = this.cli
|
|
121
|
+
rej(error)
|
|
122
|
+
})
|
|
123
|
+
proc.once('exit', (code) => {
|
|
124
|
+
this.ready = null
|
|
125
|
+
// 会话 id 留到 saved,新进程起来后用 loadSession 续上(不支持时由调用方提示频道)。
|
|
126
|
+
for (const [room, s] of this.sessions) this.saved.set(room, s.sessionId)
|
|
127
|
+
this.sessions.clear()
|
|
128
|
+
rej(new Error(`${this.cli} acp exited: ${code}`))
|
|
129
|
+
})
|
|
75
130
|
})
|
|
76
131
|
exited.catch(() => {})
|
|
77
132
|
this.exited = exited
|
|
78
133
|
this.ready = Promise.race([
|
|
79
134
|
this.conn.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} }),
|
|
80
135
|
exited,
|
|
81
|
-
])
|
|
136
|
+
]).then((init) => { this.canLoad = init?.agentCapabilities?.loadSession === true; return init })
|
|
82
137
|
return this.ready
|
|
83
138
|
}
|
|
84
139
|
|
|
85
|
-
|
|
140
|
+
/**
|
|
141
|
+
* 房间的 ACP 会话。resumeId 是上次存下的 sessionId(bindings 里的):CLI 声明了
|
|
142
|
+
* agentCapabilities.loadSession 就用 session/load 续上;不支持或续失败就开新会话,
|
|
143
|
+
* 返回 lost: true,由调用方在频道里说明上下文没了。
|
|
144
|
+
*/
|
|
145
|
+
async _session(room, resumeId) {
|
|
86
146
|
let s = this.sessions.get(room)
|
|
87
|
-
if (s) return s
|
|
147
|
+
if (s) return { ...s, resumed: false, lost: false }
|
|
88
148
|
await this._start()
|
|
149
|
+
const prevId = resumeId || this.saved.get(room)
|
|
150
|
+
this.saved.delete(room)
|
|
151
|
+
if (prevId && this.canLoad) {
|
|
152
|
+
try {
|
|
153
|
+
await Promise.race([this.conn.loadSession({ sessionId: prevId, cwd: this.cwd, mcpServers: [] }), this.exited])
|
|
154
|
+
s = { sessionId: prevId }
|
|
155
|
+
this.sessions.set(room, s)
|
|
156
|
+
return { ...s, resumed: true, lost: false }
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (!this.ready) throw err // 进程在 load 时退出:照常报错
|
|
159
|
+
}
|
|
160
|
+
}
|
|
89
161
|
const r = await Promise.race([this.conn.newSession({ cwd: this.cwd, mcpServers: [] }), this.exited])
|
|
90
|
-
s = { sessionId: r.sessionId
|
|
162
|
+
s = { sessionId: r.sessionId }
|
|
91
163
|
this.sessions.set(room, s)
|
|
92
|
-
return s
|
|
164
|
+
return { ...s, resumed: false, lost: Boolean(prevId) }
|
|
93
165
|
}
|
|
94
166
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
167
|
+
/**
|
|
168
|
+
* onText(textSoFar) 在每个公开文字分片到达时调用(#121);onTool(update) 在每个工具调用开始/更新和每个思考分片到达时调用。
|
|
169
|
+
* opts.resumeId:要续上的 sessionId;opts.onSession({ sessionId, resumed, lost }):会话就绪后、发提示词前调用。
|
|
170
|
+
* opts.attachments:随提示词发给 CLI 的 ACP 内容块(图片/音频/资源)。
|
|
171
|
+
* opts.onPermission(request):本轮的 ACP session/request_permission 处理器,返回 ACP outcome。
|
|
172
|
+
*/
|
|
173
|
+
ask(text, room = 'default', onText, onTool, opts = {}) {
|
|
97
174
|
const prev = this.queue.get(room) || Promise.resolve()
|
|
98
|
-
const run = prev.catch(() => {}).then(() => this._ask(text, room, onText))
|
|
175
|
+
const run = prev.catch(() => {}).then(() => this._ask(text, room, onText, onTool, opts))
|
|
99
176
|
this.queue.set(room, run)
|
|
100
177
|
return run
|
|
101
178
|
}
|
|
102
179
|
|
|
103
|
-
async _ask(text, room, onText) {
|
|
104
|
-
const s = await this._session(room)
|
|
105
|
-
|
|
106
|
-
|
|
180
|
+
async _ask(text, room, onText, onTool, { resumeId, onSession, attachments = [], onPermission } = {}) {
|
|
181
|
+
const s = await this._session(room, resumeId)
|
|
182
|
+
await onSession?.(s)
|
|
183
|
+
// persona 每轮都拼:续上的或新开的会话都不会丢人设。
|
|
184
|
+
const body = this.persona ? `${this.persona}\n\n${text}` : text
|
|
107
185
|
this.buffers.set(s.sessionId, '')
|
|
186
|
+
this.toolSince.delete(s.sessionId)
|
|
108
187
|
if (onText) this.onText.set(s.sessionId, onText)
|
|
188
|
+
if (onTool) this.onTool.set(s.sessionId, onTool)
|
|
189
|
+
if (onPermission) this.onPermission.set(s.sessionId, onPermission)
|
|
109
190
|
try {
|
|
110
191
|
await Promise.race([
|
|
111
|
-
this.conn.prompt({ sessionId: s.sessionId, prompt: [{ type: 'text', text: body }] }),
|
|
192
|
+
this.conn.prompt({ sessionId: s.sessionId, prompt: [{ type: 'text', text: body }, ...attachments] }),
|
|
112
193
|
this.exited,
|
|
113
194
|
])
|
|
114
195
|
return this.buffers.get(s.sessionId)
|
|
115
196
|
} finally {
|
|
116
197
|
this.buffers.delete(s.sessionId)
|
|
198
|
+
this.toolSince.delete(s.sessionId)
|
|
117
199
|
this.onText.delete(s.sessionId)
|
|
200
|
+
this.onTool.delete(s.sessionId)
|
|
201
|
+
this.onPermission.delete(s.sessionId)
|
|
118
202
|
}
|
|
119
203
|
}
|
|
120
204
|
|
|
121
205
|
close() {
|
|
122
206
|
this.proc?.kill()
|
|
123
207
|
this.sessions.clear()
|
|
208
|
+
this.saved.clear()
|
|
124
209
|
this.ready = null
|
|
125
210
|
}
|
|
126
211
|
}
|