@seamnet/client 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Seam IM Plugin — 腾讯IM SDK 适配器。
3
+ *
4
+ * 契约(Plugin v1):
5
+ * - Actions (MCP 可调用):
6
+ * send_im : 一对一文本消息 (req.to, req.text)
7
+ * send_group : 群聊文本消息 (req.groupId, req.text)
8
+ * im_status : 查询登录状态
9
+ *
10
+ * 公开 API(供其他插件通过 hub.get('im') 调用):
11
+ * - sendMessage(to, text)
12
+ * - sendGroupMessage(groupId, text)
13
+ * - sendImage(to, filePath)
14
+ *
15
+ * 事件(通过 event-bus service):
16
+ * - 'im.message' { from, text, isGroup, conversationType } —— 收到一对一或群消息时
17
+ *
18
+ * 错误码:
19
+ * - IM_MISSING_CREDENTIALS : init 缺少凭据
20
+ * - IM_LOGIN_FAILED : SDK 登录失败
21
+ * - IM_NOT_READY : SDK 未就绪
22
+ * - IM_UNKNOWN_ACTION : 未知 action
23
+ */
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+ const { seamError, wrap } = require('../../errors.cjs');
28
+ const { validatePayload } = require('../../contracts/actions.cjs');
29
+
30
+ // === Polyfills(全局,只做一次)===
31
+ (function setupPolyfills() {
32
+ try {
33
+ const WS = require('ws');
34
+ if (!global.WebSocket) global.WebSocket = WS;
35
+ } catch (e) {
36
+ throw new Error('IM plugin requires `ws` package');
37
+ }
38
+
39
+ if (!global.XMLHttpRequest) {
40
+ try {
41
+ global.XMLHttpRequest = require('xhr2');
42
+ } catch (e) {}
43
+ }
44
+ if (global.XMLHttpRequest && !global.XMLHttpRequest.prototype._imPatched) {
45
+ const _origSend = global.XMLHttpRequest.prototype.send;
46
+ global.XMLHttpRequest.prototype._imPatched = true;
47
+ global.XMLHttpRequest.prototype.send = function (data) {
48
+ if (data && typeof data === 'object' && data._buffer) {
49
+ return _origSend.call(this, data._buffer);
50
+ }
51
+ return _origSend.call(this, data);
52
+ };
53
+ }
54
+
55
+ const Module = require('node:module');
56
+ if (!Module.prototype._imPatched) {
57
+ Module.prototype._imPatched = true;
58
+ const _origCompile = Module.prototype._compile;
59
+ Module.prototype._compile = function (content, filename) {
60
+ if (filename.includes('@tencentcloud/chat') && filename.endsWith('index.js')) {
61
+ const p1 =
62
+ '_getImageInfoArray",value:function(t,n){var o=this,i="".concat(this._n,"._getImageInfoArray")';
63
+ const r1 =
64
+ '_getImageInfoArray",value:function(t,n){return Promise.resolve();var o=this,i="".concat(this._n,"._getImageInfoArray")';
65
+ if (content.includes(p1)) content = content.replace(p1, r1);
66
+ const p2 =
67
+ '_getDownloadIP",value:function(e,n){var o="".concat(this._n,"._getDownloadIP")';
68
+ const r2 =
69
+ '_getDownloadIP",value:function(e,n){return Promise.resolve();var o="".concat(this._n,"._getDownloadIP")';
70
+ if (content.includes(p2)) content = content.replace(p2, r2);
71
+ }
72
+ return _origCompile.call(this, content, filename);
73
+ };
74
+ }
75
+ })();
76
+
77
+ function setupSdkGlobals() {
78
+ if (global._imSdkGlobalsSet) return;
79
+ global._imSdkGlobalsSet = true;
80
+ global.window = {
81
+ location: {
82
+ href: 'http://localhost',
83
+ protocol: 'https:',
84
+ host: 'localhost',
85
+ hostname: 'localhost',
86
+ },
87
+ addEventListener: () => {},
88
+ removeEventListener: () => {},
89
+ URL: Object.assign(
90
+ function (...a) {
91
+ return new (require('node:url').URL)(...a);
92
+ },
93
+ {
94
+ createObjectURL: () => 'blob:node/1',
95
+ revokeObjectURL: () => {},
96
+ }
97
+ ),
98
+ };
99
+ global.document = {
100
+ addEventListener: () => {},
101
+ removeEventListener: () => {},
102
+ characterSet: 'UTF-8',
103
+ };
104
+ global.navigator = { userAgent: 'node', language: 'en', platform: 'linux' };
105
+ global.HTMLInputElement = global.HTMLInputElement || class {};
106
+ global.Image = class {
107
+ constructor() {
108
+ this.width = 100;
109
+ this.height = 100;
110
+ this._ol = null;
111
+ }
112
+ set onload(fn) {
113
+ this._ol = fn;
114
+ }
115
+ get onload() {
116
+ return this._ol;
117
+ }
118
+ set onerror(fn) {}
119
+ set src(v) {
120
+ if (this._ol) setTimeout(() => this._ol(), 10);
121
+ }
122
+ };
123
+ }
124
+
125
+ function createImPlugin() {
126
+ let chat = null;
127
+ let TencentCloudChat = null;
128
+ let sdkReady = false;
129
+ let log = null;
130
+ let hub = null;
131
+ let eventBus = null;
132
+ let messageBuffer = null;
133
+ let myUserId = null;
134
+ // userId → nick 缓存;null 表示已查过但无 nick,避免反复拉
135
+ const displayNameCache = new Map();
136
+
137
+ async function prefetchDisplayName(userId) {
138
+ if (!chat || !userId || displayNameCache.has(userId)) return;
139
+ displayNameCache.set(userId, null);
140
+ try {
141
+ const res = await chat.getUserProfile({ userIDList: [userId] });
142
+ const profile = res.data?.[0];
143
+ const nick = profile?.nick;
144
+ if (nick && nick !== userId) {
145
+ displayNameCache.set(userId, nick);
146
+ log.info('display_name_resolved', { userId, nick });
147
+ }
148
+ } catch (err) {
149
+ log.warn('get_user_profile_failed', { userId, message: err.message });
150
+ }
151
+ }
152
+
153
+ function formatSenderDisplay(msg) {
154
+ const userId = msg.from;
155
+ const inline = msg.nick && msg.nick !== userId ? msg.nick : null;
156
+ const cached = inline ? null : displayNameCache.get(userId);
157
+ const name = inline || cached;
158
+ return name ? `${name} · ${userId}` : userId;
159
+ }
160
+
161
+ function readContactsFile() {
162
+ try {
163
+ const p = path.join(process.cwd(), '.seam', 'contacts.json');
164
+ if (!fs.existsSync(p)) return [];
165
+ const data = JSON.parse(fs.readFileSync(p, 'utf8'));
166
+ return Array.isArray(data) ? data : [];
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
171
+
172
+ function writeContactsFile(list) {
173
+ try {
174
+ const dir = path.join(process.cwd(), '.seam');
175
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
176
+ const p = path.join(dir, 'contacts.json');
177
+ fs.writeFileSync(p, JSON.stringify(list, null, 2));
178
+ } catch (err) {
179
+ log.warn('write_contacts_failed', { message: err.message });
180
+ }
181
+ }
182
+
183
+ function seedDisplayNameFromContacts() {
184
+ for (const c of readContactsFile()) {
185
+ if (c?.userId && c?.name && c.name !== c.userId) {
186
+ displayNameCache.set(c.userId, c.name);
187
+ }
188
+ }
189
+ }
190
+
191
+ async function refreshContactsFromFriendList() {
192
+ if (!chat) return;
193
+ let friendList = [];
194
+ try {
195
+ const r = await chat.getFriendList();
196
+ friendList = r?.data || [];
197
+ } catch (err) {
198
+ log.warn('get_friend_list_failed', { message: err.message });
199
+ return;
200
+ }
201
+ const friendIds = friendList
202
+ .map((f) => f?.userID || f?.profile?.userID)
203
+ .filter((id) => id && id !== myUserId);
204
+
205
+ // 批量拿 nick
206
+ const nickMap = new Map();
207
+ if (friendIds.length > 0) {
208
+ try {
209
+ const res = await chat.getUserProfile({ userIDList: friendIds });
210
+ for (const p of res?.data || []) {
211
+ if (p?.userID) nickMap.set(p.userID, p.nick || '');
212
+ }
213
+ } catch (err) {
214
+ log.warn('get_user_profile_batch_failed', { message: err.message });
215
+ }
216
+ }
217
+
218
+ // 合并 init 写入的 relation(譬如 inviter)
219
+ const existing = readContactsFile();
220
+ const relationMap = new Map();
221
+ for (const c of existing) {
222
+ if (c?.userId) relationMap.set(c.userId, c.relation || 'friend');
223
+ }
224
+
225
+ const merged = friendIds.map((id) => {
226
+ const nick = nickMap.get(id);
227
+ const existingEntry = existing.find((c) => c.userId === id);
228
+ const name = nick || existingEntry?.name || id;
229
+ return {
230
+ userId: id,
231
+ name,
232
+ relation: relationMap.get(id) || 'friend',
233
+ };
234
+ });
235
+
236
+ // 保留 existing 里 friendList 没覆盖到的条目(例如 inviter 可能还没同步成好友)
237
+ for (const c of existing) {
238
+ if (c?.userId && !friendIds.includes(c.userId)) {
239
+ merged.push(c);
240
+ }
241
+ }
242
+
243
+ writeContactsFile(merged);
244
+ // 更新 displayNameCache
245
+ for (const c of merged) {
246
+ if (c.userId && c.name && c.name !== c.userId) {
247
+ displayNameCache.set(c.userId, c.name);
248
+ }
249
+ }
250
+ log.info('contacts_refreshed', { count: merged.length });
251
+ }
252
+
253
+ async function sendMessage(to, text) {
254
+ if (!sdkReady) {
255
+ throw seamError({
256
+ code: 'IM_NOT_READY',
257
+ message: 'SDK not ready',
258
+ hint: '等 SDK_READY 事件,或检查 userSig 是否过期',
259
+ docs: 'docs/maintainer-guide.md#IM_NOT_READY',
260
+ });
261
+ }
262
+ const m = chat.createTextMessage({
263
+ to,
264
+ conversationType: TencentCloudChat.TYPES.CONV_C2C,
265
+ payload: { text },
266
+ });
267
+ await chat.sendMessage(m);
268
+ return { ok: true };
269
+ }
270
+
271
+ async function sendGroupMessage(groupId, text) {
272
+ if (!sdkReady) {
273
+ throw seamError({ code: 'IM_NOT_READY', message: 'SDK not ready' });
274
+ }
275
+ const m = chat.createTextMessage({
276
+ to: groupId,
277
+ conversationType: TencentCloudChat.TYPES.CONV_GROUP,
278
+ payload: { text },
279
+ });
280
+ await chat.sendMessage(m);
281
+ return { ok: true };
282
+ }
283
+
284
+ function buildFakeFile(filePath, mimeType) {
285
+ const fileData = fs.readFileSync(filePath);
286
+ const fileName = path.basename(filePath);
287
+ const stub = {
288
+ name: fileName,
289
+ size: fileData.length,
290
+ type: mimeType,
291
+ _buffer: fileData,
292
+ };
293
+ stub.files = [stub];
294
+ return stub;
295
+ }
296
+
297
+ async function sendImage(to, filePath, { isGroup = false } = {}) {
298
+ if (!sdkReady) {
299
+ throw seamError({ code: 'IM_NOT_READY', message: 'SDK not ready' });
300
+ }
301
+ const fakeFile = buildFakeFile(filePath, 'image/png');
302
+ const m = chat.createImageMessage({
303
+ to,
304
+ conversationType: isGroup
305
+ ? TencentCloudChat.TYPES.CONV_GROUP
306
+ : TencentCloudChat.TYPES.CONV_C2C,
307
+ payload: { file: fakeFile },
308
+ });
309
+ await chat.sendMessage(m);
310
+ return { ok: true };
311
+ }
312
+
313
+ async function sendFile(to, filePath, { isGroup = false } = {}) {
314
+ if (!sdkReady) {
315
+ throw seamError({ code: 'IM_NOT_READY', message: 'SDK not ready' });
316
+ }
317
+ const fakeFile = buildFakeFile(filePath, 'application/octet-stream');
318
+ const m = chat.createFileMessage({
319
+ to,
320
+ conversationType: isGroup
321
+ ? TencentCloudChat.TYPES.CONV_GROUP
322
+ : TencentCloudChat.TYPES.CONV_C2C,
323
+ payload: { file: fakeFile },
324
+ });
325
+ await chat.sendMessage(m);
326
+ return { ok: true };
327
+ }
328
+
329
+ async function init(h) {
330
+ hub = h;
331
+ log = hub.logger('im');
332
+ eventBus = hub.service('event-bus');
333
+ messageBuffer = hub.service('message-buffer');
334
+ const stateService = hub.service('state');
335
+ const stateScope = stateService ? stateService.scope('im') : null;
336
+ const credentials = hub.credentials || {};
337
+
338
+ if (!credentials.userId || !credentials.userSig || !credentials.sdkAppId) {
339
+ throw seamError({
340
+ code: 'IM_MISSING_CREDENTIALS',
341
+ message: 'credentials must contain userId, userSig, sdkAppId',
342
+ hint: '运行 `npx seam-client init` 先注册',
343
+ docs: 'docs/maintainer-guide.md#IM_MISSING_CREDENTIALS',
344
+ });
345
+ }
346
+
347
+ myUserId = credentials.userId;
348
+
349
+ // 先 delete window 让 SDK 走 node 路径
350
+ delete global.window;
351
+ TencentCloudChat = require('@tencentcloud/chat');
352
+ chat = TencentCloudChat.create({ SDKAppID: Number(credentials.sdkAppId) });
353
+
354
+ // 然后补上 SDK 运行所需的 window/document 等全局
355
+ setupSdkGlobals();
356
+
357
+ try {
358
+ const TIMUploadPlugin = require('tim-upload-plugin');
359
+ chat.registerPlugin({ 'tim-upload-plugin': TIMUploadPlugin });
360
+ log.info('upload_plugin_registered');
361
+ } catch (e) {
362
+ log.warn('upload_plugin_unavailable', { message: e.message });
363
+ }
364
+
365
+ chat.setLogLevel(4);
366
+
367
+ chat.on(TencentCloudChat.EVENT.SDK_READY, () => {
368
+ sdkReady = true;
369
+ log.info('sdk_ready', { userId: myUserId });
370
+ });
371
+
372
+ chat.on(TencentCloudChat.EVENT.SDK_NOT_READY, () => {
373
+ sdkReady = false;
374
+ log.warn('sdk_not_ready');
375
+ });
376
+
377
+ chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, (event) => {
378
+ for (const msg of event.data) {
379
+ if (msg.from === myUserId) continue;
380
+
381
+ const isGroup = msg.conversationType === TencentCloudChat.TYPES.CONV_GROUP;
382
+ // sender 用于 event-bus(保持 userId 原始值);senderDisplay 用于注入终端(name · userId)
383
+ const sender = msg.from;
384
+ // 若无 msg.nick 且未缓存,异步拉 profile(下次消息就能用)
385
+ if (!msg.nick && !displayNameCache.has(sender)) {
386
+ prefetchDisplayName(sender).catch(() => {});
387
+ }
388
+ const senderDisplay = formatSenderDisplay(msg);
389
+ const groupId = isGroup ? msg.to : null;
390
+ const ts = new Date().toTimeString().slice(0, 5);
391
+ const conversationType = msg.conversationType;
392
+
393
+ const replyTool = isGroup ? 'seam msg group' : 'seam msg send';
394
+
395
+ // 图片消息:下载 URL 存 inbox
396
+ if (msg.type === TencentCloudChat.TYPES.MSG_IMAGE) {
397
+ const inbox = hub.service('inbox');
398
+ const imgInfo =
399
+ msg.payload?.imageInfoArray?.find((i) => i.sizeType === 3) ||
400
+ msg.payload?.imageInfoArray?.[0];
401
+ if (imgInfo?.url && inbox) {
402
+ (async () => {
403
+ try {
404
+ const resp = await fetch(imgInfo.url);
405
+ if (!resp.ok) throw new Error(`http ${resp.status}`);
406
+ const buf = Buffer.from(await resp.arrayBuffer());
407
+ const p = inbox.save(buf, { ext: 'jpg', src: 'im' });
408
+ log.info('im_image_saved', { sender, bytes: buf.length, path: p });
409
+ const prefix = isGroup
410
+ ? `💬 [Seam群图片 ${ts} ${groupId} → ${replyTool}]`
411
+ : `💬 [Seam图片 ${ts} → ${replyTool}]`;
412
+ hub.inject(`${prefix} ${senderDisplay} → ${p}`);
413
+ } catch (e) {
414
+ log.error('im_image_download_failed', e, { sender });
415
+ hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${senderDisplay}: [图片下载失败: ${e.message}]`);
416
+ }
417
+ })();
418
+ }
419
+ continue;
420
+ }
421
+
422
+ // 文件消息
423
+ if (msg.type === TencentCloudChat.TYPES.MSG_FILE) {
424
+ const inbox = hub.service('inbox');
425
+ const fileUrl = msg.payload?.fileUrl;
426
+ const fileName = msg.payload?.fileName || `file_${Date.now()}.bin`;
427
+ const ext = path.extname(fileName).replace(/^\./, '') || 'bin';
428
+ if (fileUrl && inbox) {
429
+ (async () => {
430
+ try {
431
+ const resp = await fetch(fileUrl);
432
+ if (!resp.ok) throw new Error(`http ${resp.status}`);
433
+ const buf = Buffer.from(await resp.arrayBuffer());
434
+ const p = inbox.save(buf, { ext, src: 'im' });
435
+ log.info('im_file_saved', { sender, bytes: buf.length, path: p });
436
+ const prefix = isGroup
437
+ ? `💬 [Seam群文件 ${ts} ${groupId} → ${replyTool}]`
438
+ : `💬 [Seam文件 ${ts} → ${replyTool}]`;
439
+ hub.inject(
440
+ `${prefix} ${senderDisplay} → ${p} (原名: ${fileName}, ${buf.length} bytes)`
441
+ );
442
+ } catch (e) {
443
+ log.error('im_file_download_failed', e, { sender });
444
+ hub.inject(`💬 [Seam ${ts} → ${replyTool}] ${sender}: [文件下载失败: ${e.message}]`);
445
+ }
446
+ })();
447
+ }
448
+ continue;
449
+ }
450
+
451
+ // 文本消息(含其他未处理类型用占位)
452
+ let text = '';
453
+ if (msg.type === TencentCloudChat.TYPES.MSG_TEXT) {
454
+ text = msg.payload.text;
455
+ } else {
456
+ text = `[${msg.type}]`;
457
+ }
458
+ if (!text) continue;
459
+
460
+ log.info('message_received', {
461
+ from: msg.from,
462
+ isGroup,
463
+ textLength: text.length,
464
+ });
465
+
466
+ const bufferKey = isGroup
467
+ ? `im:group:${msg.to}:${msg.from}`
468
+ : `im:${msg.from}`;
469
+
470
+ const makePrefix = (ts2) =>
471
+ isGroup
472
+ ? `💬 [Seam群 ${ts2} ${groupId} → ${replyTool}]`
473
+ : `💬 [Seam ${ts2} → ${replyTool}]`;
474
+
475
+ if (messageBuffer) {
476
+ messageBuffer.queueText(
477
+ bufferKey,
478
+ text,
479
+ (items) => {
480
+ const ts = new Date().toTimeString().slice(0, 5);
481
+ const prefix = makePrefix(ts);
482
+ const joined = items.map((i) => i.content).join('\n');
483
+ if (items.length === 1) {
484
+ hub.inject(`${prefix} ${senderDisplay}: ${joined}`);
485
+ } else {
486
+ hub.inject(`${prefix} ${senderDisplay}:\n${joined}`);
487
+ }
488
+ if (eventBus) {
489
+ eventBus.emit('im.message', {
490
+ from: sender,
491
+ text: joined,
492
+ isGroup,
493
+ groupId,
494
+ conversationType,
495
+ });
496
+ }
497
+ },
498
+ { delay: 5000, maxDelay: 15000 }
499
+ );
500
+ } else {
501
+ const ts = new Date().toTimeString().slice(0, 5);
502
+ hub.inject(`${makePrefix(ts)} ${senderDisplay}: ${text}`);
503
+ if (eventBus) {
504
+ eventBus.emit('im.message', { from: sender, text, isGroup, groupId, conversationType });
505
+ }
506
+ }
507
+ }
508
+ });
509
+
510
+ try {
511
+ await chat.login({ userID: myUserId, userSig: credentials.userSig });
512
+ log.info('login_ok', { userId: myUserId });
513
+ } catch (err) {
514
+ log.error('login_failed', err);
515
+ throw wrap(err, {
516
+ code: 'IM_LOGIN_FAILED',
517
+ hint: '检查 userSig 是否过期、sdkAppId 是否正确',
518
+ docs: 'docs/maintainer-guide.md#IM_LOGIN_FAILED',
519
+ });
520
+ }
521
+
522
+ // 等 SDK_READY(最多 10s)
523
+ await new Promise((resolve) => {
524
+ if (sdkReady) return resolve();
525
+ const timer = setTimeout(() => {
526
+ log.warn('sdk_ready_wait_timeout');
527
+ resolve();
528
+ }, 10000);
529
+ chat.on(TencentCloudChat.EVENT.SDK_READY, () => {
530
+ clearTimeout(timer);
531
+ resolve();
532
+ });
533
+ });
534
+
535
+ // 拉一次好友列表建立关系表 contacts.json
536
+ seedDisplayNameFromContacts();
537
+ await refreshContactsFromFriendList();
538
+
539
+ // 监听好友列表变化(加/删好友触发),自动更新 contacts.json
540
+ try {
541
+ chat.on(TencentCloudChat.EVENT.FRIEND_LIST_UPDATED, () => {
542
+ refreshContactsFromFriendList().catch((err) => {
543
+ log.warn('refresh_contacts_on_update_failed', { message: err.message });
544
+ });
545
+ });
546
+ } catch (err) {
547
+ log.warn('friend_list_listener_register_failed', { message: err.message });
548
+ }
549
+
550
+ // 首次入网:给邀请人发一条上线消息(仅一次,用 state 记录)
551
+ if (
552
+ stateScope
553
+ && !stateScope.get('announcedToInviter')
554
+ && credentials.inviter
555
+ && credentials.inviter !== myUserId
556
+ ) {
557
+ const displayName = credentials.name || myUserId;
558
+ try {
559
+ await sendMessage(credentials.inviter, `${displayName} 已上线 Seam。`);
560
+ stateScope.set('announcedToInviter', true);
561
+ log.info('announced_to_inviter', { inviter: credentials.inviter, displayName });
562
+ } catch (err) {
563
+ log.warn('announce_to_inviter_failed', { message: err.message });
564
+ }
565
+ }
566
+ }
567
+
568
+ async function handleRequest(req) {
569
+ const check = validatePayload(req.action, req);
570
+ if (!check.ok) {
571
+ throw seamError({ code: 'IM_BAD_PAYLOAD', message: check.error });
572
+ }
573
+ if (req.action === 'send_im') {
574
+ return await sendMessage(req.to, req.text);
575
+ }
576
+ if (req.action === 'send_group') {
577
+ return await sendGroupMessage(req.groupId, req.text);
578
+ }
579
+ if (req.action === 'send_im_image') {
580
+ return await sendImage(req.to, req.filePath, { isGroup: false });
581
+ }
582
+ if (req.action === 'send_group_image') {
583
+ return await sendImage(req.groupId, req.filePath, { isGroup: true });
584
+ }
585
+ if (req.action === 'send_im_file') {
586
+ return await sendFile(req.to, req.filePath, { isGroup: false });
587
+ }
588
+ if (req.action === 'send_group_file') {
589
+ return await sendFile(req.groupId, req.filePath, { isGroup: true });
590
+ }
591
+ if (req.action === 'im_status') {
592
+ return { ok: true, ready: sdkReady, userId: myUserId };
593
+ }
594
+ throw seamError({
595
+ code: 'IM_UNKNOWN_ACTION',
596
+ message: `unknown action: ${req.action}`,
597
+ });
598
+ }
599
+
600
+ async function destroy() {
601
+ if (chat) {
602
+ try {
603
+ await chat.logout();
604
+ if (log) log.info('logged_out');
605
+ } catch (e) {
606
+ if (log) log.warn('logout_failed', { message: e.message });
607
+ }
608
+ }
609
+ }
610
+
611
+ return {
612
+ name: 'im',
613
+ description: '腾讯IM收发消息(文本/图片/文件,私聊/群聊)',
614
+ actions: [
615
+ 'send_im',
616
+ 'send_group',
617
+ 'send_im_image',
618
+ 'send_group_image',
619
+ 'send_im_file',
620
+ 'send_group_file',
621
+ 'im_status',
622
+ ],
623
+ init,
624
+ handleRequest,
625
+ destroy,
626
+ // Extension API(给其他插件调用,不在 MCP 上暴露)
627
+ sendMessage,
628
+ sendGroupMessage,
629
+ sendImage,
630
+ sendFile,
631
+ };
632
+ }
633
+
634
+ module.exports = { createImPlugin };