@kafca/agentdock 0.1.7
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 +70 -0
- package/bin/agentdock.mjs +275 -0
- package/build/icon.png +0 -0
- package/dist/renderer/assets/index-BaNpzC6d.js +482 -0
- package/dist/renderer/assets/index-CLWJyO0E.css +11 -0
- package/dist/renderer/favicon.svg +19 -0
- package/dist/renderer/index.html +14 -0
- package/dist/renderer/logo-options.svg +153 -0
- package/dist-electron/electron/knowledge-skill-script.test.js +125 -0
- package/dist-electron/electron/lac-cli.test.js +221 -0
- package/dist-electron/electron/local-core-refactor.test.js +1075 -0
- package/dist-electron/electron/main.js +163 -0
- package/dist-electron/electron/plugin-kernel.test.js +534 -0
- package/dist-electron/electron/preload.js +2 -0
- package/dist-electron/package.json +3 -0
- package/dist-electron/packages/contracts/src/index.js +18 -0
- package/dist-electron/packages/contracts/src/local-core.js +2 -0
- package/dist-electron/packages/core-sdk/src/index.js +406 -0
- package/dist-electron/packages/knowledge-api/src/ai-vector-provider.js +388 -0
- package/dist-electron/packages/knowledge-api/src/index.js +146 -0
- package/dist-electron/packages/knowledge-api/src/sqlite-store.js +467 -0
- package/dist-electron/packages/knowledge-api/src/thread-knowledge-store.js +21 -0
- package/dist-electron/packages/knowledge-api/test/ai-vector-provider.test.js +412 -0
- package/dist-electron/packages/plugin-sdk/src/index.js +2 -0
- package/dist-electron/services/local-ai-core/src/acp/local-core-acp-backend.js +369 -0
- package/dist-electron/services/local-ai-core/src/acp/local-core-acp-response-processor.js +110 -0
- package/dist-electron/services/local-ai-core/src/acp/local-core-acp-session-coordinator.js +171 -0
- package/dist-electron/services/local-ai-core/src/acp/local-core-acp-store.js +635 -0
- package/dist-electron/services/local-ai-core/src/acp/local-core-acp-transport.js +190 -0
- package/dist-electron/services/local-ai-core/src/acp/local-core-acp-turn-coordinator.js +244 -0
- package/dist-electron/services/local-ai-core/src/acp/workspace-acp-permissions.js +52 -0
- package/dist-electron/services/local-ai-core/src/cli/lac.js +290 -0
- package/dist-electron/services/local-ai-core/src/gateway/local-core-lark-gateway.js +976 -0
- package/dist-electron/services/local-ai-core/src/gateway/local-core-weixin-gateway.js +1143 -0
- package/dist-electron/services/local-ai-core/src/kernel/bootstrap.js +291 -0
- package/dist-electron/services/local-ai-core/src/kernel/capability-registry.js +67 -0
- package/dist-electron/services/local-ai-core/src/kernel/diagnostics.js +27 -0
- package/dist-electron/services/local-ai-core/src/kernel/event-bus.js +27 -0
- package/dist-electron/services/local-ai-core/src/kernel/lifecycle-manager.js +85 -0
- package/dist-electron/services/local-ai-core/src/kernel/plugin-registry.js +60 -0
- package/dist-electron/services/local-ai-core/src/plugins/builtin/agent-localcore-acp-plugin.js +114 -0
- package/dist-electron/services/local-ai-core/src/plugins/builtin/channel-lark-plugin.js +59 -0
- package/dist-electron/services/local-ai-core/src/plugins/builtin/channel-weixin-plugin.js +56 -0
- package/dist-electron/services/local-ai-core/src/plugins/builtin/knowledge-ai-vector-plugin.js +7 -0
- package/dist-electron/services/local-ai-core/src/plugins/builtin/knowledge-noop-plugin.js +7 -0
- package/dist-electron/services/local-ai-core/src/plugins/builtin/runtime-capabilities-plugin.js +62 -0
- package/dist-electron/services/local-ai-core/src/plugins/builtin/scheduler-cron-plugin.js +49 -0
- package/dist-electron/services/local-ai-core/src/plugins/builtin/scheduler-lark-plugin.js +38 -0
- package/dist-electron/services/local-ai-core/src/plugins/builtin/scheduler-weixin-plugin.js +38 -0
- package/dist-electron/services/local-ai-core/src/router/workspace-route-config.js +230 -0
- package/dist-electron/services/local-ai-core/src/router/workspace-router-types.js +2 -0
- package/dist-electron/services/local-ai-core/src/router/workspace-router.js +389 -0
- package/dist-electron/services/local-ai-core/src/runtime/local-core-controller.js +315 -0
- package/dist-electron/services/local-ai-core/src/runtime/local-core-runtime-state.js +282 -0
- package/dist-electron/services/local-ai-core/src/runtime/server.js +465 -0
- package/dist-electron/services/local-ai-core/src/runtime/standalone.js +38 -0
- package/dist-electron/services/local-ai-core/src/scheduler/adapters.js +2 -0
- package/dist-electron/services/local-ai-core/src/scheduler/cron-command-detector.js +70 -0
- package/dist-electron/services/local-ai-core/src/scheduler/cron.js +53 -0
- package/dist-electron/services/local-ai-core/src/scheduler/execution-policy.js +2 -0
- package/dist-electron/services/local-ai-core/src/scheduler/lark-execution-policies.js +66 -0
- package/dist-electron/services/local-ai-core/src/scheduler/lark-schedule-adapter.js +77 -0
- package/dist-electron/services/local-ai-core/src/scheduler/scheduled-conversation-executor.js +46 -0
- package/dist-electron/services/local-ai-core/src/scheduler/scheduler-run-lifecycle.js +64 -0
- package/dist-electron/services/local-ai-core/src/scheduler/scheduler-service.js +133 -0
- package/dist-electron/services/local-ai-core/src/scheduler/weixin-execution-policies.js +66 -0
- package/dist-electron/services/local-ai-core/src/scheduler/weixin-schedule-adapter.js +78 -0
- package/dist-electron/services/local-ai-core/src/thread/workspace-thread-id.js +17 -0
- package/dist-electron/services/local-ai-core/src/thread/workspace-thread-mappers.js +43 -0
- package/dist-electron/shared/desktop.js +195 -0
- package/dist-electron/src/api/client.js +73 -0
- package/dist-electron/src/api/sessions.js +21 -0
- package/dist-electron/src/lib/session-utils.js +51 -0
- package/dist-electron/src/pages/Threads/thread-chat-model.js +246 -0
- package/dist-electron/src/pages/Threads/thread-chat-model.test.js +76 -0
- package/dist-electron/src/pages/Threads/thread-chat-permission.js +26 -0
- package/dist-electron/src/pages/Threads/thread-chat-permission.test.js +102 -0
- package/dist-electron/src/pages/Threads/thread-chat-task-state.js +65 -0
- package/package.json +146 -0
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.LocalCoreWeixinGateway = void 0;
|
|
7
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const node_events_1 = require("node:events");
|
|
11
|
+
const node_crypto_2 = require("node:crypto");
|
|
12
|
+
const desktop_js_1 = require("../../../../shared/desktop.js");
|
|
13
|
+
// ==================== Constants ====================
|
|
14
|
+
const PAIRING_EXPIRY_MS = 10 * 60 * 1000;
|
|
15
|
+
const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com';
|
|
16
|
+
const DEFAULT_CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c';
|
|
17
|
+
const LONG_POLL_TIMEOUT_MS = 35_000;
|
|
18
|
+
const API_TIMEOUT_MS = 15_000;
|
|
19
|
+
const RETRY_DELAY_MS = 2_000;
|
|
20
|
+
const BACKOFF_DELAY_MS = 30_000;
|
|
21
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
22
|
+
const PROCESSED_MESSAGE_TTL_MS = 10 * 60 * 1000;
|
|
23
|
+
const WEIXIN_TEXT_MESSAGE_MAX_BYTES = 900;
|
|
24
|
+
const WEIXIN_CONTEXT_REPLY_MAX_BYTES = 3500;
|
|
25
|
+
const WEIXIN_CONTEXT_SEND_LIMIT = 10;
|
|
26
|
+
const WEIXIN_RESERVED_TERMINAL_SENDS = 1;
|
|
27
|
+
const WEIXIN_PROGRESS_SEND_BUDGET = WEIXIN_CONTEXT_SEND_LIMIT - WEIXIN_RESERVED_TERMINAL_SENDS;
|
|
28
|
+
const WEIXIN_CHANNEL_VERSION = '2.1.7';
|
|
29
|
+
const WEIXIN_ILINK_APP_ID = 'bot';
|
|
30
|
+
const WEIXIN_ILINK_APP_CLIENT_VERSION = '131335';
|
|
31
|
+
const RESPONSE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
32
|
+
const TEXT_ITEM_TYPE = 1;
|
|
33
|
+
const IMAGE_ITEM_TYPE = 2;
|
|
34
|
+
const VOICE_ITEM_TYPE = 3;
|
|
35
|
+
const FILE_ITEM_TYPE = 4;
|
|
36
|
+
// ==================== Utilities ====================
|
|
37
|
+
function stripHtml(html) {
|
|
38
|
+
let result = html;
|
|
39
|
+
let prev;
|
|
40
|
+
do {
|
|
41
|
+
prev = result;
|
|
42
|
+
result = result.replace(/<[^>]*>/g, '');
|
|
43
|
+
} while (result !== prev);
|
|
44
|
+
return result.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ');
|
|
45
|
+
}
|
|
46
|
+
function formatError(err) {
|
|
47
|
+
if (err instanceof Error) {
|
|
48
|
+
const cause = err.cause;
|
|
49
|
+
return cause !== undefined ? `${err.message}: ${String(cause)}` : err.message;
|
|
50
|
+
}
|
|
51
|
+
return String(err);
|
|
52
|
+
}
|
|
53
|
+
function sleep(ms, signal) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const t = setTimeout(resolve, ms);
|
|
56
|
+
const onAbort = () => { clearTimeout(t); reject(new Error('aborted')); };
|
|
57
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function safeFilePart(value) {
|
|
61
|
+
return String(value || 'default').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80) || 'default';
|
|
62
|
+
}
|
|
63
|
+
function createWechatUin() {
|
|
64
|
+
return Buffer.from(String(node_crypto_1.default.randomInt(0, 0xffffffff))).toString('base64');
|
|
65
|
+
}
|
|
66
|
+
function createIlinkHeaders() {
|
|
67
|
+
return {
|
|
68
|
+
'iLink-App-Id': WEIXIN_ILINK_APP_ID,
|
|
69
|
+
'iLink-App-ClientVersion': WEIXIN_ILINK_APP_CLIENT_VERSION,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function utf8ByteLength(value) {
|
|
73
|
+
return Buffer.byteLength(value, 'utf-8');
|
|
74
|
+
}
|
|
75
|
+
function splitTextByUtf8Bytes(text, maxBytes) {
|
|
76
|
+
const normalized = String(text || '').replace(/\r\n/g, '\n').trim();
|
|
77
|
+
if (!normalized)
|
|
78
|
+
return [];
|
|
79
|
+
if (utf8ByteLength(normalized) <= maxBytes)
|
|
80
|
+
return [normalized];
|
|
81
|
+
const chunks = [];
|
|
82
|
+
let current = '';
|
|
83
|
+
const pushCurrent = () => {
|
|
84
|
+
const trimmed = current.trim();
|
|
85
|
+
if (trimmed)
|
|
86
|
+
chunks.push(trimmed);
|
|
87
|
+
current = '';
|
|
88
|
+
};
|
|
89
|
+
const appendPart = (part) => {
|
|
90
|
+
if (!part.trim())
|
|
91
|
+
return;
|
|
92
|
+
const separator = current ? '\n\n' : '';
|
|
93
|
+
if (current && utf8ByteLength(`${current}${separator}${part}`) <= maxBytes) {
|
|
94
|
+
current = `${current}${separator}${part}`;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (current)
|
|
98
|
+
pushCurrent();
|
|
99
|
+
if (utf8ByteLength(part) <= maxBytes) {
|
|
100
|
+
current = part;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
let segment = '';
|
|
104
|
+
for (const char of Array.from(part)) {
|
|
105
|
+
if (segment && utf8ByteLength(`${segment}${char}`) > maxBytes) {
|
|
106
|
+
chunks.push(segment);
|
|
107
|
+
segment = '';
|
|
108
|
+
}
|
|
109
|
+
segment += char;
|
|
110
|
+
}
|
|
111
|
+
current = segment;
|
|
112
|
+
};
|
|
113
|
+
for (const part of normalized.split(/\n{2,}/)) {
|
|
114
|
+
appendPart(part);
|
|
115
|
+
}
|
|
116
|
+
pushCurrent();
|
|
117
|
+
return chunks;
|
|
118
|
+
}
|
|
119
|
+
function truncateTextByUtf8Bytes(text, maxBytes) {
|
|
120
|
+
const normalized = String(text || '').replace(/\r\n/g, '\n').trim();
|
|
121
|
+
if (utf8ByteLength(normalized) <= maxBytes)
|
|
122
|
+
return normalized;
|
|
123
|
+
const suffix = '\n\n(内容过长,已截断以保证微信送达)';
|
|
124
|
+
const budget = Math.max(0, maxBytes - utf8ByteLength(suffix));
|
|
125
|
+
let result = '';
|
|
126
|
+
for (const char of Array.from(normalized)) {
|
|
127
|
+
if (utf8ByteLength(`${result}${char}`) > budget)
|
|
128
|
+
break;
|
|
129
|
+
result += char;
|
|
130
|
+
}
|
|
131
|
+
return `${result.trim()}${suffix}`;
|
|
132
|
+
}
|
|
133
|
+
function stripToolResultForWeixin(content) {
|
|
134
|
+
const normalized = String(content || '').trim();
|
|
135
|
+
if (!normalized.startsWith('🔧 '))
|
|
136
|
+
return normalized;
|
|
137
|
+
const parts = normalized.split(' - ');
|
|
138
|
+
if (parts[0] === '🔧 Tool update' && parts[1] === 'completed')
|
|
139
|
+
return '';
|
|
140
|
+
if (parts.length <= 2)
|
|
141
|
+
return normalized;
|
|
142
|
+
return parts.slice(0, 2).join(' - ');
|
|
143
|
+
}
|
|
144
|
+
// ==================== Gateway Class ====================
|
|
145
|
+
class LocalCoreWeixinGateway extends node_events_1.EventEmitter {
|
|
146
|
+
options;
|
|
147
|
+
runtime = new Map();
|
|
148
|
+
threadRouting = new Map();
|
|
149
|
+
outboundEventChains = new Map();
|
|
150
|
+
outboundTurns = new Map();
|
|
151
|
+
processedInboundMessages = new Map();
|
|
152
|
+
mutedThreadBridgeCounts = new Map();
|
|
153
|
+
platform = 'weixin';
|
|
154
|
+
routeType = 'channel.chat';
|
|
155
|
+
constructor(options) {
|
|
156
|
+
super();
|
|
157
|
+
this.options = options;
|
|
158
|
+
}
|
|
159
|
+
// ==================== Lifecycle ====================
|
|
160
|
+
async refreshBindings() {
|
|
161
|
+
const config = await this.options.readConfig();
|
|
162
|
+
const bindings = this.collectBindings(config);
|
|
163
|
+
const nextWorkspaceIds = new Set(bindings.map((b) => b.workspaceId));
|
|
164
|
+
for (const workspaceId of [...this.runtime.keys()]) {
|
|
165
|
+
if (!nextWorkspaceIds.has(workspaceId)) {
|
|
166
|
+
await this.stopWorkspace(workspaceId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const binding of bindings) {
|
|
170
|
+
const current = this.runtime.get(binding.workspaceId);
|
|
171
|
+
if (!binding.enabled) {
|
|
172
|
+
if (current) {
|
|
173
|
+
await this.stopWorkspace(binding.workspaceId);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
this.runtime.set(binding.workspaceId, {
|
|
177
|
+
workspaceId: binding.workspaceId,
|
|
178
|
+
enabled: false,
|
|
179
|
+
status: 'disabled',
|
|
180
|
+
connected: false,
|
|
181
|
+
accountId: binding.accountId,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (current?.status === 'running' && current.accountId === binding.accountId) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
await this.startWorkspace(binding);
|
|
190
|
+
}
|
|
191
|
+
this.notifyRuntimeStateChanged();
|
|
192
|
+
}
|
|
193
|
+
async testConnection(workspaceId) {
|
|
194
|
+
const binding = await this.getBinding(workspaceId);
|
|
195
|
+
try {
|
|
196
|
+
const bufPath = this.getBufPath(binding);
|
|
197
|
+
node_fs_1.default.accessSync(bufPath);
|
|
198
|
+
return { success: true, platform: 'weixin', workspaceId, appId: binding.accountId };
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return { success: false, platform: 'weixin', workspaceId, error: 'No sync buf found for account. Ensure the plugin has been connected at least once.' };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async enable(workspaceId) {
|
|
205
|
+
const binding = await this.getBinding(workspaceId);
|
|
206
|
+
await this.startWorkspace(binding);
|
|
207
|
+
return this.getStatus(workspaceId);
|
|
208
|
+
}
|
|
209
|
+
async disable(workspaceId) {
|
|
210
|
+
await this.stopWorkspace(workspaceId);
|
|
211
|
+
return this.getStatus(workspaceId);
|
|
212
|
+
}
|
|
213
|
+
getStatus(workspaceId) {
|
|
214
|
+
this.options.store.expirePendingPairings();
|
|
215
|
+
const binding = this.runtime.get(workspaceId);
|
|
216
|
+
const pairings = this.options.store.listPendingPairings(workspaceId)
|
|
217
|
+
.filter((row) => row.platform === 'weixin' && row.expires_at >= new Date().toISOString());
|
|
218
|
+
const users = this.options.store.listAuthorizedUsers(workspaceId, 'weixin');
|
|
219
|
+
return {
|
|
220
|
+
workspaceId,
|
|
221
|
+
platform: 'weixin',
|
|
222
|
+
enabled: Boolean(binding?.enabled),
|
|
223
|
+
connected: Boolean(binding?.connected),
|
|
224
|
+
status: binding?.status || 'disabled',
|
|
225
|
+
appId: binding?.accountId || '',
|
|
226
|
+
lastError: binding?.lastError,
|
|
227
|
+
connectedAt: binding?.connectedAt,
|
|
228
|
+
pendingPairings: pairings.length,
|
|
229
|
+
authorizedUsers: users.length,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
listStatuses() {
|
|
233
|
+
return [...this.runtime.keys()].sort().map((workspaceId) => this.getStatus(workspaceId));
|
|
234
|
+
}
|
|
235
|
+
listPendingPairings(workspaceId) {
|
|
236
|
+
this.options.store.expirePendingPairings();
|
|
237
|
+
return this.options.store
|
|
238
|
+
.listPairingRequests(workspaceId, 'weixin')
|
|
239
|
+
.filter((item) => item.status === 'pending' && item.expiresAt >= new Date().toISOString());
|
|
240
|
+
}
|
|
241
|
+
listAuthorizedUsers(workspaceId) {
|
|
242
|
+
return this.options.store.listAuthorizedUsers(workspaceId, 'weixin');
|
|
243
|
+
}
|
|
244
|
+
async start() {
|
|
245
|
+
await this.refreshBindings();
|
|
246
|
+
}
|
|
247
|
+
async stop() {
|
|
248
|
+
this.close();
|
|
249
|
+
}
|
|
250
|
+
close() {
|
|
251
|
+
return Promise.all([...this.runtime.keys()].map((id) => this.stopWorkspace(id))).then(() => undefined);
|
|
252
|
+
}
|
|
253
|
+
// ==================== QR Code Login ====================
|
|
254
|
+
async getQrCode(workspaceId) {
|
|
255
|
+
const binding = await this.getBinding(workspaceId);
|
|
256
|
+
const data = await this.apiGet(binding, `ilink/bot/get_bot_qrcode?bot_type=3`);
|
|
257
|
+
if (data.errcode && data.errcode !== 0) {
|
|
258
|
+
throw new Error(`Failed to get QR code: ${data.errmsg || data.errcode}`);
|
|
259
|
+
}
|
|
260
|
+
if (!data.qrcode) {
|
|
261
|
+
throw new Error('No QR code returned from iLink');
|
|
262
|
+
}
|
|
263
|
+
const qrCodeUrl = data.qrcode_img_content || `${binding.baseUrl.replace(/\/$/, '')}/ilink/bot/qr_code/${data.qrcode}`;
|
|
264
|
+
return { ticket: data.qrcode, expiresIn: data.expired || 180, qrCodeUrl };
|
|
265
|
+
}
|
|
266
|
+
async checkQrCodeStatus(workspaceId, ticket) {
|
|
267
|
+
const binding = await this.getBinding(workspaceId);
|
|
268
|
+
const data = await this.apiGet(binding, `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(ticket)}`);
|
|
269
|
+
if (data.errcode && data.errcode !== 0) {
|
|
270
|
+
throw new Error(`Failed to check QR code status: ${data.errmsg || data.errcode}`);
|
|
271
|
+
}
|
|
272
|
+
const status = this.normalizeQrStatus(data.status);
|
|
273
|
+
if (status === 'confirmed') {
|
|
274
|
+
if (!data.bot_token) {
|
|
275
|
+
throw new Error('WeChat QR code confirmed but no bot token was returned.');
|
|
276
|
+
}
|
|
277
|
+
this.saveCredentials(binding, {
|
|
278
|
+
token: data.bot_token,
|
|
279
|
+
baseUrl: data.baseurl || binding.baseUrl,
|
|
280
|
+
botId: data.ilink_bot_id,
|
|
281
|
+
userId: data.ilink_user_id || data.user_id,
|
|
282
|
+
savedAt: new Date().toISOString(),
|
|
283
|
+
});
|
|
284
|
+
await this.startWorkspace(await this.getBinding(workspaceId));
|
|
285
|
+
}
|
|
286
|
+
if (!['wait', 'signed', 'confirmed', 'expired'].includes(status)) {
|
|
287
|
+
return { status: 'wait' };
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
status: status,
|
|
291
|
+
userName: data.user_name || undefined,
|
|
292
|
+
userId: data.user_id || undefined,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
// ==================== Pairing ====================
|
|
296
|
+
approvePairing(code) {
|
|
297
|
+
this.options.store.expirePendingPairings();
|
|
298
|
+
const pairing = this.options.store.getPairingRequest(code);
|
|
299
|
+
if (!pairing)
|
|
300
|
+
throw new Error(`Pairing code not found: ${code}`);
|
|
301
|
+
if (pairing.platform !== 'weixin')
|
|
302
|
+
throw new Error(`Pairing code ${code} is not a WeChat pairing`);
|
|
303
|
+
if (pairing.status !== 'pending')
|
|
304
|
+
throw new Error(`Pairing code ${code} is already ${pairing.status}`);
|
|
305
|
+
if (pairing.expires_at < new Date().toISOString()) {
|
|
306
|
+
this.options.store.updatePairingStatus(code, 'expired');
|
|
307
|
+
throw new Error(`Pairing code ${code} has expired`);
|
|
308
|
+
}
|
|
309
|
+
const existing = this.options.store.getAuthorizedUser(pairing.workspace_id, pairing.platform_user_id, 'weixin');
|
|
310
|
+
const userId = existing?.id || `wx-user-${(0, node_crypto_2.randomUUID)()}`;
|
|
311
|
+
const authorizedAt = new Date().toISOString();
|
|
312
|
+
this.options.store.createAuthorizedUser({
|
|
313
|
+
id: userId,
|
|
314
|
+
workspace_id: pairing.workspace_id,
|
|
315
|
+
platform: 'weixin',
|
|
316
|
+
platform_user_id: pairing.platform_user_id,
|
|
317
|
+
chat_id: pairing.chat_id,
|
|
318
|
+
display_name: pairing.display_name,
|
|
319
|
+
thread_id: existing?.thread_id || null,
|
|
320
|
+
authorized_at: authorizedAt,
|
|
321
|
+
});
|
|
322
|
+
this.options.store.updatePairingStatus(code, 'approved');
|
|
323
|
+
this.notifyRuntimeStateChanged();
|
|
324
|
+
const user = this.options.store.listAuthorizedUsers(pairing.workspace_id, 'weixin').find((e) => e.id === userId);
|
|
325
|
+
if (!user)
|
|
326
|
+
throw new Error('Authorized user lookup failed after approval');
|
|
327
|
+
return user;
|
|
328
|
+
}
|
|
329
|
+
rejectPairing(code) {
|
|
330
|
+
const pairing = this.options.store.getPairingRequest(code);
|
|
331
|
+
if (!pairing)
|
|
332
|
+
throw new Error(`Pairing code not found: ${code}`);
|
|
333
|
+
if (pairing.platform !== 'weixin')
|
|
334
|
+
throw new Error(`Pairing code ${code} is not a WeChat pairing`);
|
|
335
|
+
this.options.store.updatePairingStatus(code, 'rejected');
|
|
336
|
+
this.notifyRuntimeStateChanged();
|
|
337
|
+
return { rejected: true };
|
|
338
|
+
}
|
|
339
|
+
// ==================== Bridge Event Handling ====================
|
|
340
|
+
muteThreadBridge(threadId) {
|
|
341
|
+
const current = this.mutedThreadBridgeCounts.get(threadId) || 0;
|
|
342
|
+
this.mutedThreadBridgeCounts.set(threadId, current + 1);
|
|
343
|
+
}
|
|
344
|
+
unmuteThreadBridge(threadId) {
|
|
345
|
+
const current = this.mutedThreadBridgeCounts.get(threadId) || 0;
|
|
346
|
+
if (current <= 1) {
|
|
347
|
+
this.mutedThreadBridgeCounts.delete(threadId);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.mutedThreadBridgeCounts.set(threadId, current - 1);
|
|
351
|
+
}
|
|
352
|
+
async onBridgeEvent(event) {
|
|
353
|
+
if (!event.sessionKey) {
|
|
354
|
+
this.options.log?.(`localcore-weixin bridge event ignored without sessionKey: ${event.type}`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const sessionKey = event.sessionKey;
|
|
358
|
+
const route = this.threadRouting.get(sessionKey);
|
|
359
|
+
if (!route) {
|
|
360
|
+
this.options.log?.(`localcore-weixin bridge route miss for sessionKey=${sessionKey} type=${event.type}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const state = this.runtime.get(route.workspaceId);
|
|
364
|
+
if (!state?.connected) {
|
|
365
|
+
this.options.log?.(`localcore-weixin bridge event ignored because workspace is not connected: ${route.workspaceId}`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const initialBinding = this.options.store.getPlatformThreadBinding(route.workspaceId, route.chatId, route.platformUserId, 'weixin');
|
|
369
|
+
if (!initialBinding) {
|
|
370
|
+
this.options.log?.(`localcore-weixin bridge binding miss for workspace=${route.workspaceId}`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (event.type !== 'preview_start'
|
|
374
|
+
&& event.type !== 'update_message'
|
|
375
|
+
&& event.type !== 'reply'
|
|
376
|
+
&& event.type !== 'buttons'
|
|
377
|
+
&& event.type !== 'typing_start'
|
|
378
|
+
&& event.type !== 'typing_stop'
|
|
379
|
+
&& event.type !== 'status') {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const previous = this.outboundEventChains.get(sessionKey) || Promise.resolve();
|
|
383
|
+
const current = previous
|
|
384
|
+
.catch(() => undefined)
|
|
385
|
+
.then(async () => {
|
|
386
|
+
const binding = this.options.store.getPlatformThreadBinding(route.workspaceId, route.chatId, route.platformUserId, 'weixin');
|
|
387
|
+
if (!binding)
|
|
388
|
+
return;
|
|
389
|
+
if (this.mutedThreadBridgeCounts.has(binding.thread_id))
|
|
390
|
+
return;
|
|
391
|
+
const turn = this.getOrCreateTurnState(sessionKey);
|
|
392
|
+
if (event.replyCtx) {
|
|
393
|
+
// WeChat doesn't support reply context; ignore
|
|
394
|
+
}
|
|
395
|
+
this.consumeBridgeEvent(turn, event);
|
|
396
|
+
if (event.type !== 'reply' && event.type !== 'buttons' && event.type !== 'status')
|
|
397
|
+
return;
|
|
398
|
+
const rendered = this.renderTurnText(turn);
|
|
399
|
+
if (!rendered)
|
|
400
|
+
return;
|
|
401
|
+
if (rendered === turn.lastSentText)
|
|
402
|
+
return;
|
|
403
|
+
const terminalMessage = this.isTerminalBridgeMessage(event, rendered);
|
|
404
|
+
if (binding.last_platform_message_id && !terminalMessage && turn.sentCount >= WEIXIN_PROGRESS_SEND_BUDGET) {
|
|
405
|
+
turn.foldedProgressCount += 1;
|
|
406
|
+
this.options.log?.(`localcore-weixin folded progress for sessionKey=${sessionKey}: sent=${turn.sentCount} folded=${turn.foldedProgressCount}`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (binding.last_platform_message_id && terminalMessage && turn.sentCount >= WEIXIN_CONTEXT_SEND_LIMIT) {
|
|
410
|
+
this.options.log?.(`localcore-weixin skipped terminal message after context budget exhausted for sessionKey=${sessionKey}: sent=${turn.sentCount}`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const outbound = terminalMessage && turn.foldedProgressCount > 0
|
|
414
|
+
? `(已省略 ${turn.foldedProgressCount} 条过程消息,避免超过微信每轮 10 条限制)\n\n${rendered}`
|
|
415
|
+
: rendered;
|
|
416
|
+
try {
|
|
417
|
+
await this.sendTextMessage(state, route.chatId, outbound, binding.last_platform_message_id || undefined);
|
|
418
|
+
turn.sentCount += 1;
|
|
419
|
+
if (terminalMessage)
|
|
420
|
+
turn.foldedProgressCount = 0;
|
|
421
|
+
turn.lastSentAt = Date.now();
|
|
422
|
+
turn.lastSentText = rendered;
|
|
423
|
+
this.options.log?.(`localcore-weixin sent message for sessionKey=${sessionKey} type=${event.type} sent=${turn.sentCount}/${binding.last_platform_message_id ? WEIXIN_CONTEXT_SEND_LIMIT : 'unlimited'}`);
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
this.options.log?.(`localcore-weixin send failed for sessionKey=${sessionKey}: ${formatError(error)}`);
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
.finally(() => {
|
|
430
|
+
if (this.outboundEventChains.get(sessionKey) === current) {
|
|
431
|
+
this.outboundEventChains.delete(sessionKey);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
this.outboundEventChains.set(sessionKey, current);
|
|
435
|
+
await current;
|
|
436
|
+
}
|
|
437
|
+
async sendScheduledCard(workspaceId, chatId, text) {
|
|
438
|
+
return this.sendScheduledMessage(workspaceId, { type: 'channel.chat', channelId: chatId }, text);
|
|
439
|
+
}
|
|
440
|
+
async sendScheduledMessage(workspaceId, route, text) {
|
|
441
|
+
const state = this.runtime.get(workspaceId);
|
|
442
|
+
if (!state?.connected) {
|
|
443
|
+
this.options.log?.(`localcore-weixin scheduled message skipped: workspace not connected: ${workspaceId}`);
|
|
444
|
+
return '';
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const stripped = stripHtml(text);
|
|
448
|
+
await this.sendTextMessage(state, route.channelId, stripped);
|
|
449
|
+
return `wx_sched_${(0, node_crypto_2.randomUUID)()}`;
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
this.options.log?.(`localcore-weixin scheduled message failed for ${workspaceId}: ${formatError(error)}`);
|
|
453
|
+
return '';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// ==================== Inbound Message Handling ====================
|
|
457
|
+
async handleInboundMessage(input) {
|
|
458
|
+
if (this.isDuplicateInboundMessage(input)) {
|
|
459
|
+
this.options.log?.(`localcore-weixin skipped duplicate inbound message workspace=${input.workspaceId} chat=${input.chatId} id=${input.contextToken || input.messageId}`);
|
|
460
|
+
return { paired: true, duplicate: true };
|
|
461
|
+
}
|
|
462
|
+
this.options.eventBus.emit({
|
|
463
|
+
type: 'platform.message.received',
|
|
464
|
+
payload: {
|
|
465
|
+
platform: this.platform,
|
|
466
|
+
workspaceId: input.workspaceId,
|
|
467
|
+
participantId: input.platformUserId,
|
|
468
|
+
channelId: input.chatId,
|
|
469
|
+
displayName: input.displayName,
|
|
470
|
+
text: input.text,
|
|
471
|
+
messageId: input.messageId,
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
this.options.store.expirePendingPairings();
|
|
475
|
+
const binding = await this.getBinding(input.workspaceId);
|
|
476
|
+
let authorized = this.options.store.getAuthorizedUser(input.workspaceId, input.platformUserId, 'weixin');
|
|
477
|
+
if (!authorized) {
|
|
478
|
+
if (binding.allowFrom === '*') {
|
|
479
|
+
const authorizedAt = new Date().toISOString();
|
|
480
|
+
this.options.store.createAuthorizedUser({
|
|
481
|
+
id: `wx-user-${(0, node_crypto_2.randomUUID)()}`,
|
|
482
|
+
workspace_id: input.workspaceId,
|
|
483
|
+
platform: 'weixin',
|
|
484
|
+
platform_user_id: input.platformUserId,
|
|
485
|
+
chat_id: input.chatId,
|
|
486
|
+
display_name: input.displayName,
|
|
487
|
+
thread_id: null,
|
|
488
|
+
authorized_at: authorizedAt,
|
|
489
|
+
});
|
|
490
|
+
authorized = this.options.store.getAuthorizedUser(input.workspaceId, input.platformUserId, 'weixin');
|
|
491
|
+
this.options.log?.(`localcore-weixin auto-approved user for ${input.workspaceId}: ${input.platformUserId}`);
|
|
492
|
+
this.notifyRuntimeStateChanged();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (!authorized) {
|
|
496
|
+
const existingPending = this.options.store.listPendingPairings(input.workspaceId).find((item) => item.platform === 'weixin' && item.platform_user_id === input.platformUserId && item.chat_id === input.chatId && item.status === 'pending');
|
|
497
|
+
let pairingCode = existingPending?.code || '';
|
|
498
|
+
if (!existingPending) {
|
|
499
|
+
const now = new Date();
|
|
500
|
+
pairingCode = String((0, node_crypto_2.randomInt)(100000, 1000000));
|
|
501
|
+
this.options.store.createPairingRequest({
|
|
502
|
+
code: pairingCode,
|
|
503
|
+
workspace_id: input.workspaceId,
|
|
504
|
+
platform: 'weixin',
|
|
505
|
+
platform_user_id: input.platformUserId,
|
|
506
|
+
chat_id: input.chatId,
|
|
507
|
+
display_name: input.displayName,
|
|
508
|
+
requested_at: now.toISOString(),
|
|
509
|
+
expires_at: new Date(now.getTime() + PAIRING_EXPIRY_MS).toISOString(),
|
|
510
|
+
status: 'pending',
|
|
511
|
+
});
|
|
512
|
+
this.notifyRuntimeStateChanged();
|
|
513
|
+
}
|
|
514
|
+
const state = this.runtime.get(input.workspaceId);
|
|
515
|
+
if (state?.connected) {
|
|
516
|
+
await this.sendTextMessage(state, input.chatId, `**已收到消息**\n\n当前账号还未授权接入这个工作区。\n请在桌面端完成审批后再次发送消息。\n\n配对码:\`${pairingCode}\``, input.contextToken);
|
|
517
|
+
}
|
|
518
|
+
return { paired: false };
|
|
519
|
+
}
|
|
520
|
+
const router = this.options.getWorkspaceRouter();
|
|
521
|
+
const threadBinding = this.options.store.getPlatformThreadBinding(input.workspaceId, input.chatId, input.platformUserId, 'weixin');
|
|
522
|
+
let threadId = threadBinding?.thread_id || authorized.thread_id || '';
|
|
523
|
+
if (!threadId) {
|
|
524
|
+
const thread = await router.createThread(input.workspaceId, input.displayName || `WeChat ${input.chatId}`);
|
|
525
|
+
threadId = thread.id;
|
|
526
|
+
this.options.store.updateAuthorizedUserThread(input.workspaceId, input.platformUserId, threadId, 'weixin');
|
|
527
|
+
const now = new Date().toISOString();
|
|
528
|
+
this.options.store.upsertPlatformThreadBinding({
|
|
529
|
+
workspace_id: input.workspaceId,
|
|
530
|
+
platform: 'weixin',
|
|
531
|
+
chat_id: input.chatId,
|
|
532
|
+
platform_user_id: input.platformUserId,
|
|
533
|
+
thread_id: threadId,
|
|
534
|
+
last_platform_message_id: null,
|
|
535
|
+
created_at: now,
|
|
536
|
+
updated_at: now,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const sessionKey = router.getThreadSessionKey(threadId);
|
|
540
|
+
const normalizedText = String(input.text || '').trim().toLowerCase();
|
|
541
|
+
const permissionThreadId = (normalizedText === 'allow' || normalizedText === 'allow all' || normalizedText === 'deny') ? this.findAwaitingPermissionThreadId(input.workspaceId, input.chatId, input.platformUserId) : '';
|
|
542
|
+
if (permissionThreadId && permissionThreadId !== threadId) {
|
|
543
|
+
threadId = permissionThreadId;
|
|
544
|
+
}
|
|
545
|
+
const effectiveSessionKey = router.getThreadSessionKey(threadId);
|
|
546
|
+
this.threadRouting.set(effectiveSessionKey, {
|
|
547
|
+
workspaceId: input.workspaceId,
|
|
548
|
+
platformUserId: input.platformUserId,
|
|
549
|
+
chatId: input.chatId,
|
|
550
|
+
threadId,
|
|
551
|
+
});
|
|
552
|
+
// Handle slash commands
|
|
553
|
+
const slashCommand = this.parseSlashCommand(input.text);
|
|
554
|
+
if (slashCommand?.name === 'new') {
|
|
555
|
+
const title = slashCommand.args.join(' ').trim() || `${input.displayName || 'WeChat'} ${new Date().toLocaleTimeString()}`;
|
|
556
|
+
const nextThread = await router.createThread(input.workspaceId, title);
|
|
557
|
+
const now = new Date().toISOString();
|
|
558
|
+
this.options.store.updateAuthorizedUserThread(input.workspaceId, input.platformUserId, nextThread.id, 'weixin');
|
|
559
|
+
this.options.store.upsertPlatformThreadBinding({
|
|
560
|
+
workspace_id: input.workspaceId,
|
|
561
|
+
platform: 'weixin',
|
|
562
|
+
chat_id: input.chatId,
|
|
563
|
+
platform_user_id: input.platformUserId,
|
|
564
|
+
thread_id: nextThread.id,
|
|
565
|
+
last_platform_message_id: null,
|
|
566
|
+
created_at: now,
|
|
567
|
+
updated_at: now,
|
|
568
|
+
});
|
|
569
|
+
this.threadRouting.set(router.getThreadSessionKey(nextThread.id), {
|
|
570
|
+
workspaceId: input.workspaceId,
|
|
571
|
+
platformUserId: input.platformUserId,
|
|
572
|
+
chatId: input.chatId,
|
|
573
|
+
threadId: nextThread.id,
|
|
574
|
+
});
|
|
575
|
+
const st = this.runtime.get(input.workspaceId);
|
|
576
|
+
if (st?.connected) {
|
|
577
|
+
await this.sendTextMessage(st, input.chatId, '**已开始新会话**', input.contextToken);
|
|
578
|
+
}
|
|
579
|
+
return { paired: true, threadId: nextThread.id };
|
|
580
|
+
}
|
|
581
|
+
const latestRun = this.options.store.getLatestRunForThread(threadId);
|
|
582
|
+
if ((normalizedText === 'allow' || normalizedText === 'allow all' || normalizedText === 'deny')
|
|
583
|
+
&& latestRun?.status === 'awaiting_input') {
|
|
584
|
+
await router.sendThreadAction(threadId, input.text);
|
|
585
|
+
return { paired: true, threadId };
|
|
586
|
+
}
|
|
587
|
+
this.options.store.updatePlatformThreadMessageId(input.workspaceId, input.chatId, input.platformUserId, input.contextToken || input.messageId, 'weixin');
|
|
588
|
+
await router.sendThreadMessage(threadId, (0, desktop_js_1.wrapUserMessageWithSchedulerProtocol)(input.text));
|
|
589
|
+
return { paired: true, threadId };
|
|
590
|
+
}
|
|
591
|
+
// ==================== Private: Bindings ====================
|
|
592
|
+
async getBinding(workspaceId) {
|
|
593
|
+
const config = await this.options.readConfig();
|
|
594
|
+
const binding = this.collectBindings(config).find((e) => e.workspaceId === workspaceId);
|
|
595
|
+
if (!binding)
|
|
596
|
+
throw new Error(`No WeChat binding configured for workspace "${workspaceId}"`);
|
|
597
|
+
return binding;
|
|
598
|
+
}
|
|
599
|
+
collectBindings(config) {
|
|
600
|
+
const projects = Array.isArray(config?.projects) ? config.projects : [];
|
|
601
|
+
return projects.flatMap((project) => {
|
|
602
|
+
const platforms = Array.isArray(project.platforms) ? project.platforms : [];
|
|
603
|
+
return platforms
|
|
604
|
+
.map((platform) => ({
|
|
605
|
+
platformType: (0, desktop_js_1.normalizeDesktopPlatformType)(platform?.type),
|
|
606
|
+
options: platform?.options && typeof platform.options === 'object'
|
|
607
|
+
? platform.options
|
|
608
|
+
: {},
|
|
609
|
+
}))
|
|
610
|
+
.filter((p) => p.platformType === 'weixin')
|
|
611
|
+
.map((p) => {
|
|
612
|
+
const stateDir = String(p.options.state_dir || this.getDefaultStateDir()).trim();
|
|
613
|
+
const credentials = this.loadCredentials(project.name, stateDir);
|
|
614
|
+
const configuredToken = String(p.options.token || '').trim();
|
|
615
|
+
const configuredBaseUrl = String(p.options.base_url || '').trim();
|
|
616
|
+
const accountId = String(p.options.account_id || credentials?.botId || 'qr-login').trim();
|
|
617
|
+
return {
|
|
618
|
+
workspaceId: project.name,
|
|
619
|
+
token: configuredToken || credentials?.token || '',
|
|
620
|
+
accountId,
|
|
621
|
+
baseUrl: configuredBaseUrl || credentials?.baseUrl || DEFAULT_BASE_URL,
|
|
622
|
+
cdnBaseUrl: String(p.options.cdn_base_url || DEFAULT_CDN_BASE_URL).trim(),
|
|
623
|
+
allowFrom: String(p.options.allow_from || '*').trim(),
|
|
624
|
+
routeTag: String(p.options.route_tag || '').trim(),
|
|
625
|
+
longPollTimeoutMs: Number(p.options.long_poll_timeout_ms || LONG_POLL_TIMEOUT_MS) || LONG_POLL_TIMEOUT_MS,
|
|
626
|
+
stateDir,
|
|
627
|
+
proxy: String(p.options.proxy || '').trim(),
|
|
628
|
+
proxyUsername: String(p.options.proxy_username || '').trim(),
|
|
629
|
+
proxyPassword: String(p.options.proxy_password || '').trim(),
|
|
630
|
+
enabled: true,
|
|
631
|
+
project,
|
|
632
|
+
};
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
getDefaultStateDir() {
|
|
637
|
+
return node_path_1.default.join(process.cwd(), 'weixin-monitor');
|
|
638
|
+
}
|
|
639
|
+
// ==================== Private: Workspace Lifecycle ====================
|
|
640
|
+
async startWorkspace(binding) {
|
|
641
|
+
await this.stopWorkspace(binding.workspaceId);
|
|
642
|
+
const status = {
|
|
643
|
+
workspaceId: binding.workspaceId,
|
|
644
|
+
enabled: true,
|
|
645
|
+
status: 'starting',
|
|
646
|
+
connected: false,
|
|
647
|
+
accountId: binding.accountId,
|
|
648
|
+
};
|
|
649
|
+
this.runtime.set(binding.workspaceId, status);
|
|
650
|
+
this.notifyRuntimeStateChanged();
|
|
651
|
+
try {
|
|
652
|
+
const abortController = new AbortController();
|
|
653
|
+
status.abortController = abortController;
|
|
654
|
+
if (!binding.token) {
|
|
655
|
+
status.status = 'stopped';
|
|
656
|
+
status.connected = false;
|
|
657
|
+
status.lastError = 'Scan the WeChat QR code to finish login before starting message polling.';
|
|
658
|
+
this.notifyRuntimeStateChanged();
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
status.status = 'running';
|
|
662
|
+
status.connected = true;
|
|
663
|
+
status.connectedAt = new Date().toISOString();
|
|
664
|
+
status.lastError = undefined;
|
|
665
|
+
// Start long-poll loop in background
|
|
666
|
+
this.runMonitorLoop(binding, abortController.signal).catch((err) => {
|
|
667
|
+
if (!abortController.signal.aborted) {
|
|
668
|
+
this.options.log?.(`localcore-weixin monitor terminated for ${binding.workspaceId}: ${formatError(err)}`);
|
|
669
|
+
status.status = 'error';
|
|
670
|
+
status.connected = false;
|
|
671
|
+
status.lastError = formatError(err);
|
|
672
|
+
this.notifyRuntimeStateChanged();
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
status.status = 'error';
|
|
678
|
+
status.connected = false;
|
|
679
|
+
status.lastError = formatError(error);
|
|
680
|
+
this.options.log?.(`localcore-weixin start failed for ${binding.workspaceId}: ${status.lastError}`);
|
|
681
|
+
}
|
|
682
|
+
this.notifyRuntimeStateChanged();
|
|
683
|
+
}
|
|
684
|
+
async stopWorkspace(workspaceId) {
|
|
685
|
+
const state = this.runtime.get(workspaceId);
|
|
686
|
+
if (!state)
|
|
687
|
+
return;
|
|
688
|
+
try {
|
|
689
|
+
state.abortController?.abort();
|
|
690
|
+
}
|
|
691
|
+
catch { }
|
|
692
|
+
this.runtime.set(workspaceId, {
|
|
693
|
+
workspaceId,
|
|
694
|
+
enabled: false,
|
|
695
|
+
status: 'stopped',
|
|
696
|
+
connected: false,
|
|
697
|
+
accountId: state.accountId,
|
|
698
|
+
});
|
|
699
|
+
this.notifyRuntimeStateChanged();
|
|
700
|
+
}
|
|
701
|
+
// ==================== Private: Long-poll Monitor ====================
|
|
702
|
+
getBufPath(binding) {
|
|
703
|
+
return node_path_1.default.join(binding.stateDir, `${binding.accountId}.buf`);
|
|
704
|
+
}
|
|
705
|
+
getCredentialsPath(workspaceId, stateDir) {
|
|
706
|
+
return node_path_1.default.join(stateDir, `${safeFilePart(workspaceId)}.credentials.json`);
|
|
707
|
+
}
|
|
708
|
+
loadCredentials(workspaceId, stateDir) {
|
|
709
|
+
try {
|
|
710
|
+
const raw = node_fs_1.default.readFileSync(this.getCredentialsPath(workspaceId, stateDir), 'utf-8');
|
|
711
|
+
const parsed = JSON.parse(raw);
|
|
712
|
+
const token = String(parsed.token || '').trim();
|
|
713
|
+
if (!token)
|
|
714
|
+
return null;
|
|
715
|
+
return {
|
|
716
|
+
token,
|
|
717
|
+
baseUrl: parsed.baseUrl ? String(parsed.baseUrl) : undefined,
|
|
718
|
+
botId: parsed.botId ? String(parsed.botId) : undefined,
|
|
719
|
+
userId: parsed.userId ? String(parsed.userId) : undefined,
|
|
720
|
+
savedAt: parsed.savedAt ? String(parsed.savedAt) : new Date().toISOString(),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
saveCredentials(binding, credentials) {
|
|
728
|
+
node_fs_1.default.mkdirSync(binding.stateDir, { recursive: true });
|
|
729
|
+
node_fs_1.default.writeFileSync(this.getCredentialsPath(binding.workspaceId, binding.stateDir), JSON.stringify(credentials, null, 2), 'utf-8');
|
|
730
|
+
}
|
|
731
|
+
normalizeQrStatus(status) {
|
|
732
|
+
const normalized = String(status || 'wait').trim().toLowerCase();
|
|
733
|
+
if (normalized === 'scaned' || normalized === 'scanned' || normalized === 'signed')
|
|
734
|
+
return 'signed';
|
|
735
|
+
if (normalized === 'confirmed')
|
|
736
|
+
return 'confirmed';
|
|
737
|
+
if (normalized === 'expired')
|
|
738
|
+
return 'expired';
|
|
739
|
+
return 'wait';
|
|
740
|
+
}
|
|
741
|
+
loadBuf(binding) {
|
|
742
|
+
try {
|
|
743
|
+
return node_fs_1.default.readFileSync(this.getBufPath(binding), 'utf-8');
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return '';
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
saveBuf(binding, buf) {
|
|
750
|
+
const dir = binding.stateDir;
|
|
751
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
752
|
+
node_fs_1.default.writeFileSync(this.getBufPath(binding), buf, 'utf-8');
|
|
753
|
+
}
|
|
754
|
+
async runMonitorLoop(binding, signal) {
|
|
755
|
+
let buf = this.loadBuf(binding);
|
|
756
|
+
let consecutiveFailures = 0;
|
|
757
|
+
while (!signal.aborted) {
|
|
758
|
+
try {
|
|
759
|
+
const resp = await this.getUpdates(binding, buf, signal);
|
|
760
|
+
const isApiError = (resp.ret !== undefined && resp.ret !== 0) || (resp.errcode !== undefined && resp.errcode !== 0);
|
|
761
|
+
if (isApiError) {
|
|
762
|
+
consecutiveFailures++;
|
|
763
|
+
const errorText = resp.errmsg ? ` errmsg=${resp.errmsg}` : '';
|
|
764
|
+
this.options.log?.(`localcore-weixin getUpdates failed for ${binding.workspaceId}: ret=${resp.ret} errcode=${resp.errcode}${errorText} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`);
|
|
765
|
+
if (resp.errcode === -14 || resp.ret === -14) {
|
|
766
|
+
const state = this.runtime.get(binding.workspaceId);
|
|
767
|
+
if (state) {
|
|
768
|
+
state.status = 'error';
|
|
769
|
+
state.connected = false;
|
|
770
|
+
state.lastError = 'WeChat login expired. Generate and scan a new QR code.';
|
|
771
|
+
this.notifyRuntimeStateChanged();
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
775
|
+
consecutiveFailures = 0;
|
|
776
|
+
await sleep(BACKOFF_DELAY_MS, signal);
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
await sleep(RETRY_DELAY_MS, signal);
|
|
780
|
+
}
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
consecutiveFailures = 0;
|
|
784
|
+
if (resp.get_updates_buf) {
|
|
785
|
+
buf = resp.get_updates_buf;
|
|
786
|
+
this.saveBuf(binding, buf);
|
|
787
|
+
}
|
|
788
|
+
for (const msg of resp.msgs ?? []) {
|
|
789
|
+
const items = msg.item_list ?? [];
|
|
790
|
+
const textItem = items.find((i) => i.type === TEXT_ITEM_TYPE);
|
|
791
|
+
const voiceTextItems = items.filter((i) => i.type === VOICE_ITEM_TYPE && i.voice_item?.text);
|
|
792
|
+
const mediaItems = items.filter((i) => i.type === IMAGE_ITEM_TYPE || i.type === FILE_ITEM_TYPE);
|
|
793
|
+
if (!textItem && voiceTextItems.length === 0 && mediaItems.length === 0)
|
|
794
|
+
continue;
|
|
795
|
+
const conversationId = msg.from_user_id ?? '';
|
|
796
|
+
const text = [textItem?.text_item?.text?.trim(), ...voiceTextItems.map((i) => i.voice_item?.text?.trim())]
|
|
797
|
+
.filter((part) => Boolean(part))
|
|
798
|
+
.join('\n\n');
|
|
799
|
+
const msgId = msg.msg_id ?? String(Date.now());
|
|
800
|
+
// Handle attachments
|
|
801
|
+
let attachmentText = '';
|
|
802
|
+
if (mediaItems.length > 0) {
|
|
803
|
+
const uploadsDir = node_path_1.default.join(binding.stateDir, 'weixin-uploads');
|
|
804
|
+
for (const [idx, item] of mediaItems.entries()) {
|
|
805
|
+
try {
|
|
806
|
+
const att = await this.downloadMediaItem(item, msgId, idx, uploadsDir, binding);
|
|
807
|
+
if (att) {
|
|
808
|
+
attachmentText += attachmentText ? '\n' : '';
|
|
809
|
+
attachmentText += att.kind === 'image' ? `[Image: ${att.path}]` : `[File "${att.name}": ${att.path}]`;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
catch (dlErr) {
|
|
813
|
+
this.options.log?.(`localcore-weixin attachment download failed (${conversationId}#${idx}): ${formatError(dlErr)}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const fullText = [text, attachmentText].filter(Boolean).join('\n\n');
|
|
818
|
+
if (!fullText)
|
|
819
|
+
continue;
|
|
820
|
+
await this.handleInboundMessage({
|
|
821
|
+
workspaceId: binding.workspaceId,
|
|
822
|
+
platformUserId: conversationId,
|
|
823
|
+
chatId: conversationId,
|
|
824
|
+
displayName: conversationId.slice(-6),
|
|
825
|
+
text: fullText,
|
|
826
|
+
messageId: msgId,
|
|
827
|
+
contextToken: msg.context_token,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
catch (err) {
|
|
832
|
+
if (signal.aborted)
|
|
833
|
+
return;
|
|
834
|
+
consecutiveFailures++;
|
|
835
|
+
this.options.log?.(`localcore-weixin getUpdates error for ${binding.workspaceId} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${formatError(err)}`);
|
|
836
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
837
|
+
consecutiveFailures = 0;
|
|
838
|
+
await sleep(BACKOFF_DELAY_MS, signal);
|
|
839
|
+
}
|
|
840
|
+
else {
|
|
841
|
+
await sleep(RETRY_DELAY_MS, signal);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// ==================== Private: HTTP API ====================
|
|
847
|
+
async apiPost(binding, endpoint, bodyObj, timeoutMs, signal) {
|
|
848
|
+
const url = `${binding.baseUrl.replace(/\/$/, '')}/${endpoint}`;
|
|
849
|
+
const body = JSON.stringify(bodyObj);
|
|
850
|
+
const controller = new AbortController();
|
|
851
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
852
|
+
const onAbort = () => controller.abort();
|
|
853
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
854
|
+
try {
|
|
855
|
+
const headers = {
|
|
856
|
+
'Content-Type': 'application/json',
|
|
857
|
+
'Content-Length': String(Buffer.byteLength(body, 'utf-8')),
|
|
858
|
+
'X-WECHAT-UIN': createWechatUin(),
|
|
859
|
+
...createIlinkHeaders(),
|
|
860
|
+
};
|
|
861
|
+
if (binding.token) {
|
|
862
|
+
headers.AuthorizationType = 'ilink_bot_token';
|
|
863
|
+
headers.Authorization = `Bearer ${binding.token}`;
|
|
864
|
+
}
|
|
865
|
+
const res = await fetch(url, {
|
|
866
|
+
method: 'POST',
|
|
867
|
+
headers,
|
|
868
|
+
body,
|
|
869
|
+
signal: controller.signal,
|
|
870
|
+
});
|
|
871
|
+
if (!res.ok)
|
|
872
|
+
throw new Error(`HTTP ${res.status}`);
|
|
873
|
+
return (await res.json());
|
|
874
|
+
}
|
|
875
|
+
finally {
|
|
876
|
+
clearTimeout(timer);
|
|
877
|
+
signal?.removeEventListener('abort', onAbort);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
async apiGet(binding, endpoint, timeoutMs = API_TIMEOUT_MS) {
|
|
881
|
+
const url = `${binding.baseUrl.replace(/\/$/, '')}/${endpoint}`;
|
|
882
|
+
const controller = new AbortController();
|
|
883
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
884
|
+
try {
|
|
885
|
+
const headers = createIlinkHeaders();
|
|
886
|
+
if (binding.token) {
|
|
887
|
+
headers.AuthorizationType = 'ilink_bot_token';
|
|
888
|
+
headers.Authorization = `Bearer ${binding.token}`;
|
|
889
|
+
}
|
|
890
|
+
const res = await fetch(url, {
|
|
891
|
+
method: 'GET',
|
|
892
|
+
headers,
|
|
893
|
+
signal: controller.signal,
|
|
894
|
+
});
|
|
895
|
+
if (!res.ok)
|
|
896
|
+
throw new Error(`HTTP ${res.status}`);
|
|
897
|
+
return (await res.json());
|
|
898
|
+
}
|
|
899
|
+
finally {
|
|
900
|
+
clearTimeout(timer);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
async getUpdates(binding, buf, signal) {
|
|
904
|
+
return this.apiPost(binding, 'ilink/bot/getupdates', { get_updates_buf: buf, base_info: { channel_version: WEIXIN_CHANNEL_VERSION } }, binding.longPollTimeoutMs, signal);
|
|
905
|
+
}
|
|
906
|
+
async sendTextMessage(state, toUserId, text, contextToken, options = {}) {
|
|
907
|
+
const binding = await this.getBinding(state.workspaceId);
|
|
908
|
+
const stripped = stripHtml(text);
|
|
909
|
+
const chunks = contextToken
|
|
910
|
+
? [truncateTextByUtf8Bytes(stripped, WEIXIN_CONTEXT_REPLY_MAX_BYTES)].filter(Boolean)
|
|
911
|
+
: splitTextByUtf8Bytes(stripped, WEIXIN_TEXT_MESSAGE_MAX_BYTES);
|
|
912
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
913
|
+
const finalChunk = options.final && index === chunks.length - 1;
|
|
914
|
+
const resp = await this.sendTextMessageChunk(binding, toUserId, chunk, contextToken, {
|
|
915
|
+
clientId: options.clientId,
|
|
916
|
+
final: finalChunk,
|
|
917
|
+
});
|
|
918
|
+
if (this.isSendMessageError(resp)) {
|
|
919
|
+
throw new Error(`WeChat sendmessage failed: ret=${resp.ret} errcode=${resp.errcode}${resp.errmsg ? ` errmsg=${resp.errmsg}` : ''} chunk=${index + 1}/${chunks.length} bytes=${utf8ByteLength(chunk)} context=${contextToken ? 'yes' : 'no'} message_state=${finalChunk ? 2 : 1}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
this.options.log?.(`localcore-weixin sent message to ${toUserId} for workspace ${state.workspaceId}${chunks.length > 1 ? ` chunks=${chunks.length}` : ''}`);
|
|
923
|
+
}
|
|
924
|
+
async sendTextMessageChunk(binding, toUserId, text, contextToken, options = {}) {
|
|
925
|
+
return this.apiPost(binding, 'ilink/bot/sendmessage', {
|
|
926
|
+
msg: {
|
|
927
|
+
from_user_id: '',
|
|
928
|
+
to_user_id: toUserId,
|
|
929
|
+
client_id: options.clientId || `openclaw-weixin-${node_crypto_1.default.randomUUID()}`,
|
|
930
|
+
message_type: 2,
|
|
931
|
+
message_state: options.final === false ? 1 : 2,
|
|
932
|
+
item_list: [{ type: TEXT_ITEM_TYPE, text_item: { text } }],
|
|
933
|
+
...(contextToken ? { context_token: contextToken } : {}),
|
|
934
|
+
},
|
|
935
|
+
base_info: { channel_version: WEIXIN_CHANNEL_VERSION },
|
|
936
|
+
}, API_TIMEOUT_MS);
|
|
937
|
+
}
|
|
938
|
+
isSendMessageError(resp) {
|
|
939
|
+
return (resp.ret !== undefined && resp.ret !== 0) || (resp.errcode !== undefined && resp.errcode !== 0);
|
|
940
|
+
}
|
|
941
|
+
// ==================== Private: Attachment Download ====================
|
|
942
|
+
sniffExtAndKind(buf) {
|
|
943
|
+
if (buf[0] === 0xff && buf[1] === 0xd8)
|
|
944
|
+
return { ext: '.jpg', kind: 'image' };
|
|
945
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
|
|
946
|
+
return { ext: '.png', kind: 'image' };
|
|
947
|
+
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46)
|
|
948
|
+
return { ext: '.gif', kind: 'image' };
|
|
949
|
+
if (buf[0] === 0x25 && buf[1] === 0x50 && buf[2] === 0x44 && buf[3] === 0x46)
|
|
950
|
+
return { ext: '.pdf', kind: 'file' };
|
|
951
|
+
if (buf[0] === 0x50 && buf[1] === 0x4b)
|
|
952
|
+
return { ext: '.zip', kind: 'file' };
|
|
953
|
+
return { ext: '.bin', kind: 'file' };
|
|
954
|
+
}
|
|
955
|
+
async downloadMediaItem(item, msgId, idx, uploadsDir, binding) {
|
|
956
|
+
const itemData = item.image_item ?? item.file_item ?? null;
|
|
957
|
+
const encryptQueryParam = itemData?.media?.encrypt_query_param;
|
|
958
|
+
if (!encryptQueryParam)
|
|
959
|
+
return null;
|
|
960
|
+
let aesKey;
|
|
961
|
+
const aesKeyHex = itemData?.aeskey;
|
|
962
|
+
const aesKeyB64 = itemData?.media?.aes_key;
|
|
963
|
+
if (aesKeyHex) {
|
|
964
|
+
aesKey = Buffer.from(aesKeyHex, 'hex');
|
|
965
|
+
}
|
|
966
|
+
else if (aesKeyB64) {
|
|
967
|
+
const decoded = Buffer.from(aesKeyB64, 'base64');
|
|
968
|
+
aesKey = decoded.length === 16 ? decoded : decoded.length === 32 ? Buffer.from(decoded.toString('ascii'), 'hex') : undefined;
|
|
969
|
+
}
|
|
970
|
+
const cdnUrl = `${binding.cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}`;
|
|
971
|
+
const resp = await fetch(cdnUrl, { signal: AbortSignal.timeout(30_000) });
|
|
972
|
+
if (!resp.ok)
|
|
973
|
+
throw new Error(`CDN HTTP ${resp.status}`);
|
|
974
|
+
const rawBuf = Buffer.from(await resp.arrayBuffer());
|
|
975
|
+
if (rawBuf.length === 0)
|
|
976
|
+
throw new Error('CDN returned empty data');
|
|
977
|
+
let resultBuf;
|
|
978
|
+
if (aesKey) {
|
|
979
|
+
const decipher = node_crypto_1.default.createDecipheriv('aes-128-ecb', aesKey, null);
|
|
980
|
+
decipher.setAutoPadding(true);
|
|
981
|
+
resultBuf = Buffer.concat([decipher.update(rawBuf), decipher.final()]);
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
resultBuf = rawBuf;
|
|
985
|
+
}
|
|
986
|
+
const { ext, kind } = this.sniffExtAndKind(resultBuf);
|
|
987
|
+
const declaredName = String(itemData?.file_name ?? (item.type === IMAGE_ITEM_TYPE ? 'image' : 'file'));
|
|
988
|
+
const safeName = declaredName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64);
|
|
989
|
+
const safeMsgId = msgId.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 48);
|
|
990
|
+
const fileName = `${safeMsgId}-${idx}-${safeName}${ext}`;
|
|
991
|
+
const filePath = node_path_1.default.join(uploadsDir, fileName);
|
|
992
|
+
node_fs_1.default.mkdirSync(uploadsDir, { recursive: true });
|
|
993
|
+
node_fs_1.default.writeFileSync(filePath, resultBuf);
|
|
994
|
+
return { path: filePath, kind, name: declaredName };
|
|
995
|
+
}
|
|
996
|
+
// ==================== Private: Turn State ====================
|
|
997
|
+
createTurnState(sessionKey) {
|
|
998
|
+
const turn = {
|
|
999
|
+
sessionKey,
|
|
1000
|
+
sentCount: 0,
|
|
1001
|
+
foldedProgressCount: 0,
|
|
1002
|
+
awaitingPermission: false,
|
|
1003
|
+
processing: false,
|
|
1004
|
+
previewText: '',
|
|
1005
|
+
finalText: '',
|
|
1006
|
+
statusLines: [],
|
|
1007
|
+
buttonRows: [],
|
|
1008
|
+
lastSentAt: 0,
|
|
1009
|
+
lastSentText: '',
|
|
1010
|
+
};
|
|
1011
|
+
this.outboundTurns.set(sessionKey, turn);
|
|
1012
|
+
return turn;
|
|
1013
|
+
}
|
|
1014
|
+
getOrCreateTurnState(sessionKey) {
|
|
1015
|
+
return this.outboundTurns.get(sessionKey) || this.createTurnState(sessionKey);
|
|
1016
|
+
}
|
|
1017
|
+
consumeBridgeEvent(turn, event) {
|
|
1018
|
+
const content = stripToolResultForWeixin(String(event.content || '').trim());
|
|
1019
|
+
if (event.type === 'typing_start') {
|
|
1020
|
+
turn.processing = true;
|
|
1021
|
+
turn.previewText = '';
|
|
1022
|
+
turn.finalText = '';
|
|
1023
|
+
turn.statusLines = [];
|
|
1024
|
+
turn.buttonRows = [];
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (event.type === 'typing_stop') {
|
|
1028
|
+
turn.processing = false;
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (event.type === 'preview_start' || event.type === 'update_message') {
|
|
1032
|
+
turn.previewText = content;
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (event.type === 'status') {
|
|
1036
|
+
if (content) {
|
|
1037
|
+
this.pushUnique(turn.statusLines, content);
|
|
1038
|
+
turn.finalText = content;
|
|
1039
|
+
turn.previewText = content;
|
|
1040
|
+
}
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if (event.type === 'buttons') {
|
|
1044
|
+
turn.awaitingPermission = true;
|
|
1045
|
+
turn.buttonRows = Array.isArray(event.buttonRows)
|
|
1046
|
+
? event.buttonRows
|
|
1047
|
+
.map((row) => Array.isArray(row)
|
|
1048
|
+
? row.filter((b) => Boolean(b?.text && b?.data))
|
|
1049
|
+
.map((b) => ({ text: b.text, data: b.data }))
|
|
1050
|
+
: [])
|
|
1051
|
+
.filter((row) => row.length > 0)
|
|
1052
|
+
: [];
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (!content)
|
|
1056
|
+
return;
|
|
1057
|
+
turn.finalText = content;
|
|
1058
|
+
turn.previewText = content;
|
|
1059
|
+
}
|
|
1060
|
+
renderTurnText(turn) {
|
|
1061
|
+
const sections = [];
|
|
1062
|
+
if (turn.finalText) {
|
|
1063
|
+
sections.push(turn.finalText);
|
|
1064
|
+
}
|
|
1065
|
+
else if (turn.previewText) {
|
|
1066
|
+
sections.push(turn.previewText);
|
|
1067
|
+
}
|
|
1068
|
+
else if (turn.processing && turn.statusLines.length > 0) {
|
|
1069
|
+
sections.push(`**处理中**\n${turn.statusLines.slice(-3).map((l) => `• ${l.replace(/\s+/g, ' ').trim()}`).join('\n')}`);
|
|
1070
|
+
}
|
|
1071
|
+
else if (turn.processing) {
|
|
1072
|
+
sections.push('**处理中**\n正在思考...');
|
|
1073
|
+
}
|
|
1074
|
+
if (turn.awaitingPermission) {
|
|
1075
|
+
sections.push('\n回复:`allow` / `allow all` / `deny`');
|
|
1076
|
+
}
|
|
1077
|
+
return sections.join('\n\n').trim();
|
|
1078
|
+
}
|
|
1079
|
+
isTerminalBridgeMessage(event, rendered) {
|
|
1080
|
+
if (event.type === 'buttons')
|
|
1081
|
+
return true;
|
|
1082
|
+
if (event.type !== 'reply')
|
|
1083
|
+
return false;
|
|
1084
|
+
const normalized = rendered.trim();
|
|
1085
|
+
if (!normalized)
|
|
1086
|
+
return false;
|
|
1087
|
+
if (normalized.startsWith('🔧 ') || normalized.startsWith('💭 '))
|
|
1088
|
+
return false;
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
// ==================== Private: Helpers ====================
|
|
1092
|
+
pushUnique(target, value) {
|
|
1093
|
+
const normalized = value.trim();
|
|
1094
|
+
if (!normalized)
|
|
1095
|
+
return;
|
|
1096
|
+
if (target[target.length - 1] === normalized)
|
|
1097
|
+
return;
|
|
1098
|
+
target.push(normalized);
|
|
1099
|
+
if (target.length > 8)
|
|
1100
|
+
target.splice(0, target.length - 8);
|
|
1101
|
+
}
|
|
1102
|
+
isDuplicateInboundMessage(input) {
|
|
1103
|
+
const messageKey = input.contextToken || input.messageId;
|
|
1104
|
+
if (!messageKey)
|
|
1105
|
+
return false;
|
|
1106
|
+
const now = Date.now();
|
|
1107
|
+
for (const [key, expiresAt] of this.processedInboundMessages.entries()) {
|
|
1108
|
+
if (expiresAt <= now)
|
|
1109
|
+
this.processedInboundMessages.delete(key);
|
|
1110
|
+
}
|
|
1111
|
+
const key = `${input.workspaceId}:${input.chatId}:${messageKey}`;
|
|
1112
|
+
if (this.processedInboundMessages.has(key))
|
|
1113
|
+
return true;
|
|
1114
|
+
this.processedInboundMessages.set(key, now + PROCESSED_MESSAGE_TTL_MS);
|
|
1115
|
+
return false;
|
|
1116
|
+
}
|
|
1117
|
+
findAwaitingPermissionThreadId(workspaceId, chatId, platformUserId) {
|
|
1118
|
+
for (const [sessionKey, route] of this.threadRouting.entries()) {
|
|
1119
|
+
if (route.workspaceId !== workspaceId || route.chatId !== chatId || route.platformUserId !== platformUserId)
|
|
1120
|
+
continue;
|
|
1121
|
+
const turn = this.outboundTurns.get(sessionKey);
|
|
1122
|
+
if (turn?.awaitingPermission && route.threadId)
|
|
1123
|
+
return route.threadId;
|
|
1124
|
+
}
|
|
1125
|
+
return '';
|
|
1126
|
+
}
|
|
1127
|
+
parseSlashCommand(text) {
|
|
1128
|
+
const normalized = String(text || '').trim();
|
|
1129
|
+
if (!normalized.startsWith('/'))
|
|
1130
|
+
return null;
|
|
1131
|
+
const [name = '', ...args] = normalized.slice(1).split(/\s+/);
|
|
1132
|
+
if (!name)
|
|
1133
|
+
return null;
|
|
1134
|
+
return { name: name.trim().toLowerCase(), args };
|
|
1135
|
+
}
|
|
1136
|
+
notifyRuntimeStateChanged() {
|
|
1137
|
+
this.options.eventBus.emit({
|
|
1138
|
+
type: 'runtime.state.changed',
|
|
1139
|
+
payload: { reason: 'channel-bindings' },
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
exports.LocalCoreWeixinGateway = LocalCoreWeixinGateway;
|