@zhin.js/adapter-satori 1.1.26 → 1.1.28

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/src/protocol.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
2
2
  /** Satori protocol helpers. Spec: https://satori.chat/zh-CN/protocol/overview.html */
3
3
 
4
- import { EndpointDeliveryError, isMediaRef, type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
5
+ import { buildNotice, buildRequest, composeSideEventName, senderFromId, sideEventConversation, type IncomingNotice, type IncomingRequest } from '@zhin.js/core';
6
+ import { EndpointDeliveryError, isMediaRef, type ConversationKind, type ConversationRef, type Segment } from '@zhin.js/im-contract';
5
7
  import { formatCompact, getLogger } from '@zhin.js/logger';
6
8
 
7
9
  const logger = getLogger('satori');
@@ -27,6 +29,52 @@ export interface SatoriUser {
27
29
  readonly avatar?: string;
28
30
  }
29
31
 
32
+ /**
33
+ * Satori Emoji。字段对齐文档 https://satori.chat/zh-CN/resources/emoji.html (`id`、`name?`)。
34
+ * `@imhelper/satori-v1` 1.0.7 / 1.0.10 没有这个类型。上游 `feat/satori-1.3-alignment`
35
+ * 会补上,但这次不发布 npm,适配器先用本地声明。上游发版后可以换成 SDK 导出的官方类型。
36
+ */
37
+ export interface SatoriEmoji {
38
+ readonly id: string;
39
+ readonly name?: string;
40
+ }
41
+
42
+ /**
43
+ * 群组角色。文档只声明 `id` 和 `name?`。
44
+ * 协议源码里还有 color、position 等字段,这里以文档为准,不建模它们。
45
+ * `@imhelper/satori-v1` 没有这个类型。上游 `feat/satori-1.3-alignment` 会补,但未发 npm。
46
+ * 上游发版后可以换成官方类型。
47
+ * @see https://satori.chat/zh-CN/resources/role.html
48
+ */
49
+ export interface SatoriGuildRole {
50
+ readonly id: string;
51
+ readonly name?: string;
52
+ }
53
+
54
+ /**
55
+ * 好友。文档形状是 `{ user?, nick? }`,不是带顶层 `id` / `name` / `avatar` 的 User。
56
+ * 1.0.10 的 `directory-api.getFriendList` 仍按 User 读取 `friend.id`。本地按文档解析。
57
+ * 上游发版后可以换成官方类型。
58
+ * @see https://satori.chat/zh-CN/resources/friend.html
59
+ */
60
+ export interface SatoriFriend {
61
+ readonly user?: SatoriUser;
62
+ readonly nick?: string;
63
+ }
64
+
65
+ /**
66
+ * 群组成员。`roles` 在 1.3 起是 `GuildRole[]`,不再是 `string[]`。
67
+ * 本地类型。上游发版后可以换成官方类型。
68
+ * @see https://satori.chat/zh-CN/resources/member.html
69
+ */
70
+ export interface SatoriGuildMember {
71
+ readonly user?: SatoriUser;
72
+ readonly nick?: string;
73
+ readonly avatar?: string;
74
+ readonly joined_at?: number;
75
+ readonly roles?: readonly SatoriGuildRole[];
76
+ }
77
+
30
78
  export interface SatoriChannel {
31
79
  readonly id: string;
32
80
  /** 0=TEXT, 1=DIRECT, 2=CATEGORY, 3=VOICE */
@@ -61,7 +109,26 @@ export interface SatoriEventBody {
61
109
  readonly channel?: SatoriChannel;
62
110
  readonly user?: SatoriUser;
63
111
  readonly guild?: { readonly id: string; readonly name?: string };
64
- readonly member?: { readonly user?: SatoriUser; readonly nick?: string; readonly roles?: string[] };
112
+ readonly member?: SatoriGuildMember;
113
+ /**
114
+ * 事件里的好友。本地字段,原因见 {@link SatoriFriend}。
115
+ * @see https://satori.chat/zh-CN/protocol/events.html
116
+ */
117
+ readonly friend?: SatoriFriend;
118
+ readonly operator?: SatoriUser;
119
+ /**
120
+ * 事件表情。本地字段,原因见 {@link SatoriEmoji}。
121
+ * @see https://satori.chat/zh-CN/protocol/events.html
122
+ */
123
+ readonly emoji?: SatoriEmoji;
124
+ /**
125
+ * 被动请求的来源上下文。协议不约束内部结构,发送时必须原样回传。
126
+ * 资源页 `message.create` 表格目前没列出该字段,但被动请求页要求传入,
127
+ * 协议方法表也包含 `referrer`。这里以被动请求页为准。
128
+ * 本地字段:`SatoriV1Event` 尚未声明它。上游发版后可以换成官方类型。
129
+ * @see https://satori.chat/zh-CN/advanced/passive.html
130
+ */
131
+ readonly referrer?: unknown;
65
132
  readonly [key: string]: unknown;
66
133
  }
67
134
 
@@ -76,6 +143,7 @@ export interface SatoriApiOptions {
76
143
  export interface SatoriWireSegment {
77
144
  readonly type: string;
78
145
  readonly data?: Record<string, unknown>;
146
+ readonly platform?: Record<string, unknown>;
79
147
  }
80
148
 
81
149
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
@@ -232,6 +300,128 @@ export function formatInboundContent(body: SatoriEventBody & { message: SatoriMe
232
300
  return typeof content === 'string' ? content : String(content);
233
301
  }
234
302
 
303
+ /**
304
+ * 入站元素解码。`content` 仍保留协议原文,结构化元素另外放进 segments。
305
+ * 只识别标准元素 at / img / emoji / audio / video / file。
306
+ * @see https://satori.chat/zh-CN/protocol/elements.html
307
+ */
308
+ const MAX_SATORI_ELEMENT_DEPTH = 8;
309
+
310
+ export function decodeSatoriContent(content: string, depth = 0): Segment[] {
311
+ if (depth >= MAX_SATORI_ELEMENT_DEPTH) {
312
+ return [{ type: 'text', data: { text: unescapeXml(content) } }];
313
+ }
314
+ const segments: Segment[] = [];
315
+ let text = '';
316
+ const flush = () => {
317
+ if (!text) return;
318
+ segments.push({ type: 'text', data: { text: unescapeXml(text) } });
319
+ text = '';
320
+ };
321
+ for (let index = 0; index < content.length;) {
322
+ if (content[index] !== '<') {
323
+ text += content[index];
324
+ index += 1;
325
+ continue;
326
+ }
327
+ const scanned = readSatoriElement(content, index);
328
+ if (scanned.kind === 'incomplete') {
329
+ text += content.slice(index);
330
+ break;
331
+ }
332
+ if (scanned.kind === 'literal' || !DECODABLE_SATORI_TAGS.has(scanned.element.name)) {
333
+ text += content[index];
334
+ index += 1;
335
+ continue;
336
+ }
337
+ const parsed = scanned.element;
338
+ flush();
339
+ segments.push(segmentFromSatoriElement(parsed.name, parsed.attrs));
340
+ if (parsed.inner) segments.push(...decodeSatoriContent(parsed.inner, depth + 1));
341
+ index = parsed.end;
342
+ }
343
+ flush();
344
+ return segments;
345
+ }
346
+
347
+ /** 仅在出现非文本元素时把解码结果送进消息管道,纯文本保持原来的 content-only 形状。 */
348
+ export function inboundSatoriSegments(content: string): readonly Segment[] | undefined {
349
+ const segments = decodeSatoriContent(content);
350
+ return segments.some(segment => segment.type !== 'text') ? segments : undefined;
351
+ }
352
+
353
+ const GUILD_EMOJI_NOTICE: Readonly<Record<string, string>> = {
354
+ 'guild-emoji-added': 'emoji_added',
355
+ 'guild-emoji-updated': 'emoji_updated',
356
+ 'guild-emoji-deleted': 'emoji_deleted',
357
+ };
358
+
359
+ /**
360
+ * `guild-emoji-*` → `notice.group.emoji_*`。
361
+ * 文档只要求资源 `emoji`,没有可回复频道,因此不填 conversation。
362
+ * 上游 SDK 目前只把这些事件原样 emit("event"),映射放在适配器内。
363
+ * @see https://satori.chat/zh-CN/resources/emoji.html
364
+ */
365
+ export function mapGuildEmojiNotice(body: SatoriEventBody, endpointId: string): IncomingNotice | undefined {
366
+ const subType = body.type ? GUILD_EMOJI_NOTICE[body.type] : undefined;
367
+ if (!subType) return undefined;
368
+ const emoji = normalizeSatoriEmoji(body.emoji);
369
+ if (!emoji) return undefined;
370
+ const label = noticeLabel(emoji.name ?? emoji.id);
371
+ return buildNotice(body, {
372
+ id: ['satori', body.type, body.guild?.id ?? '', emoji.id, String(body.sn ?? body.timestamp ?? 0)].join(':'),
373
+ clientAdapter: 'satori',
374
+ endpointId,
375
+ type: 'notice',
376
+ name: composeSideEventName('notice', 'group', subType),
377
+ ...(body.operator?.id ? { actor: senderFromId(body.operator.id, body.operator.name) } : {}),
378
+ timestamp: typeof body.timestamp === 'number' ? body.timestamp : Date.now(),
379
+ ...(label ? { value: label } : {}),
380
+ });
381
+ }
382
+
383
+ /**
384
+ * 被动回复的 referrer 只在这次入站处理结束前有效。
385
+ * 处理返回的 Promise 结算后,同一异步资源里更晚的发送不再附带它。
386
+ * @see https://satori.chat/zh-CN/advanced/passive.html
387
+ */
388
+ interface SatoriReferrerFrame {
389
+ readonly value: unknown;
390
+ open: boolean;
391
+ }
392
+
393
+ const satoriReferrer = new AsyncLocalStorage<SatoriReferrerFrame>();
394
+
395
+ export function withSatoriReferrer<T>(referrer: unknown, run: () => T): T {
396
+ const frame: SatoriReferrerFrame = { value: referrer, open: true };
397
+ const close = () => { frame.open = false; };
398
+ return satoriReferrer.run(frame, () => {
399
+ try {
400
+ const result = run();
401
+ if (isPromiseLike(result)) return result.finally(close) as T;
402
+ close();
403
+ return result;
404
+ } catch (error) {
405
+ close();
406
+ throw error;
407
+ }
408
+ });
409
+ }
410
+
411
+ function isPromiseLike(value: unknown): value is Promise<unknown> {
412
+ return typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function';
413
+ }
414
+
415
+ /** `message.create` 参数。处理尚未结束时原样附带 referrer,不挑字段、不改名。 */
416
+ export function satoriMessageCreateParams(channelId: string, content: string): Record<string, unknown> {
417
+ const frame = satoriReferrer.getStore();
418
+ return {
419
+ channel_id: channelId,
420
+ content,
421
+ ...(frame?.open ? { referrer: frame.value } : {}),
422
+ };
423
+ }
424
+
235
425
 
236
426
  /**
237
427
  * 入站归一化 → ConversationRef:Channel.type 1 (DIRECT) → kind 'private';
@@ -318,8 +508,12 @@ function resolveMediaSrc(type: string, data: Record<string, unknown>): string |
318
508
  }
319
509
 
320
510
  /**
321
- * Wire-encode an already-rendered outbound payload into Satori message content.
322
- * Segment canonicalization is intentionally not done here.
511
+ * 把已渲染的出站载荷编成 Satori 消息字符串。
512
+ * at / mention → `<at>`,image → `<img>`,其余媒体用同名标准元素,emoji → `<emoji>`。
513
+ * 整段字符串原样发送(调用方可能已经写好标记);段数组里的文本会转义。
514
+ * 上游 message-codec 把段编成 `{type, attrs}` 对象,而且不会把 mention/image 改成 at/img。
515
+ * 这里在适配器内按文档元素编码。上游发版后如果 SDK 直接产出这些元素,可以改走 SDK。
516
+ * @see https://satori.chat/zh-CN/protocol/elements.html
323
517
  */
324
518
  export function formatSatoriOutbound(payload: unknown): string {
325
519
  if (typeof payload === 'string') return payload;
@@ -338,24 +532,29 @@ export function formatSatoriOutbound(payload: unknown): string {
338
532
  const parts: string[] = [];
339
533
  for (const item of segments) {
340
534
  if (typeof item === 'string') {
341
- parts.push(item);
535
+ parts.push(escapeXmlText(item));
342
536
  continue;
343
537
  }
344
538
  const data = item.data ?? {};
539
+ const platform = item.platform;
345
540
  switch (item.type) {
346
541
  case 'text':
347
- parts.push(String(data.text ?? data.content ?? ''));
542
+ parts.push(escapeXmlText(String(data.text ?? data.content ?? '')));
348
543
  break;
349
544
  case 'at':
350
545
  case 'mention':
351
- parts.push(`@${String(data.name ?? data.id ?? data.target ?? '')}`);
546
+ parts.push(encodeAtElement(data, platform));
547
+ break;
548
+ case 'emoji':
549
+ case 'face':
550
+ parts.push(encodeEmojiElement(data));
352
551
  break;
353
552
  case 'image':
354
553
  case 'audio':
355
554
  case 'video':
356
555
  case 'file': {
357
556
  const src = resolveMediaSrc(item.type, data);
358
- if (src) parts.push(`[${item.type}:${src}]`);
557
+ if (src) parts.push(encodeMediaElement(item.type, src, data, platform));
359
558
  break;
360
559
  }
361
560
  default:
@@ -365,6 +564,331 @@ export function formatSatoriOutbound(payload: unknown): string {
365
564
  return parts.join('');
366
565
  }
367
566
 
567
+ const MEDIA_ELEMENT_NAMES = {
568
+ image: 'img',
569
+ audio: 'audio',
570
+ video: 'video',
571
+ file: 'file',
572
+ } as const;
573
+
574
+ const DECODABLE_SATORI_TAGS = new Set(['at', 'img', 'emoji', 'audio', 'video', 'file']);
575
+
576
+ function encodeAtElement(data: Record<string, unknown>, platform?: Record<string, unknown>): string {
577
+ const explicitType = readString(data.type) ?? readString(platform?.type);
578
+ const rawId = readString(data.id) ?? readString(data.target);
579
+ const id = rawId === 'all' || rawId === 'here' ? undefined : rawId;
580
+ const type = explicitType ?? (rawId === 'all' || rawId === 'here' ? rawId : undefined);
581
+ return renderSatoriElement('at', [
582
+ ['id', id],
583
+ ['name', readString(data.name)],
584
+ ['role', readString(data.role) ?? readString(platform?.role)],
585
+ ['type', type],
586
+ ]);
587
+ }
588
+
589
+ function encodeEmojiElement(data: Record<string, unknown>): string {
590
+ return renderSatoriElement('emoji', [
591
+ ['id', readToken(data.id) ?? readToken(data.face_id) ?? readString(data.emoji)],
592
+ ['name', readString(data.name)],
593
+ ]);
594
+ }
595
+
596
+ function encodeMediaElement(
597
+ type: keyof typeof MEDIA_ELEMENT_NAMES,
598
+ src: string,
599
+ data: Record<string, unknown>,
600
+ platform?: Record<string, unknown>,
601
+ ): string {
602
+ const title = readString(data.title) ?? readString(data.name);
603
+ const fileName = isMediaRef(data.media) ? data.media.file_name : undefined;
604
+ const duration = typeof data.duration === 'number' ? String(data.duration) : undefined;
605
+ const poster = readString(data.poster) ?? readString(platform?.poster);
606
+ const timed = type === 'audio' || type === 'video';
607
+ return renderSatoriElement(MEDIA_ELEMENT_NAMES[type], [
608
+ ['src', src],
609
+ ['title', title ?? fileName],
610
+ ['duration', timed ? duration : undefined],
611
+ ['poster', timed || type === 'file' ? poster : undefined],
612
+ ]);
613
+ }
614
+
615
+ function renderSatoriElement(
616
+ name: string,
617
+ attrs: ReadonlyArray<readonly [string, string | undefined]>,
618
+ ): string {
619
+ const rendered = attrs
620
+ .filter((entry): entry is readonly [string, string] => typeof entry[1] === 'string' && entry[1] !== '')
621
+ .map(([key, value]) => ` ${key}="${escapeXmlAttr(value)}"`)
622
+ .join('');
623
+ return `<${name}${rendered}/>`;
624
+ }
625
+
626
+ function readString(value: unknown): string | undefined {
627
+ return typeof value === 'string' ? value : undefined;
628
+ }
629
+
630
+ function readToken(value: unknown): string | undefined {
631
+ if (typeof value === 'string' && value) return value;
632
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
633
+ return undefined;
634
+ }
635
+
636
+ function escapeXmlText(value: string): string {
637
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
638
+ }
639
+
640
+ function escapeXmlAttr(value: string): string {
641
+ return escapeXmlText(value).replace(/"/g, '&quot;');
642
+ }
643
+
644
+ function unescapeXml(value: string): string {
645
+ let out = '';
646
+ for (let index = 0; index < value.length;) {
647
+ if (value[index] !== '&') {
648
+ out += value[index];
649
+ index += 1;
650
+ continue;
651
+ }
652
+ const window = value.slice(index + 1, index + 13);
653
+ const semi = window.indexOf(';');
654
+ if (semi <= 0) {
655
+ out += '&';
656
+ index += 1;
657
+ continue;
658
+ }
659
+ const decoded = decodeXmlEntity(window.slice(0, semi));
660
+ if (decoded === undefined) {
661
+ out += '&';
662
+ index += 1;
663
+ continue;
664
+ }
665
+ out += decoded;
666
+ index += semi + 2;
667
+ }
668
+ return out;
669
+ }
670
+
671
+ function decodeXmlEntity(body: string): string | undefined {
672
+ switch (body) {
673
+ case 'quot': return '"';
674
+ case 'apos': return "'";
675
+ case 'lt': return '<';
676
+ case 'gt': return '>';
677
+ case 'amp': return '&';
678
+ default: break;
679
+ }
680
+ if (body.startsWith('#x') || body.startsWith('#X')) return xmlCodePoint(body.slice(2), 16);
681
+ if (body.startsWith('#')) return xmlCodePoint(body.slice(1), 10);
682
+ return undefined;
683
+ }
684
+
685
+ function xmlCodePoint(body: string, radix: 10 | 16): string | undefined {
686
+ if (!body || (radix === 16 ? !isHex(body) : !isDigits(body))) return undefined;
687
+ const code = Number.parseInt(body, radix);
688
+ if (!Number.isInteger(code) || code < 0 || code > 0x10ffff) return undefined;
689
+ if (code >= 0xd800 && code <= 0xdfff) return undefined;
690
+ return String.fromCodePoint(code);
691
+ }
692
+
693
+ function isDigits(body: string): boolean {
694
+ for (const char of body) {
695
+ if (char < '0' || char > '9') return false;
696
+ }
697
+ return true;
698
+ }
699
+
700
+ function isHex(body: string): boolean {
701
+ for (const char of body) {
702
+ const hex = (char >= '0' && char <= '9')
703
+ || (char >= 'a' && char <= 'f')
704
+ || (char >= 'A' && char <= 'F');
705
+ if (!hex) return false;
706
+ }
707
+ return true;
708
+ }
709
+
710
+ interface SatoriParsedElement {
711
+ readonly name: string;
712
+ readonly attrs: Readonly<Record<string, string>>;
713
+ readonly inner?: string;
714
+ readonly end: number;
715
+ }
716
+
717
+ type SatoriTagScan =
718
+ | { readonly kind: 'element'; readonly element: SatoriParsedElement }
719
+ | { readonly kind: 'incomplete' }
720
+ | { readonly kind: 'literal' };
721
+
722
+ function readSatoriElement(content: string, start: number): SatoriTagScan {
723
+ if (content[start] !== '<') return { kind: 'literal' };
724
+ const next = content[start + 1];
725
+ if (!next || next === '/' || next === '!' || next === '?' || !isAsciiLetter(next)) return { kind: 'literal' };
726
+ let index = start + 2;
727
+ while (index < content.length && isTagNameChar(content[index] ?? '')) index += 1;
728
+ const name = content.slice(start + 1, index).toLowerCase();
729
+ const attrStart = index;
730
+ let quote = '';
731
+ while (index < content.length) {
732
+ const char = content[index] ?? '';
733
+ if (quote) {
734
+ if (char === quote) quote = '';
735
+ index += 1;
736
+ continue;
737
+ }
738
+ if (char === '"' || char === "'") {
739
+ quote = char;
740
+ index += 1;
741
+ continue;
742
+ }
743
+ if (char === '>') break;
744
+ index += 1;
745
+ }
746
+ if (index >= content.length || content[index] !== '>') return { kind: 'incomplete' };
747
+ const rawAttrs = content.slice(attrStart, index);
748
+ const trimmed = rawAttrs.trimEnd();
749
+ const selfClosing = trimmed.endsWith('/');
750
+ const attrs = parseXmlAttributes(selfClosing ? trimmed.slice(0, -1) : rawAttrs);
751
+ const end = index + 1;
752
+ if (selfClosing) return { kind: 'element', element: { name, attrs, end } };
753
+ const close = findClosingTag(content, end, name);
754
+ if (!close) return { kind: 'element', element: { name, attrs, end } };
755
+ const inner = content.slice(end, close.start);
756
+ return { kind: 'element', element: { name, attrs, ...(inner ? { inner } : {}), end: close.end } };
757
+ }
758
+
759
+ function parseXmlAttributes(source: string): Readonly<Record<string, string>> {
760
+ const attrs: Record<string, string> = {};
761
+ let index = 0;
762
+ while (index < source.length) {
763
+ while (index < source.length && isXmlSpace(source[index] ?? '')) index += 1;
764
+ if (index >= source.length) break;
765
+ const first = source[index] ?? '';
766
+ if (!isAttrNameStart(first)) {
767
+ index += 1;
768
+ continue;
769
+ }
770
+ const keyStart = index;
771
+ index += 1;
772
+ while (index < source.length && isAttrNameChar(source[index] ?? '')) index += 1;
773
+ const key = source.slice(keyStart, index).toLowerCase();
774
+ while (index < source.length && isXmlSpace(source[index] ?? '')) index += 1;
775
+ if (source[index] !== '=') continue;
776
+ index += 1;
777
+ while (index < source.length && isXmlSpace(source[index] ?? '')) index += 1;
778
+ const quote = source[index];
779
+ if (quote !== '"' && quote !== "'") continue;
780
+ index += 1;
781
+ const valueStart = index;
782
+ while (index < source.length && source[index] !== quote) index += 1;
783
+ const value = source.slice(valueStart, index);
784
+ if (index < source.length) index += 1;
785
+ attrs[key] = unescapeXml(value);
786
+ }
787
+ return attrs;
788
+ }
789
+
790
+ function findClosingTag(content: string, from: number, name: string): { start: number; end: number } | undefined {
791
+ for (let index = from; index < content.length; index += 1) {
792
+ if (content[index] !== '<' || content[index + 1] !== '/') continue;
793
+ let cursor = index + 2;
794
+ let matched = true;
795
+ for (let offset = 0; offset < name.length; offset += 1) {
796
+ if ((content[cursor + offset] ?? '').toLowerCase() !== name[offset]) {
797
+ matched = false;
798
+ break;
799
+ }
800
+ }
801
+ if (!matched) continue;
802
+ cursor += name.length;
803
+ while (isXmlSpace(content[cursor] ?? '')) cursor += 1;
804
+ if (content[cursor] === '>') return { start: index, end: cursor + 1 };
805
+ }
806
+ return undefined;
807
+ }
808
+
809
+ function isAsciiLetter(char: string): boolean {
810
+ return (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z');
811
+ }
812
+
813
+ function isTagNameChar(char: string): boolean {
814
+ return isAsciiLetter(char) || isAsciiDigit(char) || char === '_' || char === ':' || char === '-';
815
+ }
816
+
817
+ function isAttrNameStart(char: string): boolean {
818
+ return isAsciiLetter(char) || char === '_' || char === ':';
819
+ }
820
+
821
+ function isAttrNameChar(char: string): boolean {
822
+ return isAttrNameStart(char) || isAsciiDigit(char) || char === '.' || char === '-';
823
+ }
824
+
825
+ function isAsciiDigit(char: string): boolean {
826
+ return char >= '0' && char <= '9';
827
+ }
828
+
829
+ function isXmlSpace(char: string): boolean {
830
+ return char === ' ' || char === '\t' || char === '\n' || char === '\r';
831
+ }
832
+
833
+ function segmentFromSatoriElement(name: string, attrs: Readonly<Record<string, string>>): Segment {
834
+ if (name === 'at') {
835
+ const type = attrs.type;
836
+ const target = attrs.id || (type === 'all' || type === 'here' ? type : attrs.role);
837
+ const platform: Record<string, string> = {};
838
+ if (attrs.role) platform.role = attrs.role;
839
+ if (type) platform.type = type;
840
+ return {
841
+ type: 'mention',
842
+ data: {
843
+ ...(target ? { target } : {}),
844
+ ...(attrs.name ? { name: attrs.name } : {}),
845
+ },
846
+ ...(Object.keys(platform).length > 0 ? { platform } : {}),
847
+ };
848
+ }
849
+ if (name === 'emoji') {
850
+ return {
851
+ type: 'emoji',
852
+ data: {
853
+ ...(attrs.id ? { id: attrs.id } : {}),
854
+ ...(attrs.name ? { name: attrs.name } : {}),
855
+ },
856
+ };
857
+ }
858
+ const type = name === 'img' ? 'image' : name;
859
+ const src = attrs.src ?? '';
860
+ const base64 = src.startsWith('base64://');
861
+ const duration = attrs.duration && Number.isFinite(Number(attrs.duration)) ? Number(attrs.duration) : undefined;
862
+ return {
863
+ type,
864
+ data: {
865
+ media: base64
866
+ ? { kind: 'base64', value: src.slice('base64://'.length) }
867
+ : { kind: 'url', value: src },
868
+ ...(attrs.title ? { name: attrs.title } : {}),
869
+ ...(duration !== undefined ? { duration } : {}),
870
+ ...(attrs.poster ? { poster: attrs.poster } : {}),
871
+ },
872
+ ...(attrs.poster ? { platform: { poster: attrs.poster } } : {}),
873
+ };
874
+ }
875
+
876
+ function normalizeSatoriEmoji(value: unknown): SatoriEmoji | undefined {
877
+ if (!value || typeof value !== 'object') return undefined;
878
+ const record = value as { id?: unknown; name?: unknown };
879
+ if (typeof record.id !== 'string' || !record.id) return undefined;
880
+ return {
881
+ id: record.id,
882
+ ...(typeof record.name === 'string' ? { name: record.name } : {}),
883
+ };
884
+ }
885
+
886
+ function noticeLabel(value: string): string | undefined {
887
+ const flat = value.replace(/\s+/g, ' ').trim();
888
+ if (!flat) return undefined;
889
+ return flat.length > 40 ? `${flat.slice(0, 39)}…` : flat;
890
+ }
891
+
368
892
  export function extractCreatedMessageId(result: unknown): string {
369
893
  const list = Array.isArray(result)
370
894
  ? result
@@ -372,3 +896,142 @@ export function extractCreatedMessageId(result: unknown): string {
372
896
  const msg = list?.[0] as { id?: string } | undefined;
373
897
  return msg?.id ?? '';
374
898
  }
899
+
900
+ /**
901
+ * 把 `guild-member.list` 的 `roles` 收成 `GuildRole[]`。
902
+ * 旧 SDK 可能仍下发 `string[]`,这里收成 `{ id }`。上游发版并保证对象数组后,字符串分支可以去掉。
903
+ */
904
+ export function normalizeSatoriGuildRoles(value: unknown): readonly SatoriGuildRole[] | undefined {
905
+ if (!Array.isArray(value)) return undefined;
906
+ const roles: SatoriGuildRole[] = [];
907
+ for (const role of value) {
908
+ if (typeof role === 'string' && role) {
909
+ roles.push({ id: role });
910
+ continue;
911
+ }
912
+ if (!role || typeof role !== 'object') continue;
913
+ const record = role as { id?: unknown; name?: unknown };
914
+ if (typeof record.id !== 'string' || !record.id) continue;
915
+ roles.push({
916
+ id: record.id,
917
+ ...(typeof record.name === 'string' ? { name: record.name } : {}),
918
+ });
919
+ }
920
+ return roles;
921
+ }
922
+
923
+ /** 成员列表原样保留其余字段,只把 roles 收成 GuildRole[]。 */
924
+ export function withSatoriGuildRoles<T>(member: T): T {
925
+ if (!member || typeof member !== 'object' || !('roles' in member)) return member;
926
+ const roles = normalizeSatoriGuildRoles((member as { roles?: unknown }).roles);
927
+ if (!roles) return member;
928
+ return { ...member, roles };
929
+ }
930
+
931
+ /**
932
+ * 按 `{ user, nick }` 读取好友。顶层 `id` / `name` 是 1.0.10 误用的 User 形状,这里不认。
933
+ */
934
+ export function readSatoriFriend(value: unknown): SatoriFriend | undefined {
935
+ if (!value || typeof value !== 'object') return undefined;
936
+ const record = value as { user?: unknown; nick?: unknown };
937
+ if (!record.user || typeof record.user !== 'object') return undefined;
938
+ const user = record.user as { id?: unknown; name?: unknown; avatar?: unknown };
939
+ if (typeof user.id !== 'string' || !user.id) return undefined;
940
+ return {
941
+ user: {
942
+ id: user.id,
943
+ ...(typeof user.name === 'string' ? { name: user.name } : {}),
944
+ ...(typeof user.avatar === 'string' ? { avatar: user.avatar } : {}),
945
+ },
946
+ ...(typeof record.nick === 'string' ? { nick: record.nick } : {}),
947
+ };
948
+ }
949
+
950
+ export type SatoriReactionMethod = 'create' | 'delete' | 'clear' | 'list';
951
+
952
+ /**
953
+ * reaction.* 的参数名是 `emoji_id`,不是 1.0.10 `addMessageReactionIn` 用的 `emoji`。
954
+ * clear 不传 emoji_id 表示清除全部表态。
955
+ * @see https://satori.chat/zh-CN/resources/reaction.html
956
+ */
957
+ export function satoriReactionParams(
958
+ method: SatoriReactionMethod,
959
+ input: {
960
+ readonly channelId: string;
961
+ readonly messageId: string;
962
+ readonly emojiId?: string;
963
+ readonly userId?: string;
964
+ readonly next?: string;
965
+ },
966
+ ): Record<string, unknown> {
967
+ const params: Record<string, unknown> = {
968
+ channel_id: input.channelId,
969
+ message_id: input.messageId,
970
+ };
971
+ if (input.emojiId !== undefined && (method !== 'clear' || input.emojiId !== '')) {
972
+ params.emoji_id = input.emojiId;
973
+ }
974
+ if (method === 'delete' && input.userId !== undefined) params.user_id = input.userId;
975
+ if (method === 'list' && input.next !== undefined) params.next = input.next;
976
+ return params;
977
+ }
978
+
979
+ /** `friend.approve` 同时表达通过和拒绝。 */
980
+ export function satoriFriendApproveParams(
981
+ messageId: string,
982
+ approve: boolean,
983
+ comment?: string,
984
+ ): Record<string, unknown> {
985
+ return {
986
+ message_id: messageId,
987
+ approve,
988
+ ...(comment !== undefined ? { comment } : {}),
989
+ };
990
+ }
991
+
992
+ export interface SatoriFriendRequestActions {
993
+ approve(messageId: string, comment?: string): Promise<void>;
994
+ reject(messageId: string, reason?: string): Promise<void>;
995
+ }
996
+
997
+ /**
998
+ * `friend-request` → `request.friend.add`,让它进入 request.receive 管道。
999
+ * 文档的必需资源是 `user`。`friend.approve` 的请求 ID 是 `message_id`;
1000
+ * 事件没有 `message.id` 时仍然投递,但批准/拒绝会说明缺少该字段。
1001
+ * @see https://satori.chat/zh-CN/resources/friend.html
1002
+ */
1003
+ export function mapSatoriFriendRequest(
1004
+ body: SatoriEventBody,
1005
+ endpointId: string,
1006
+ actions: SatoriFriendRequestActions,
1007
+ ): IncomingRequest | undefined {
1008
+ if (body.type !== 'friend-request') return undefined;
1009
+ const user = body.user ?? body.friend?.user;
1010
+ if (!user?.id) return undefined;
1011
+ const actor = senderFromId(user.id, user.name ?? body.friend?.nick);
1012
+ if (!actor) return undefined;
1013
+ const messageId = typeof body.message?.id === 'string' && body.message.id ? body.message.id : undefined;
1014
+ const requireMessageId = (kind: 'approve' | 'reject') => {
1015
+ if (!messageId) {
1016
+ throw new Error(`Satori friend.approve 需要事件 message.id 作为 message_id,无法${kind === 'approve' ? '通过' : '拒绝'}该好友申请`);
1017
+ }
1018
+ return messageId;
1019
+ };
1020
+ return buildRequest(body, {
1021
+ id: messageId ?? `satori:friend-request:${user.id}:${String(body.sn ?? body.timestamp ?? 0)}`,
1022
+ clientAdapter: 'satori',
1023
+ endpointId,
1024
+ type: 'request',
1025
+ name: 'request.friend.add',
1026
+ conversation: sideEventConversation('friend', user.id),
1027
+ actor,
1028
+ ...(typeof body.message?.content === 'string' ? { comment: body.message.content } : {}),
1029
+ timestamp: typeof body.timestamp === 'number' ? body.timestamp : Date.now(),
1030
+ async $approve(remark?: string) {
1031
+ await actions.approve(requireMessageId('approve'), remark);
1032
+ },
1033
+ async $reject(reason?: string) {
1034
+ await actions.reject(requireMessageId('reject'), reason);
1035
+ },
1036
+ });
1037
+ }