imtoagent 0.2.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.
- package/README.md +234 -0
- package/bin/imtoagent +453 -0
- package/index.ts +1129 -0
- package/modules/agent/claude-adapter.ts +258 -0
- package/modules/agent/claude.ts +160 -0
- package/modules/agent/codex-adapter.ts +232 -0
- package/modules/agent/codex-exec-server.ts +513 -0
- package/modules/agent/codex.ts +275 -0
- package/modules/agent/opencode-adapter.ts +308 -0
- package/modules/agent/opencode.ts +247 -0
- package/modules/bot-context.ts +26 -0
- package/modules/capabilities.ts +189 -0
- package/modules/cli/setup.ts +424 -0
- package/modules/core/config.ts +275 -0
- package/modules/core/error.ts +124 -0
- package/modules/core/index.ts +39 -0
- package/modules/core/runtime.ts +282 -0
- package/modules/core/session.ts +256 -0
- package/modules/core/stats.ts +92 -0
- package/modules/core/types.ts +250 -0
- package/modules/im/feishu.ts +731 -0
- package/modules/im/telegram.ts +639 -0
- package/modules/im/wechat.ts +1094 -0
- package/modules/im/wecom.ts +603 -0
- package/modules/media/feishu-inbound-adapter.ts +108 -0
- package/modules/media/index.ts +27 -0
- package/modules/media/media-store.ts +273 -0
- package/modules/media/resolver.ts +178 -0
- package/modules/media/telegram-inbound-adapter.ts +124 -0
- package/modules/media/types.ts +76 -0
- package/modules/prompt-builder.ts +123 -0
- package/modules/proxy/anthropic-proxy.ts +1083 -0
- package/modules/proxy/codex-proxy.ts +657 -0
- package/modules/rate-limiter.ts +58 -0
- package/modules/types.ts +144 -0
- package/modules/utils/backend-check.ts +121 -0
- package/modules/utils/paths.ts +218 -0
- package/package.json +53 -0
- package/scripts/postinstall.ts +70 -0
- package/templates/config.template.json +57 -0
- package/templates/opencode.template.json +28 -0
- package/templates/providers.template.json +19 -0
- package/templates/soul.template/identity.md +6 -0
- package/templates/soul.template/profile.md +11 -0
- package/templates/soul.template/rules.md +7 -0
- package/templates/soul.template/skills.md +3 -0
- package/templates/soul.template/workspace.md +4 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// WeCom (企业微信) IM 模块 — 扫码绑定 + WebSocket 长连接版
|
|
2
|
+
// 参考: @wecom/wecom-openclaw-plugin (v2026.5.14) 官方架构
|
|
3
|
+
//
|
|
4
|
+
// 不再需要: corpId / agentId / HTTP 回调 / 公网 IP / AES 加解密
|
|
5
|
+
// 扫码即可获得 botId + secret,通过 @wecom/aibot-node-sdk 建立 WebSocket 长连接
|
|
6
|
+
|
|
7
|
+
import * as https from 'node:https';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as crypto from 'node:crypto';
|
|
12
|
+
import * as http from 'node:http';
|
|
13
|
+
import { URL } from 'node:url';
|
|
14
|
+
import { WSClient } from '@wecom/aibot-node-sdk';
|
|
15
|
+
import type { WeComMediaType } from '@wecom/aibot-node-sdk';
|
|
16
|
+
import * as qrcode from 'qrcode';
|
|
17
|
+
import type { IMModule, IMCapabilities, MessageHandler } from '../types';
|
|
18
|
+
import type { UnifiedBlock } from '../capabilities';
|
|
19
|
+
import type { MessageAttachment } from '../core/types';
|
|
20
|
+
|
|
21
|
+
// ================================================================
|
|
22
|
+
// 常量
|
|
23
|
+
// ================================================================
|
|
24
|
+
|
|
25
|
+
const QR_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate';
|
|
26
|
+
const QR_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result';
|
|
27
|
+
const POLL_INTERVAL_MS = 3000;
|
|
28
|
+
const POLL_TIMEOUT_MS = 300_000; // 5 分钟
|
|
29
|
+
const WS_HEARTBEAT_MS = 30_000;
|
|
30
|
+
const WS_MAX_RECONNECT = 10;
|
|
31
|
+
const WS_MAX_AUTH_FAIL = 5;
|
|
32
|
+
const TEXT_MAX = 4000;
|
|
33
|
+
|
|
34
|
+
function getPlatCode(): number {
|
|
35
|
+
switch (os.platform()) {
|
|
36
|
+
case 'darwin': return 1;
|
|
37
|
+
case 'win32': return 2;
|
|
38
|
+
case 'linux': return 3;
|
|
39
|
+
default: return 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ================================================================
|
|
44
|
+
// 凭证本地存储
|
|
45
|
+
// ================================================================
|
|
46
|
+
|
|
47
|
+
interface StoredCreds {
|
|
48
|
+
botId: string;
|
|
49
|
+
secret: string;
|
|
50
|
+
boundAt: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CREDS_FILE = path.join(os.homedir(), '.imtoagent', 'wecom-creds.json');
|
|
54
|
+
|
|
55
|
+
function loadCreds(): StoredCreds | null {
|
|
56
|
+
try {
|
|
57
|
+
if (!fs.existsSync(CREDS_FILE)) return null;
|
|
58
|
+
return JSON.parse(fs.readFileSync(CREDS_FILE, 'utf8')) as StoredCreds;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function saveCreds(creds: StoredCreds): void {
|
|
65
|
+
fs.mkdirSync(path.dirname(CREDS_FILE), { recursive: true });
|
|
66
|
+
fs.writeFileSync(CREDS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ================================================================
|
|
70
|
+
// QR 扫码流程
|
|
71
|
+
// ================================================================
|
|
72
|
+
|
|
73
|
+
function httpsGet(url: string): Promise<string> {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
https.get(url, res => {
|
|
76
|
+
let data = '';
|
|
77
|
+
res.on('data', c => (data += c));
|
|
78
|
+
res.on('end', () => resolve(data));
|
|
79
|
+
}).on('error', reject);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fetchQRCode(): Promise<{ scode: string; authUrl: string }> {
|
|
84
|
+
const plat = getPlatCode();
|
|
85
|
+
const url = `${QR_GENERATE_URL}?source=wecom-cli&plat=${plat}`;
|
|
86
|
+
const raw = await httpsGet(url);
|
|
87
|
+
const resp = JSON.parse(raw);
|
|
88
|
+
if (!resp?.data?.scode || !resp?.data?.auth_url) {
|
|
89
|
+
throw new Error(`获取二维码失败: ${raw.slice(0, 200)}`);
|
|
90
|
+
}
|
|
91
|
+
return { scode: resp.data.scode, authUrl: resp.data.auth_url };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function renderQR(authUrl: string): Promise<void> {
|
|
95
|
+
const qr = await qrcode.toString(authUrl, { type: 'terminal', small: true });
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(qr);
|
|
98
|
+
console.log('');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function pollResult(scode: string): Promise<{ botId: string; secret: string }> {
|
|
102
|
+
const start = Date.now();
|
|
103
|
+
const url = `${QR_QUERY_URL}?scode=${encodeURIComponent(scode)}`;
|
|
104
|
+
|
|
105
|
+
while (Date.now() - start < POLL_TIMEOUT_MS) {
|
|
106
|
+
const raw = await httpsGet(url);
|
|
107
|
+
const resp = JSON.parse(raw);
|
|
108
|
+
const status = resp?.data?.status;
|
|
109
|
+
|
|
110
|
+
if (status === 'success') {
|
|
111
|
+
const bi = resp.data.bot_info;
|
|
112
|
+
if (!bi?.botid || !bi?.secret) {
|
|
113
|
+
throw new Error('扫码成功但未获取到 Bot 信息');
|
|
114
|
+
}
|
|
115
|
+
return { botId: bi.botid, secret: bi.secret };
|
|
116
|
+
}
|
|
117
|
+
process.stdout.write('.');
|
|
118
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log('\n⏱ 扫码超时(5 分钟),请重试');
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 执行 QR 扫码绑定流程
|
|
127
|
+
* 调用后会在终端显示二维码,用户扫码后自动获取 botId 和 secret 并保存到本地
|
|
128
|
+
*/
|
|
129
|
+
export async function bindWeComQR(): Promise<{ botId: string; secret: string }> {
|
|
130
|
+
console.log('\n📱 企业微信扫码绑定');
|
|
131
|
+
console.log('正在获取二维码...');
|
|
132
|
+
|
|
133
|
+
const { scode, authUrl } = await fetchQRCode();
|
|
134
|
+
|
|
135
|
+
console.log('请使用企业微信扫描以下二维码:');
|
|
136
|
+
await renderQR(authUrl);
|
|
137
|
+
console.log('等待扫码中...');
|
|
138
|
+
|
|
139
|
+
const result = await pollResult(scode);
|
|
140
|
+
console.log('\n✅ 扫码成功!Bot ID 和 Secret 已保存');
|
|
141
|
+
|
|
142
|
+
const creds: StoredCreds = {
|
|
143
|
+
botId: result.botId,
|
|
144
|
+
secret: result.secret,
|
|
145
|
+
boundAt: new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
saveCreds(creds);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ================================================================
|
|
152
|
+
// WeCom 配置
|
|
153
|
+
// ================================================================
|
|
154
|
+
|
|
155
|
+
export interface WeComConfig {
|
|
156
|
+
/** Bot ID(可选,无凭证时自动触发扫码绑定) */
|
|
157
|
+
botId?: string;
|
|
158
|
+
/** Secret(可选,无凭证时自动触发扫码绑定) */
|
|
159
|
+
secret?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ================================================================
|
|
163
|
+
// WeCom IM 模块
|
|
164
|
+
// ================================================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 企业微信 IM 模块 — 扫码绑定 + WebSocket 长连接版
|
|
168
|
+
*
|
|
169
|
+
* 架构说明:
|
|
170
|
+
* - 首次启动无凭证时,显示二维码引导用户扫码
|
|
171
|
+
* - 扫码后自动获取 botId + secret 并保存到 ~/.imtoagent/wecom-creds.json
|
|
172
|
+
* - 使用 @wecom/aibot-node-sdk 建立 WebSocket 长连接
|
|
173
|
+
* - 无需公网 IP、无需 HTTP 回调
|
|
174
|
+
*
|
|
175
|
+
* 消息收发:
|
|
176
|
+
* - 接收: WSClient.on('message', frame) → 解析 body → 回调 handler
|
|
177
|
+
* - 发送: WSClient.sendMessage(chatId, { msgtype, ... })
|
|
178
|
+
*/
|
|
179
|
+
export class WeComIMModule implements IMModule {
|
|
180
|
+
private ws: WSClient | null = null;
|
|
181
|
+
private handler: MessageHandler | null = null;
|
|
182
|
+
private running = false;
|
|
183
|
+
private cfg: WeComConfig;
|
|
184
|
+
|
|
185
|
+
// 被动回复:保存最近收到的 message frame(按 chatId),reply() 优先走被动回复通道
|
|
186
|
+
private pendingFrames = new Map<string, any>();
|
|
187
|
+
|
|
188
|
+
constructor(cfg: WeComConfig = {}) {
|
|
189
|
+
this.cfg = cfg;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getCapabilities(): IMCapabilities {
|
|
193
|
+
return {
|
|
194
|
+
text: true,
|
|
195
|
+
codeBlock: false, // 企微不支持代码块
|
|
196
|
+
cardMessage: true, // 模板卡片消息
|
|
197
|
+
fileSend: true,
|
|
198
|
+
imageSend: true,
|
|
199
|
+
audioSend: false,
|
|
200
|
+
buttonAction: true, // 模板卡片按钮回调
|
|
201
|
+
maxTextLength: TEXT_MAX,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── 启动 / 停止 ──
|
|
206
|
+
|
|
207
|
+
start(handler: MessageHandler): void {
|
|
208
|
+
if (this.running) {
|
|
209
|
+
console.warn('[WeCom] 已在运行中');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.handler = handler;
|
|
213
|
+
this.running = true;
|
|
214
|
+
this._connect();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
stop(): void {
|
|
218
|
+
this.running = false;
|
|
219
|
+
if (this.ws) {
|
|
220
|
+
this.ws.disconnect();
|
|
221
|
+
this.ws = null;
|
|
222
|
+
}
|
|
223
|
+
this.handler = null;
|
|
224
|
+
console.log('[WeCom] 已断开');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── WebSocket 连接 ──
|
|
228
|
+
|
|
229
|
+
private async _connect(): Promise<void> {
|
|
230
|
+
// 1. 尝试获取凭证:配置 → 本地存储 → 扫码绑定
|
|
231
|
+
let botId = this.cfg.botId;
|
|
232
|
+
let secret = this.cfg.secret;
|
|
233
|
+
|
|
234
|
+
if (!botId || !secret) {
|
|
235
|
+
const stored = loadCreds();
|
|
236
|
+
if (stored) {
|
|
237
|
+
botId = stored.botId;
|
|
238
|
+
secret = stored.secret;
|
|
239
|
+
console.log('[WeCom] 已加载本地凭证');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!botId || !secret) {
|
|
244
|
+
console.log('[WeCom] 未找到凭证,启动扫码绑定...');
|
|
245
|
+
const bound = await bindWeComQR();
|
|
246
|
+
botId = bound.botId;
|
|
247
|
+
secret = bound.secret;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log(`[WeCom] 正在连接 WebSocket (bot: ${botId.slice(0, 6)}...)`);
|
|
251
|
+
|
|
252
|
+
// 2. 创建 WSClient
|
|
253
|
+
this.ws = new WSClient({
|
|
254
|
+
botId,
|
|
255
|
+
secret,
|
|
256
|
+
logger: {
|
|
257
|
+
info: m => console.log(`[WeCom-SDK] ${m}`),
|
|
258
|
+
warn: m => console.warn(`[WeCom-SDK] ${m}`),
|
|
259
|
+
error: m => console.error(`[WeCom-SDK] ${m}`),
|
|
260
|
+
debug: m => console.debug(`[WeCom-SDK] ${m}`),
|
|
261
|
+
},
|
|
262
|
+
heartbeatInterval: WS_HEARTBEAT_MS,
|
|
263
|
+
maxReconnectAttempts: WS_MAX_RECONNECT,
|
|
264
|
+
maxAuthFailureAttempts: WS_MAX_AUTH_FAIL,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// 3. 事件监听
|
|
268
|
+
this.ws.on('connected', () => {
|
|
269
|
+
console.log('[WeCom] WebSocket 已连接');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
this.ws.on('authenticated', () => {
|
|
273
|
+
console.log('[WeCom] 认证成功');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
this.ws.on('disconnected', (reason: string) => {
|
|
277
|
+
console.log(`[WeCom] 断开连接: ${reason}`);
|
|
278
|
+
if (this.running) {
|
|
279
|
+
console.log('[WeCom] 5 秒后重连...');
|
|
280
|
+
setTimeout(() => { if (this.running) this._connect(); }, 5000);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
this.ws.on('error', (err: any) => {
|
|
285
|
+
console.error(`[WeCom] 错误: ${err?.message || err}`);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// 4. 接收消息
|
|
289
|
+
this.ws.on('message', async (frame: any) => {
|
|
290
|
+
try {
|
|
291
|
+
await this._handleMessage(frame);
|
|
292
|
+
} catch (e: any) {
|
|
293
|
+
console.error(`[WeCom] 消息处理异常: ${e.message}`);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── 消息解析 ──
|
|
299
|
+
|
|
300
|
+
private async _handleMessage(frame: any): Promise<void> {
|
|
301
|
+
const body = frame.body || {};
|
|
302
|
+
const msgType = (body.msgtype || '').toLowerCase();
|
|
303
|
+
|
|
304
|
+
// 事件消息(模板卡片回调等)
|
|
305
|
+
if (msgType === 'event') {
|
|
306
|
+
const evt = body.event;
|
|
307
|
+
if (evt?.eventtype === 'template_card_event') {
|
|
308
|
+
// 模板卡片按钮点击 → 转为文本
|
|
309
|
+
const items = evt.selected_items?.selected_item ?? [];
|
|
310
|
+
const lines = items.map((it: any) => {
|
|
311
|
+
const ids = it.option_ids?.option_id?.filter(Boolean) ?? [];
|
|
312
|
+
return `- ${it.question_key || '?'}: ${ids.join(', ') || '(未选择)'}`;
|
|
313
|
+
});
|
|
314
|
+
const text = [
|
|
315
|
+
'[模板卡片回调]',
|
|
316
|
+
`card_type: ${evt.card_type || '?'}`,
|
|
317
|
+
`event_key: ${evt.event_key || '?'}`,
|
|
318
|
+
...lines,
|
|
319
|
+
].join('\n');
|
|
320
|
+
const chatId = body.chatid || body.from?.userid || '';
|
|
321
|
+
const userId = body.from?.userid || '';
|
|
322
|
+
if (this.handler && chatId) {
|
|
323
|
+
await this.handler(chatId, text, userId);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const fromUser = body.from?.userid || '';
|
|
330
|
+
const chatId = body.chatid || fromUser;
|
|
331
|
+
const chatType = (body.chattype || 'single').toLowerCase();
|
|
332
|
+
if (!chatId || !fromUser) return;
|
|
333
|
+
|
|
334
|
+
let text = '';
|
|
335
|
+
const attachments: MessageAttachment[] = [];
|
|
336
|
+
|
|
337
|
+
switch (msgType) {
|
|
338
|
+
case 'text':
|
|
339
|
+
text = body.content?.text || body.text?.content || body.content || '';
|
|
340
|
+
break;
|
|
341
|
+
case 'image':
|
|
342
|
+
text = '[图片]';
|
|
343
|
+
if (body.image?.mediaid) {
|
|
344
|
+
const localPath = await this._downloadMedia(body.image.mediaid, body.image.aeskey, 'image.png');
|
|
345
|
+
attachments.push({ type: 'image', localPath: localPath || '', sourceKey: body.image.mediaid, mimeType: 'image/png' });
|
|
346
|
+
}
|
|
347
|
+
break;
|
|
348
|
+
case 'voice':
|
|
349
|
+
text = body.voice?.recognition || body.recognition || '[语音]';
|
|
350
|
+
if (body.voice?.mediaid) {
|
|
351
|
+
const localPath = await this._downloadMedia(body.voice.mediaid, body.voice.aeskey, 'voice.amr');
|
|
352
|
+
attachments.push({ type: 'file', localPath: localPath || '', filename: 'voice.amr', sourceKey: body.voice.mediaid });
|
|
353
|
+
}
|
|
354
|
+
break;
|
|
355
|
+
case 'video':
|
|
356
|
+
text = '[视频]';
|
|
357
|
+
if (body.video?.mediaid) {
|
|
358
|
+
const localPath = await this._downloadMedia(body.video.mediaid, body.video.aeskey, 'video.mp4');
|
|
359
|
+
attachments.push({ type: 'file', localPath: localPath || '', filename: 'video.mp4', sourceKey: body.video.mediaid });
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
362
|
+
case 'file':
|
|
363
|
+
text = `[文件: ${body.file?.title || body.title || '未知'}]`;
|
|
364
|
+
if (body.file?.mediaid) {
|
|
365
|
+
const localPath = await this._downloadMedia(body.file.mediaid, body.file.aeskey, body.file.title || 'file');
|
|
366
|
+
attachments.push({ type: 'file', localPath: localPath || '', filename: body.file.title || 'file', sourceKey: body.file.mediaid });
|
|
367
|
+
}
|
|
368
|
+
break;
|
|
369
|
+
case 'markdown':
|
|
370
|
+
text = body.markdown?.content || '';
|
|
371
|
+
break;
|
|
372
|
+
default:
|
|
373
|
+
text = `[${msgType}消息]`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
|
|
377
|
+
console.log(`[WeCom] ${chatType === 'group' ? '群' : '私聊'} ${fromUser}@${chatId}: ${preview}`);
|
|
378
|
+
|
|
379
|
+
// 保存 frame 用于被动回复
|
|
380
|
+
this.pendingFrames.set(chatId, frame);
|
|
381
|
+
|
|
382
|
+
if (this.handler) {
|
|
383
|
+
await this.handler(chatId, text.trim(), fromUser, attachments.length ? attachments : undefined);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── 发送消息 ──
|
|
388
|
+
|
|
389
|
+
async reply(chatId: string, text: string): Promise<void> {
|
|
390
|
+
if (!this.ws?.isConnected) {
|
|
391
|
+
console.error('[WeCom] WS 未连接');
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const safe = text.length > TEXT_MAX ? text.slice(0, TEXT_MAX) + '\n…截断' : text;
|
|
395
|
+
const body = {
|
|
396
|
+
msgtype: 'markdown',
|
|
397
|
+
markdown: { content: safe },
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// 优先被动回复(挂在用户消息下方,形成对话线程)
|
|
401
|
+
const frame = this.pendingFrames.get(chatId);
|
|
402
|
+
if (frame) {
|
|
403
|
+
try {
|
|
404
|
+
await this.ws.reply(frame, body);
|
|
405
|
+
return;
|
|
406
|
+
} catch (e: any) {
|
|
407
|
+
console.warn(`[WeCom] 被动回复失败,fallback 到主动推送: ${e.message}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// fallback: 主动推送
|
|
412
|
+
try {
|
|
413
|
+
await this.ws.sendMessage(chatId, body);
|
|
414
|
+
} catch (e: any) {
|
|
415
|
+
console.error(`[WeCom] 发送失败: ${e.message}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 流式回复(token-by-token)
|
|
421
|
+
* 需要 Agent 层支持流式输出回调才能发挥效果
|
|
422
|
+
*
|
|
423
|
+
* @param chatId 会话 ID
|
|
424
|
+
* @param streamId 流式消息 ID(同一流内保持一致)
|
|
425
|
+
* @param content 当前 token 内容
|
|
426
|
+
* @param finish 是否结束
|
|
427
|
+
*/
|
|
428
|
+
async replyStream(chatId: string, streamId: string, content: string, finish: boolean): Promise<void> {
|
|
429
|
+
if (!this.ws?.isConnected) return;
|
|
430
|
+
const frame = this.pendingFrames.get(chatId);
|
|
431
|
+
if (!frame) {
|
|
432
|
+
// 无 frame 时降级为主动推送(用 sendMessage 不支持流式)
|
|
433
|
+
if (finish) await this.reply(chatId, content);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
await this.ws.replyStream(frame, streamId, content, finish);
|
|
438
|
+
} catch (e: any) {
|
|
439
|
+
console.error(`[WeCom] 流式发送失败: ${e.message}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 非阻塞流式回复
|
|
445
|
+
* 当上一条消息还未收到 ACK 时跳过中间帧,避免慢连接下排队积压
|
|
446
|
+
* finish=true 的最终帧不受限制,始终发送
|
|
447
|
+
*/
|
|
448
|
+
async replyStreamNonBlocking(chatId: string, streamId: string, content: string, finish: boolean): Promise<void> {
|
|
449
|
+
if (!this.ws?.isConnected) return;
|
|
450
|
+
const frame = this.pendingFrames.get(chatId);
|
|
451
|
+
if (!frame) {
|
|
452
|
+
if (finish) await this.reply(chatId, content);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const result = await this.ws.replyStreamNonBlocking(frame, streamId, content, finish);
|
|
457
|
+
if (result === 'skipped' && !finish) {
|
|
458
|
+
// 静默跳过中间帧(非阻塞保护生效)
|
|
459
|
+
}
|
|
460
|
+
} catch (e: any) {
|
|
461
|
+
console.error(`[WeCom] 非阻塞流式发送失败: ${e.message}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async sendProgress(chatId: string, text: string): Promise<void> {
|
|
466
|
+
await this.reply(chatId, text);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async sendBlocks(chatId: string, blocks: UnifiedBlock[]): Promise<void> {
|
|
470
|
+
const texts: string[] = [];
|
|
471
|
+
for (const b of blocks) {
|
|
472
|
+
switch (b.type) {
|
|
473
|
+
case 'text': texts.push(b.content); break;
|
|
474
|
+
case 'code_block': texts.push(`\`${b.language || 'code'}\`\n${b.code}`); break;
|
|
475
|
+
case 'card': texts.push(`**${b.title}**\n${b.content || ''}`); break;
|
|
476
|
+
case 'divider': texts.push('---'); break;
|
|
477
|
+
case 'table':
|
|
478
|
+
texts.push('| ' + b.headers.join(' | ') + ' |\n' + b.rows.map(r => '| ' + r.join(' | ') + ' |').join('\n'));
|
|
479
|
+
break;
|
|
480
|
+
case 'image':
|
|
481
|
+
if (b.url) {
|
|
482
|
+
try {
|
|
483
|
+
const mediaId = await this._uploadMediaFromSource(b.url, 'image', b.title || 'image.png');
|
|
484
|
+
if (mediaId) await this.ws!.sendMediaMessage(chatId, 'image', mediaId);
|
|
485
|
+
} catch (e: any) { console.error(`[WeCom] 图片上传失败: ${e.message}`); }
|
|
486
|
+
}
|
|
487
|
+
break;
|
|
488
|
+
case 'file':
|
|
489
|
+
if (b.url) {
|
|
490
|
+
try {
|
|
491
|
+
const mediaId = await this._uploadMediaFromSource(b.url, 'file', b.title || 'file');
|
|
492
|
+
if (mediaId) await this.ws!.sendMediaMessage(chatId, 'file', mediaId);
|
|
493
|
+
} catch (e: any) { console.error(`[WeCom] 文件上传失败: ${e.message}`); }
|
|
494
|
+
}
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (texts.length) await this.reply(chatId, texts.join('\n\n'));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async sendImage(chatId: string, imageKey: string, _alt?: string): Promise<void> {
|
|
502
|
+
if (!this.ws?.isConnected) { console.error('[WeCom] WS 未连接'); return; }
|
|
503
|
+
try {
|
|
504
|
+
const mediaId = await this._uploadMediaFromSource(imageKey, 'image', this._basename(imageKey));
|
|
505
|
+
if (mediaId) await this.ws.sendMediaMessage(chatId, 'image', mediaId);
|
|
506
|
+
} catch (e: any) { console.error(`[WeCom] 图片发送失败: ${e.message}`); }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async sendFile(chatId: string, fileKey: string, fileName: string): Promise<void> {
|
|
510
|
+
if (!this.ws?.isConnected) { console.error('[WeCom] WS 未连接'); return; }
|
|
511
|
+
try {
|
|
512
|
+
const mediaId = await this._uploadMediaFromSource(fileKey, 'file', fileName);
|
|
513
|
+
if (mediaId) await this.ws.sendMediaMessage(chatId, 'file', mediaId);
|
|
514
|
+
} catch (e: any) { console.error(`[WeCom] 文件发送失败: ${e.message}`); }
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ── 媒体上传 ──
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 从本地路径或 URL 读取文件,上传到企微获取 media_id
|
|
521
|
+
*/
|
|
522
|
+
private async _uploadMediaFromSource(source: string, mediaType: WeComMediaType, fileName: string): Promise<string | null> {
|
|
523
|
+
let buffer: Buffer;
|
|
524
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
525
|
+
buffer = await this._fetchUrlBuffer(source);
|
|
526
|
+
} else if (source.startsWith('data:')) {
|
|
527
|
+
// data URI
|
|
528
|
+
const commaIdx = source.indexOf(',');
|
|
529
|
+
const b64 = source.substring(commaIdx + 1);
|
|
530
|
+
buffer = Buffer.from(b64, 'base64');
|
|
531
|
+
} else {
|
|
532
|
+
// 本地文件路径
|
|
533
|
+
if (!fs.existsSync(source)) {
|
|
534
|
+
throw new Error(`文件不存在: ${source}`);
|
|
535
|
+
}
|
|
536
|
+
buffer = fs.readFileSync(source);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (buffer.length === 0) throw new Error('文件为空');
|
|
540
|
+
|
|
541
|
+
const result = await this.ws!.uploadMedia(buffer, { type: mediaType, filename: fileName });
|
|
542
|
+
console.log(`[WeCom] 媒体上传成功: ${fileName} → ${result.media_id}`);
|
|
543
|
+
return result.media_id;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** 从 HTTP(S) URL 下载文件为 Buffer */
|
|
547
|
+
private _fetchUrlBuffer(url: string): Promise<Buffer> {
|
|
548
|
+
return new Promise((resolve, reject) => {
|
|
549
|
+
const parsed = new URL(url);
|
|
550
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
551
|
+
lib.get(url, res => {
|
|
552
|
+
// 处理重定向
|
|
553
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
554
|
+
const location = res.headers.location;
|
|
555
|
+
if (location) {
|
|
556
|
+
this._fetchUrlBuffer(location).then(resolve).catch(reject);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; }
|
|
561
|
+
const chunks: Buffer[] = [];
|
|
562
|
+
res.on('data', c => chunks.push(c));
|
|
563
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
564
|
+
res.on('error', reject);
|
|
565
|
+
}).on('error', reject);
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** 从路径提取文件名 */
|
|
570
|
+
private _basename(p: string): string {
|
|
571
|
+
return path.basename(p.split('?')[0]); // 去掉 query string
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ── 媒体下载 ──
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 下载企微媒体文件(需要 AES 解密)
|
|
578
|
+
* @param mediaId 媒体 ID
|
|
579
|
+
* @param aesKey AES 密钥(Base64),来自消息体 image.aeskey / file.aeskey 等
|
|
580
|
+
* @param fallbackName 默认文件名
|
|
581
|
+
* @returns 下载后的本地文件路径
|
|
582
|
+
*/
|
|
583
|
+
private async _downloadMedia(mediaId: string, aesKey: string | undefined, fallbackName: string): Promise<string | null> {
|
|
584
|
+
try {
|
|
585
|
+
// SDK 的 downloadFile 需要完整 URL,企微媒体下载地址格式:
|
|
586
|
+
// https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=TOKEN&media_id=MEDIA_ID
|
|
587
|
+
// 但 SDK 内部封装了下载 + AES 解密,直接用 mediaId 和 aesKey 即可
|
|
588
|
+
const { buffer, filename } = await this.ws!.downloadFile(mediaId, aesKey);
|
|
589
|
+
if (!buffer || buffer.length === 0) return null;
|
|
590
|
+
|
|
591
|
+
const finalName = filename || fallbackName;
|
|
592
|
+
const tempDir = path.join(os.homedir(), '.imtoagent', 'wecom-media');
|
|
593
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
594
|
+
const filePath = path.join(tempDir, `${Date.now()}_${finalName}`);
|
|
595
|
+
fs.writeFileSync(filePath, buffer);
|
|
596
|
+
console.log(`[WeCom] 媒体下载成功: ${mediaId} → ${filePath}`);
|
|
597
|
+
return filePath;
|
|
598
|
+
} catch (e: any) {
|
|
599
|
+
console.error(`[WeCom] 媒体下载失败 (${mediaId}): ${e.message}`);
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|