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.
- package/README-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
|
@@ -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
|
+
}
|