@zhin.js/adapter-milky 3.0.2 → 5.0.0

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +464 -0
  2. package/README.md +39 -124
  3. package/adapters/milky.ts +68 -0
  4. package/lib/endpoint.d.ts +7 -0
  5. package/lib/endpoint.js +6 -0
  6. package/lib/index.d.ts +3 -0
  7. package/lib/index.js +3 -0
  8. package/lib/milky-agent-deps.d.ts +24 -0
  9. package/lib/milky-agent-deps.js +30 -0
  10. package/lib/milky-auth.d.ts +3 -0
  11. package/lib/milky-auth.js +30 -0
  12. package/lib/protocol.d.ts +162 -0
  13. package/lib/protocol.js +337 -0
  14. package/lib/sse-client.d.ts +20 -0
  15. package/lib/sse-client.js +85 -0
  16. package/lib/sse-endpoint.d.ts +50 -0
  17. package/lib/sse-endpoint.js +261 -0
  18. package/lib/webhook-endpoint.d.ts +40 -0
  19. package/lib/webhook-endpoint.js +212 -0
  20. package/lib/ws-endpoint.d.ts +42 -0
  21. package/lib/ws-endpoint.js +320 -0
  22. package/lib/ws-types.d.ts +11 -0
  23. package/lib/ws-types.js +1 -0
  24. package/lib/wss-endpoint.d.ts +40 -0
  25. package/lib/wss-endpoint.js +263 -0
  26. package/package.json +43 -20
  27. package/plugin.ts +8 -0
  28. package/schema.json +64 -0
  29. package/src/endpoint.ts +26 -0
  30. package/src/index.ts +59 -57
  31. package/src/milky-agent-deps.ts +44 -8
  32. package/src/milky-auth.ts +29 -0
  33. package/src/protocol.ts +491 -0
  34. package/src/sse-client.ts +106 -0
  35. package/src/sse-endpoint.ts +318 -0
  36. package/src/webhook-endpoint.ts +262 -0
  37. package/src/ws-endpoint.ts +373 -0
  38. package/src/ws-types.ts +12 -0
  39. package/src/wss-endpoint.ts +305 -0
  40. package/lib/src/adapter.js +0 -99
  41. package/lib/src/adapter.js.map +0 -1
  42. package/lib/src/api.js +0 -48
  43. package/lib/src/api.js.map +0 -1
  44. package/lib/src/endpoint-sse.js +0 -197
  45. package/lib/src/endpoint-sse.js.map +0 -1
  46. package/lib/src/endpoint-webhook.js +0 -189
  47. package/lib/src/endpoint-webhook.js.map +0 -1
  48. package/lib/src/endpoint-ws.js +0 -264
  49. package/lib/src/endpoint-ws.js.map +0 -1
  50. package/lib/src/endpoint-wss.js +0 -227
  51. package/lib/src/endpoint-wss.js.map +0 -1
  52. package/lib/src/index.js +0 -45
  53. package/lib/src/index.js.map +0 -1
  54. package/lib/src/milky-agent-deps.js +0 -10
  55. package/lib/src/milky-agent-deps.js.map +0 -1
  56. package/lib/src/segment-mapper.js +0 -2
  57. package/lib/src/segment-mapper.js.map +0 -1
  58. package/lib/src/types.js +0 -5
  59. package/lib/src/types.js.map +0 -1
  60. package/lib/src/utils.js +0 -169
  61. package/lib/src/utils.js.map +0 -1
  62. package/plugin.yml +0 -3
  63. package/src/adapter.ts +0 -111
  64. package/src/api.ts +0 -63
  65. package/src/endpoint-sse.ts +0 -232
  66. package/src/endpoint-webhook.ts +0 -225
  67. package/src/endpoint-ws.ts +0 -300
  68. package/src/endpoint-wss.ts +0 -272
  69. package/src/segment-mapper.ts +0 -1
  70. package/src/types.ts +0 -73
  71. package/src/utils.ts +0 -182
@@ -1,272 +0,0 @@
1
- /**
2
- * Milky 反向 WebSocket Bot:应用开 WS 服务端,协议端来连;鉴权后收事件同正向
3
- */
4
- import { IncomingMessage } from 'http';
5
- import WebSocket, { WebSocketServer } from 'ws';
6
- import { EventEmitter } from 'events';
7
- import { clearInterval } from 'node:timers';
8
- import { formatCompact, Endpoint, Message, segment, SendOptions, expandInteractiveSegmentsInContent,} from 'zhin.js';
9
- import type { Router } from '@zhin.js/host-router';
10
- import { callApi } from './api.js';
11
- import type { MilkyWssConfig, MilkyEvent } from './types.js';
12
- import type { MilkyAdapter } from './adapter.js';
13
- import {
14
- formatMilkyMessagePayload,
15
- parseMessageReceiveData,
16
- toMilkyOutgoingSegments,
17
- parseMilkyMessageId,
18
- } from './utils.js';
19
- import { fromCanonicalSegments } from './segment-mapper.js';
20
-
21
- function getAccessTokenFromWsRequest(req: IncomingMessage): string | undefined {
22
- const auth = req.headers['authorization'];
23
- if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7);
24
- const url = req.url ?? '';
25
- const idx = url.indexOf('?');
26
- if (idx >= 0) {
27
- const params = new URLSearchParams(url.slice(idx));
28
- return params.get('access_token') ?? undefined;
29
- }
30
- return undefined;
31
- }
32
-
33
- export class MilkyWssServer extends EventEmitter implements Endpoint<MilkyWssConfig, MilkyEvent> {
34
- $connected: boolean;
35
- #wss?: WebSocketServer;
36
- #client?: WebSocket;
37
- private heartbeatTimer?: NodeJS.Timeout;
38
-
39
- get logger() {
40
- return this.adapter.plugin.logger;
41
- }
42
-
43
- constructor(
44
- public adapter: MilkyAdapter,
45
- public router: Router,
46
- public $config: MilkyWssConfig,
47
- ) {
48
- super();
49
- this.$connected = false;
50
- }
51
-
52
- get $id() {
53
- return this.$config.name;
54
- }
55
-
56
- private apiOptions() {
57
- return { baseUrl: this.$config.baseUrl, access_token: this.$config.access_token };
58
- }
59
-
60
- async $connect(): Promise<void> {
61
- const path = this.$config.path.startsWith('/') ? this.$config.path : `/${this.$config.path}`;
62
- const token = this.$config.access_token;
63
- if (!token) this.logger.warn(formatCompact({ endpoint: this.$id, ok: false, error: 'missing access_token' }));
64
-
65
- this.#wss = this.router.ws(path, {
66
- verifyClient: (info: { req: IncomingMessage }) => {
67
- const received = getAccessTokenFromWsRequest(info.req);
68
- if (token && received !== token) {
69
- this.logger.error('反向 WS 鉴权失败');
70
- return false;
71
- }
72
- return true;
73
- },
74
- });
75
-
76
- this.$connected = true;
77
- this.logger.debug(formatCompact( { op: 'listen', endpoint: this.$id, mode: 'wss', path }));
78
-
79
- this.#wss.on('connection', (client, req) => {
80
- this.#client = client;
81
- this.startHeartbeat();
82
- this.logger.debug(formatCompact({ endpoint: this.$id, peer: req.socket?.remoteAddress }));
83
-
84
- client.on('message', (data) => {
85
- try {
86
- const event = JSON.parse(data.toString()) as MilkyEvent;
87
- this.handleEvent(event);
88
- } catch (err) {
89
- this.emit('error', err);
90
- }
91
- });
92
-
93
- client.on('close', () => {
94
- this.#client = undefined;
95
- this.logger.warn(formatCompact( { op: 'disconnect', endpoint: this.$id }));
96
- });
97
-
98
- client.on('error', (err) => this.logger.error('反向 WS 连接错误', err));
99
- });
100
- }
101
-
102
- async $disconnect(): Promise<void> {
103
- if (this.heartbeatTimer) {
104
- clearInterval(this.heartbeatTimer);
105
- this.heartbeatTimer = undefined;
106
- }
107
- this.#wss?.close();
108
- this.#wss = undefined;
109
- this.#client = undefined;
110
- this.$connected = false;
111
- }
112
-
113
- $formatMessage(event: MilkyEvent): Message<MilkyEvent> {
114
- const data = parseMessageReceiveData(event);
115
- if (!data) {
116
- return Message.from(event, {
117
- $id: '',
118
- $adapter: 'milky',
119
- $endpoint: this.$config.name,
120
- $channel: { id: '', type: 'private' },
121
- $sender: { id: '', name: '' },
122
- $content: [],
123
- $raw: '',
124
- $timestamp: event.time ?? 0,
125
- $recall: async () => {},
126
- $reply: async () => '',
127
- });
128
- }
129
- const payload = formatMilkyMessagePayload(
130
- event,
131
- data,
132
- (id) => this.$recallMessage(id),
133
- (channel, content) =>
134
- this.adapter.sendMessage({
135
- ...channel,
136
- context: 'milky',
137
- endpoint: this.$config.name,
138
- content: content as import('zhin.js').SendContent,
139
- }),
140
- 'milky',
141
- this.$config.name,
142
- );
143
- return Message.from(event, payload);
144
- }
145
-
146
- private handleEvent(event: MilkyEvent): void {
147
- const data = parseMessageReceiveData(event);
148
- if (data) {
149
- const message = this.$formatMessage(event);
150
- this.adapter.emit('message.receive', message);
151
- this.logger.debug(
152
- `${this.$config.name} recv ${message.$channel.type}(${message.$channel.id}):${segment.raw(message.$content)}`,
153
- );
154
- }
155
- }
156
-
157
- async $sendMessage(options: SendOptions): Promise<string> {
158
- const expanded = expandInteractiveSegmentsInContent(options.content);
159
- const arr = Array.isArray(expanded) ? expanded : [expanded];
160
- const wire = fromCanonicalSegments(
161
- arr.map((c) => (typeof c === 'string' ? { type: 'text' as const, data: { text: c } } : c)),
162
- );
163
- const message = toMilkyOutgoingSegments(
164
- wire as (string | { type: string; data?: Record<string, unknown> })[],
165
- );
166
- if (options.type === 'group') {
167
- const result = await callApi(this.apiOptions(), 'send_group_message', {
168
- group_id: parseInt(options.id, 10),
169
- message,
170
- });
171
- const seq = (result as { message_seq?: number }).message_seq;
172
- this.logger.debug(`${this.$config.name} send group(${options.id}):${segment.raw(options.content)}`);
173
- return seq != null ? `group:${options.id}:${seq}` : '';
174
- }
175
- if (options.type === 'private') {
176
- const result = await callApi(this.apiOptions(), 'send_private_message', {
177
- user_id: parseInt(options.id, 10),
178
- message,
179
- });
180
- const seq = (result as { message_seq?: number }).message_seq;
181
- this.logger.debug(`${this.$config.name} send private(${options.id}):${segment.raw(options.content)}`);
182
- return seq != null ? `friend:${options.id}:${seq}` : '';
183
- }
184
- throw new Error('Either group or private must be provided');
185
- }
186
-
187
- async $recallMessage(id: string): Promise<void> {
188
- const parsed = parseMilkyMessageId(id);
189
- if (!parsed) throw new Error(`Invalid message id: ${id}`);
190
- if (parsed.message_scene === 'group') {
191
- await callApi(this.apiOptions(), 'recall_group_message', {
192
- group_id: parsed.peer_id,
193
- message_seq: parsed.message_seq,
194
- });
195
- } else {
196
- await callApi(this.apiOptions(), 'recall_private_message', {
197
- user_id: parsed.peer_id,
198
- message_seq: parsed.message_seq,
199
- });
200
- }
201
- }
202
-
203
- async kickMember(groupId: number, userId: number, rejectAddRequest = false): Promise<boolean> {
204
- await callApi(this.apiOptions(), 'kick_group_member', {
205
- group_id: groupId,
206
- user_id: userId,
207
- reject_add_request: rejectAddRequest,
208
- });
209
- return true;
210
- }
211
-
212
- async muteMember(groupId: number, userId: number, duration = 600): Promise<boolean> {
213
- await callApi(this.apiOptions(), 'set_group_member_mute', {
214
- group_id: groupId,
215
- user_id: userId,
216
- duration,
217
- });
218
- return true;
219
- }
220
-
221
- async muteAll(groupId: number, enable = true): Promise<boolean> {
222
- await callApi(this.apiOptions(), 'set_group_whole_mute', { group_id: groupId, is_mute: enable });
223
- return true;
224
- }
225
-
226
- async setAdmin(groupId: number, userId: number, enable = true): Promise<boolean> {
227
- await callApi(this.apiOptions(), 'set_group_member_admin', {
228
- group_id: groupId,
229
- user_id: userId,
230
- is_set: enable,
231
- });
232
- return true;
233
- }
234
-
235
- async setCard(groupId: number, userId: number, card: string): Promise<boolean> {
236
- await callApi(this.apiOptions(), 'set_group_member_card', {
237
- group_id: groupId,
238
- user_id: userId,
239
- card,
240
- });
241
- return true;
242
- }
243
-
244
- async setTitle(groupId: number, userId: number, title: string): Promise<boolean> {
245
- await callApi(this.apiOptions(), 'set_group_member_special_title', {
246
- group_id: groupId,
247
- user_id: userId,
248
- special_title: title,
249
- });
250
- return true;
251
- }
252
-
253
- async setGroupName(groupId: number, name: string): Promise<boolean> {
254
- await callApi(this.apiOptions(), 'set_group_name', { group_id: groupId, new_group_name: name });
255
- return true;
256
- }
257
-
258
- async getMemberList(groupId: number): Promise<unknown[]> {
259
- return callApi(this.apiOptions(), 'get_group_member_list', { group_id: groupId }) as Promise<unknown[]>;
260
- }
261
-
262
- async getGroupInfo(groupId: number): Promise<unknown> {
263
- return callApi(this.apiOptions(), 'get_group_info', { group_id: groupId });
264
- }
265
-
266
- private startHeartbeat(): void {
267
- const interval = this.$config.heartbeat_interval ?? 30000;
268
- this.heartbeatTimer = setInterval(() => {
269
- if (this.#client?.readyState === WebSocket.OPEN) this.#client.ping();
270
- }, interval);
271
- }
272
- }
@@ -1 +0,0 @@
1
- export { toCanonicalSegments, fromCanonicalSegments } from 'zhin.js';
package/src/types.ts DELETED
@@ -1,73 +0,0 @@
1
- /**
2
- * Milky 适配器类型与配置(与官方文档一致)
3
- */
4
-
5
- /** 配置公共字段;单一适配器下 context 均为 'milky',连接方式由 connection 区分 */
6
- export interface MilkyConfigBase {
7
- context: 'milky';
8
- name: string;
9
- baseUrl: string;
10
- access_token?: string;
11
- }
12
-
13
- /** WebSocket 正向连接 */
14
- export interface MilkyWsConfig extends MilkyConfigBase {
15
- connection: 'ws';
16
- reconnect_interval?: number;
17
- heartbeat_interval?: number;
18
- }
19
-
20
- /** SSE 连接 */
21
- export interface MilkySseConfig extends MilkyConfigBase {
22
- connection: 'sse';
23
- }
24
-
25
- /** Webhook(协议端 POST 到应用) */
26
- export interface MilkyWebhookConfig extends MilkyConfigBase {
27
- connection: 'webhook';
28
- path: string;
29
- }
30
-
31
- /** WebSocket 反向(协议端连应用) */
32
- export interface MilkyWssConfig extends MilkyConfigBase {
33
- connection: 'wss';
34
- path: string;
35
- heartbeat_interval?: number;
36
- }
37
-
38
- export type MilkyEndpointConfig = MilkyWsConfig | MilkySseConfig | MilkyWebhookConfig | MilkyWssConfig;
39
-
40
- /** 协议端 API 响应:status、retcode、data?、message? */
41
- export interface MilkyApiResponse<T = unknown> {
42
- status: string;
43
- retcode: number;
44
- data?: T;
45
- message?: string;
46
- }
47
-
48
- /** 事件结构:event_type、time、self_id、data */
49
- export interface MilkyEvent {
50
- event_type: string;
51
- time: number;
52
- self_id: number;
53
- data?: Record<string, unknown>;
54
- }
55
-
56
- /** 接收消息 data(message_receive):message_scene、peer_id、message_seq、sender_id、time、segments、可选 friend/group/group_member */
57
- export interface MilkyIncomingMessage {
58
- message_scene: 'friend' | 'group' | 'temp';
59
- peer_id: number;
60
- message_seq: number;
61
- sender_id: number;
62
- time: number;
63
- segments: MilkyIncomingSegment[];
64
- friend?: { user_id: number; nickname?: string };
65
- group?: { group_id: number; group_name?: string };
66
- group_member?: { user_id: number; nickname?: string; card?: string; role?: string };
67
- }
68
-
69
- /** 接收消息段:type、data */
70
- export interface MilkyIncomingSegment {
71
- type: string;
72
- data?: Record<string, unknown>;
73
- }
package/src/utils.ts DELETED
@@ -1,182 +0,0 @@
1
- /**
2
- * Milky 事件/消息段与 zhin Message 的转换
3
- */
4
- import { Message, applyQqSenderRoleToMessageSender, type MessageBase, type SendContent } from 'zhin.js';
5
-
6
- import type { MilkyEvent, MilkyIncomingMessage, MilkyIncomingSegment } from './types.js';
7
- import { toCanonicalSegments } from './segment-mapper.js';
8
-
9
- /** 将 Milky 接收消息段转为 zhin 的 $content(MessageSegment[]) */
10
- export function formatMilkySegments(segments: MilkyIncomingSegment[]): Array<{ type: string; data: Record<string, unknown> }> {
11
- return segments.map((seg) => {
12
- const type = seg.type;
13
- const data = seg.data ?? {};
14
- switch (type) {
15
- case 'text':
16
- return { type: 'text', data: { text: (data as { text?: string }).text ?? '' } };
17
- case 'mention':
18
- return { type: 'at', data: { id: String((data as { user_id?: number }).user_id ?? '') } };
19
- case 'mention_all':
20
- return { type: 'at', data: { type: 'all' } };
21
- case 'face':
22
- return { type: 'face', data: { id: (data as { face_id?: string }).face_id ?? '' } };
23
- case 'reply':
24
- return {
25
- type: 'reply',
26
- data: { message_seq: (data as { message_seq?: number }).message_seq },
27
- };
28
- case 'image':
29
- return {
30
- type: 'image',
31
- data: {
32
- url: (data as { temp_url?: string }).temp_url ?? (data as { resource_id?: string }).resource_id ?? '',
33
- },
34
- };
35
- case 'record':
36
- return {
37
- type: 'record',
38
- data: {
39
- url: (data as { temp_url?: string }).temp_url ?? (data as { resource_id?: string }).resource_id ?? '',
40
- },
41
- };
42
- case 'video':
43
- return {
44
- type: 'video',
45
- data: {
46
- url: (data as { temp_url?: string }).temp_url ?? (data as { resource_id?: string }).resource_id ?? '',
47
- },
48
- };
49
- case 'file':
50
- return {
51
- type: 'file',
52
- data: {
53
- file_id: (data as { file_id?: string }).file_id,
54
- name: (data as { file_name?: string }).file_name,
55
- },
56
- };
57
- default:
58
- return { type, data: data as Record<string, unknown> };
59
- }
60
- });
61
- }
62
-
63
- /** message_receive 事件的 data 转为 zhin Message 的构造参数(含 $adapter、$endpoint) */
64
- export function formatMilkyMessagePayload(
65
- event: MilkyEvent,
66
- data: MilkyIncomingMessage,
67
- recallFn: (msgId: string) => Promise<void>,
68
- replyFn: (channel: { id: string; type: 'group' | 'private' }, content: (string | { type: string; data?: Record<string, unknown> })[], quote?: boolean | string) => Promise<string>,
69
- adapterName: 'milky',
70
- endpointName: string,
71
- ): MessageBase {
72
- const scene = data.message_scene;
73
- const isGroup = scene === 'group';
74
- const channelId = data.peer_id.toString();
75
- const msgId = `${data.message_scene}:${data.peer_id}:${data.message_seq}`;
76
- const channel = { id: channelId, type: (isGroup ? 'group' : 'private') as 'group' | 'private' };
77
- const senderId = data.sender_id.toString();
78
- const senderName =
79
- (isGroup ? data.group_member?.card ?? data.group_member?.nickname : data.friend?.nickname) ?? senderId;
80
- const content = toCanonicalSegments(formatMilkySegments(data.segments));
81
- const raw = content.map((c) => (c.type === 'text' ? (c.data as { text?: string }).text : '')).join('');
82
-
83
- return {
84
- $id: msgId,
85
- $adapter: 'milky',
86
- $endpoint: endpointName,
87
- $channel: channel,
88
- $sender: (() => {
89
- const sender: { id: string; name: string; role?: string; permissions?: string[] } = {
90
- id: senderId,
91
- name: senderName,
92
- };
93
- applyQqSenderRoleToMessageSender(sender, data.group_member?.role);
94
- return sender;
95
- })(),
96
- $content: content,
97
- $raw: raw,
98
- $timestamp: data.time,
99
- $recall: () => recallFn(msgId),
100
- $reply: (cnt: SendContent, quote?: boolean | string) =>
101
- replyFn(channel, (Array.isArray(cnt) ? cnt : [cnt]) as (string | { type: string; data?: Record<string, unknown> })[], quote),
102
- };
103
- }
104
-
105
- /** 根据 event_type 判断是否为 message_receive,并解析 data 为 MilkyIncomingMessage */
106
- export function parseMessageReceiveData(event: MilkyEvent): MilkyIncomingMessage | null {
107
- if (event.event_type !== 'message_receive' || !event.data) return null;
108
- const data = event.data as unknown as MilkyIncomingMessage;
109
- if (!data.message_scene || !Number.isInteger(data.peer_id) || !Array.isArray(data.segments)) return null;
110
- return data;
111
- }
112
-
113
- /** zhin SendContent(string | MessageElement 或数组)转为 Milky OutgoingSegment[](发送用) */
114
- export function toMilkyOutgoingSegments(
115
- content: (string | { type: string; data?: Record<string, unknown> })[],
116
- ): Array<{ type: string; data: Record<string, unknown> }> {
117
- const out: Array<{ type: string; data: Record<string, unknown> }> = [];
118
- for (const seg of content) {
119
- const type = typeof seg === 'string' ? 'text' : seg.type;
120
- const data = typeof seg === 'string' ? { text: seg } : (seg.data ?? {});
121
- switch (type) {
122
- case 'text':
123
- out.push({ type: 'text', data: { text: String((data as { text?: string }).text ?? '') } });
124
- break;
125
- case 'at':
126
- if ((data as { type?: string }).type === 'all') {
127
- out.push({ type: 'mention_all', data: {} });
128
- } else {
129
- const id = (data as { id?: string }).id;
130
- if (id) out.push({ type: 'mention', data: { user_id: Number(id) || 0 } });
131
- }
132
- break;
133
- case 'face':
134
- out.push({ type: 'face', data: { face_id: String((data as { id?: string }).id ?? ''), is_large: false } });
135
- break;
136
- case 'reply':
137
- // message_id 可能为 scene:peer:seq 或仅 message_seq
138
- const mid = (data as { message_id?: string; message_seq?: number }).message_id ?? (data as { message_seq?: number }).message_seq;
139
- const seq = typeof mid === 'number' ? mid : parseInt(String(mid).split(':').pop() ?? '0', 10);
140
- out.push({ type: 'reply', data: { message_seq: seq } });
141
- break;
142
- case 'image':
143
- out.push({
144
- type: 'image',
145
- data: { uri: String((data as { url?: string }).url ?? (data as { uri?: string }).uri ?? '') },
146
- });
147
- break;
148
- case 'record':
149
- out.push({
150
- type: 'record',
151
- data: { uri: String((data as { url?: string }).url ?? (data as { uri?: string }).uri ?? '') },
152
- });
153
- break;
154
- case 'video':
155
- out.push({
156
- type: 'video',
157
- data: {
158
- uri: String((data as { url?: string }).url ?? (data as { uri?: string }).uri ?? ''),
159
- thumb_uri: (data as { thumb_uri?: string }).thumb_uri,
160
- },
161
- });
162
- break;
163
- default:
164
- out.push({ type, data: data as Record<string, unknown> });
165
- }
166
- }
167
- return out;
168
- }
169
-
170
- /** 解析 $id 为 message_scene、peer_id、message_seq(用于撤回) */
171
- export function parseMilkyMessageId(
172
- msgId: string,
173
- ): { message_scene: 'friend' | 'group' | 'temp'; peer_id: number; message_seq: number } | null {
174
- const parts = msgId.split(':');
175
- if (parts.length < 3) return null;
176
- const [scene, peer, seq] = parts;
177
- if (scene !== 'friend' && scene !== 'group' && scene !== 'temp') return null;
178
- const peerId = parseInt(peer, 10);
179
- const messageSeq = parseInt(seq, 10);
180
- if (Number.isNaN(peerId) || Number.isNaN(messageSeq)) return null;
181
- return { message_scene: scene, peer_id: peerId, message_seq: messageSeq };
182
- }