dsh-a2a-groups 0.0.0-stage → 0.1.2

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.
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Invite links: the one artifact a human copies out of a Session into a chat.
3
+ *
4
+ * Why this is a separate module: the link format is a *wire* format shared with
5
+ * three other parties — the left-hand group panel, the prompt template a model
6
+ * pastes verbatim, and the invitee's `a2a_group_join`. Keeping the encoder and
7
+ * the decoder in one file is what makes "what `invite()` emits" and "what
8
+ * `join()` accepts" impossible to drift apart. A drift here is not a bug that
9
+ * shows up loudly; it is an invite that silently fails to parse on the
10
+ * invitee's machine, days later, in somebody else's chat window.
11
+ *
12
+ * Boundary: this module only *formats*. It never hashes, never stores, and
13
+ * never decides whether a token is still valid — the token is opaque random
14
+ * text minted by groups.js, and only the group host knows its hash, expiry and
15
+ * use count. Parsing is deliberately forgiving about the prose *around* a link
16
+ * and strict about the fields *inside* it, because the two failure modes
17
+ * differ: a link embedded in a chat sentence must still work, while a link
18
+ * missing its group id must fail loudly instead of producing a half-valid join
19
+ * attempt that costs a round trip to reject.
20
+ *
21
+ * @module dsh-a2a/invite
22
+ */
23
+
24
+ import { randomBytes } from 'node:crypto';
25
+
26
+ /** Custom scheme. Fixed: the UI and the prompt template match on it literally. */
27
+ export const LINK_SCHEME = 'agent-a2a';
28
+
29
+ /** The only host this scheme defines. Anything else is a different link type. */
30
+ export const LINK_HOST = 'group';
31
+
32
+ /** Link schema version. A future incompatible format bumps this instead of lying. */
33
+ export const LINK_VERSION = '1';
34
+
35
+ /** Prefix of every invite link, without the query. */
36
+ export const LINK_PREFIX = `${LINK_SCHEME}://${LINK_HOST}`;
37
+
38
+ /**
39
+ * How much of a link a query value may be.
40
+ *
41
+ * A link lives in one IM message, so it has to stay short enough that a chat
42
+ * client will not wrap or truncate it. `gw` + `agent` + group id + a 32-char
43
+ * token fits in ~200 characters; this cap only exists so a hostile paste cannot
44
+ * make us parse a megabyte of "link".
45
+ */
46
+ export const MAX_LINK_CHARS = 2048;
47
+
48
+ /**
49
+ * Characters that end a link when it is embedded in prose.
50
+ *
51
+ * Chinese punctuation has to be listed explicitly: `/` `\S+/` looks correct and
52
+ * then swallows `,群密码:abc` when a user pastes the prompt line without the
53
+ * space that our own template happens to put there.
54
+ */
55
+ const LINK_IN_TEXT = /agent-a2a:\/\/group\?[^\s"'<>`,。、;:()【】「」!?]+/gu;
56
+
57
+ /** Unambiguous alphabet: no O/0, no I/l/1 — these get retyped from paper and IM. */
58
+ const PASSWORD_ALPHABET = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
59
+
60
+ /** An invite-link problem a human can act on. `code` is stable for the RPC layer. */
61
+ export class InviteLinkError extends Error {
62
+ /**
63
+ * @param {string} code stable machine code (`bad_link`, `incomplete_link`, …)
64
+ * @param {string} message human-readable Chinese text, safe to show in a tool result
65
+ * @param {object} [details] structured extras; never contains a token or password
66
+ */
67
+ constructor(code, message, details) {
68
+ super(message);
69
+ this.name = 'InviteLinkError';
70
+ this.code = code;
71
+ this.details = details ?? {};
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Escape one query value, keeping the characters a human needs to recognise a URL.
77
+ *
78
+ * `URLSearchParams` is correct but unreadable: it escapes `:` and `/`, so a gateway address turns
79
+ * into `http%3A%2F%2F...`. Those three characters are legal unescaped inside a query value
80
+ * (RFC 3986), and `URLSearchParams.get` decodes both forms to the same string, so leaving them
81
+ * literal costs nothing and buys a link that looks like an address instead of like noise.
82
+ *
83
+ * Everything else is escaped exactly as before, including non-ASCII: Chinese cannot appear raw in
84
+ * a URI, and `&` `=` `#` `?` would change where the value ends.
85
+ *
86
+ * @param {string} value - the raw value.
87
+ * @returns {string} the escaped value.
88
+ */
89
+ function encodeValue(value) {
90
+ return encodeURIComponent(value)
91
+ .replace(/%3A/giu, ':')
92
+ .replace(/%2F/giu, '/')
93
+ .replace(/%40/giu, '@');
94
+ }
95
+
96
+ /**
97
+ * Build the query string in the fixed field order the prompt relies on.
98
+ *
99
+ * @param {Array<[string, string|undefined]>} pairs - field/value pairs, in emit order.
100
+ * @returns {string} the query string, without a leading `?`.
101
+ */
102
+ function buildQuery(pairs) {
103
+ const parts = [];
104
+ for (const [key, value] of pairs) {
105
+ // A field is omitted entirely rather than emitted empty: the parser treats a present-but-empty
106
+ // value differently from an absent one (an empty `n` is a group with no name, an absent one
107
+ // means "not carried").
108
+ if (value === undefined) continue;
109
+ parts.push(`${encodeURIComponent(key)}=${encodeValue(value)}`);
110
+ }
111
+ return parts.join('&');
112
+ }
113
+
114
+ /**
115
+ * Build the canonical invite link.
116
+ *
117
+ * Field order is fixed (`v,gw,agent,g,t,n,e`) because the prompt and the UI both
118
+ * show this string to humans, and two spellings of the same invite invite
119
+ * "which one is right?" questions in a review.
120
+ *
121
+ * @param {object} input
122
+ * @param {string} input.groupId group id, e.g. `g-7f3a1c`
123
+ * @param {string} input.token one-time invite token (plaintext, this is the only channel it travels on)
124
+ * @param {string} input.gatewayBase gateway base that routes to the group agent
125
+ * @param {string} input.agent agent name inside that gateway
126
+ * @param {string} [input.name] group display name
127
+ * @param {number|string|Date} [input.expiresAt] epoch ms (or ISO) the invite stops working
128
+ * @returns {string} the link, ready to paste
129
+ */
130
+ export function buildLink(input) {
131
+ const groupId = requireText(input?.groupId, 'groupId');
132
+ const token = requireText(input?.token, 'token');
133
+ const gatewayBase = stripTrailingSlash(requireText(input?.gatewayBase, 'gatewayBase'));
134
+ const agent = requireText(input?.agent, 'agent');
135
+ const name = optionalText(input?.name);
136
+ const expiresAt = input?.expiresAt === undefined ? undefined : toIso(input.expiresAt);
137
+
138
+ // Field order is fixed (`v,gw,agent,g,t,n,e`) and documented: the prompt and the tests both
139
+ // rely on it, and a stable order makes two links for the same group diffable.
140
+ const query = buildQuery([
141
+ ['v', LINK_VERSION],
142
+ ['gw', gatewayBase],
143
+ ['agent', agent],
144
+ ['g', groupId],
145
+ ['t', token],
146
+ ['n', name],
147
+ ['e', expiresAt],
148
+ ]);
149
+ return `${LINK_PREFIX}?${query}`;
150
+ }
151
+
152
+ /**
153
+ * Parse a link back into its fields, for `a2a_group_join`.
154
+ *
155
+ * Accepts either a bare link or the whole prompt/message the link arrived in:
156
+ * a user pastes "用 a2a_group_join 加入这个群:<link> ,群密码:xxx" at least as
157
+ * often as they paste the link alone, and rejecting that would be a UX bug
158
+ * masquerading as validation.
159
+ *
160
+ * @param {unknown} input link or free text containing one
161
+ * @param {object} [options]
162
+ * @param {number} [options.now] epoch ms, only used to report `expired`; parsing never refuses an expired link
163
+ * @returns {{version:number, gatewayBase:string|undefined, agent:string|undefined, url:string|undefined,
164
+ * groupId:string, token:string, name:string|undefined, expiresAt:number|undefined,
165
+ * expiresAtIso:string|undefined, expired:boolean, raw:string}}
166
+ * @throws {InviteLinkError} `bad_link` / `unsupported_version` / `incomplete_link` / `bad_expiry`
167
+ */
168
+ export function parseLink(input, options = {}) {
169
+ const raw = extractLink(input);
170
+ if (raw.length > MAX_LINK_CHARS) {
171
+ throw new InviteLinkError('bad_link', `邀请链接过长(超过 ${MAX_LINK_CHARS} 字符),可能粘贴错了。`);
172
+ }
173
+
174
+ let url;
175
+ try {
176
+ url = new URL(raw);
177
+ } catch {
178
+ throw new InviteLinkError('bad_link', '这不是有效的邀请链接:格式无法解析。请确认完整粘贴了 agent-a2a://group?... 开头的整行。');
179
+ }
180
+ // `URL` lowercases the protocol/host, so a hand-typed AGENT-A2A://Group still works.
181
+ if (url.protocol !== `${LINK_SCHEME}:` || url.host !== LINK_HOST) {
182
+ throw new InviteLinkError('bad_link', `这不是群邀请链接:期望 ${LINK_PREFIX}?… ,实际是 ${url.protocol}//${url.host}。`);
183
+ }
184
+
185
+ const version = url.searchParams.get('v') ?? '';
186
+ if (version !== LINK_VERSION) {
187
+ throw new InviteLinkError(
188
+ 'unsupported_version',
189
+ version === ''
190
+ ? '邀请链接缺少版本号 v=1,可能是旧版或手工拼的链接。'
191
+ : `不支持的邀请链接版本 v=${version},本插件只支持 v=${LINK_VERSION}。`,
192
+ { version },
193
+ );
194
+ }
195
+
196
+ const groupId = optionalText(url.searchParams.get('g'));
197
+ const token = optionalText(url.searchParams.get('t'));
198
+ if (groupId === undefined || token === undefined) {
199
+ throw new InviteLinkError(
200
+ 'incomplete_link',
201
+ `邀请链接缺少${groupId === undefined ? '群 id(g=)' : ''}${groupId === undefined && token === undefined ? '和' : ''}${
202
+ token === undefined ? '邀请令牌(t=)' : ''
203
+ },请让群主重新生成链接。`,
204
+ { hasGroupId: groupId !== undefined, hasToken: token !== undefined },
205
+ );
206
+ }
207
+
208
+ const gatewayBase = optionalText(url.searchParams.get('gw'));
209
+ const agent = optionalText(url.searchParams.get('agent'));
210
+ // The contract's form is gw+agent (a gateway routes to the group agent). `url`
211
+ // is the local-development alternative from PLAN §5.2; we accept it on input
212
+ // so a direct-connect invite is not rejected, but we never emit it.
213
+ const direct = optionalText(url.searchParams.get('url'));
214
+ if (direct === undefined && (gatewayBase === undefined || agent === undefined)) {
215
+ throw new InviteLinkError(
216
+ 'incomplete_link',
217
+ '邀请链接缺少网关信息(gw= 与 agent=),无法定位群所在的 agent。请让群主检查网关配置后重新生成。',
218
+ { hasGateway: gatewayBase !== undefined, hasAgent: agent !== undefined },
219
+ );
220
+ }
221
+
222
+ const name = optionalText(url.searchParams.get('n'));
223
+ const expiresAtIso = optionalText(url.searchParams.get('e'));
224
+ let expiresAt;
225
+ if (expiresAtIso !== undefined) {
226
+ expiresAt = Date.parse(expiresAtIso);
227
+ if (!Number.isFinite(expiresAt)) {
228
+ throw new InviteLinkError('bad_expiry', `邀请链接的过期时间(e=${expiresAtIso})不是合法时间,请让群主重新生成。`, {
229
+ expiresAtIso,
230
+ });
231
+ }
232
+ }
233
+
234
+ const now = typeof options.now === 'number' ? options.now : Date.now();
235
+ return {
236
+ version: Number(version),
237
+ gatewayBase,
238
+ agent,
239
+ url: direct,
240
+ // The one field a caller should use to actually reach the group: a direct
241
+ // address when the link carries one, otherwise the gateway route built from
242
+ // `gw` + `agent`. Without it every consumer had to know which of the two
243
+ // spellings this particular link used and join them itself — and a joiner who
244
+ // gets that wrong has to be told the gateway address by a human, which is
245
+ // exactly the manual step the invite link exists to remove.
246
+ endpoint: direct ?? (gatewayBase === undefined || agent === undefined
247
+ ? undefined
248
+ : `${gatewayBase.replace(/\/+$/, '')}/${agent}`),
249
+ groupId,
250
+ token,
251
+ name,
252
+ expiresAt,
253
+ expiresAtIso: expiresAt === undefined ? undefined : new Date(expiresAt).toISOString(),
254
+ expired: expiresAt === undefined ? false : expiresAt <= now,
255
+ raw,
256
+ };
257
+ }
258
+
259
+ /**
260
+ * The Chinese prompt an owner pastes into IM. Text is copied verbatim from
261
+ * CONTRACT §2.6: models and humans both key off these exact lines, and the
262
+ * `/a2a-join` command form is parsed by the invitee's own Session, so a
263
+ * "nicer" rewording would break the paste-and-send path.
264
+ *
265
+ * Interpolation is plain replacement, not a template engine: a group named
266
+ * `${x}` must come out as `${x}`.
267
+ *
268
+ * @param {object} input
269
+ * @param {string} input.name group display name
270
+ * @param {string} input.link invite link
271
+ * @param {number|string|Date} [input.expiresAt] epoch ms or ISO; omitted means the
272
+ * link never expires, and the text says so instead of printing a fake date
273
+ * @returns {string} ready-to-paste text
274
+ */
275
+ export function buildPrompt(input) {
276
+ const name = requireText(input?.name, 'name');
277
+ const link = requireText(input?.link, 'link');
278
+ const expiresAt = input?.expiresAt === undefined ? undefined : toIso(input.expiresAt);
279
+
280
+ return [
281
+ `我在 DSH 里建了个协作群「${name}」,邀请你加入。`,
282
+ '',
283
+ '把下面这一行贴到你的 DSH 会话里直接发送即可:',
284
+ `/a2a-join ${link}`,
285
+ '',
286
+ '也可以直接用自然语言让我加群:',
287
+ ` 用 a2a_group_join 加入这个群:${link}`,
288
+ '',
289
+ '入群后:群里有人发言或派活时会自动出现在你会话里(左侧「收到任务消息」卡片),',
290
+ '直接回复就是在群里回话。想静音用 a2a_group_inbox off,想退出用 a2a_group_leave。',
291
+ expiresAt === undefined
292
+ ? '(此邀请长期有效;群主可以随时撤销这条链接。)'
293
+ : `(此邀请在 ${expiresAt} 前有效;群主可以随时撤销。链接谁拿到谁能用,别转发到无关的地方。)`,
294
+ ].join('\n');
295
+ }
296
+
297
+ /**
298
+ * A high-entropy opaque secret, URL-safe.
299
+ *
300
+ * 24 random bytes = 192 bits. Invite tokens are the thing that leaks (they get
301
+ * forwarded in chat), so they are sized so that guessing is not a thing that
302
+ * happens — the only real defence needed is per-invite expiry/use-count, which
303
+ * is what groups.js enforces. base64url keeps it paste-safe inside a query
304
+ * string with no encoding.
305
+ *
306
+ * @param {number} [bytes] entropy in bytes
307
+ * @returns {string} base64url text
308
+ */
309
+ export function randomToken(bytes = 24) {
310
+ return randomBytes(bytes).toString('base64url');
311
+ }
312
+
313
+ /**
314
+ * A generated group password a human has to retype.
315
+ *
316
+ * Rejection sampling, not `% alphabet.length`: modulo bias on a 57-character
317
+ * alphabet is small but real, and "we generate weak passwords on purpose" is not
318
+ * a sentence to have to write in a review. The alphabet drops visually
319
+ * confusable glyphs (O/0, I/l/1) because this string travels by screen-share
320
+ * and phone.
321
+ *
322
+ * @param {number} [length] characters to generate
323
+ * @returns {string} the password
324
+ */
325
+ export function randomPassword(length = 12) {
326
+ const requested = Number(length);
327
+ const size = Number.isFinite(requested) && requested > 0 ? Math.max(4, Math.trunc(requested)) : 12;
328
+ const limit = 256 - (256 % PASSWORD_ALPHABET.length);
329
+ const out = [];
330
+ while (out.length < size) {
331
+ for (const byte of randomBytes(size * 2)) {
332
+ if (byte >= limit) continue; // biased tail of the byte range, discard
333
+ out.push(PASSWORD_ALPHABET[byte % PASSWORD_ALPHABET.length]);
334
+ if (out.length === size) break;
335
+ }
336
+ }
337
+ return out.join('');
338
+ }
339
+
340
+ /** Find the first invite-looking substring in a string, or fail readably. */
341
+ function extractLink(input) {
342
+ if (typeof input !== 'string') {
343
+ throw new InviteLinkError('bad_link', `邀请链接必须是字符串,收到的是 ${describeType(input)}。`);
344
+ }
345
+ const text = input.trim();
346
+ if (text === '') {
347
+ throw new InviteLinkError('bad_link', '邀请链接是空的,请完整复制群主发来的那一行。');
348
+ }
349
+ if (text.length > 64 * 1024) {
350
+ throw new InviteLinkError('bad_link', '粘贴的内容太长(超过 64KB),请只粘贴邀请链接那一行。');
351
+ }
352
+ if (text.startsWith(LINK_PREFIX)) return text.split(/\s+/u)[0];
353
+ const found = text.match(LINK_IN_TEXT);
354
+ if (found !== null) return found[0];
355
+ // No link at all. Distinguish "you pasted the wrong kind of URL" (a common
356
+ // mistake: a web link, an https gateway card) from "you pasted prose", because
357
+ // the two need different fixes.
358
+ const scheme = text.match(/^([a-z][a-z0-9+.-]*):\/\/([^\s/?#]*)/iu);
359
+ if (scheme !== null) {
360
+ throw new InviteLinkError(
361
+ 'bad_link',
362
+ `这不是群邀请链接:收到的是 ${scheme[1]}://${scheme[2]} ,群邀请必须是 ${LINK_PREFIX}?... 开头。`,
363
+ );
364
+ }
365
+ throw new InviteLinkError(
366
+ 'bad_link',
367
+ `没有找到邀请链接:需要以 ${LINK_PREFIX}? 开头,例如 ${LINK_PREFIX}?v=1&gw=…&g=…&t=…。`,
368
+ );
369
+ }
370
+
371
+ /** Require a non-blank string, or throw a link error naming the field. */
372
+ function requireText(value, field) {
373
+ if (typeof value !== 'string' || value.trim() === '') {
374
+ throw new InviteLinkError('incomplete_link', `邀请链接缺少必填字段 ${field},请让群主重新生成。`, { field });
375
+ }
376
+ return value;
377
+ }
378
+
379
+ /** Trimmed non-empty string, else `undefined` (an absent or blank param is absent). */
380
+ function optionalText(value) {
381
+ if (typeof value !== 'string') return undefined;
382
+ const trimmed = value.trim();
383
+ return trimmed === '' ? undefined : trimmed;
384
+ }
385
+
386
+ /** Normalize epoch ms / ISO / Date to an ISO string for the link and the prompt. */
387
+ function toIso(value) {
388
+ if (value instanceof Date) {
389
+ if (Number.isNaN(value.getTime())) {
390
+ throw new InviteLinkError('bad_expiry', '过期时间是空日期。', { value: String(value) });
391
+ }
392
+ return value.toISOString();
393
+ }
394
+ if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
395
+ if (typeof value === 'string') {
396
+ const parsed = Date.parse(value);
397
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
398
+ }
399
+ throw new InviteLinkError('bad_expiry', `过期时间不是合法时间:${String(value)}。`, { value: String(value) });
400
+ }
401
+
402
+ /** Drop trailing slashes so `http://gw/` and `http://gw` produce the same link. */
403
+ function stripTrailingSlash(value) {
404
+ return value.replace(/\/+$/u, '');
405
+ }
406
+
407
+ /** Type name for an error message, without `Object` for null. */
408
+ function describeType(value) {
409
+ if (value === null) return 'null';
410
+ if (Array.isArray(value)) return 'array';
411
+ return typeof value;
412
+ }