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,1094 @@
|
|
|
1
|
+
// 微信(个人微信)IM 模块 — iLink 协议 HTTP long-poll 版
|
|
2
|
+
// 参考: @tencent-weixin/openclaw-weixin@2.4.3 官方插件逆向研究
|
|
3
|
+
//
|
|
4
|
+
// 协议:iLink(Tencent 内部 Bot 协议)
|
|
5
|
+
// 连接模式:HTTP long-poll(非 WebSocket)
|
|
6
|
+
// API Base:https://ilinkai.weixin.qq.com
|
|
7
|
+
// CDN Base:https://novac2c.cdn.weixin.qq.com/c2c
|
|
8
|
+
//
|
|
9
|
+
// 认证流程:QR 扫码 → get_bot_qrcode → get_qrcode_status → 获取 bot_token
|
|
10
|
+
// 收消息:ilink/bot/getupdates(35s 长轮询,带续传 buf)
|
|
11
|
+
// 发消息:ilink/bot/sendmessage(需携带 context_token)
|
|
12
|
+
// 媒体:AES-128-ECB 加密,CDN 上传/下载
|
|
13
|
+
|
|
14
|
+
import * as https from 'node:https';
|
|
15
|
+
import * as http from 'node:http';
|
|
16
|
+
import * as os from 'node:os';
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import * as crypto from 'node:crypto';
|
|
20
|
+
import * as qrcode from 'qrcode';
|
|
21
|
+
import type { IMModule, IMCapabilities, MessageHandler } from '../types';
|
|
22
|
+
import type { UnifiedBlock } from '../capabilities';
|
|
23
|
+
import type { MessageAttachment } from '../core/types';
|
|
24
|
+
|
|
25
|
+
// ================================================================
|
|
26
|
+
// 常量
|
|
27
|
+
// ================================================================
|
|
28
|
+
|
|
29
|
+
const API_BASE = 'https://ilinkai.weixin.qq.com';
|
|
30
|
+
const CDN_BASE = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
31
|
+
const ILINK_APP_ID = 'bot';
|
|
32
|
+
const CHANNEL_VERSION = '2.4.3';
|
|
33
|
+
|
|
34
|
+
// 认证
|
|
35
|
+
const QR_POLL_INTERVAL_MS = 3000;
|
|
36
|
+
const QR_POLL_TIMEOUT_MS = 300_000; // 5 分钟
|
|
37
|
+
const MAX_QR_REFRESH = 3;
|
|
38
|
+
|
|
39
|
+
// Long-poll
|
|
40
|
+
const LONGPOLL_TIMEOUT_MS = 35000;
|
|
41
|
+
const LONGPOLL_RETRY_DELAY_MS = 2000;
|
|
42
|
+
|
|
43
|
+
// 流式
|
|
44
|
+
const STREAM_PIECE_MAX = 50; // 单次 syncStream 最多上传 50 个 piece
|
|
45
|
+
|
|
46
|
+
// 文本限制
|
|
47
|
+
const TEXT_MAX = 4000;
|
|
48
|
+
|
|
49
|
+
// Session
|
|
50
|
+
const SESSION_PAUSE_MS = 60 * 60 * 1000; // 过期后暂停 1 小时
|
|
51
|
+
|
|
52
|
+
// 凭证 & context_token 存储
|
|
53
|
+
const DATA_DIR = path.join(os.homedir(), '.imtoagent');
|
|
54
|
+
const CREDS_FILE = path.join(DATA_DIR, 'wechat-creds.json');
|
|
55
|
+
const CONTEXT_TOKENS_FILE = path.join(DATA_DIR, 'wechat-context-tokens.json');
|
|
56
|
+
const MEDIA_DIR = path.join(DATA_DIR, 'wechat-media');
|
|
57
|
+
|
|
58
|
+
// ================================================================
|
|
59
|
+
// 类型定义
|
|
60
|
+
// ================================================================
|
|
61
|
+
|
|
62
|
+
// MessageItemType — 消息项类型
|
|
63
|
+
enum MessageItemType {
|
|
64
|
+
NONE = 0,
|
|
65
|
+
TEXT = 1,
|
|
66
|
+
IMAGE = 2,
|
|
67
|
+
VOICE = 3,
|
|
68
|
+
FILE = 4,
|
|
69
|
+
VIDEO = 5,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// QR 扫码状态
|
|
73
|
+
type QrStatus = 'wait' | 'scaned' | 'need_verifycode' | 'confirmed' | 'binded_redirect' | 'scaned_but_redirect' | 'expired' | 'verify_code_blocked';
|
|
74
|
+
|
|
75
|
+
// iLink 消息 item
|
|
76
|
+
interface ILinkItem {
|
|
77
|
+
type: number;
|
|
78
|
+
text_item?: { text: string };
|
|
79
|
+
image_item?: { media: ILinkMedia; aeskey?: string };
|
|
80
|
+
voice_item?: { media: ILinkMedia; aeskey?: string };
|
|
81
|
+
file_item?: { media: ILinkMedia; aeskey?: string };
|
|
82
|
+
video_item?: { media: ILinkMedia; aeskey?: string };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ILinkMedia {
|
|
86
|
+
encrypt_query_param?: string;
|
|
87
|
+
full_url?: string;
|
|
88
|
+
aes_key?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// iLink 消息
|
|
92
|
+
interface ILinkMessage {
|
|
93
|
+
from_user_id: string;
|
|
94
|
+
message_id: string;
|
|
95
|
+
session_id: string;
|
|
96
|
+
context_token: string;
|
|
97
|
+
create_time_ms: number;
|
|
98
|
+
item_list: ILinkItem[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// getUpdates 响应
|
|
102
|
+
interface GetUpdatesResponse {
|
|
103
|
+
ret: number;
|
|
104
|
+
msgs?: ILinkMessage[];
|
|
105
|
+
get_updates_buf?: string;
|
|
106
|
+
longpolling_timeout_ms?: number;
|
|
107
|
+
errmsg?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Stored credentials
|
|
111
|
+
interface StoredCreds {
|
|
112
|
+
botId: string;
|
|
113
|
+
botToken: string;
|
|
114
|
+
ilinkUserId: string;
|
|
115
|
+
boundAt: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Context token 持久化
|
|
119
|
+
interface StoredContextTokens {
|
|
120
|
+
[accountUser: string]: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ================================================================
|
|
124
|
+
// 工具函数
|
|
125
|
+
// ================================================================
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 通用 HTTPS POST 请求(iLink API)
|
|
129
|
+
*/
|
|
130
|
+
async function ilinkPost(endpoint: string, body: any, token?: string): Promise<any> {
|
|
131
|
+
const url = `${API_BASE}/${endpoint}`;
|
|
132
|
+
const bodyStr = JSON.stringify(body);
|
|
133
|
+
const headers: Record<string, string> = {
|
|
134
|
+
'Content-Type': 'application/json',
|
|
135
|
+
'Content-Length': String(Buffer.byteLength(bodyStr)),
|
|
136
|
+
'iLink-App-Id': ILINK_APP_ID,
|
|
137
|
+
'iLink-App-ClientVersion': encodeClientVersion(),
|
|
138
|
+
'AuthorizationType': 'ilink_bot_token',
|
|
139
|
+
'X-WECHAT-UIN': base64RandomUin(),
|
|
140
|
+
};
|
|
141
|
+
if (token) {
|
|
142
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const req = https.request(url, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers,
|
|
149
|
+
timeout: 60000,
|
|
150
|
+
}, res => {
|
|
151
|
+
let data = '';
|
|
152
|
+
res.on('data', c => (data += c));
|
|
153
|
+
res.on('end', () => {
|
|
154
|
+
try {
|
|
155
|
+
resolve(JSON.parse(data));
|
|
156
|
+
} catch {
|
|
157
|
+
reject(new Error(`解析响应失败: ${data.slice(0, 200)}`));
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
req.on('error', reject);
|
|
162
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('请求超时')); });
|
|
163
|
+
req.write(bodyStr);
|
|
164
|
+
req.end();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 通用 HTTPS GET 请求
|
|
170
|
+
*/
|
|
171
|
+
async function ilinkGet(endpoint: string, token?: string): Promise<any> {
|
|
172
|
+
const url = `${API_BASE}/${endpoint}`;
|
|
173
|
+
const headers: Record<string, string> = {
|
|
174
|
+
'iLink-App-Id': ILINK_APP_ID,
|
|
175
|
+
'iLink-App-ClientVersion': encodeClientVersion(),
|
|
176
|
+
'AuthorizationType': 'ilink_bot_token',
|
|
177
|
+
'X-WECHAT-UIN': base64RandomUin(),
|
|
178
|
+
};
|
|
179
|
+
if (token) {
|
|
180
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
https.get(url, { headers, timeout: 60000 }, res => {
|
|
185
|
+
let data = '';
|
|
186
|
+
res.on('data', c => (data += c));
|
|
187
|
+
res.on('end', () => {
|
|
188
|
+
try {
|
|
189
|
+
resolve(JSON.parse(data));
|
|
190
|
+
} catch {
|
|
191
|
+
reject(new Error(`解析响应失败: ${data.slice(0, 200)}`));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}).on('error', reject).on('timeout', reject);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 版本号编码:0x00MMNNPP → "M.N.P"
|
|
200
|
+
* 硬编码 2.4.3 → 0x00020403
|
|
201
|
+
*/
|
|
202
|
+
function encodeClientVersion(): string {
|
|
203
|
+
return '2.4.3';
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 生成随机 UIN 并 base64 编码
|
|
208
|
+
*/
|
|
209
|
+
function base64RandomUin(): string {
|
|
210
|
+
const val = Math.floor(Math.random() * 0xFFFFFFFF);
|
|
211
|
+
return Buffer.from(String(val)).toString('base64');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* AES-128-ECB 解密
|
|
216
|
+
*/
|
|
217
|
+
function aesDecrypt(encrypted: Buffer, keyHex: string): Buffer {
|
|
218
|
+
const key = Buffer.from(keyHex, 'hex'); // hex → 16 bytes
|
|
219
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
220
|
+
decipher.setAutoPadding(true);
|
|
221
|
+
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* AES-128-ECB 加密
|
|
226
|
+
*/
|
|
227
|
+
function aesEncrypt(plain: Buffer, keyHex: string): Buffer {
|
|
228
|
+
const key = Buffer.from(keyHex, 'hex');
|
|
229
|
+
const cipher = crypto.createCipheriv('aes-128-ecb', key, null);
|
|
230
|
+
cipher.setAutoPadding(true);
|
|
231
|
+
return Buffer.concat([cipher.update(plain), cipher.final()]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 生成随机 hex key(16 bytes → 32 hex chars)
|
|
236
|
+
*/
|
|
237
|
+
function generateAesKeyHex(): string {
|
|
238
|
+
return crypto.randomBytes(16).toString('hex');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 计算文件 MD5(hex)
|
|
243
|
+
*/
|
|
244
|
+
function md5Hex(buffer: Buffer): string {
|
|
245
|
+
return crypto.createHash('md5').update(buffer).digest('hex');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ================================================================
|
|
249
|
+
// 凭证 & Context Token 管理
|
|
250
|
+
// ================================================================
|
|
251
|
+
|
|
252
|
+
function loadCreds(): StoredCreds | null {
|
|
253
|
+
try {
|
|
254
|
+
if (!fs.existsSync(CREDS_FILE)) return null;
|
|
255
|
+
return JSON.parse(fs.readFileSync(CREDS_FILE, 'utf8')) as StoredCreds;
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function saveCreds(creds: StoredCreds): void {
|
|
262
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
263
|
+
fs.writeFileSync(CREDS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function loadContextTokens(): StoredContextTokens {
|
|
267
|
+
try {
|
|
268
|
+
if (!fs.existsSync(CONTEXT_TOKENS_FILE)) return {};
|
|
269
|
+
return JSON.parse(fs.readFileSync(CONTEXT_TOKENS_FILE, 'utf8')) as StoredContextTokens;
|
|
270
|
+
} catch {
|
|
271
|
+
return {};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function saveContextTokens(tokens: StoredContextTokens): void {
|
|
276
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
277
|
+
fs.writeFileSync(CONTEXT_TOKENS_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ================================================================
|
|
281
|
+
// QR 扫码认证
|
|
282
|
+
// ================================================================
|
|
283
|
+
|
|
284
|
+
async function getBotQrcode(localTokenList: string[], token?: string): Promise<{ qrcode: string; qrcode_img_content: string }> {
|
|
285
|
+
const result = await ilinkPost('ilink/bot/get_bot_qrcode', {
|
|
286
|
+
bot_type: 3,
|
|
287
|
+
local_token_list: localTokenList,
|
|
288
|
+
}, token);
|
|
289
|
+
if (result.ret !== 0 || !result.qrcode) {
|
|
290
|
+
throw new Error(`获取二维码失败: ${JSON.stringify(result).slice(0, 200)}`);
|
|
291
|
+
}
|
|
292
|
+
return { qrcode: result.qrcode, qrcode_img_content: result.qrcode_img_content };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function getQrcodeStatus(qrcode: string, token?: string): Promise<{ status: QrStatus; ilink_bot_id?: string; bot_token?: string; ilink_user_id?: string; baseurl?: string }> {
|
|
296
|
+
const result = await ilinkGet(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, token);
|
|
297
|
+
return {
|
|
298
|
+
status: result.status as QrStatus,
|
|
299
|
+
ilink_bot_id: result.ilink_bot_id,
|
|
300
|
+
bot_token: result.bot_token,
|
|
301
|
+
ilink_user_id: result.ilink_user_id,
|
|
302
|
+
baseurl: result.baseurl,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function renderQR(qrContent: string): Promise<void> {
|
|
307
|
+
let qrUrl = qrContent;
|
|
308
|
+
// qrcode_img_content 可能是 URL 或 base64
|
|
309
|
+
if (qrContent.startsWith('http')) {
|
|
310
|
+
// URL 类型,下载到终端渲染
|
|
311
|
+
try {
|
|
312
|
+
const qr = await qrcode.toString(qrContent, { type: 'terminal', small: true });
|
|
313
|
+
console.log('\n' + qr + '\n');
|
|
314
|
+
return;
|
|
315
|
+
} catch {
|
|
316
|
+
// 降级:尝试直接作为文本内容生成二维码
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// 尝试用扫码内容本身生成二维码
|
|
320
|
+
const qr = await qrcode.toString(qrContent, { type: 'terminal', small: true });
|
|
321
|
+
console.log('\n' + qr + '\n');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 执行 QR 扫码绑定流程
|
|
326
|
+
* 返回 ilink_bot_id, bot_token, ilink_user_id
|
|
327
|
+
*/
|
|
328
|
+
export async function bindWechatQR(): Promise<{ botId: string; botToken: string; ilinkUserId: string }> {
|
|
329
|
+
console.log('\n📱 微信扫码绑定');
|
|
330
|
+
console.log('正在获取二维码...');
|
|
331
|
+
|
|
332
|
+
let refreshCount = 0;
|
|
333
|
+
let currentToken: string | undefined;
|
|
334
|
+
|
|
335
|
+
while (refreshCount < MAX_QR_REFRESH) {
|
|
336
|
+
const { qrcode, qrcode_img_content } = await getBotQrcode([], currentToken);
|
|
337
|
+
|
|
338
|
+
console.log('请使用微信扫描以下二维码:');
|
|
339
|
+
await renderQR(qrcode_img_content || qrcode);
|
|
340
|
+
console.log('等待扫码中...');
|
|
341
|
+
|
|
342
|
+
const start = Date.now();
|
|
343
|
+
while (Date.now() - start < QR_POLL_TIMEOUT_MS) {
|
|
344
|
+
const statusResult = await getQrcodeStatus(qrcode, currentToken);
|
|
345
|
+
|
|
346
|
+
switch (statusResult.status) {
|
|
347
|
+
case 'confirmed':
|
|
348
|
+
console.log('\n✅ 扫码成功!');
|
|
349
|
+
const creds: StoredCreds = {
|
|
350
|
+
botId: statusResult.ilink_bot_id!,
|
|
351
|
+
botToken: statusResult.bot_token!,
|
|
352
|
+
ilinkUserId: statusResult.ilink_user_id!,
|
|
353
|
+
boundAt: new Date().toISOString(),
|
|
354
|
+
};
|
|
355
|
+
saveCreds(creds);
|
|
356
|
+
return { botId: creds.botId, botToken: creds.botToken, ilinkUserId: creds.ilinkUserId };
|
|
357
|
+
|
|
358
|
+
case 'binded_redirect':
|
|
359
|
+
// 已绑定过,需要用 token 重新获取
|
|
360
|
+
if (statusResult.bot_token) {
|
|
361
|
+
currentToken = statusResult.bot_token;
|
|
362
|
+
// 继续用 token 刷新二维码
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
|
|
366
|
+
case 'scaned_but_redirect':
|
|
367
|
+
// IDC 重定向,刷新二维码
|
|
368
|
+
break;
|
|
369
|
+
|
|
370
|
+
case 'scaned':
|
|
371
|
+
process.stdout.write('.');
|
|
372
|
+
break;
|
|
373
|
+
|
|
374
|
+
case 'need_verifycode':
|
|
375
|
+
console.log('\n⚠️ 需要输入配对码(手机微信显示的数字)');
|
|
376
|
+
console.log('此功能暂不支持自动处理,请在手机上完成验证后重试');
|
|
377
|
+
throw new Error('需要配对码验证');
|
|
378
|
+
|
|
379
|
+
case 'verify_code_blocked':
|
|
380
|
+
throw new Error('配对码多次错误,请重新扫码');
|
|
381
|
+
|
|
382
|
+
case 'expired':
|
|
383
|
+
console.log('\n⏱ 二维码过期,刷新中...');
|
|
384
|
+
refreshCount++;
|
|
385
|
+
break;
|
|
386
|
+
|
|
387
|
+
case 'wait':
|
|
388
|
+
default:
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
await new Promise(r => setTimeout(r, QR_POLL_INTERVAL_MS));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
refreshCount++;
|
|
396
|
+
console.log('\n⏱ 扫码超时,刷新二维码...');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log('\n❌ 二维码刷新次数超限,请重试');
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ================================================================
|
|
404
|
+
// WeChat 配置
|
|
405
|
+
// ================================================================
|
|
406
|
+
|
|
407
|
+
export interface WeChatConfig {
|
|
408
|
+
/** Bot ID(可选,无凭证时自动触发扫码绑定) */
|
|
409
|
+
botId?: string;
|
|
410
|
+
/** Bot Token(可选,无凭证时自动触发扫码绑定) */
|
|
411
|
+
botToken?: string;
|
|
412
|
+
/** iLink User ID(可选) */
|
|
413
|
+
ilinkUserId?: string;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ================================================================
|
|
417
|
+
// Stream 管理
|
|
418
|
+
// ================================================================
|
|
419
|
+
|
|
420
|
+
interface StreamState {
|
|
421
|
+
streamTicket: string;
|
|
422
|
+
pieceSeq: number;
|
|
423
|
+
pieces: Array<{ seq: number; piece_data: string }>;
|
|
424
|
+
phase: 'thinking' | 'result';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ================================================================
|
|
428
|
+
// WeChat IM 模块
|
|
429
|
+
// ================================================================
|
|
430
|
+
|
|
431
|
+
export class WeChatIMModule implements IMModule {
|
|
432
|
+
private handler: MessageHandler | null = null;
|
|
433
|
+
private running = false;
|
|
434
|
+
private cfg: WeChatConfig;
|
|
435
|
+
|
|
436
|
+
// 认证状态
|
|
437
|
+
private botId = '';
|
|
438
|
+
private botToken = '';
|
|
439
|
+
private ilinkUserId = '';
|
|
440
|
+
|
|
441
|
+
// Long-poll
|
|
442
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
443
|
+
private getUpdatesBuf = '';
|
|
444
|
+
private sessionPausedUntil = 0;
|
|
445
|
+
|
|
446
|
+
// 被动回复:保存最近收到的消息 frame(按 chatId)
|
|
447
|
+
private pendingFrames = new Map<string, ILinkMessage>();
|
|
448
|
+
|
|
449
|
+
// context_token 管理
|
|
450
|
+
private contextTokens = new Map<string, string>(); // key: userId@chatId → token
|
|
451
|
+
|
|
452
|
+
// Stream 管理
|
|
453
|
+
private streams = new Map<string, StreamState>();
|
|
454
|
+
|
|
455
|
+
constructor(cfg: WeChatConfig = {}) {
|
|
456
|
+
this.cfg = cfg;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
getCapabilities(): IMCapabilities {
|
|
460
|
+
return {
|
|
461
|
+
text: true,
|
|
462
|
+
codeBlock: false, // 微信不支持代码块
|
|
463
|
+
cardMessage: false,
|
|
464
|
+
fileSend: true,
|
|
465
|
+
imageSend: true,
|
|
466
|
+
audioSend: false,
|
|
467
|
+
buttonAction: false,
|
|
468
|
+
maxTextLength: TEXT_MAX,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── 启动 / 停止 ──
|
|
473
|
+
|
|
474
|
+
start(handler: MessageHandler): void {
|
|
475
|
+
if (this.running) {
|
|
476
|
+
console.warn('[WeChat] 已在运行中');
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
this.handler = handler;
|
|
480
|
+
this.running = true;
|
|
481
|
+
this._connect();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
stop(): void {
|
|
485
|
+
this.running = false;
|
|
486
|
+
if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
|
|
487
|
+
this._notifyStop().catch(() => {});
|
|
488
|
+
this.handler = null;
|
|
489
|
+
console.log('[WeChat] 已断开');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── 连接 & 认证 ──
|
|
493
|
+
|
|
494
|
+
private async _connect(): Promise<void> {
|
|
495
|
+
// 1. 获取凭证:配置 → 本地存储 → 扫码绑定
|
|
496
|
+
this.botId = this.cfg.botId || '';
|
|
497
|
+
this.botToken = this.cfg.botToken || '';
|
|
498
|
+
this.ilinkUserId = this.cfg.ilinkUserId || '';
|
|
499
|
+
|
|
500
|
+
if (!this.botId || !this.botToken) {
|
|
501
|
+
const stored = loadCreds();
|
|
502
|
+
if (stored) {
|
|
503
|
+
this.botId = stored.botId;
|
|
504
|
+
this.botToken = stored.botToken;
|
|
505
|
+
this.ilinkUserId = stored.ilinkUserId;
|
|
506
|
+
console.log('[WeChat] 已加载本地凭证');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (!this.botId || !this.botToken) {
|
|
511
|
+
console.log('[WeChat] 未找到凭证,启动扫码绑定...');
|
|
512
|
+
const bound = await bindWechatQR();
|
|
513
|
+
this.botId = bound.botId;
|
|
514
|
+
this.botToken = bound.botToken;
|
|
515
|
+
this.ilinkUserId = bound.ilinkUserId;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 2. 加载 context_token
|
|
519
|
+
this.contextTokens = new Map(Object.entries(loadContextTokens()));
|
|
520
|
+
|
|
521
|
+
console.log(`[WeChat] 已认证 (bot: ${this.botId.slice(0, 8)}...)`);
|
|
522
|
+
|
|
523
|
+
// 3. 通知上线
|
|
524
|
+
await this._notifyStart();
|
|
525
|
+
|
|
526
|
+
// 4. 启动 long-poll
|
|
527
|
+
this._pollLoop();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ── 上线/下线通知 ──
|
|
531
|
+
|
|
532
|
+
private async _notifyStart(): Promise<void> {
|
|
533
|
+
try {
|
|
534
|
+
await ilinkPost('ilink/bot/msg/notifystart', {
|
|
535
|
+
base_info: { channel_version: CHANNEL_VERSION, bot_agent: 'IMtoAgent' },
|
|
536
|
+
}, this.botToken);
|
|
537
|
+
console.log('[WeChat] 已通知上线');
|
|
538
|
+
} catch (e: any) {
|
|
539
|
+
console.warn(`[WeChat] 通知上线失败: ${e.message}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private async _notifyStop(): Promise<void> {
|
|
544
|
+
if (!this.botToken) return;
|
|
545
|
+
try {
|
|
546
|
+
await ilinkPost('ilink/bot/msg/notifystop', {
|
|
547
|
+
base_info: { channel_version: CHANNEL_VERSION, bot_agent: 'IMtoAgent' },
|
|
548
|
+
}, this.botToken);
|
|
549
|
+
} catch {}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ── Long-Poll 收消息 ──
|
|
553
|
+
|
|
554
|
+
private _pollLoop(): void {
|
|
555
|
+
if (!this.running) return;
|
|
556
|
+
|
|
557
|
+
// 检查 session 是否暂停(过期冷却)
|
|
558
|
+
if (Date.now() < this.sessionPausedUntil) {
|
|
559
|
+
const remaining = Math.ceil((this.sessionPausedUntil - Date.now()) / 1000);
|
|
560
|
+
console.log(`[WeChat] Session 冷却中,剩余 ${remaining}s`);
|
|
561
|
+
this.pollTimer = setTimeout(() => this._pollLoop(), Math.min(remaining * 1000, 60000));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
this._pollOnce().catch(e => {
|
|
566
|
+
console.error(`[WeChat] 轮询错误: ${e.message}`);
|
|
567
|
+
if (this.running) {
|
|
568
|
+
this.pollTimer = setTimeout(() => this._pollLoop(), LONGPOLL_RETRY_DELAY_MS);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private async _pollOnce(): Promise<void> {
|
|
574
|
+
if (!this.running) return;
|
|
575
|
+
|
|
576
|
+
const result: GetUpdatesResponse = await ilinkPost('ilink/bot/getupdates', {
|
|
577
|
+
get_updates_buf: this.getUpdatesBuf,
|
|
578
|
+
base_info: { channel_version: CHANNEL_VERSION, bot_agent: 'IMtoAgent' },
|
|
579
|
+
}, this.botToken);
|
|
580
|
+
|
|
581
|
+
if (result.ret === -14) {
|
|
582
|
+
// Session 过期,暂停 1 小时
|
|
583
|
+
console.error('[WeChat] ⚠️ Session 过期,暂停 1 小时后重试');
|
|
584
|
+
this.sessionPausedUntil = Date.now() + SESSION_PAUSE_MS;
|
|
585
|
+
this.pollTimer = setTimeout(() => this._pollLoop(), SESSION_PAUSE_MS);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (result.ret !== 0) {
|
|
590
|
+
throw new Error(`getupdates 错误: ret=${result.ret} ${result.errmsg || ''}`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 保存续传 buf
|
|
594
|
+
if (result.get_updates_buf) {
|
|
595
|
+
this.getUpdatesBuf = result.get_updates_buf;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 处理消息
|
|
599
|
+
if (result.msgs && result.msgs.length > 0) {
|
|
600
|
+
for (const msg of result.msgs) {
|
|
601
|
+
try {
|
|
602
|
+
await this._handleMessage(msg);
|
|
603
|
+
} catch (e: any) {
|
|
604
|
+
console.error(`[WeChat] 消息处理异常: ${e.message}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// 继续下一轮
|
|
610
|
+
if (this.running) {
|
|
611
|
+
this._pollLoop();
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ── 消息解析 ──
|
|
616
|
+
|
|
617
|
+
private async _handleMessage(msg: ILinkMessage): Promise<void> {
|
|
618
|
+
const fromUser = msg.from_user_id;
|
|
619
|
+
if (!fromUser) return;
|
|
620
|
+
|
|
621
|
+
// 标准化 chatId:去掉 @im.wechat 后缀用于内部标识
|
|
622
|
+
const chatId = fromUser;
|
|
623
|
+
const userId = fromUser;
|
|
624
|
+
|
|
625
|
+
// 保存 context_token
|
|
626
|
+
this.contextTokens.set(`${userId}`, msg.context_token);
|
|
627
|
+
saveContextTokens(Object.fromEntries(this.contextTokens));
|
|
628
|
+
|
|
629
|
+
let text = '';
|
|
630
|
+
const attachments: MessageAttachment[] = [];
|
|
631
|
+
|
|
632
|
+
for (const item of msg.item_list) {
|
|
633
|
+
switch (item.type) {
|
|
634
|
+
case MessageItemType.TEXT:
|
|
635
|
+
if (item.text_item) {
|
|
636
|
+
text += item.text_item.text;
|
|
637
|
+
}
|
|
638
|
+
break;
|
|
639
|
+
|
|
640
|
+
case MessageItemType.IMAGE:
|
|
641
|
+
text += text ? ' [图片]' : '[图片]';
|
|
642
|
+
if (item.image_item?.media) {
|
|
643
|
+
const localPath = await this._downloadMedia(item.image_item.media, item.image_item.aeskey, 'image.png');
|
|
644
|
+
if (localPath) {
|
|
645
|
+
attachments.push({ type: 'image', localPath, sourceKey: msg.message_id, mimeType: 'image/png' });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
break;
|
|
649
|
+
|
|
650
|
+
case MessageItemType.VOICE:
|
|
651
|
+
text += text ? ' [语音]' : '[语音]';
|
|
652
|
+
if (item.voice_item?.media) {
|
|
653
|
+
const localPath = await this._downloadMedia(item.voice_item.media, item.voice_item.aeskey, 'voice.silk');
|
|
654
|
+
if (localPath) {
|
|
655
|
+
attachments.push({ type: 'audio', localPath, filename: 'voice.silk', sourceKey: msg.message_id });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
break;
|
|
659
|
+
|
|
660
|
+
case MessageItemType.FILE:
|
|
661
|
+
text += text ? ' [文件]' : '[文件]';
|
|
662
|
+
if (item.file_item?.media) {
|
|
663
|
+
const localPath = await this._downloadMedia(item.file_item.media, item.file_item.aeskey, 'file');
|
|
664
|
+
if (localPath) {
|
|
665
|
+
attachments.push({ type: 'file', localPath, filename: 'file', sourceKey: msg.message_id });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
break;
|
|
669
|
+
|
|
670
|
+
case MessageItemType.VIDEO:
|
|
671
|
+
text += text ? ' [视频]' : '[视频]';
|
|
672
|
+
if (item.video_item?.media) {
|
|
673
|
+
const localPath = await this._downloadMedia(item.video_item.media, item.video_item.aeskey, 'video.mp4');
|
|
674
|
+
if (localPath) {
|
|
675
|
+
attachments.push({ type: 'file', localPath, filename: 'video.mp4', sourceKey: msg.message_id });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const preview = text.length > 80 ? text.slice(0, 80) + '...' : text;
|
|
683
|
+
console.log(`[WeChat] 私聊 ${userId.slice(0, 12)}...: ${preview}`);
|
|
684
|
+
|
|
685
|
+
// 保存 frame 用于被动回复
|
|
686
|
+
this.pendingFrames.set(chatId, msg);
|
|
687
|
+
// 限制 pendingFrames 大小
|
|
688
|
+
if (this.pendingFrames.size > 100) {
|
|
689
|
+
const firstKey = this.pendingFrames.keys().next().value;
|
|
690
|
+
if (firstKey) this.pendingFrames.delete(firstKey);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (this.handler) {
|
|
694
|
+
await this.handler(chatId, text.trim(), userId, attachments.length ? attachments : undefined);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ── 发送消息 ──
|
|
699
|
+
|
|
700
|
+
async reply(chatId: string, text: string, maxLen?: number): Promise<void> {
|
|
701
|
+
const max = maxLen || TEXT_MAX;
|
|
702
|
+
const safe = text.length > max ? text.slice(0, max) + '\n…截断' : text;
|
|
703
|
+
await this._sendMessage(chatId, [
|
|
704
|
+
{ type: MessageItemType.TEXT, text_item: { text: safe } },
|
|
705
|
+
]);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async sendProgress(chatId: string, text: string): Promise<void> {
|
|
709
|
+
await this.reply(chatId, text);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 流式回复(token-by-token)
|
|
714
|
+
*/
|
|
715
|
+
async replyStream(chatId: string, streamId: string, content: string, finish: boolean): Promise<void> {
|
|
716
|
+
let state = this.streams.get(streamId);
|
|
717
|
+
|
|
718
|
+
if (!state) {
|
|
719
|
+
// 初始化流式通道
|
|
720
|
+
try {
|
|
721
|
+
const ticket = await this._initStream();
|
|
722
|
+
state = { streamTicket: ticket, pieceSeq: 0, pieces: [], phase: 'result' };
|
|
723
|
+
this.streams.set(streamId, state);
|
|
724
|
+
// 发送 thinking 阶段结束信号
|
|
725
|
+
await this._sendStreamSignal(state.streamTicket, 'thinking', true);
|
|
726
|
+
} catch (e: any) {
|
|
727
|
+
console.error(`[WeChat] 流式初始化失败,降级为普通回复: ${e.message}`);
|
|
728
|
+
await this.reply(chatId, content);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// 添加 piece
|
|
734
|
+
const pieceData = JSON.stringify({ type: 'text', text: content });
|
|
735
|
+
state.pieces.push({
|
|
736
|
+
seq: state.pieceSeq++,
|
|
737
|
+
piece_data: Buffer.from(pieceData).toString('base64'),
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// 达到批量上传阈值或结束,上传
|
|
741
|
+
if (state.pieces.length >= STREAM_PIECE_MAX || finish) {
|
|
742
|
+
await this._syncStream(state.streamTicket, state.pieces, finish ? state.pieceSeq - 1 : undefined);
|
|
743
|
+
state.pieces = [];
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (finish) {
|
|
747
|
+
this.streams.delete(streamId);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* 非阻塞流式回复
|
|
753
|
+
*/
|
|
754
|
+
async replyStreamNonBlocking(chatId: string, streamId: string, content: string, finish: boolean): Promise<void> {
|
|
755
|
+
if (finish) {
|
|
756
|
+
// 最终帧始终发送
|
|
757
|
+
await this.replyStream(chatId, streamId, content, true);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
// 非阻塞模式:跳过中间帧(简化实现,微信流式本身就有排队机制)
|
|
761
|
+
await this.replyStream(chatId, streamId, content, false);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async sendBlocks(chatId: string, blocks: UnifiedBlock[]): Promise<void> {
|
|
765
|
+
const texts: string[] = [];
|
|
766
|
+
for (const b of blocks) {
|
|
767
|
+
switch (b.type) {
|
|
768
|
+
case 'text':
|
|
769
|
+
texts.push(b.content);
|
|
770
|
+
break;
|
|
771
|
+
case 'code_block':
|
|
772
|
+
texts.push(`\`${b.language || 'code'}\`\n${b.code}`);
|
|
773
|
+
break;
|
|
774
|
+
case 'card':
|
|
775
|
+
texts.push(`**${b.title}**\n${b.content || ''}`);
|
|
776
|
+
break;
|
|
777
|
+
case 'divider':
|
|
778
|
+
texts.push('---');
|
|
779
|
+
break;
|
|
780
|
+
case 'table':
|
|
781
|
+
texts.push('| ' + b.headers.join(' | ') + ' |\n' + b.rows.map(r => '| ' + r.join(' | ') + ' |').join('\n'));
|
|
782
|
+
break;
|
|
783
|
+
case 'image':
|
|
784
|
+
if (b.url) {
|
|
785
|
+
try {
|
|
786
|
+
await this._sendImageFromSource(chatId, b.url, b.title || 'image.png');
|
|
787
|
+
} catch (e: any) {
|
|
788
|
+
console.error(`[WeChat] 图片发送失败: ${e.message}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
break;
|
|
792
|
+
case 'file':
|
|
793
|
+
if (b.url) {
|
|
794
|
+
try {
|
|
795
|
+
await this._sendFileFromSource(chatId, b.url, b.title || b.filename || 'file');
|
|
796
|
+
} catch (e: any) {
|
|
797
|
+
console.error(`[WeChat] 文件发送失败: ${e.message}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (texts.length) await this.reply(chatId, texts.join('\n\n'));
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async sendImage(chatId: string, imageKey: string, _alt?: string): Promise<void> {
|
|
807
|
+
try {
|
|
808
|
+
await this._sendImageFromSource(chatId, imageKey, this._basename(imageKey));
|
|
809
|
+
} catch (e: any) {
|
|
810
|
+
console.error(`[WeChat] 图片发送失败: ${e.message}`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async sendFile(chatId: string, fileKey: string, fileName: string): Promise<void> {
|
|
815
|
+
try {
|
|
816
|
+
await this._sendFileFromSource(chatId, fileKey, fileName);
|
|
817
|
+
} catch (e: any) {
|
|
818
|
+
console.error(`[WeChat] 文件发送失败: ${e.message}`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ── 核心:发送消息到 iLink ──
|
|
823
|
+
|
|
824
|
+
private async _sendMessage(toUserId: string, itemList: ILinkItem[]): Promise<void> {
|
|
825
|
+
const contextToken = this.contextTokens.get(toUserId) || '';
|
|
826
|
+
|
|
827
|
+
const body = {
|
|
828
|
+
msg: {
|
|
829
|
+
from_user_id: '',
|
|
830
|
+
to_user_id: toUserId,
|
|
831
|
+
client_id: `imtoagent-${this.botId}`,
|
|
832
|
+
message_type: 2, // BOT
|
|
833
|
+
message_state: 2, // FINISH
|
|
834
|
+
item_list: itemList,
|
|
835
|
+
context_token: contextToken,
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
await ilinkPost('ilink/bot/sendmessage', body, this.botToken);
|
|
841
|
+
} catch (e: any) {
|
|
842
|
+
console.error(`[WeChat] 发送失败: ${e.message}`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── 流式相关 ──
|
|
847
|
+
|
|
848
|
+
private async _initStream(): Promise<string> {
|
|
849
|
+
const result = await ilinkPost('ilink/bot/init_stream', {
|
|
850
|
+
base_info: { channel_version: CHANNEL_VERSION, bot_agent: 'IMtoAgent' },
|
|
851
|
+
}, this.botToken);
|
|
852
|
+
if (result.ret !== 0 || !result.stream_ticket) {
|
|
853
|
+
throw new Error(`initStream 失败: ${JSON.stringify(result).slice(0, 200)}`);
|
|
854
|
+
}
|
|
855
|
+
return result.stream_ticket;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private async _sendStreamSignal(streamTicket: string, phase: 'thinking' | 'result', isEnd: boolean): Promise<void> {
|
|
859
|
+
await ilinkPost('ilink/bot/sync_stream', {
|
|
860
|
+
stream_ticket: streamTicket,
|
|
861
|
+
phase,
|
|
862
|
+
is_end: isEnd,
|
|
863
|
+
pieces: [],
|
|
864
|
+
}, this.botToken);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private async _syncStream(streamTicket: string, pieces: Array<{ seq: number; piece_data: string }>, endUpPieceSeq?: number): Promise<void> {
|
|
868
|
+
if (pieces.length === 0) return;
|
|
869
|
+
await ilinkPost('ilink/bot/sync_stream', {
|
|
870
|
+
stream_ticket: streamTicket,
|
|
871
|
+
phase: 'result',
|
|
872
|
+
is_end: false,
|
|
873
|
+
pieces,
|
|
874
|
+
...(endUpPieceSeq !== undefined ? { end_up_piece_seq: endUpPieceSeq } : {}),
|
|
875
|
+
}, this.botToken);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ── 媒体下载(CDN + AES 解密) ──
|
|
879
|
+
|
|
880
|
+
private async _downloadMedia(media: ILinkMedia, aesKeyHex: string | undefined, fallbackName: string): Promise<string | null> {
|
|
881
|
+
try {
|
|
882
|
+
// 1. 构造下载 URL
|
|
883
|
+
let downloadUrl = '';
|
|
884
|
+
if (media.full_url) {
|
|
885
|
+
downloadUrl = media.full_url;
|
|
886
|
+
} else if (media.encrypt_query_param) {
|
|
887
|
+
downloadUrl = `${CDN_BASE}?${media.encrypt_query_param}`;
|
|
888
|
+
} else {
|
|
889
|
+
console.error('[WeChat] 媒体下载:无可用 URL');
|
|
890
|
+
return null;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// 2. 下载加密文件
|
|
894
|
+
const encrypted = await this._fetchUrlBuffer(downloadUrl);
|
|
895
|
+
if (!encrypted || encrypted.length === 0) return null;
|
|
896
|
+
|
|
897
|
+
// 3. AES 解密
|
|
898
|
+
let decrypted: Buffer;
|
|
899
|
+
if (aesKeyHex) {
|
|
900
|
+
decrypted = aesDecrypt(encrypted, aesKeyHex);
|
|
901
|
+
} else if (media.aes_key) {
|
|
902
|
+
// aes_key 可能是 base64(raw 16 bytes) 或 hex
|
|
903
|
+
let keyHex: string;
|
|
904
|
+
try {
|
|
905
|
+
keyHex = Buffer.from(media.aes_key, 'base64').toString('hex');
|
|
906
|
+
} catch {
|
|
907
|
+
keyHex = media.aes_key;
|
|
908
|
+
}
|
|
909
|
+
decrypted = aesDecrypt(encrypted, keyHex);
|
|
910
|
+
} else {
|
|
911
|
+
// 无密钥,可能是未加密内容
|
|
912
|
+
decrypted = encrypted;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// 4. 保存到本地
|
|
916
|
+
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
|
917
|
+
const filePath = path.join(MEDIA_DIR, `${Date.now()}_${fallbackName}`);
|
|
918
|
+
fs.writeFileSync(filePath, decrypted);
|
|
919
|
+
console.log(`[WeChat] 媒体下载完成: ${filePath}`);
|
|
920
|
+
return filePath;
|
|
921
|
+
} catch (e: any) {
|
|
922
|
+
console.error(`[WeChat] 媒体下载失败: ${e.message}`);
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ── 媒体上传(AES 加密 + CDN 上传) ──
|
|
928
|
+
|
|
929
|
+
private async _uploadMedia(buffer: Buffer, mediaType: 'image' | 'file' | 'video'): Promise<{ encryptQuery: string; aesKey: string; fileKey: string } | null> {
|
|
930
|
+
try {
|
|
931
|
+
const fileMd5 = md5Hex(buffer);
|
|
932
|
+
const fileKey = crypto.randomBytes(16).toString('hex');
|
|
933
|
+
const aesKey = generateAesKeyHex();
|
|
934
|
+
|
|
935
|
+
// 1. 加密
|
|
936
|
+
const encrypted = aesEncrypt(buffer, aesKey);
|
|
937
|
+
|
|
938
|
+
// 2. 获取上传 URL
|
|
939
|
+
const uploadResult = await ilinkPost('ilink/bot/getuploadurl', {
|
|
940
|
+
file_md5: fileMd5,
|
|
941
|
+
file_size: encrypted.length,
|
|
942
|
+
file_type: mediaType,
|
|
943
|
+
file_key: fileKey,
|
|
944
|
+
base_info: { channel_version: CHANNEL_VERSION, bot_agent: 'IMtoAgent' },
|
|
945
|
+
}, this.botToken);
|
|
946
|
+
|
|
947
|
+
if (uploadResult.ret !== 0) {
|
|
948
|
+
throw new Error(`getUploadUrl 失败: ${JSON.stringify(uploadResult).slice(0, 200)}`);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const uploadUrl = uploadResult.upload_full_url || uploadResult.upload_url;
|
|
952
|
+
if (!uploadUrl) {
|
|
953
|
+
throw new Error('未获取到上传 URL');
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// 3. 上传加密文件到 CDN
|
|
957
|
+
await this._uploadToCdn(uploadUrl, encrypted);
|
|
958
|
+
|
|
959
|
+
// 4. 返回上传结果
|
|
960
|
+
const encryptQuery = uploadResult.encrypt_query_param || uploadResult.encrypt_query;
|
|
961
|
+
return { encryptQuery, aesKey, fileKey };
|
|
962
|
+
} catch (e: any) {
|
|
963
|
+
console.error(`[WeChat] 媒体上传失败: ${e.message}`);
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private _uploadToCdn(url: string, buffer: Buffer): Promise<void> {
|
|
969
|
+
return new Promise((resolve, reject) => {
|
|
970
|
+
const parsed = new URL(url);
|
|
971
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
972
|
+
// CDN 上传默认 POST(Tencent CDN 标准)
|
|
973
|
+
const req = lib.request(url, {
|
|
974
|
+
method: 'POST',
|
|
975
|
+
headers: {
|
|
976
|
+
'Content-Type': 'application/octet-stream',
|
|
977
|
+
'Content-Length': String(buffer.length),
|
|
978
|
+
},
|
|
979
|
+
timeout: 60000,
|
|
980
|
+
}, res => {
|
|
981
|
+
let data = '';
|
|
982
|
+
res.on('data', c => (data += c));
|
|
983
|
+
res.on('end', () => {
|
|
984
|
+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
|
985
|
+
resolve();
|
|
986
|
+
} else {
|
|
987
|
+
reject(new Error(`CDN 上传失败: HTTP ${res.statusCode} ${data.slice(0, 100)}`));
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
req.on('error', reject);
|
|
992
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('CDN 上传超时')); });
|
|
993
|
+
req.write(buffer);
|
|
994
|
+
req.end();
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ── 从来源发送图片 ──
|
|
999
|
+
|
|
1000
|
+
private async _sendImageFromSource(chatId: string, source: string, fileName: string): Promise<void> {
|
|
1001
|
+
const buffer = await this._readSource(source);
|
|
1002
|
+
if (!buffer) return;
|
|
1003
|
+
|
|
1004
|
+
const upload = await this._uploadMedia(buffer, 'image');
|
|
1005
|
+
if (!upload) return;
|
|
1006
|
+
|
|
1007
|
+
await this._sendMessage(chatId, [
|
|
1008
|
+
{
|
|
1009
|
+
type: MessageItemType.IMAGE,
|
|
1010
|
+
image_item: {
|
|
1011
|
+
media: {
|
|
1012
|
+
encrypt_query_param: upload.encryptQuery,
|
|
1013
|
+
aes_key: upload.aesKey,
|
|
1014
|
+
},
|
|
1015
|
+
aeskey: upload.aesKey,
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
]);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ── 从来源发送文件 ──
|
|
1022
|
+
|
|
1023
|
+
private async _sendFileFromSource(chatId: string, source: string, fileName: string): Promise<void> {
|
|
1024
|
+
const buffer = await this._readSource(source);
|
|
1025
|
+
if (!buffer) return;
|
|
1026
|
+
|
|
1027
|
+
const upload = await this._uploadMedia(buffer, 'file');
|
|
1028
|
+
if (!upload) return;
|
|
1029
|
+
|
|
1030
|
+
await this._sendMessage(chatId, [
|
|
1031
|
+
{
|
|
1032
|
+
type: MessageItemType.FILE,
|
|
1033
|
+
file_item: {
|
|
1034
|
+
media: {
|
|
1035
|
+
encrypt_query_param: upload.encryptQuery,
|
|
1036
|
+
aes_key: upload.aesKey,
|
|
1037
|
+
},
|
|
1038
|
+
aeskey: upload.aesKey,
|
|
1039
|
+
},
|
|
1040
|
+
},
|
|
1041
|
+
]);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// ── 读取来源(本地路径 / URL / data URI) ──
|
|
1045
|
+
|
|
1046
|
+
private async _readSource(source: string): Promise<Buffer | null> {
|
|
1047
|
+
if (source.startsWith('data:')) {
|
|
1048
|
+
const commaIdx = source.indexOf(',');
|
|
1049
|
+
const b64 = source.substring(commaIdx + 1);
|
|
1050
|
+
return Buffer.from(b64, 'base64');
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
1054
|
+
return this._fetchUrlBuffer(source);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (fs.existsSync(source)) {
|
|
1058
|
+
return fs.readFileSync(source);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
console.error(`[WeChat] 文件不存在: ${source}`);
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ── HTTP 下载 ──
|
|
1066
|
+
|
|
1067
|
+
private _fetchUrlBuffer(url: string): Promise<Buffer> {
|
|
1068
|
+
return new Promise((resolve, reject) => {
|
|
1069
|
+
const parsed = new URL(url);
|
|
1070
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
1071
|
+
lib.get(url, { timeout: 30000 }, res => {
|
|
1072
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
1073
|
+
const location = res.headers.location;
|
|
1074
|
+
if (location) {
|
|
1075
|
+
this._fetchUrlBuffer(location).then(resolve).catch(reject);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (res.statusCode !== 200) {
|
|
1080
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const chunks: Buffer[] = [];
|
|
1084
|
+
res.on('data', c => chunks.push(c));
|
|
1085
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
1086
|
+
res.on('error', reject);
|
|
1087
|
+
}).on('error', reject).on('timeout', reject);
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
private _basename(p: string): string {
|
|
1092
|
+
return path.basename(p.split('?')[0]);
|
|
1093
|
+
}
|
|
1094
|
+
}
|