cc2im 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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +120 -0
  3. package/README.md +120 -0
  4. package/dist/cli.d.ts +16 -0
  5. package/dist/cli.js +314 -0
  6. package/dist/hub/agent-manager.d.ts +63 -0
  7. package/dist/hub/agent-manager.js +311 -0
  8. package/dist/hub/hub-context.d.ts +27 -0
  9. package/dist/hub/hub-context.js +57 -0
  10. package/dist/hub/index.d.ts +6 -0
  11. package/dist/hub/index.js +234 -0
  12. package/dist/hub/launchd.d.ts +7 -0
  13. package/dist/hub/launchd.js +151 -0
  14. package/dist/hub/plugin-manager.d.ts +7 -0
  15. package/dist/hub/plugin-manager.js +29 -0
  16. package/dist/hub/router.d.ts +21 -0
  17. package/dist/hub/router.js +35 -0
  18. package/dist/hub/socket-server.d.ts +23 -0
  19. package/dist/hub/socket-server.js +191 -0
  20. package/dist/plugins/channel-manager/index.d.ts +10 -0
  21. package/dist/plugins/channel-manager/index.js +387 -0
  22. package/dist/plugins/cron-scheduler/db.d.ts +12 -0
  23. package/dist/plugins/cron-scheduler/db.js +160 -0
  24. package/dist/plugins/cron-scheduler/index.d.ts +4 -0
  25. package/dist/plugins/cron-scheduler/index.js +22 -0
  26. package/dist/plugins/cron-scheduler/scheduler.d.ts +20 -0
  27. package/dist/plugins/cron-scheduler/scheduler.js +129 -0
  28. package/dist/plugins/persistence/db.d.ts +24 -0
  29. package/dist/plugins/persistence/db.js +121 -0
  30. package/dist/plugins/persistence/index.d.ts +2 -0
  31. package/dist/plugins/persistence/index.js +93 -0
  32. package/dist/plugins/web-monitor/api-routes.d.ts +33 -0
  33. package/dist/plugins/web-monitor/api-routes.js +474 -0
  34. package/dist/plugins/web-monitor/index.d.ts +2 -0
  35. package/dist/plugins/web-monitor/index.js +21 -0
  36. package/dist/plugins/web-monitor/log-tailer.d.ts +13 -0
  37. package/dist/plugins/web-monitor/log-tailer.js +74 -0
  38. package/dist/plugins/web-monitor/monitor-client.d.ts +17 -0
  39. package/dist/plugins/web-monitor/monitor-client.js +68 -0
  40. package/dist/plugins/web-monitor/server.d.ts +14 -0
  41. package/dist/plugins/web-monitor/server.js +205 -0
  42. package/dist/plugins/web-monitor/stats-reader.d.ts +22 -0
  43. package/dist/plugins/web-monitor/stats-reader.js +17 -0
  44. package/dist/plugins/web-monitor/token-stats.d.ts +19 -0
  45. package/dist/plugins/web-monitor/token-stats.js +86 -0
  46. package/dist/plugins/web-monitor/usage-stats.d.ts +13 -0
  47. package/dist/plugins/web-monitor/usage-stats.js +56 -0
  48. package/dist/plugins/weixin/chunker.d.ts +16 -0
  49. package/dist/plugins/weixin/chunker.js +142 -0
  50. package/dist/plugins/weixin/connection.d.ts +46 -0
  51. package/dist/plugins/weixin/connection.js +270 -0
  52. package/dist/plugins/weixin/index.d.ts +10 -0
  53. package/dist/plugins/weixin/index.js +198 -0
  54. package/dist/plugins/weixin/media-upload.d.ts +22 -0
  55. package/dist/plugins/weixin/media-upload.js +134 -0
  56. package/dist/plugins/weixin/media.d.ts +6 -0
  57. package/dist/plugins/weixin/media.js +83 -0
  58. package/dist/plugins/weixin/permission.d.ts +35 -0
  59. package/dist/plugins/weixin/permission.js +96 -0
  60. package/dist/plugins/weixin/qr-login.d.ts +23 -0
  61. package/dist/plugins/weixin/qr-login.js +77 -0
  62. package/dist/plugins/weixin/weixin-channel.d.ts +33 -0
  63. package/dist/plugins/weixin/weixin-channel.js +123 -0
  64. package/dist/shared/channel-config.d.ts +8 -0
  65. package/dist/shared/channel-config.js +14 -0
  66. package/dist/shared/channel.d.ts +37 -0
  67. package/dist/shared/channel.js +8 -0
  68. package/dist/shared/mcp-config.d.ts +5 -0
  69. package/dist/shared/mcp-config.js +44 -0
  70. package/dist/shared/plugin.d.ts +32 -0
  71. package/dist/shared/plugin.js +1 -0
  72. package/dist/shared/socket.d.ts +5 -0
  73. package/dist/shared/socket.js +31 -0
  74. package/dist/shared/types.d.ts +136 -0
  75. package/dist/shared/types.js +1 -0
  76. package/dist/spoke/channel-server.d.ts +48 -0
  77. package/dist/spoke/channel-server.js +383 -0
  78. package/dist/spoke/index.d.ts +13 -0
  79. package/dist/spoke/index.js +115 -0
  80. package/dist/spoke/permission.d.ts +28 -0
  81. package/dist/spoke/permission.js +142 -0
  82. package/dist/spoke/socket-client.d.ts +22 -0
  83. package/dist/spoke/socket-client.js +83 -0
  84. package/dist/web-frontend/assets/index-CU9vxw8F.js +9 -0
  85. package/dist/web-frontend/index.html +82 -0
  86. package/package.json +54 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * 微信连接 + 收发
3
+ * 从 cc2wx 搬迁,适配 hub 架构
4
+ */
5
+ import { WeixinBot } from '@pinixai/weixin-bot';
6
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { join, basename } from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { downloadMedia, cleanupMedia } from './media.js';
10
+ import { loadCredentials, CRED_PATH } from './qr-login.js';
11
+ import { splitIntoChunks, formatChunks } from './chunker.js';
12
+ import { uploadMedia } from './media-upload.js';
13
+ import { SOCKET_DIR } from '../../shared/socket.js';
14
+ const ALLOWED_USERS = process.env.CC2IM_ALLOWED_USERS
15
+ ? process.env.CC2IM_ALLOWED_USERS.split(',').map(s => s.trim())
16
+ : [];
17
+ const CONTEXT_CACHE_PATH = join(SOCKET_DIR, 'weixin-context.json');
18
+ export class WeixinConnection {
19
+ bot = new WeixinBot();
20
+ recentMessages = new Map(); // userId -> raw msg for reply
21
+ onIncoming = null;
22
+ listening = false;
23
+ cleanupTimer = null;
24
+ setMessageHandler(handler) {
25
+ this.onIncoming = handler;
26
+ }
27
+ /** Persist context tokens to disk so replies work after hub restart */
28
+ saveContextCache(channelId) {
29
+ const cache = {};
30
+ for (const [userId, msg] of this.recentMessages) {
31
+ if (msg._contextToken) {
32
+ cache[userId] = { userId, _contextToken: msg._contextToken };
33
+ }
34
+ }
35
+ try {
36
+ const path = channelId
37
+ ? join(SOCKET_DIR, `weixin-context-${channelId}.json`)
38
+ : CONTEXT_CACHE_PATH;
39
+ writeFileSync(path, JSON.stringify(cache) + '\n');
40
+ console.log(`[weixin] Saved context cache (${Object.keys(cache).length} users)`);
41
+ }
42
+ catch { }
43
+ }
44
+ /** Restore context tokens from disk. Call after login, before startListening. */
45
+ restoreContextCache(channelId) {
46
+ const path = channelId
47
+ ? join(SOCKET_DIR, `weixin-context-${channelId}.json`)
48
+ : CONTEXT_CACHE_PATH;
49
+ try {
50
+ if (!existsSync(path)) {
51
+ // Fall back to global file for backward compat
52
+ if (channelId && existsSync(CONTEXT_CACHE_PATH)) {
53
+ return this.restoreContextCache();
54
+ }
55
+ return;
56
+ }
57
+ const cache = JSON.parse(readFileSync(path, 'utf8'));
58
+ let restored = 0;
59
+ for (const [userId, entry] of Object.entries(cache)) {
60
+ if (entry._contextToken) {
61
+ this.recentMessages.set(userId, { userId, _contextToken: entry._contextToken });
62
+ this.bot.contextTokens?.set(userId, entry._contextToken);
63
+ restored++;
64
+ }
65
+ }
66
+ if (restored > 0) {
67
+ console.log(`[weixin] Restored context cache (${restored} users)`);
68
+ }
69
+ }
70
+ catch { }
71
+ }
72
+ async login(channelId) {
73
+ // Load per-channel credentials (falls back to global file)
74
+ const channelCreds = loadCredentials(channelId);
75
+ if (!channelCreds) {
76
+ throw new Error('未找到微信登录凭证! 请先运行: cc2im login');
77
+ }
78
+ // Write per-channel creds to the global path so the SDK picks them up.
79
+ // TODO: Race condition if two channels call login() concurrently — channel B
80
+ // could overwrite the global file before channel A's bot.login() reads it.
81
+ // Currently safe because channel-manager starts channels sequentially.
82
+ writeFileSync(CRED_PATH, JSON.stringify(channelCreds, null, 2) + '\n', { mode: 0o600 });
83
+ console.log('[hub] 使用已保存的凭证登录微信...');
84
+ const creds = await this.bot.login();
85
+ console.log(`[hub] 微信连接成功! accountId=${creds.accountId}`);
86
+ if (ALLOWED_USERS.length === 0) {
87
+ console.log('[hub] ⚠ 白名单为空,将接受所有用户消息');
88
+ console.log('[hub] 设置 CC2IM_ALLOWED_USERS 环境变量限制用户');
89
+ }
90
+ return creds.accountId;
91
+ }
92
+ startListening() {
93
+ if (this.listening)
94
+ return;
95
+ this.listening = true;
96
+ // Clean up expired media on startup + every 6 hours
97
+ cleanupMedia();
98
+ this.cleanupTimer = setInterval(cleanupMedia, 6 * 60 * 60 * 1000);
99
+ this.bot.onMessage(async (msg) => {
100
+ // Allowlist check
101
+ if (ALLOWED_USERS.length > 0 && !ALLOWED_USERS.includes(msg.userId)) {
102
+ console.log(`[hub] Blocked message from unlisted user: ${msg.userId}`);
103
+ return;
104
+ }
105
+ console.log(`[hub] 收到微信消息 from=${msg.userId} type=${msg.type}: ${msg.text?.slice(0, 100)}`);
106
+ // Cache for reply + persist context token to disk
107
+ this.recentMessages.set(msg.userId, msg);
108
+ if (this.recentMessages.size > 50) {
109
+ const oldest = this.recentMessages.keys().next().value;
110
+ if (oldest)
111
+ this.recentMessages.delete(oldest);
112
+ }
113
+ this.saveContextCache();
114
+ // Handle media
115
+ let mediaPath = null;
116
+ let voiceText = null;
117
+ if (msg.type !== 'text' && msg.raw?.item_list?.[0]) {
118
+ if (msg.type === 'voice') {
119
+ voiceText = msg.text || msg.raw?.item_list?.[0]?.voice_item?.text || null;
120
+ }
121
+ else {
122
+ mediaPath = await downloadMedia(msg.raw.item_list[0]);
123
+ }
124
+ }
125
+ if (this.onIncoming) {
126
+ Promise.resolve(this.onIncoming({ ...msg, mediaPath, voiceText })).catch((err) => {
127
+ console.error(`[weixin-bot] Message handler error: ${err instanceof Error ? err.message : String(err)}`);
128
+ });
129
+ }
130
+ });
131
+ }
132
+ async startPolling() {
133
+ console.log('[hub] 开始监听微信消息...');
134
+ await this.bot.run();
135
+ }
136
+ /** Stop the polling loop and clear the message handler so reconnect starts clean. */
137
+ stop() {
138
+ this.bot.stop();
139
+ if (this.cleanupTimer) {
140
+ clearInterval(this.cleanupTimer);
141
+ this.cleanupTimer = null;
142
+ }
143
+ this.onIncoming = null;
144
+ // Note: do NOT reset this.listening — bot.onMessage() is additive (SDK has no
145
+ // removeHandler). The registered handler delegates to this.onIncoming, which is
146
+ // cleared above, making it a no-op until reconnect sets a fresh handler.
147
+ }
148
+ async startTyping(userId) {
149
+ try {
150
+ await this.bot.sendTyping(userId);
151
+ }
152
+ catch { }
153
+ }
154
+ async stopTyping(userId) {
155
+ try {
156
+ await this.bot.stopTyping(userId);
157
+ }
158
+ catch { }
159
+ }
160
+ async send(userId, text) {
161
+ const chunks = formatChunks(splitIntoChunks(text));
162
+ const cachedMsg = this.recentMessages.get(userId);
163
+ for (let i = 0; i < chunks.length; i++) {
164
+ try {
165
+ if (cachedMsg) {
166
+ await this.bot.reply(cachedMsg, chunks[i]);
167
+ }
168
+ else {
169
+ await this.bot.send(userId, chunks[i]);
170
+ }
171
+ }
172
+ catch (err) {
173
+ // WeChat SDK needs a cached context token to send.
174
+ // After hub restart, cache is empty until user sends a new message.
175
+ console.error(`[weixin] Failed to send to ${userId}: ${err.message}`);
176
+ return;
177
+ }
178
+ if (i < chunks.length - 1)
179
+ await new Promise(r => setTimeout(r, 500));
180
+ }
181
+ }
182
+ /** Upload a local image and send it as an image message. */
183
+ async sendImage(userId, filePath) {
184
+ const { baseUrl, token, contextToken } = await this.getBotCredentials(userId);
185
+ const { cdnMedia, rawSize } = await uploadMedia(filePath, 'image', baseUrl, token, userId);
186
+ const msg = {
187
+ from_user_id: '',
188
+ to_user_id: userId,
189
+ client_id: randomUUID(),
190
+ message_type: 2, // MessageType.BOT
191
+ message_state: 2, // MessageState.FINISH
192
+ context_token: contextToken,
193
+ item_list: [{
194
+ type: 2, // MessageItemType.IMAGE
195
+ image_item: {
196
+ media: cdnMedia,
197
+ mid_size: rawSize,
198
+ },
199
+ }],
200
+ };
201
+ await this.callSendMessage(baseUrl, token, msg);
202
+ console.log(`[weixin] Image sent to ${userId}: ${filePath}`);
203
+ }
204
+ /** Upload a local file and send it as a file message. */
205
+ async sendFile(userId, filePath) {
206
+ const { baseUrl, token, contextToken } = await this.getBotCredentials(userId);
207
+ const { cdnMedia, rawSize } = await uploadMedia(filePath, 'file', baseUrl, token, userId);
208
+ const msg = {
209
+ from_user_id: '',
210
+ to_user_id: userId,
211
+ client_id: randomUUID(),
212
+ message_type: 2, // MessageType.BOT
213
+ message_state: 2, // MessageState.FINISH
214
+ context_token: contextToken,
215
+ item_list: [{
216
+ type: 4, // MessageItemType.FILE
217
+ file_item: {
218
+ media: cdnMedia,
219
+ file_name: basename(filePath),
220
+ len: String(rawSize),
221
+ },
222
+ }],
223
+ };
224
+ await this.callSendMessage(baseUrl, token, msg);
225
+ console.log(`[weixin] File sent to ${userId}: ${basename(filePath)}`);
226
+ }
227
+ // ── private helpers for media send ────────────────────────────────
228
+ /** Extract baseUrl, token, and contextToken from the SDK internals. */
229
+ async getBotCredentials(userId) {
230
+ const bot = this.bot;
231
+ const baseUrl = bot.baseUrl;
232
+ const creds = await bot.ensureCredentials();
233
+ const token = creds.token;
234
+ // contextToken: prefer SDK's internal map, fall back to our cache
235
+ const contextToken = bot.contextTokens?.get(userId) ??
236
+ this.recentMessages.get(userId)?._contextToken;
237
+ if (!contextToken) {
238
+ throw new Error(`No context token for user ${userId}. The user must send a message first.`);
239
+ }
240
+ return { baseUrl, token, contextToken };
241
+ }
242
+ /**
243
+ * POST /ilink/bot/sendmessage — mirrors the SDK's sendMessage()
244
+ * which is not re-exported from the package's main entry.
245
+ */
246
+ async callSendMessage(baseUrl, token, msg) {
247
+ const url = new URL('/ilink/bot/sendmessage', `${baseUrl.replace(/\/+$/, '')}/`);
248
+ const resp = await fetch(url, {
249
+ method: 'POST',
250
+ headers: {
251
+ 'Content-Type': 'application/json',
252
+ AuthorizationType: 'ilink_bot_token',
253
+ Authorization: `Bearer ${token}`,
254
+ },
255
+ body: JSON.stringify({
256
+ msg,
257
+ base_info: { channel_version: '1.0.0' },
258
+ }),
259
+ signal: AbortSignal.timeout(15_000),
260
+ });
261
+ if (!resp.ok) {
262
+ const text = await resp.text();
263
+ throw new Error(`sendMessage failed: HTTP ${resp.status} — ${text}`);
264
+ }
265
+ const body = (await resp.json());
266
+ if (body.ret && body.ret !== 0) {
267
+ throw new Error(`sendMessage returned ret=${body.ret}: ${body.errmsg ?? ''}`);
268
+ }
269
+ }
270
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * WeChat connector plugin — bridges WeChat ↔ hub ↔ spokes.
3
+ * Owns: WeixinConnection, PermissionManager, user tracking, message routing.
4
+ */
5
+ import type { Cc2imPlugin } from '../../shared/plugin.js';
6
+ /**
7
+ * @deprecated Use createChannelManagerPlugin + WeixinChannel instead.
8
+ * Kept for reference during migration — no longer registered in hub.
9
+ */
10
+ export declare function createWeixinPlugin(): Cc2imPlugin;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * WeChat connector plugin — bridges WeChat ↔ hub ↔ spokes.
3
+ * Owns: WeixinConnection, PermissionManager, user tracking, message routing.
4
+ */
5
+ import { basename, join } from 'node:path';
6
+ import { copyFileSync, mkdirSync } from 'node:fs';
7
+ import { SOCKET_DIR } from '../../shared/socket.js';
8
+ import { WeixinConnection } from './connection.js';
9
+ import { PermissionManager } from './permission.js';
10
+ const TYPING_ACK_DELAY_MS = 10_000; // 10s 后发"处理中"
11
+ /**
12
+ * @deprecated Use createChannelManagerPlugin + WeixinChannel instead.
13
+ * Kept for reference during migration — no longer registered in hub.
14
+ */
15
+ export function createWeixinPlugin() {
16
+ let weixin;
17
+ let permissionMgr;
18
+ let cleanupInterval;
19
+ const lastUserByAgent = new Map();
20
+ let lastGlobalUser = null;
21
+ // Per-agent pending ack timer: agentId → { userId, timer }
22
+ const pendingAck = new Map();
23
+ return {
24
+ name: 'weixin',
25
+ async init(ctx) {
26
+ weixin = new WeixinConnection();
27
+ permissionMgr = new PermissionManager();
28
+ /** Clear pending ack timer for an agent (called when agent responds) */
29
+ function clearPendingAck(agentId) {
30
+ const pending = pendingAck.get(agentId);
31
+ if (pending) {
32
+ clearTimeout(pending.timer);
33
+ pendingAck.delete(agentId);
34
+ weixin.stopTyping(pending.userId).catch(() => { });
35
+ }
36
+ }
37
+ /** Start typing indicator + delayed ack for a user→agent message */
38
+ function startPendingAck(agentId, userId) {
39
+ clearPendingAck(agentId); // clear any previous
40
+ weixin.startTyping(userId).catch(() => { });
41
+ const timer = setTimeout(async () => {
42
+ pendingAck.delete(agentId);
43
+ await weixin.send(userId, `⏳ 收到,正在处理...`).catch(() => { });
44
+ }, TYPING_ACK_DELAY_MS);
45
+ pendingAck.set(agentId, { userId, timer });
46
+ }
47
+ // --- Spoke → WeChat: handle spoke messages ---
48
+ ctx.on('spoke:message', async (agentId, msg) => {
49
+ switch (msg.type) {
50
+ case 'reply': {
51
+ clearPendingAck(agentId);
52
+ console.log(`[hub] Reply from ${agentId} to ${msg.userId}: ${msg.text.slice(0, 100)}`);
53
+ ctx.broadcastMonitor({ kind: 'message_out', agentId, userId: msg.userId, text: msg.text, timestamp: new Date().toISOString() });
54
+ await weixin.send(msg.userId, msg.text);
55
+ break;
56
+ }
57
+ case 'permission_request': {
58
+ console.log(`[hub] Permission request from ${agentId}: ${msg.toolName}`);
59
+ const sendFn = async (userId, text) => { await weixin.send(userId, text); };
60
+ await permissionMgr.handleRequest(agentId, msg, ctx, sendFn, lastUserByAgent, lastGlobalUser);
61
+ break;
62
+ }
63
+ case 'status': {
64
+ console.log(`[hub] Agent ${agentId} status: ${msg.status}`);
65
+ break;
66
+ }
67
+ case 'permission_timeout': {
68
+ permissionMgr.handleTimeout(msg.requestId);
69
+ break;
70
+ }
71
+ case 'send_file': {
72
+ clearPendingAck(agentId);
73
+ const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']);
74
+ const VIDEO_EXTS = new Set(['mp4', 'mov', 'avi', 'webm']);
75
+ const ext = msg.filePath.split('.').pop()?.toLowerCase() || '';
76
+ const isImage = IMAGE_EXTS.has(ext);
77
+ const isVideo = VIDEO_EXTS.has(ext);
78
+ const msgType = isImage ? 'image' : isVideo ? 'video' : 'file';
79
+ try {
80
+ if (isImage) {
81
+ await weixin.sendImage(msg.userId, msg.filePath);
82
+ }
83
+ else {
84
+ await weixin.sendFile(msg.userId, msg.filePath);
85
+ }
86
+ // Copy to media dir so dashboard can preview
87
+ const mediaDir = join(SOCKET_DIR, 'media');
88
+ const mediaName = `${Date.now()}-${basename(msg.filePath)}`;
89
+ try {
90
+ mkdirSync(mediaDir, { recursive: true });
91
+ copyFileSync(msg.filePath, join(mediaDir, mediaName));
92
+ }
93
+ catch { }
94
+ console.log(`[hub] File sent from ${agentId} to ${msg.userId}: ${msg.filePath}`);
95
+ ctx.broadcastMonitor({
96
+ kind: 'message_out', agentId, userId: msg.userId,
97
+ text: isImage ? '[图片]' : `[${msgType}] ${basename(msg.filePath)}`,
98
+ timestamp: new Date().toISOString(),
99
+ msgType,
100
+ mediaUrl: `/media/${mediaName}`,
101
+ });
102
+ }
103
+ catch (err) {
104
+ console.error(`[hub] Failed to send file from ${agentId}: ${err.message}`);
105
+ }
106
+ break;
107
+ }
108
+ // NOTE: 'management' type is handled by hub core, not by this plugin
109
+ }
110
+ });
111
+ // --- WeChat → Spoke: handle incoming WeChat messages ---
112
+ weixin.setMessageHandler(async (incomingMsg) => {
113
+ const userId = incomingMsg.userId;
114
+ const ref = { userId, channelId: 'weixin' };
115
+ lastGlobalUser = ref;
116
+ // Permission verdict detection
117
+ if (permissionMgr.tryHandleVerdict(incomingMsg, ctx))
118
+ return;
119
+ // Route message
120
+ const router = ctx.getRouter();
121
+ const routed = router.route(incomingMsg.text || '');
122
+ lastUserByAgent.set(routed.agentId, ref);
123
+ // Unknown agent
124
+ if (routed.unknownAgent) {
125
+ const available = router.getAgentNames();
126
+ await weixin.send(userId, `⚠ Agent "${routed.agentId}" 不存在,可用的 agent: ${available.join(', ') || '无'}`);
127
+ return;
128
+ }
129
+ // Intercepted commands (restart, effort)
130
+ if (routed.intercepted) {
131
+ const agentManager = ctx.getAgentManager();
132
+ switch (routed.intercepted.command) {
133
+ case 'restart': {
134
+ await weixin.send(userId, `正在重启 ${routed.agentId}...`);
135
+ const result = await agentManager.restart(routed.agentId);
136
+ await weixin.send(userId, result.success ? `✓ ${routed.agentId} 已重启` : `✗ 重启失败: ${result.error}`);
137
+ return;
138
+ }
139
+ case 'effort': {
140
+ const effort = routed.intercepted.args[0];
141
+ agentManager.updateEffort(routed.agentId, effort);
142
+ await weixin.send(userId, `正在以 --effort ${effort} 重启 ${routed.agentId}...`);
143
+ const result = await agentManager.restart(routed.agentId);
144
+ await weixin.send(userId, result.success ? `✓ ${routed.agentId} 已重启 (effort: ${effort})` : `✗ 重启失败: ${result.error}`);
145
+ return;
146
+ }
147
+ }
148
+ }
149
+ // Forward to spoke — persistence plugin will queue if offline
150
+ const text = buildMessageContent(incomingMsg, routed.text);
151
+ console.log(`[hub] Forwarding to ${routed.agentId}: ${text.substring(0, 80)}`);
152
+ const mediaUrl = incomingMsg.mediaPath ? `/media/${basename(incomingMsg.mediaPath)}` : undefined;
153
+ ctx.broadcastMonitor({ kind: 'message_in', agentId: routed.agentId, userId, text: routed.text, timestamp: new Date().toISOString(), msgType: incomingMsg.type, mediaUrl });
154
+ const sent = ctx.deliverToAgent(routed.agentId, {
155
+ type: 'message',
156
+ userId,
157
+ text,
158
+ msgType: incomingMsg.type,
159
+ mediaPath: incomingMsg.mediaPath ?? undefined,
160
+ timestamp: incomingMsg.timestamp?.toISOString() ?? new Date().toISOString(),
161
+ });
162
+ if (sent) {
163
+ startPendingAck(routed.agentId, userId);
164
+ }
165
+ else {
166
+ console.log(`[hub] Message queued for offline agent "${routed.agentId}"`);
167
+ await weixin.send(userId, `📬 ${routed.agentId} 暂时离线,消息已排队,上线后自动投递。`);
168
+ }
169
+ });
170
+ // Permission cleanup
171
+ cleanupInterval = setInterval(() => permissionMgr.cleanup(), 60_000);
172
+ // Login, restore context cache, start listening
173
+ await weixin.login();
174
+ weixin.restoreContextCache();
175
+ weixin.startListening();
176
+ // startPolling() is a long-poll loop that never returns — fire and forget
177
+ weixin.startPolling().catch((err) => {
178
+ console.error(`[weixin] Polling error: ${err.message}`);
179
+ });
180
+ },
181
+ async destroy() {
182
+ if (cleanupInterval)
183
+ clearInterval(cleanupInterval);
184
+ weixin.saveContextCache();
185
+ },
186
+ };
187
+ }
188
+ function buildMessageContent(msg, routedText) {
189
+ if (msg.type === 'voice' && msg.voiceText)
190
+ return `[微信 ${msg.userId}] (语音转文字) ${msg.voiceText}`;
191
+ if (msg.type === 'voice')
192
+ return `[微信 ${msg.userId}] (语音消息,无法识别)`;
193
+ if (msg.mediaPath)
194
+ return `[微信 ${msg.userId}] (${msg.type} 已下载到 ${msg.mediaPath})`;
195
+ if (msg.type !== 'text')
196
+ return `[微信 ${msg.userId}] (${msg.type} 消息,下载失败)`;
197
+ return `[微信 ${msg.userId}] ${routedText}`;
198
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Media upload — AES-128-ECB encrypt + CDN upload
3
+ *
4
+ * Reverse of media.ts (download + decrypt).
5
+ * Encrypts a local file and uploads it to the WeChat CDN,
6
+ * returning CDNMedia metadata for use in sendMessage.
7
+ */
8
+ import type { CDNMedia } from '@pinixai/weixin-bot';
9
+ export interface UploadResult {
10
+ cdnMedia: CDNMedia;
11
+ rawSize: number;
12
+ }
13
+ /**
14
+ * Upload a local file to the WeChat CDN.
15
+ *
16
+ * 1. Read file & generate AES key
17
+ * 2. Encrypt with AES-128-ECB
18
+ * 3. POST /ilink/bot/getuploadurl to obtain CDN endpoint
19
+ * 4. PUT encrypted payload to CDN
20
+ * 5. Return CDNMedia for embedding in sendMessage
21
+ */
22
+ export declare function uploadMedia(filePath: string, mediaType: 'image' | 'video' | 'file', baseUrl: string, token: string, toUserId: string): Promise<UploadResult>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Media upload — AES-128-ECB encrypt + CDN upload
3
+ *
4
+ * Reverse of media.ts (download + decrypt).
5
+ * Encrypts a local file and uploads it to the WeChat CDN,
6
+ * returning CDNMedia metadata for use in sendMessage.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { randomBytes, createCipheriv, createHash } from 'node:crypto';
10
+ // ── helpers (not re-exported by the SDK's main entry) ────────────
11
+ function randomWechatUin() {
12
+ const value = randomBytes(4).readUInt32BE(0);
13
+ return Buffer.from(String(value), 'utf8').toString('base64');
14
+ }
15
+ function buildHeaders(token) {
16
+ return {
17
+ AuthorizationType: 'ilink_bot_token',
18
+ Authorization: `Bearer ${token}`,
19
+ 'X-WECHAT-UIN': randomWechatUin(),
20
+ };
21
+ }
22
+ // ── media-type mapping ───────────────────────────────────────────
23
+ const MEDIA_TYPE_MAP = {
24
+ image: 1,
25
+ video: 2,
26
+ file: 3,
27
+ };
28
+ // ── core ─────────────────────────────────────────────────────────
29
+ /**
30
+ * Generate a random 16-byte hex string for use as AES key.
31
+ * The WeChat protocol stores this as a hex string (not raw bytes).
32
+ */
33
+ function generateAesKeyHex() {
34
+ return randomBytes(16).toString('hex');
35
+ }
36
+ /**
37
+ * Encrypt plaintext buffer with AES-128-ECB + PKCS7 padding.
38
+ * Key is a 16-byte hex string → parse to 16-byte Buffer.
39
+ */
40
+ function encryptAes128Ecb(plain, aesKeyHex) {
41
+ const key = Buffer.from(aesKeyHex, 'hex');
42
+ const cipher = createCipheriv('aes-128-ecb', key, Buffer.alloc(0));
43
+ return Buffer.concat([cipher.update(plain), cipher.final()]);
44
+ }
45
+ /**
46
+ * Encode the hex AES key as base64 (matches download-side parseAesKey expectation).
47
+ *
48
+ * Download does: base64 → utf8 hex string → Buffer.from(hex)
49
+ * So upload must: hex string → utf8 bytes → base64
50
+ */
51
+ function aesKeyToBase64(aesKeyHex) {
52
+ return Buffer.from(aesKeyHex, 'utf8').toString('base64');
53
+ }
54
+ /**
55
+ * Upload a local file to the WeChat CDN.
56
+ *
57
+ * 1. Read file & generate AES key
58
+ * 2. Encrypt with AES-128-ECB
59
+ * 3. POST /ilink/bot/getuploadurl to obtain CDN endpoint
60
+ * 4. PUT encrypted payload to CDN
61
+ * 5. Return CDNMedia for embedding in sendMessage
62
+ */
63
+ export async function uploadMedia(filePath, mediaType, baseUrl, token, toUserId) {
64
+ // 1. Read & encrypt
65
+ const plain = readFileSync(filePath);
66
+ const aesKeyHex = generateAesKeyHex();
67
+ const encrypted = encryptAes128Ecb(plain, aesKeyHex);
68
+ const filekey = randomBytes(16).toString('hex');
69
+ const rawfilemd5 = createHash('md5').update(plain).digest('hex');
70
+ // 2. Get upload URL
71
+ const body = {
72
+ filekey,
73
+ media_type: MEDIA_TYPE_MAP[mediaType],
74
+ to_user_id: toUserId,
75
+ rawsize: plain.length,
76
+ rawfilemd5,
77
+ filesize: encrypted.length,
78
+ aeskey: aesKeyHex,
79
+ no_need_thumb: true,
80
+ base_info: { channel_version: '1.0.0' },
81
+ };
82
+ const normalizedBase = baseUrl.replace(/\/+$/, '');
83
+ const uploadUrlResp = await fetch(new URL('/ilink/bot/getuploadurl', `${normalizedBase}/`), {
84
+ method: 'POST',
85
+ headers: {
86
+ 'Content-Type': 'application/json',
87
+ ...buildHeaders(token),
88
+ },
89
+ body: JSON.stringify(body),
90
+ signal: AbortSignal.timeout(15_000),
91
+ });
92
+ if (!uploadUrlResp.ok) {
93
+ const text = await uploadUrlResp.text();
94
+ throw new Error(`getuploadurl failed: HTTP ${uploadUrlResp.status} — ${text}`);
95
+ }
96
+ const uploadInfo = (await uploadUrlResp.json());
97
+ if (uploadInfo.ret && uploadInfo.ret !== 0) {
98
+ throw new Error(`getuploadurl returned ret=${uploadInfo.ret}`);
99
+ }
100
+ // 3. Upload encrypted payload to CDN
101
+ const cdnBase = uploadInfo.upload_url || 'https://novac2c.cdn.weixin.qq.com/c2c/upload';
102
+ const cdnUrl = `${cdnBase}?encrypted_query_param=${encodeURIComponent(uploadInfo.upload_param)}&filekey=${filekey}`;
103
+ const cdnResp = await fetch(cdnUrl, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/octet-stream',
107
+ ...buildHeaders(token),
108
+ },
109
+ body: new Uint8Array(encrypted),
110
+ signal: AbortSignal.timeout(60_000),
111
+ });
112
+ if (!cdnResp.ok) {
113
+ const text = await cdnResp.text();
114
+ throw new Error(`CDN upload failed: HTTP ${cdnResp.status} — ${text}`);
115
+ }
116
+ // 4. Extract encrypt_query_param from response
117
+ // Prefer header, fall back to JSON body
118
+ let encryptQueryParam = cdnResp.headers.get('x-encrypted-param') ?? '';
119
+ if (!encryptQueryParam) {
120
+ const cdnBody = (await cdnResp.json());
121
+ encryptQueryParam = cdnBody.encrypt_query_param ?? '';
122
+ }
123
+ if (!encryptQueryParam) {
124
+ throw new Error('CDN upload succeeded but no encrypt_query_param returned');
125
+ }
126
+ return {
127
+ cdnMedia: {
128
+ encrypt_query_param: encryptQueryParam,
129
+ aes_key: aesKeyToBase64(aesKeyHex),
130
+ encrypt_type: 1,
131
+ },
132
+ rawSize: plain.length,
133
+ };
134
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 媒体下载 + AES 解密
3
+ * 从 cc2wx.ts:31-106 搬迁,逻辑不变
4
+ */
5
+ export declare function cleanupMedia(): void;
6
+ export declare function downloadMedia(item: any): Promise<string | null>;