@zhin.js/adapter-satori 1.1.25 → 1.1.27
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/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/adapters/satori/index.js +5 -0
- package/adapters/satori/index.ts +5 -0
- package/lib/console-spec.js +55 -5
- package/lib/endpoint.d.ts +2 -1
- package/lib/endpoint.js +191 -64
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/protocol.d.ts +121 -9
- package/lib/protocol.js +561 -6
- package/package.json +8 -8
- package/src/console-spec.ts +55 -5
- package/src/endpoint.ts +218 -61
- package/src/index.ts +16 -0
- package/src/protocol.ts +671 -8
package/lib/protocol.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/** Satori protocol helpers. Spec: https://satori.chat/zh-CN/protocol/overview.html */
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import { buildNotice, buildRequest, composeSideEventName, senderFromId, sideEventConversation } from '@zhin.js/core';
|
|
2
4
|
import { EndpointDeliveryError, isMediaRef } from '@zhin.js/im-contract';
|
|
3
5
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
4
6
|
const logger = getLogger('satori');
|
|
@@ -108,6 +110,115 @@ export function formatInboundContent(body) {
|
|
|
108
110
|
const content = body.message.content ?? '';
|
|
109
111
|
return typeof content === 'string' ? content : String(content);
|
|
110
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* 入站元素解码。`content` 仍保留协议原文,结构化元素另外放进 segments。
|
|
115
|
+
* 只识别标准元素 at / img / emoji / audio / video / file。
|
|
116
|
+
* @see https://satori.chat/zh-CN/protocol/elements.html
|
|
117
|
+
*/
|
|
118
|
+
const MAX_SATORI_ELEMENT_DEPTH = 8;
|
|
119
|
+
export function decodeSatoriContent(content, depth = 0) {
|
|
120
|
+
if (depth >= MAX_SATORI_ELEMENT_DEPTH) {
|
|
121
|
+
return [{ type: 'text', data: { text: unescapeXml(content) } }];
|
|
122
|
+
}
|
|
123
|
+
const segments = [];
|
|
124
|
+
let text = '';
|
|
125
|
+
const flush = () => {
|
|
126
|
+
if (!text)
|
|
127
|
+
return;
|
|
128
|
+
segments.push({ type: 'text', data: { text: unescapeXml(text) } });
|
|
129
|
+
text = '';
|
|
130
|
+
};
|
|
131
|
+
for (let index = 0; index < content.length;) {
|
|
132
|
+
if (content[index] !== '<') {
|
|
133
|
+
text += content[index];
|
|
134
|
+
index += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const scanned = readSatoriElement(content, index);
|
|
138
|
+
if (scanned.kind === 'incomplete') {
|
|
139
|
+
text += content.slice(index);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
if (scanned.kind === 'literal' || !DECODABLE_SATORI_TAGS.has(scanned.element.name)) {
|
|
143
|
+
text += content[index];
|
|
144
|
+
index += 1;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const parsed = scanned.element;
|
|
148
|
+
flush();
|
|
149
|
+
segments.push(segmentFromSatoriElement(parsed.name, parsed.attrs));
|
|
150
|
+
if (parsed.inner)
|
|
151
|
+
segments.push(...decodeSatoriContent(parsed.inner, depth + 1));
|
|
152
|
+
index = parsed.end;
|
|
153
|
+
}
|
|
154
|
+
flush();
|
|
155
|
+
return segments;
|
|
156
|
+
}
|
|
157
|
+
/** 仅在出现非文本元素时把解码结果送进消息管道,纯文本保持原来的 content-only 形状。 */
|
|
158
|
+
export function inboundSatoriSegments(content) {
|
|
159
|
+
const segments = decodeSatoriContent(content);
|
|
160
|
+
return segments.some(segment => segment.type !== 'text') ? segments : undefined;
|
|
161
|
+
}
|
|
162
|
+
const GUILD_EMOJI_NOTICE = {
|
|
163
|
+
'guild-emoji-added': 'emoji_added',
|
|
164
|
+
'guild-emoji-updated': 'emoji_updated',
|
|
165
|
+
'guild-emoji-deleted': 'emoji_deleted',
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* `guild-emoji-*` → `notice.group.emoji_*`。
|
|
169
|
+
* 文档只要求资源 `emoji`,没有可回复频道,因此不填 conversation。
|
|
170
|
+
* 上游 SDK 目前只把这些事件原样 emit("event"),映射放在适配器内。
|
|
171
|
+
* @see https://satori.chat/zh-CN/resources/emoji.html
|
|
172
|
+
*/
|
|
173
|
+
export function mapGuildEmojiNotice(body, endpointId) {
|
|
174
|
+
const subType = body.type ? GUILD_EMOJI_NOTICE[body.type] : undefined;
|
|
175
|
+
if (!subType)
|
|
176
|
+
return undefined;
|
|
177
|
+
const emoji = normalizeSatoriEmoji(body.emoji);
|
|
178
|
+
if (!emoji)
|
|
179
|
+
return undefined;
|
|
180
|
+
const label = noticeLabel(emoji.name ?? emoji.id);
|
|
181
|
+
return buildNotice(body, {
|
|
182
|
+
id: ['satori', body.type, body.guild?.id ?? '', emoji.id, String(body.sn ?? body.timestamp ?? 0)].join(':'),
|
|
183
|
+
clientAdapter: 'satori',
|
|
184
|
+
endpointId,
|
|
185
|
+
type: 'notice',
|
|
186
|
+
name: composeSideEventName('notice', 'group', subType),
|
|
187
|
+
...(body.operator?.id ? { actor: senderFromId(body.operator.id, body.operator.name) } : {}),
|
|
188
|
+
timestamp: typeof body.timestamp === 'number' ? body.timestamp : Date.now(),
|
|
189
|
+
...(label ? { value: label } : {}),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const satoriReferrer = new AsyncLocalStorage();
|
|
193
|
+
export function withSatoriReferrer(referrer, run) {
|
|
194
|
+
const frame = { value: referrer, open: true };
|
|
195
|
+
const close = () => { frame.open = false; };
|
|
196
|
+
return satoriReferrer.run(frame, () => {
|
|
197
|
+
try {
|
|
198
|
+
const result = run();
|
|
199
|
+
if (isPromiseLike(result))
|
|
200
|
+
return result.finally(close);
|
|
201
|
+
close();
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
close();
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
function isPromiseLike(value) {
|
|
211
|
+
return typeof value === 'object' && value !== null && typeof value.then === 'function';
|
|
212
|
+
}
|
|
213
|
+
/** `message.create` 参数。处理尚未结束时原样附带 referrer,不挑字段、不改名。 */
|
|
214
|
+
export function satoriMessageCreateParams(channelId, content) {
|
|
215
|
+
const frame = satoriReferrer.getStore();
|
|
216
|
+
return {
|
|
217
|
+
channel_id: channelId,
|
|
218
|
+
content,
|
|
219
|
+
...(frame?.open ? { referrer: frame.value } : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
111
222
|
/**
|
|
112
223
|
* 入站归一化 → ConversationRef:Channel.type 1 (DIRECT) → kind 'private';
|
|
113
224
|
* 其余频道消息 → kind 'group',所属 guild 容器进 `parent`(kind 'channel')。
|
|
@@ -182,8 +293,12 @@ function resolveMediaSrc(type, data) {
|
|
|
182
293
|
return undefined;
|
|
183
294
|
}
|
|
184
295
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
296
|
+
* 把已渲染的出站载荷编成 Satori 消息字符串。
|
|
297
|
+
* at / mention → `<at>`,image → `<img>`,其余媒体用同名标准元素,emoji → `<emoji>`。
|
|
298
|
+
* 整段字符串原样发送(调用方可能已经写好标记);段数组里的文本会转义。
|
|
299
|
+
* 上游 message-codec 把段编成 `{type, attrs}` 对象,而且不会把 mention/image 改成 at/img。
|
|
300
|
+
* 这里在适配器内按文档元素编码。上游发版后如果 SDK 直接产出这些元素,可以改走 SDK。
|
|
301
|
+
* @see https://satori.chat/zh-CN/protocol/elements.html
|
|
187
302
|
*/
|
|
188
303
|
export function formatSatoriOutbound(payload) {
|
|
189
304
|
if (typeof payload === 'string')
|
|
@@ -201,17 +316,22 @@ export function formatSatoriOutbound(payload) {
|
|
|
201
316
|
const parts = [];
|
|
202
317
|
for (const item of segments) {
|
|
203
318
|
if (typeof item === 'string') {
|
|
204
|
-
parts.push(item);
|
|
319
|
+
parts.push(escapeXmlText(item));
|
|
205
320
|
continue;
|
|
206
321
|
}
|
|
207
322
|
const data = item.data ?? {};
|
|
323
|
+
const platform = item.platform;
|
|
208
324
|
switch (item.type) {
|
|
209
325
|
case 'text':
|
|
210
|
-
parts.push(String(data.text ?? data.content ?? ''));
|
|
326
|
+
parts.push(escapeXmlText(String(data.text ?? data.content ?? '')));
|
|
211
327
|
break;
|
|
212
328
|
case 'at':
|
|
213
329
|
case 'mention':
|
|
214
|
-
parts.push(
|
|
330
|
+
parts.push(encodeAtElement(data, platform));
|
|
331
|
+
break;
|
|
332
|
+
case 'emoji':
|
|
333
|
+
case 'face':
|
|
334
|
+
parts.push(encodeEmojiElement(data));
|
|
215
335
|
break;
|
|
216
336
|
case 'image':
|
|
217
337
|
case 'audio':
|
|
@@ -219,7 +339,7 @@ export function formatSatoriOutbound(payload) {
|
|
|
219
339
|
case 'file': {
|
|
220
340
|
const src = resolveMediaSrc(item.type, data);
|
|
221
341
|
if (src)
|
|
222
|
-
parts.push(
|
|
342
|
+
parts.push(encodeMediaElement(item.type, src, data, platform));
|
|
223
343
|
break;
|
|
224
344
|
}
|
|
225
345
|
default:
|
|
@@ -228,6 +348,319 @@ export function formatSatoriOutbound(payload) {
|
|
|
228
348
|
}
|
|
229
349
|
return parts.join('');
|
|
230
350
|
}
|
|
351
|
+
const MEDIA_ELEMENT_NAMES = {
|
|
352
|
+
image: 'img',
|
|
353
|
+
audio: 'audio',
|
|
354
|
+
video: 'video',
|
|
355
|
+
file: 'file',
|
|
356
|
+
};
|
|
357
|
+
const DECODABLE_SATORI_TAGS = new Set(['at', 'img', 'emoji', 'audio', 'video', 'file']);
|
|
358
|
+
function encodeAtElement(data, platform) {
|
|
359
|
+
const explicitType = readString(data.type) ?? readString(platform?.type);
|
|
360
|
+
const rawId = readString(data.id) ?? readString(data.target);
|
|
361
|
+
const id = rawId === 'all' || rawId === 'here' ? undefined : rawId;
|
|
362
|
+
const type = explicitType ?? (rawId === 'all' || rawId === 'here' ? rawId : undefined);
|
|
363
|
+
return renderSatoriElement('at', [
|
|
364
|
+
['id', id],
|
|
365
|
+
['name', readString(data.name)],
|
|
366
|
+
['role', readString(data.role) ?? readString(platform?.role)],
|
|
367
|
+
['type', type],
|
|
368
|
+
]);
|
|
369
|
+
}
|
|
370
|
+
function encodeEmojiElement(data) {
|
|
371
|
+
return renderSatoriElement('emoji', [
|
|
372
|
+
['id', readToken(data.id) ?? readToken(data.face_id) ?? readString(data.emoji)],
|
|
373
|
+
['name', readString(data.name)],
|
|
374
|
+
]);
|
|
375
|
+
}
|
|
376
|
+
function encodeMediaElement(type, src, data, platform) {
|
|
377
|
+
const title = readString(data.title) ?? readString(data.name);
|
|
378
|
+
const fileName = isMediaRef(data.media) ? data.media.file_name : undefined;
|
|
379
|
+
const duration = typeof data.duration === 'number' ? String(data.duration) : undefined;
|
|
380
|
+
const poster = readString(data.poster) ?? readString(platform?.poster);
|
|
381
|
+
const timed = type === 'audio' || type === 'video';
|
|
382
|
+
return renderSatoriElement(MEDIA_ELEMENT_NAMES[type], [
|
|
383
|
+
['src', src],
|
|
384
|
+
['title', title ?? fileName],
|
|
385
|
+
['duration', timed ? duration : undefined],
|
|
386
|
+
['poster', timed || type === 'file' ? poster : undefined],
|
|
387
|
+
]);
|
|
388
|
+
}
|
|
389
|
+
function renderSatoriElement(name, attrs) {
|
|
390
|
+
const rendered = attrs
|
|
391
|
+
.filter((entry) => typeof entry[1] === 'string' && entry[1] !== '')
|
|
392
|
+
.map(([key, value]) => ` ${key}="${escapeXmlAttr(value)}"`)
|
|
393
|
+
.join('');
|
|
394
|
+
return `<${name}${rendered}/>`;
|
|
395
|
+
}
|
|
396
|
+
function readString(value) {
|
|
397
|
+
return typeof value === 'string' ? value : undefined;
|
|
398
|
+
}
|
|
399
|
+
function readToken(value) {
|
|
400
|
+
if (typeof value === 'string' && value)
|
|
401
|
+
return value;
|
|
402
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
403
|
+
return String(value);
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
function escapeXmlText(value) {
|
|
407
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
408
|
+
}
|
|
409
|
+
function escapeXmlAttr(value) {
|
|
410
|
+
return escapeXmlText(value).replace(/"/g, '"');
|
|
411
|
+
}
|
|
412
|
+
function unescapeXml(value) {
|
|
413
|
+
let out = '';
|
|
414
|
+
for (let index = 0; index < value.length;) {
|
|
415
|
+
if (value[index] !== '&') {
|
|
416
|
+
out += value[index];
|
|
417
|
+
index += 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const window = value.slice(index + 1, index + 13);
|
|
421
|
+
const semi = window.indexOf(';');
|
|
422
|
+
if (semi <= 0) {
|
|
423
|
+
out += '&';
|
|
424
|
+
index += 1;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const decoded = decodeXmlEntity(window.slice(0, semi));
|
|
428
|
+
if (decoded === undefined) {
|
|
429
|
+
out += '&';
|
|
430
|
+
index += 1;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
out += decoded;
|
|
434
|
+
index += semi + 2;
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
function decodeXmlEntity(body) {
|
|
439
|
+
switch (body) {
|
|
440
|
+
case 'quot': return '"';
|
|
441
|
+
case 'apos': return "'";
|
|
442
|
+
case 'lt': return '<';
|
|
443
|
+
case 'gt': return '>';
|
|
444
|
+
case 'amp': return '&';
|
|
445
|
+
default: break;
|
|
446
|
+
}
|
|
447
|
+
if (body.startsWith('#x') || body.startsWith('#X'))
|
|
448
|
+
return xmlCodePoint(body.slice(2), 16);
|
|
449
|
+
if (body.startsWith('#'))
|
|
450
|
+
return xmlCodePoint(body.slice(1), 10);
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
function xmlCodePoint(body, radix) {
|
|
454
|
+
if (!body || (radix === 16 ? !isHex(body) : !isDigits(body)))
|
|
455
|
+
return undefined;
|
|
456
|
+
const code = Number.parseInt(body, radix);
|
|
457
|
+
if (!Number.isInteger(code) || code < 0 || code > 0x10ffff)
|
|
458
|
+
return undefined;
|
|
459
|
+
if (code >= 0xd800 && code <= 0xdfff)
|
|
460
|
+
return undefined;
|
|
461
|
+
return String.fromCodePoint(code);
|
|
462
|
+
}
|
|
463
|
+
function isDigits(body) {
|
|
464
|
+
for (const char of body) {
|
|
465
|
+
if (char < '0' || char > '9')
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
function isHex(body) {
|
|
471
|
+
for (const char of body) {
|
|
472
|
+
const hex = (char >= '0' && char <= '9')
|
|
473
|
+
|| (char >= 'a' && char <= 'f')
|
|
474
|
+
|| (char >= 'A' && char <= 'F');
|
|
475
|
+
if (!hex)
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
function readSatoriElement(content, start) {
|
|
481
|
+
if (content[start] !== '<')
|
|
482
|
+
return { kind: 'literal' };
|
|
483
|
+
const next = content[start + 1];
|
|
484
|
+
if (!next || next === '/' || next === '!' || next === '?' || !isAsciiLetter(next))
|
|
485
|
+
return { kind: 'literal' };
|
|
486
|
+
let index = start + 2;
|
|
487
|
+
while (index < content.length && isTagNameChar(content[index] ?? ''))
|
|
488
|
+
index += 1;
|
|
489
|
+
const name = content.slice(start + 1, index).toLowerCase();
|
|
490
|
+
const attrStart = index;
|
|
491
|
+
let quote = '';
|
|
492
|
+
while (index < content.length) {
|
|
493
|
+
const char = content[index] ?? '';
|
|
494
|
+
if (quote) {
|
|
495
|
+
if (char === quote)
|
|
496
|
+
quote = '';
|
|
497
|
+
index += 1;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (char === '"' || char === "'") {
|
|
501
|
+
quote = char;
|
|
502
|
+
index += 1;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (char === '>')
|
|
506
|
+
break;
|
|
507
|
+
index += 1;
|
|
508
|
+
}
|
|
509
|
+
if (index >= content.length || content[index] !== '>')
|
|
510
|
+
return { kind: 'incomplete' };
|
|
511
|
+
const rawAttrs = content.slice(attrStart, index);
|
|
512
|
+
const trimmed = rawAttrs.trimEnd();
|
|
513
|
+
const selfClosing = trimmed.endsWith('/');
|
|
514
|
+
const attrs = parseXmlAttributes(selfClosing ? trimmed.slice(0, -1) : rawAttrs);
|
|
515
|
+
const end = index + 1;
|
|
516
|
+
if (selfClosing)
|
|
517
|
+
return { kind: 'element', element: { name, attrs, end } };
|
|
518
|
+
const close = findClosingTag(content, end, name);
|
|
519
|
+
if (!close)
|
|
520
|
+
return { kind: 'element', element: { name, attrs, end } };
|
|
521
|
+
const inner = content.slice(end, close.start);
|
|
522
|
+
return { kind: 'element', element: { name, attrs, ...(inner ? { inner } : {}), end: close.end } };
|
|
523
|
+
}
|
|
524
|
+
function parseXmlAttributes(source) {
|
|
525
|
+
const attrs = {};
|
|
526
|
+
let index = 0;
|
|
527
|
+
while (index < source.length) {
|
|
528
|
+
while (index < source.length && isXmlSpace(source[index] ?? ''))
|
|
529
|
+
index += 1;
|
|
530
|
+
if (index >= source.length)
|
|
531
|
+
break;
|
|
532
|
+
const first = source[index] ?? '';
|
|
533
|
+
if (!isAttrNameStart(first)) {
|
|
534
|
+
index += 1;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const keyStart = index;
|
|
538
|
+
index += 1;
|
|
539
|
+
while (index < source.length && isAttrNameChar(source[index] ?? ''))
|
|
540
|
+
index += 1;
|
|
541
|
+
const key = source.slice(keyStart, index).toLowerCase();
|
|
542
|
+
while (index < source.length && isXmlSpace(source[index] ?? ''))
|
|
543
|
+
index += 1;
|
|
544
|
+
if (source[index] !== '=')
|
|
545
|
+
continue;
|
|
546
|
+
index += 1;
|
|
547
|
+
while (index < source.length && isXmlSpace(source[index] ?? ''))
|
|
548
|
+
index += 1;
|
|
549
|
+
const quote = source[index];
|
|
550
|
+
if (quote !== '"' && quote !== "'")
|
|
551
|
+
continue;
|
|
552
|
+
index += 1;
|
|
553
|
+
const valueStart = index;
|
|
554
|
+
while (index < source.length && source[index] !== quote)
|
|
555
|
+
index += 1;
|
|
556
|
+
const value = source.slice(valueStart, index);
|
|
557
|
+
if (index < source.length)
|
|
558
|
+
index += 1;
|
|
559
|
+
attrs[key] = unescapeXml(value);
|
|
560
|
+
}
|
|
561
|
+
return attrs;
|
|
562
|
+
}
|
|
563
|
+
function findClosingTag(content, from, name) {
|
|
564
|
+
for (let index = from; index < content.length; index += 1) {
|
|
565
|
+
if (content[index] !== '<' || content[index + 1] !== '/')
|
|
566
|
+
continue;
|
|
567
|
+
let cursor = index + 2;
|
|
568
|
+
let matched = true;
|
|
569
|
+
for (let offset = 0; offset < name.length; offset += 1) {
|
|
570
|
+
if ((content[cursor + offset] ?? '').toLowerCase() !== name[offset]) {
|
|
571
|
+
matched = false;
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (!matched)
|
|
576
|
+
continue;
|
|
577
|
+
cursor += name.length;
|
|
578
|
+
while (isXmlSpace(content[cursor] ?? ''))
|
|
579
|
+
cursor += 1;
|
|
580
|
+
if (content[cursor] === '>')
|
|
581
|
+
return { start: index, end: cursor + 1 };
|
|
582
|
+
}
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
function isAsciiLetter(char) {
|
|
586
|
+
return (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z');
|
|
587
|
+
}
|
|
588
|
+
function isTagNameChar(char) {
|
|
589
|
+
return isAsciiLetter(char) || isAsciiDigit(char) || char === '_' || char === ':' || char === '-';
|
|
590
|
+
}
|
|
591
|
+
function isAttrNameStart(char) {
|
|
592
|
+
return isAsciiLetter(char) || char === '_' || char === ':';
|
|
593
|
+
}
|
|
594
|
+
function isAttrNameChar(char) {
|
|
595
|
+
return isAttrNameStart(char) || isAsciiDigit(char) || char === '.' || char === '-';
|
|
596
|
+
}
|
|
597
|
+
function isAsciiDigit(char) {
|
|
598
|
+
return char >= '0' && char <= '9';
|
|
599
|
+
}
|
|
600
|
+
function isXmlSpace(char) {
|
|
601
|
+
return char === ' ' || char === '\t' || char === '\n' || char === '\r';
|
|
602
|
+
}
|
|
603
|
+
function segmentFromSatoriElement(name, attrs) {
|
|
604
|
+
if (name === 'at') {
|
|
605
|
+
const type = attrs.type;
|
|
606
|
+
const target = attrs.id || (type === 'all' || type === 'here' ? type : attrs.role);
|
|
607
|
+
const platform = {};
|
|
608
|
+
if (attrs.role)
|
|
609
|
+
platform.role = attrs.role;
|
|
610
|
+
if (type)
|
|
611
|
+
platform.type = type;
|
|
612
|
+
return {
|
|
613
|
+
type: 'mention',
|
|
614
|
+
data: {
|
|
615
|
+
...(target ? { target } : {}),
|
|
616
|
+
...(attrs.name ? { name: attrs.name } : {}),
|
|
617
|
+
},
|
|
618
|
+
...(Object.keys(platform).length > 0 ? { platform } : {}),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
if (name === 'emoji') {
|
|
622
|
+
return {
|
|
623
|
+
type: 'emoji',
|
|
624
|
+
data: {
|
|
625
|
+
...(attrs.id ? { id: attrs.id } : {}),
|
|
626
|
+
...(attrs.name ? { name: attrs.name } : {}),
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const type = name === 'img' ? 'image' : name;
|
|
631
|
+
const src = attrs.src ?? '';
|
|
632
|
+
const base64 = src.startsWith('base64://');
|
|
633
|
+
const duration = attrs.duration && Number.isFinite(Number(attrs.duration)) ? Number(attrs.duration) : undefined;
|
|
634
|
+
return {
|
|
635
|
+
type,
|
|
636
|
+
data: {
|
|
637
|
+
media: base64
|
|
638
|
+
? { kind: 'base64', value: src.slice('base64://'.length) }
|
|
639
|
+
: { kind: 'url', value: src },
|
|
640
|
+
...(attrs.title ? { name: attrs.title } : {}),
|
|
641
|
+
...(duration !== undefined ? { duration } : {}),
|
|
642
|
+
...(attrs.poster ? { poster: attrs.poster } : {}),
|
|
643
|
+
},
|
|
644
|
+
...(attrs.poster ? { platform: { poster: attrs.poster } } : {}),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function normalizeSatoriEmoji(value) {
|
|
648
|
+
if (!value || typeof value !== 'object')
|
|
649
|
+
return undefined;
|
|
650
|
+
const record = value;
|
|
651
|
+
if (typeof record.id !== 'string' || !record.id)
|
|
652
|
+
return undefined;
|
|
653
|
+
return {
|
|
654
|
+
id: record.id,
|
|
655
|
+
...(typeof record.name === 'string' ? { name: record.name } : {}),
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
function noticeLabel(value) {
|
|
659
|
+
const flat = value.replace(/\s+/g, ' ').trim();
|
|
660
|
+
if (!flat)
|
|
661
|
+
return undefined;
|
|
662
|
+
return flat.length > 40 ? `${flat.slice(0, 39)}…` : flat;
|
|
663
|
+
}
|
|
231
664
|
export function extractCreatedMessageId(result) {
|
|
232
665
|
const list = Array.isArray(result)
|
|
233
666
|
? result
|
|
@@ -235,3 +668,125 @@ export function extractCreatedMessageId(result) {
|
|
|
235
668
|
const msg = list?.[0];
|
|
236
669
|
return msg?.id ?? '';
|
|
237
670
|
}
|
|
671
|
+
/**
|
|
672
|
+
* 把 `guild-member.list` 的 `roles` 收成 `GuildRole[]`。
|
|
673
|
+
* 旧 SDK 可能仍下发 `string[]`,这里收成 `{ id }`。上游发版并保证对象数组后,字符串分支可以去掉。
|
|
674
|
+
*/
|
|
675
|
+
export function normalizeSatoriGuildRoles(value) {
|
|
676
|
+
if (!Array.isArray(value))
|
|
677
|
+
return undefined;
|
|
678
|
+
const roles = [];
|
|
679
|
+
for (const role of value) {
|
|
680
|
+
if (typeof role === 'string' && role) {
|
|
681
|
+
roles.push({ id: role });
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (!role || typeof role !== 'object')
|
|
685
|
+
continue;
|
|
686
|
+
const record = role;
|
|
687
|
+
if (typeof record.id !== 'string' || !record.id)
|
|
688
|
+
continue;
|
|
689
|
+
roles.push({
|
|
690
|
+
id: record.id,
|
|
691
|
+
...(typeof record.name === 'string' ? { name: record.name } : {}),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return roles;
|
|
695
|
+
}
|
|
696
|
+
/** 成员列表原样保留其余字段,只把 roles 收成 GuildRole[]。 */
|
|
697
|
+
export function withSatoriGuildRoles(member) {
|
|
698
|
+
if (!member || typeof member !== 'object' || !('roles' in member))
|
|
699
|
+
return member;
|
|
700
|
+
const roles = normalizeSatoriGuildRoles(member.roles);
|
|
701
|
+
if (!roles)
|
|
702
|
+
return member;
|
|
703
|
+
return { ...member, roles };
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* 按 `{ user, nick }` 读取好友。顶层 `id` / `name` 是 1.0.10 误用的 User 形状,这里不认。
|
|
707
|
+
*/
|
|
708
|
+
export function readSatoriFriend(value) {
|
|
709
|
+
if (!value || typeof value !== 'object')
|
|
710
|
+
return undefined;
|
|
711
|
+
const record = value;
|
|
712
|
+
if (!record.user || typeof record.user !== 'object')
|
|
713
|
+
return undefined;
|
|
714
|
+
const user = record.user;
|
|
715
|
+
if (typeof user.id !== 'string' || !user.id)
|
|
716
|
+
return undefined;
|
|
717
|
+
return {
|
|
718
|
+
user: {
|
|
719
|
+
id: user.id,
|
|
720
|
+
...(typeof user.name === 'string' ? { name: user.name } : {}),
|
|
721
|
+
...(typeof user.avatar === 'string' ? { avatar: user.avatar } : {}),
|
|
722
|
+
},
|
|
723
|
+
...(typeof record.nick === 'string' ? { nick: record.nick } : {}),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* reaction.* 的参数名是 `emoji_id`,不是 1.0.10 `addMessageReactionIn` 用的 `emoji`。
|
|
728
|
+
* clear 不传 emoji_id 表示清除全部表态。
|
|
729
|
+
* @see https://satori.chat/zh-CN/resources/reaction.html
|
|
730
|
+
*/
|
|
731
|
+
export function satoriReactionParams(method, input) {
|
|
732
|
+
const params = {
|
|
733
|
+
channel_id: input.channelId,
|
|
734
|
+
message_id: input.messageId,
|
|
735
|
+
};
|
|
736
|
+
if (input.emojiId !== undefined && (method !== 'clear' || input.emojiId !== '')) {
|
|
737
|
+
params.emoji_id = input.emojiId;
|
|
738
|
+
}
|
|
739
|
+
if (method === 'delete' && input.userId !== undefined)
|
|
740
|
+
params.user_id = input.userId;
|
|
741
|
+
if (method === 'list' && input.next !== undefined)
|
|
742
|
+
params.next = input.next;
|
|
743
|
+
return params;
|
|
744
|
+
}
|
|
745
|
+
/** `friend.approve` 同时表达通过和拒绝。 */
|
|
746
|
+
export function satoriFriendApproveParams(messageId, approve, comment) {
|
|
747
|
+
return {
|
|
748
|
+
message_id: messageId,
|
|
749
|
+
approve,
|
|
750
|
+
...(comment !== undefined ? { comment } : {}),
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* `friend-request` → `request.friend.add`,让它进入 request.receive 管道。
|
|
755
|
+
* 文档的必需资源是 `user`。`friend.approve` 的请求 ID 是 `message_id`;
|
|
756
|
+
* 事件没有 `message.id` 时仍然投递,但批准/拒绝会说明缺少该字段。
|
|
757
|
+
* @see https://satori.chat/zh-CN/resources/friend.html
|
|
758
|
+
*/
|
|
759
|
+
export function mapSatoriFriendRequest(body, endpointId, actions) {
|
|
760
|
+
if (body.type !== 'friend-request')
|
|
761
|
+
return undefined;
|
|
762
|
+
const user = body.user ?? body.friend?.user;
|
|
763
|
+
if (!user?.id)
|
|
764
|
+
return undefined;
|
|
765
|
+
const actor = senderFromId(user.id, user.name ?? body.friend?.nick);
|
|
766
|
+
if (!actor)
|
|
767
|
+
return undefined;
|
|
768
|
+
const messageId = typeof body.message?.id === 'string' && body.message.id ? body.message.id : undefined;
|
|
769
|
+
const requireMessageId = (kind) => {
|
|
770
|
+
if (!messageId) {
|
|
771
|
+
throw new Error(`Satori friend.approve 需要事件 message.id 作为 message_id,无法${kind === 'approve' ? '通过' : '拒绝'}该好友申请`);
|
|
772
|
+
}
|
|
773
|
+
return messageId;
|
|
774
|
+
};
|
|
775
|
+
return buildRequest(body, {
|
|
776
|
+
id: messageId ?? `satori:friend-request:${user.id}:${String(body.sn ?? body.timestamp ?? 0)}`,
|
|
777
|
+
clientAdapter: 'satori',
|
|
778
|
+
endpointId,
|
|
779
|
+
type: 'request',
|
|
780
|
+
name: 'request.friend.add',
|
|
781
|
+
conversation: sideEventConversation('friend', user.id),
|
|
782
|
+
actor,
|
|
783
|
+
...(typeof body.message?.content === 'string' ? { comment: body.message.content } : {}),
|
|
784
|
+
timestamp: typeof body.timestamp === 'number' ? body.timestamp : Date.now(),
|
|
785
|
+
async $approve(remark) {
|
|
786
|
+
await actions.approve(requireMessageId('approve'), remark);
|
|
787
|
+
},
|
|
788
|
+
async $reject(reason) {
|
|
789
|
+
await actions.reject(requireMessageId('reject'), reason);
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-satori",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.27",
|
|
4
4
|
"description": "Zhin.js Satori adapter for Plugin Runtime (WebSocket client)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"@imhelper/satori-v1": "1.0.7",
|
|
42
42
|
"imhelper": "1.0.7",
|
|
43
43
|
"ws": "^8.21.1",
|
|
44
|
-
"@zhin.js/adapter": "1.1.
|
|
44
|
+
"@zhin.js/adapter": "1.1.32",
|
|
45
45
|
"@zhin.js/console-contract": "1.1.5",
|
|
46
|
-
"@zhin.js/core": "1.1.
|
|
47
|
-
"@zhin.js/feature-kit": "1.1.15",
|
|
46
|
+
"@zhin.js/core": "1.1.60",
|
|
48
47
|
"@zhin.js/host-http": "1.1.17",
|
|
48
|
+
"@zhin.js/feature-kit": "1.1.15",
|
|
49
49
|
"@zhin.js/im-contract": "1.1.13",
|
|
50
50
|
"@zhin.js/logger": "1.1.2",
|
|
51
51
|
"@zhin.js/page": "1.1.17",
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
"typescript": "^6.0.3",
|
|
58
58
|
"vitest": "^4.1.10",
|
|
59
59
|
"@zhin.js/host-http": "1.1.17",
|
|
60
|
-
"zhin.js": "1.1.
|
|
60
|
+
"zhin.js": "1.1.25"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
|
-
"@zhin.js/adapter": "^1.1.
|
|
63
|
+
"@zhin.js/adapter": "^1.1.32",
|
|
64
64
|
"@zhin.js/command": "^1.1.17",
|
|
65
|
-
"@zhin.js/core": "^1.1.
|
|
66
|
-
"zhin.js": "^1.1.
|
|
65
|
+
"@zhin.js/core": "^1.1.60",
|
|
66
|
+
"zhin.js": "^1.1.25"
|
|
67
67
|
},
|
|
68
68
|
"peerDependenciesMeta": {
|
|
69
69
|
"@zhin.js/command": {
|