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.
- package/LICENSE +21 -0
- package/README.en.md +120 -0
- package/README.md +120 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +314 -0
- package/dist/hub/agent-manager.d.ts +63 -0
- package/dist/hub/agent-manager.js +311 -0
- package/dist/hub/hub-context.d.ts +27 -0
- package/dist/hub/hub-context.js +57 -0
- package/dist/hub/index.d.ts +6 -0
- package/dist/hub/index.js +234 -0
- package/dist/hub/launchd.d.ts +7 -0
- package/dist/hub/launchd.js +151 -0
- package/dist/hub/plugin-manager.d.ts +7 -0
- package/dist/hub/plugin-manager.js +29 -0
- package/dist/hub/router.d.ts +21 -0
- package/dist/hub/router.js +35 -0
- package/dist/hub/socket-server.d.ts +23 -0
- package/dist/hub/socket-server.js +191 -0
- package/dist/plugins/channel-manager/index.d.ts +10 -0
- package/dist/plugins/channel-manager/index.js +387 -0
- package/dist/plugins/cron-scheduler/db.d.ts +12 -0
- package/dist/plugins/cron-scheduler/db.js +160 -0
- package/dist/plugins/cron-scheduler/index.d.ts +4 -0
- package/dist/plugins/cron-scheduler/index.js +22 -0
- package/dist/plugins/cron-scheduler/scheduler.d.ts +20 -0
- package/dist/plugins/cron-scheduler/scheduler.js +129 -0
- package/dist/plugins/persistence/db.d.ts +24 -0
- package/dist/plugins/persistence/db.js +121 -0
- package/dist/plugins/persistence/index.d.ts +2 -0
- package/dist/plugins/persistence/index.js +93 -0
- package/dist/plugins/web-monitor/api-routes.d.ts +33 -0
- package/dist/plugins/web-monitor/api-routes.js +474 -0
- package/dist/plugins/web-monitor/index.d.ts +2 -0
- package/dist/plugins/web-monitor/index.js +21 -0
- package/dist/plugins/web-monitor/log-tailer.d.ts +13 -0
- package/dist/plugins/web-monitor/log-tailer.js +74 -0
- package/dist/plugins/web-monitor/monitor-client.d.ts +17 -0
- package/dist/plugins/web-monitor/monitor-client.js +68 -0
- package/dist/plugins/web-monitor/server.d.ts +14 -0
- package/dist/plugins/web-monitor/server.js +205 -0
- package/dist/plugins/web-monitor/stats-reader.d.ts +22 -0
- package/dist/plugins/web-monitor/stats-reader.js +17 -0
- package/dist/plugins/web-monitor/token-stats.d.ts +19 -0
- package/dist/plugins/web-monitor/token-stats.js +86 -0
- package/dist/plugins/web-monitor/usage-stats.d.ts +13 -0
- package/dist/plugins/web-monitor/usage-stats.js +56 -0
- package/dist/plugins/weixin/chunker.d.ts +16 -0
- package/dist/plugins/weixin/chunker.js +142 -0
- package/dist/plugins/weixin/connection.d.ts +46 -0
- package/dist/plugins/weixin/connection.js +270 -0
- package/dist/plugins/weixin/index.d.ts +10 -0
- package/dist/plugins/weixin/index.js +198 -0
- package/dist/plugins/weixin/media-upload.d.ts +22 -0
- package/dist/plugins/weixin/media-upload.js +134 -0
- package/dist/plugins/weixin/media.d.ts +6 -0
- package/dist/plugins/weixin/media.js +83 -0
- package/dist/plugins/weixin/permission.d.ts +35 -0
- package/dist/plugins/weixin/permission.js +96 -0
- package/dist/plugins/weixin/qr-login.d.ts +23 -0
- package/dist/plugins/weixin/qr-login.js +77 -0
- package/dist/plugins/weixin/weixin-channel.d.ts +33 -0
- package/dist/plugins/weixin/weixin-channel.js +123 -0
- package/dist/shared/channel-config.d.ts +8 -0
- package/dist/shared/channel-config.js +14 -0
- package/dist/shared/channel.d.ts +37 -0
- package/dist/shared/channel.js +8 -0
- package/dist/shared/mcp-config.d.ts +5 -0
- package/dist/shared/mcp-config.js +44 -0
- package/dist/shared/plugin.d.ts +32 -0
- package/dist/shared/plugin.js +1 -0
- package/dist/shared/socket.d.ts +5 -0
- package/dist/shared/socket.js +31 -0
- package/dist/shared/types.d.ts +136 -0
- package/dist/shared/types.js +1 -0
- package/dist/spoke/channel-server.d.ts +48 -0
- package/dist/spoke/channel-server.js +383 -0
- package/dist/spoke/index.d.ts +13 -0
- package/dist/spoke/index.js +115 -0
- package/dist/spoke/permission.d.ts +28 -0
- package/dist/spoke/permission.js +142 -0
- package/dist/spoke/socket-client.d.ts +22 -0
- package/dist/spoke/socket-client.js +83 -0
- package/dist/web-frontend/assets/index-CU9vxw8F.js +9 -0
- package/dist/web-frontend/index.html +82 -0
- 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
|
+
}
|