@kafca/agentdock 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +6 -0
  2. package/dist/renderer/assets/{Badge-BRmQIqbd.js → Badge-B_rgzxYO.js} +1 -1
  3. package/dist/renderer/assets/{Chat-D94ODABB.js → Chat-CYtp6m7T.js} +1 -1
  4. package/dist/renderer/assets/{ChatMarkdown-DqW7OUvV.js → ChatMarkdown-DOBMwa9d.js} +1 -1
  5. package/dist/renderer/assets/{Config-9lgzpTCp.js → Config-DppMSPHF.js} +2 -2
  6. package/dist/renderer/assets/CronList-BDtB1SuA.js +1 -0
  7. package/dist/renderer/assets/{Dashboard-COISXbQy.js → Dashboard-iFlRnd7Y.js} +2 -2
  8. package/dist/renderer/assets/{EmptyState-w5-I9zg7.js → EmptyState-bKDt_T88.js} +1 -1
  9. package/dist/renderer/assets/{KnowledgeDetail-DVxwrTLw.js → KnowledgeDetail-CKPhKPIg.js} +2 -2
  10. package/dist/renderer/assets/{KnowledgeHome-BKkubcwi.js → KnowledgeHome-CFeJre6i.js} +3 -3
  11. package/dist/renderer/assets/Logs-CyxSVOFK.js +1 -0
  12. package/dist/renderer/assets/{Modal-Ceu_MP2e.js → Modal-CxPnk1Ey.js} +1 -1
  13. package/dist/renderer/assets/{Page-CpSczAz8.js → Page-kU9vubzj.js} +1 -1
  14. package/dist/renderer/assets/{ProjectDetail-FJ_bNXLF.js → ProjectDetail-D9FY9NWZ.js} +1 -1
  15. package/dist/renderer/assets/{ProjectList-DZKL4K9C.js → ProjectList-DIwr2TXg.js} +1 -1
  16. package/dist/renderer/assets/{Select-BHQk6qCU.js → Select-BaWoz5Dn.js} +2 -7
  17. package/dist/renderer/assets/SessionChat-Cm9CW8Zx.js +1 -0
  18. package/dist/renderer/assets/SessionList-UPnWlXXO.js +1 -0
  19. package/dist/renderer/assets/{ThreadChat-D3s4GXGh.js → ThreadChat-YvVsACW3.js} +7 -7
  20. package/dist/renderer/assets/{Workspace-B7looTVo.js → Workspace-ST7V8u6T.js} +2 -2
  21. package/dist/renderer/assets/{arrow-left-Dtal-eOi.js → arrow-left-BmZAVGV7.js} +1 -1
  22. package/dist/renderer/assets/{book-open-q814R_Ov.js → book-open-DZ3GXFgI.js} +1 -1
  23. package/dist/renderer/assets/{check-DjfCdLvw.js → check-CeVSvDCv.js} +1 -1
  24. package/dist/renderer/assets/chevron-down-yryuMoQ-.js +6 -0
  25. package/dist/renderer/assets/{circle-vfhmyE8v.js → circle-eASsFISH.js} +1 -1
  26. package/dist/renderer/assets/desktop-Che1cbpE.js +4 -0
  27. package/dist/renderer/assets/desktop-DkrP_s-b.js +1 -0
  28. package/dist/renderer/assets/{funnel-CUQibo1I.js → funnel-pwAe2Ixp.js} +1 -1
  29. package/dist/renderer/assets/{heart-DG9_b8P1.js → heart-B2Sp-bCm.js} +1 -1
  30. package/dist/renderer/assets/{index-Bn8ane6_.js → index-BC3MWbS1.js} +3 -3
  31. package/dist/renderer/assets/{index-Ds0IN5rP.js → index-Cse2Br64.js} +1 -1
  32. package/dist/renderer/assets/{index-3CzgyQYB.css → index-DFiJR0vv.css} +1 -1
  33. package/dist/renderer/assets/{pencil-Dij0LlRY.js → pencil-BfEunUF3.js} +1 -1
  34. package/dist/renderer/assets/{play-CPFGjIyJ.js → play-Dcs9Lj_Z.js} +1 -1
  35. package/dist/renderer/assets/{plug-DH3RKQyn.js → plug-B_YschQe.js} +1 -1
  36. package/dist/renderer/assets/{plus-afkK9_Y8.js → plus-Pqo-cXHQ.js} +1 -1
  37. package/dist/renderer/assets/projects-BCj0k_Gy.js +1 -0
  38. package/dist/renderer/assets/{save-B-cKgWcy.js → save-BKM5yi1A.js} +1 -1
  39. package/dist/renderer/assets/{search-DR6PeYkt.js → search-BU42h10M.js} +1 -1
  40. package/dist/renderer/assets/{server-BM6KOB5a.js → server-DA5t3Rtr.js} +1 -1
  41. package/dist/renderer/assets/{session-utils-BDuThM0a.js → session-utils-Cr9VhNSq.js} +1 -1
  42. package/dist/renderer/assets/{sessions-C1yKps4T.js → sessions-bvV8ZMfX.js} +1 -1
  43. package/dist/renderer/assets/{shield-check-BFjMG-r2.js → shield-check-BeOJKur_.js} +1 -1
  44. package/dist/renderer/assets/{trash-2-CW1heMCf.js → trash-2-CQ7HKO3q.js} +1 -1
  45. package/dist/renderer/assets/{user-r_2sHG7m.js → user-CtFjY9SV.js} +1 -1
  46. package/dist/renderer/assets/{wifi-off-VMUokMpE.js → wifi-off-Ci5m0ysf.js} +1 -1
  47. package/dist/renderer/index.html +2 -2
  48. package/dist-electron/electron/local-core-refactor.test.js +307 -3
  49. package/dist-electron/services/local-ai-core/src/{gateway → channel/lark}/local-core-lark-gateway.js +249 -8
  50. package/dist-electron/services/local-ai-core/src/channel/shared/file-utils.js +33 -0
  51. package/dist-electron/services/local-ai-core/src/{gateway → channel/weixin}/local-core-weixin-gateway.js +188 -1
  52. package/dist-electron/services/local-ai-core/src/cli/lac.js +41 -1
  53. package/dist-electron/services/local-ai-core/src/plugins/builtin/channel-lark-plugin.js +1 -1
  54. package/dist-electron/services/local-ai-core/src/plugins/builtin/channel-weixin-plugin.js +1 -1
  55. package/dist-electron/services/local-ai-core/src/runtime/local-core-controller.js +41 -0
  56. package/dist-electron/services/local-ai-core/src/runtime/server.js +10 -0
  57. package/dist-electron/shared/desktop.js +14 -1
  58. package/package.json +1 -1
  59. package/dist/renderer/assets/CronList-BYZ-x_bp.js +0 -1
  60. package/dist/renderer/assets/Logs-D7HxZ8Nb.js +0 -1
  61. package/dist/renderer/assets/SessionChat-DpgSXF3m.js +0 -1
  62. package/dist/renderer/assets/SessionList-CU6drQ4N.js +0 -1
  63. package/dist/renderer/assets/desktop-BPIxWbkM.js +0 -1
  64. package/dist/renderer/assets/desktop-DZHC4PDB.js +0 -3
  65. package/dist/renderer/assets/projects-DTDS9SnT.js +0 -1
@@ -36,8 +36,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.LocalCoreLarkGateway = void 0;
37
37
  const node_events_1 = require("node:events");
38
38
  const node_crypto_1 = require("node:crypto");
39
- const desktop_js_1 = require("../../../../shared/desktop.js");
39
+ const node_fs_1 = require("node:fs");
40
+ const node_path_1 = require("node:path");
41
+ const desktop_js_1 = require("../../../../../shared/desktop.js");
42
+ const file_utils_js_1 = require("../shared/file-utils.js");
43
+ function foldToolResultForLark(content) {
44
+ const normalized = String(content || '').trim();
45
+ if (!normalized.startsWith('🔧 '))
46
+ return normalized;
47
+ const parts = normalized.split(' - ');
48
+ if (parts.length <= 2)
49
+ return normalized;
50
+ return parts.slice(0, 2).join(' - ');
51
+ }
40
52
  const PAIRING_EXPIRY_MS = 10 * 60 * 1000;
53
+ const LARK_MAX_UPLOAD_FILE_SIZE = 30 * 1024 * 1024;
41
54
  class LocalCoreLarkGateway extends node_events_1.EventEmitter {
42
55
  options;
43
56
  // Lark returns 200340 when card action events are not enabled in the app's
@@ -115,6 +128,111 @@ class LocalCoreLarkGateway extends node_events_1.EventEmitter {
115
128
  async sendScheduledMessage(workspaceId, route, text) {
116
129
  return this.sendScheduledCard(workspaceId, route.channelId, text);
117
130
  }
131
+ async sendOutboundMessage(workspaceId, input) {
132
+ const state = this.runtime.get(workspaceId);
133
+ if (!state?.client || !state.connected) {
134
+ throw new Error(`Lark workspace is not connected: ${workspaceId}`);
135
+ }
136
+ const channelId = String(input.route?.channelId || '').trim();
137
+ if (!channelId) {
138
+ throw new Error('Missing Lark target channel id');
139
+ }
140
+ const messageIds = [];
141
+ const attachments = [];
142
+ for (const part of input.parts || []) {
143
+ if (part.type === 'text') {
144
+ const text = String(part.text || '').trim();
145
+ if (text) {
146
+ messageIds.push(await this.sendTextAsCard(state, channelId, text));
147
+ }
148
+ continue;
149
+ }
150
+ if (part.type === 'file') {
151
+ const sent = await this.sendFilePart(state, channelId, part);
152
+ messageIds.push(sent.messageId);
153
+ attachments.push({
154
+ kind: 'file',
155
+ attachmentId: sent.fileKey,
156
+ fileName: sent.file.fileName,
157
+ fileSize: sent.file.fileSize,
158
+ metadata: {
159
+ fileKey: sent.fileKey,
160
+ },
161
+ });
162
+ }
163
+ }
164
+ return {
165
+ platform: 'lark',
166
+ workspaceId,
167
+ channelId,
168
+ participantId: input.route.participantId,
169
+ messageIds: messageIds.filter(Boolean),
170
+ attachments,
171
+ };
172
+ }
173
+ async sendFile(workspaceId, input) {
174
+ const result = await this.sendOutboundMessage(workspaceId, {
175
+ route: {
176
+ type: 'channel.chat',
177
+ channelId: input.channelId,
178
+ participantId: input.participantId,
179
+ },
180
+ parts: [{
181
+ type: 'file',
182
+ path: input.path,
183
+ fileName: input.fileName,
184
+ }],
185
+ });
186
+ const attachment = result.attachments?.[0];
187
+ return {
188
+ platform: 'lark',
189
+ workspaceId,
190
+ channelId: result.channelId,
191
+ messageId: result.messageIds[0] || '',
192
+ fileKey: String(attachment?.metadata?.fileKey || attachment?.attachmentId || ''),
193
+ fileName: attachment?.fileName || input.fileName || '',
194
+ fileSize: attachment?.fileSize || 0,
195
+ };
196
+ }
197
+ async sendFilePart(state, channelId, part) {
198
+ const file = await (0, file_utils_js_1.prepareChannelFile)({
199
+ path: part.path,
200
+ fileName: part.fileName,
201
+ maxBytes: LARK_MAX_UPLOAD_FILE_SIZE,
202
+ platformLabel: 'Lark',
203
+ });
204
+ const upload = await state.client.im.file.create({
205
+ data: {
206
+ file_type: this.resolveLarkUploadFileType(file.fileName),
207
+ file_name: file.fileName,
208
+ file: (0, node_fs_1.createReadStream)(file.path),
209
+ },
210
+ });
211
+ const fileKey = String(upload?.file_key || upload?.data?.file_key || '').trim();
212
+ if (!fileKey) {
213
+ throw new Error('Lark file upload did not return a file key');
214
+ }
215
+ const response = await state.client.im.message.create({
216
+ params: {
217
+ receive_id_type: this.resolveReceiveIdType(channelId),
218
+ },
219
+ data: {
220
+ receive_id: channelId,
221
+ msg_type: 'file',
222
+ content: JSON.stringify({ file_key: fileKey }),
223
+ },
224
+ });
225
+ const messageId = String(response?.data?.message_id || '').trim();
226
+ if (!messageId) {
227
+ throw new Error('Lark file message did not return a message id');
228
+ }
229
+ this.options.log?.(`localcore-lark sent file ${file.fileName} (${file.fileSize} bytes) to ${channelId}`);
230
+ return {
231
+ messageId,
232
+ fileKey,
233
+ file,
234
+ };
235
+ }
118
236
  muteThreadBridge(threadId) {
119
237
  const current = this.mutedThreadBridgeCounts.get(threadId) || 0;
120
238
  this.mutedThreadBridgeCounts.set(threadId, current + 1);
@@ -881,10 +999,9 @@ class LocalCoreLarkGateway extends node_events_1.EventEmitter {
881
999
  };
882
1000
  }
883
1001
  async sendTextAsCard(state, chatId, text, buttonRows = [], sessionKey, threadId) {
884
- const receiveIdType = chatId.startsWith('oc_') ? 'chat_id' : chatId.startsWith('ou_') ? 'open_id' : 'user_id';
885
1002
  const response = await state.client.im.message.create({
886
1003
  params: {
887
- receive_id_type: receiveIdType,
1004
+ receive_id_type: this.resolveReceiveIdType(chatId),
888
1005
  },
889
1006
  data: {
890
1007
  receive_id: chatId,
@@ -894,6 +1011,33 @@ class LocalCoreLarkGateway extends node_events_1.EventEmitter {
894
1011
  });
895
1012
  return String(response?.data?.message_id || '').trim();
896
1013
  }
1014
+ resolveReceiveIdType(receiveId) {
1015
+ return receiveId.startsWith('oc_') ? 'chat_id' : receiveId.startsWith('ou_') ? 'open_id' : 'user_id';
1016
+ }
1017
+ resolveLarkUploadFileType(fileName) {
1018
+ switch ((0, node_path_1.extname)(fileName).toLowerCase()) {
1019
+ case '.opus':
1020
+ return 'opus';
1021
+ case '.mp4':
1022
+ case '.mov':
1023
+ case '.m4v':
1024
+ return 'mp4';
1025
+ case '.pdf':
1026
+ return 'pdf';
1027
+ case '.doc':
1028
+ case '.docx':
1029
+ return 'doc';
1030
+ case '.xls':
1031
+ case '.xlsx':
1032
+ case '.csv':
1033
+ return 'xls';
1034
+ case '.ppt':
1035
+ case '.pptx':
1036
+ return 'ppt';
1037
+ default:
1038
+ return 'stream';
1039
+ }
1040
+ }
897
1041
  async patchTextCard(state, messageId, text, buttonRows = [], sessionKey, threadId) {
898
1042
  await state.client.im.message.patch({
899
1043
  path: {
@@ -971,7 +1115,7 @@ class LocalCoreLarkGateway extends node_events_1.EventEmitter {
971
1115
  return turn;
972
1116
  }
973
1117
  consumeBridgeEvent(turn, event) {
974
- const content = String(event.content || '').trim();
1118
+ const content = foldToolResultForLark(String(event.content || '').trim());
975
1119
  if (event.type === 'typing_start') {
976
1120
  // Always start a fresh card for each generation phase.
977
1121
  // This keeps Feishu message order aligned with user-visible timeline.
@@ -1030,7 +1174,7 @@ class LocalCoreLarkGateway extends node_events_1.EventEmitter {
1030
1174
  turn.previewText = content;
1031
1175
  }
1032
1176
  renderBridgeEventMessage(turn, event) {
1033
- const content = String(event.content || '').trim();
1177
+ const content = foldToolResultForLark(String(event.content || '').trim());
1034
1178
  if (event.type === 'preview_start' || event.type === 'update_message') {
1035
1179
  if (content.startsWith('💭 ')) {
1036
1180
  return this.renderProgressMessage(event.previewHandle || 'thinking-preview', content);
@@ -1159,12 +1303,107 @@ class LocalCoreLarkGateway extends node_events_1.EventEmitter {
1159
1303
  }
1160
1304
  const router = this.options.getWorkspaceRouter();
1161
1305
  await router.sendThreadAction(threadId, response);
1306
+ await this.markPermissionCardActionHandled(workspaceId, data, payload, value, response, threadId);
1162
1307
  this.options.log?.(`localcore-lark processed card action for ${workspaceId}: ${response}`);
1163
1308
  }
1164
1309
  catch (error) {
1165
1310
  this.options.log?.(`localcore-lark card action failed for ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`);
1166
1311
  }
1167
1312
  }
1313
+ async markPermissionCardActionHandled(workspaceId, rootPayload, payload, value, response, threadId) {
1314
+ const state = this.runtime.get(workspaceId);
1315
+ if (!state?.client) {
1316
+ return;
1317
+ }
1318
+ const sessionKey = String(value.session_key || '').trim();
1319
+ const turn = sessionKey ? this.outboundTurns.get(sessionKey) : undefined;
1320
+ const messageId = this.extractCardActionMessageId(rootPayload, payload, value) || turn?.permissionMessageId;
1321
+ if (!messageId) {
1322
+ this.options.log?.(`localcore-lark could not find permission card message id for workspace=${workspaceId} sessionKey=${sessionKey || 'missing'}`);
1323
+ return;
1324
+ }
1325
+ try {
1326
+ await this.patchTextCard(state, messageId, [
1327
+ '**工具确认已处理**',
1328
+ `已选择:${this.formatPermissionResponseLabel(response)}`,
1329
+ '等待代理继续执行...',
1330
+ ].join('\n\n'), [], sessionKey || undefined, threadId);
1331
+ if (turn) {
1332
+ turn.awaitingPermission = false;
1333
+ turn.buttonRows = [];
1334
+ }
1335
+ }
1336
+ catch (error) {
1337
+ this.options.log?.(`localcore-lark failed to patch handled permission card ${messageId}: ${error instanceof Error ? error.message : String(error)}`);
1338
+ }
1339
+ }
1340
+ extractCardActionMessageId(...payloads) {
1341
+ for (const payload of payloads) {
1342
+ const messageId = this.extractKnownCardActionMessageId(payload);
1343
+ if (messageId) {
1344
+ return messageId;
1345
+ }
1346
+ }
1347
+ for (const payload of payloads) {
1348
+ const messageId = this.findNestedCardActionMessageId(payload);
1349
+ if (messageId) {
1350
+ return messageId;
1351
+ }
1352
+ }
1353
+ return '';
1354
+ }
1355
+ extractKnownCardActionMessageId(payload) {
1356
+ const source = payload;
1357
+ if (!source || typeof source !== 'object') {
1358
+ return '';
1359
+ }
1360
+ const candidates = [
1361
+ source?.permission_message_id,
1362
+ source?.action_message_id,
1363
+ source?.context?.open_message_id,
1364
+ source?.context?.message_id,
1365
+ source?.event?.context?.open_message_id,
1366
+ source?.event?.context?.message_id,
1367
+ source?.event_context?.open_message_id,
1368
+ source?.event_context?.message_id,
1369
+ source?.message?.message_id,
1370
+ source?.message?.open_message_id,
1371
+ source?.event?.message?.message_id,
1372
+ source?.event?.message?.open_message_id,
1373
+ source?.message_id,
1374
+ source?.open_message_id,
1375
+ ];
1376
+ return candidates
1377
+ .map((candidate) => String(candidate || '').trim())
1378
+ .find(Boolean) || '';
1379
+ }
1380
+ findNestedCardActionMessageId(payload, seen = new Set()) {
1381
+ if (!payload || typeof payload !== 'object' || seen.has(payload)) {
1382
+ return '';
1383
+ }
1384
+ seen.add(payload);
1385
+ if (Array.isArray(payload)) {
1386
+ for (const item of payload) {
1387
+ const nested = this.findNestedCardActionMessageId(item, seen);
1388
+ if (nested)
1389
+ return nested;
1390
+ }
1391
+ return '';
1392
+ }
1393
+ const source = payload;
1394
+ for (const key of ['open_message_id', 'message_id', 'permission_message_id', 'action_message_id']) {
1395
+ const value = source[key];
1396
+ if (typeof value === 'string' && value.trim()) {
1397
+ return value.trim();
1398
+ }
1399
+ }
1400
+ for (const value of Object.values(source)) {
1401
+ const nested = this.findNestedCardActionMessageId(value, seen);
1402
+ if (nested)
1403
+ return nested;
1404
+ }
1405
+ return '';
1406
+ }
1168
1407
  pushUnique(target, value) {
1169
1408
  const normalized = value.trim();
1170
1409
  if (!normalized) {
@@ -1179,8 +1418,10 @@ class LocalCoreLarkGateway extends node_events_1.EventEmitter {
1179
1418
  }
1180
1419
  }
1181
1420
  formatPermissionButtonLabel(button) {
1182
- const response = (0, desktop_js_1.normalizePermissionResponse)(button.data) || (0, desktop_js_1.normalizePermissionResponse)(button.text);
1183
- switch (response) {
1421
+ return this.formatPermissionResponseLabel(button.data || button.text);
1422
+ }
1423
+ formatPermissionResponseLabel(response) {
1424
+ switch ((0, desktop_js_1.normalizePermissionResponse)(response) || response) {
1184
1425
  case 'allow':
1185
1426
  return '允许一次';
1186
1427
  case 'allow all':
@@ -1188,7 +1429,7 @@ class LocalCoreLarkGateway extends node_events_1.EventEmitter {
1188
1429
  case 'deny':
1189
1430
  return '拒绝';
1190
1431
  default:
1191
- return button.text;
1432
+ return response;
1192
1433
  }
1193
1434
  }
1194
1435
  resolveLarkButtonType(button, index) {
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.prepareChannelFile = prepareChannelFile;
4
+ exports.sanitizeChannelFileName = sanitizeChannelFileName;
5
+ const promises_1 = require("node:fs/promises");
6
+ const node_path_1 = require("node:path");
7
+ async function prepareChannelFile(input) {
8
+ const filePath = String(input.path || '').trim();
9
+ if (!filePath) {
10
+ throw new Error('Missing file path');
11
+ }
12
+ const fileStat = await (0, promises_1.stat)(filePath).catch((error) => {
13
+ throw new Error(`Cannot read file "${filePath}": ${error instanceof Error ? error.message : String(error)}`);
14
+ });
15
+ if (!fileStat.isFile()) {
16
+ throw new Error(`Path is not a file: ${filePath}`);
17
+ }
18
+ if (fileStat.size <= 0) {
19
+ throw new Error(`File is empty: ${filePath}`);
20
+ }
21
+ if (input.maxBytes && fileStat.size > input.maxBytes) {
22
+ throw new Error(`File is too large for ${input.platformLabel} upload: ${fileStat.size} bytes`);
23
+ }
24
+ return {
25
+ path: filePath,
26
+ fileName: sanitizeChannelFileName(input.fileName || (0, node_path_1.basename)(filePath)),
27
+ fileSize: fileStat.size,
28
+ };
29
+ }
30
+ function sanitizeChannelFileName(fileName) {
31
+ const normalized = String(fileName || '').trim().replace(/[\/\\]+/g, '_');
32
+ return normalized || 'file';
33
+ }
@@ -9,7 +9,8 @@ const node_fs_1 = __importDefault(require("node:fs"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
10
  const node_events_1 = require("node:events");
11
11
  const node_crypto_2 = require("node:crypto");
12
- const desktop_js_1 = require("../../../../shared/desktop.js");
12
+ const desktop_js_1 = require("../../../../../shared/desktop.js");
13
+ const file_utils_js_1 = require("../shared/file-utils.js");
13
14
  // ==================== Constants ====================
14
15
  const PAIRING_EXPIRY_MS = 10 * 60 * 1000;
15
16
  const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com';
@@ -33,6 +34,8 @@ const TEXT_ITEM_TYPE = 1;
33
34
  const IMAGE_ITEM_TYPE = 2;
34
35
  const VOICE_ITEM_TYPE = 3;
35
36
  const FILE_ITEM_TYPE = 4;
37
+ const UPLOAD_MEDIA_TYPE_FILE = 3;
38
+ const WEIXIN_MAX_UPLOAD_FILE_SIZE = 30 * 1024 * 1024;
36
39
  // ==================== Utilities ====================
37
40
  function stripHtml(html) {
38
41
  let result = html;
@@ -453,6 +456,76 @@ class LocalCoreWeixinGateway extends node_events_1.EventEmitter {
453
456
  return '';
454
457
  }
455
458
  }
459
+ async sendOutboundMessage(workspaceId, input) {
460
+ const state = this.runtime.get(workspaceId);
461
+ if (!state?.connected) {
462
+ throw new Error(`WeChat workspace is not connected: ${workspaceId}`);
463
+ }
464
+ const channelId = String(input.route?.channelId || '').trim();
465
+ if (!channelId) {
466
+ throw new Error('Missing WeChat target channel id');
467
+ }
468
+ const binding = await this.getBinding(workspaceId);
469
+ const contextToken = this.resolveContextTokenForFileSend(workspaceId, channelId, input.route.participantId);
470
+ const messageIds = [];
471
+ const attachments = [];
472
+ for (const part of input.parts || []) {
473
+ if (part.type === 'text') {
474
+ const text = String(part.text || '').trim();
475
+ if (text) {
476
+ await this.sendTextMessage(state, channelId, text, contextToken);
477
+ messageIds.push(`wx_text_${(0, node_crypto_2.randomUUID)()}`);
478
+ }
479
+ continue;
480
+ }
481
+ if (part.type === 'file') {
482
+ const sent = await this.sendFilePart(binding, channelId, part, contextToken);
483
+ messageIds.push(sent.messageId);
484
+ attachments.push({
485
+ kind: 'file',
486
+ attachmentId: sent.uploaded.fileKey,
487
+ fileName: sent.file.fileName,
488
+ fileSize: sent.file.fileSize,
489
+ metadata: {
490
+ fileKey: sent.uploaded.fileKey,
491
+ encryptedQueryParam: sent.uploaded.encryptedQueryParam,
492
+ },
493
+ });
494
+ }
495
+ }
496
+ return {
497
+ platform: 'weixin',
498
+ workspaceId,
499
+ channelId,
500
+ participantId: input.route.participantId,
501
+ messageIds,
502
+ attachments,
503
+ };
504
+ }
505
+ async sendFile(workspaceId, input) {
506
+ const result = await this.sendOutboundMessage(workspaceId, {
507
+ route: {
508
+ type: 'channel.chat',
509
+ channelId: input.channelId,
510
+ participantId: input.participantId,
511
+ },
512
+ parts: [{
513
+ type: 'file',
514
+ path: input.path,
515
+ fileName: input.fileName,
516
+ }],
517
+ });
518
+ const attachment = result.attachments?.[0];
519
+ return {
520
+ platform: 'weixin',
521
+ workspaceId,
522
+ channelId: result.channelId,
523
+ messageId: result.messageIds[0] || '',
524
+ fileKey: String(attachment?.metadata?.fileKey || attachment?.attachmentId || ''),
525
+ fileName: attachment?.fileName || input.fileName || '',
526
+ fileSize: attachment?.fileSize || 0,
527
+ };
528
+ }
456
529
  // ==================== Inbound Message Handling ====================
457
530
  async handleInboundMessage(input) {
458
531
  if (this.isDuplicateInboundMessage(input)) {
@@ -949,9 +1022,123 @@ class LocalCoreWeixinGateway extends node_events_1.EventEmitter {
949
1022
  base_info: { channel_version: WEIXIN_CHANNEL_VERSION },
950
1023
  }, API_TIMEOUT_MS);
951
1024
  }
1025
+ async sendFileMessage(binding, toUserId, fileName, uploaded, contextToken) {
1026
+ const clientId = `openclaw-weixin-${node_crypto_1.default.randomUUID()}`;
1027
+ const resp = await this.apiPost(binding, 'ilink/bot/sendmessage', {
1028
+ msg: {
1029
+ from_user_id: '',
1030
+ to_user_id: toUserId,
1031
+ client_id: clientId,
1032
+ message_type: 2,
1033
+ message_state: 2,
1034
+ item_list: [{
1035
+ type: FILE_ITEM_TYPE,
1036
+ file_item: {
1037
+ media: {
1038
+ encrypt_query_param: uploaded.encryptedQueryParam,
1039
+ aes_key: Buffer.from(uploaded.aesKeyHex).toString('base64'),
1040
+ encrypt_type: 1,
1041
+ },
1042
+ file_name: fileName,
1043
+ len: String(uploaded.fileSize),
1044
+ },
1045
+ }],
1046
+ ...(contextToken ? { context_token: contextToken } : {}),
1047
+ },
1048
+ base_info: { channel_version: WEIXIN_CHANNEL_VERSION },
1049
+ }, API_TIMEOUT_MS);
1050
+ if (this.isSendMessageError(resp)) {
1051
+ throw new Error(`WeChat send file failed: ret=${resp.ret} errcode=${resp.errcode}${resp.errmsg ? ` errmsg=${resp.errmsg}` : ''}`);
1052
+ }
1053
+ return clientId;
1054
+ }
1055
+ async sendFilePart(binding, channelId, part, contextToken) {
1056
+ const file = await (0, file_utils_js_1.prepareChannelFile)({
1057
+ path: part.path,
1058
+ fileName: part.fileName,
1059
+ maxBytes: WEIXIN_MAX_UPLOAD_FILE_SIZE,
1060
+ platformLabel: 'WeChat',
1061
+ });
1062
+ const uploaded = await this.uploadFileToCdn(binding, channelId, file.path);
1063
+ const messageId = await this.sendFileMessage(binding, channelId, file.fileName, uploaded, contextToken);
1064
+ this.options.log?.(`localcore-weixin sent file ${file.fileName} (${file.fileSize} bytes) to ${channelId}`);
1065
+ return {
1066
+ messageId,
1067
+ uploaded,
1068
+ file,
1069
+ };
1070
+ }
952
1071
  isSendMessageError(resp) {
953
1072
  return (resp.ret !== undefined && resp.ret !== 0) || (resp.errcode !== undefined && resp.errcode !== 0);
954
1073
  }
1074
+ async uploadFileToCdn(binding, toUserId, filePath) {
1075
+ const plaintext = await node_fs_1.default.promises.readFile(filePath);
1076
+ const fileKey = node_crypto_1.default.randomBytes(16).toString('hex');
1077
+ const aesKey = node_crypto_1.default.randomBytes(16);
1078
+ const aesKeyHex = aesKey.toString('hex');
1079
+ const rawMd5 = node_crypto_1.default.createHash('md5').update(plaintext).digest('hex');
1080
+ const cipherSize = this.getAesEcbPaddedSize(plaintext.length);
1081
+ const uploadUrlResp = await this.apiPost(binding, 'ilink/bot/getuploadurl', {
1082
+ filekey: fileKey,
1083
+ media_type: UPLOAD_MEDIA_TYPE_FILE,
1084
+ to_user_id: toUserId,
1085
+ rawsize: plaintext.length,
1086
+ rawfilemd5: rawMd5,
1087
+ filesize: cipherSize,
1088
+ no_need_thumb: true,
1089
+ aeskey: aesKeyHex,
1090
+ base_info: { channel_version: WEIXIN_CHANNEL_VERSION },
1091
+ }, API_TIMEOUT_MS);
1092
+ if (this.isSendMessageError(uploadUrlResp) || !uploadUrlResp.upload_param) {
1093
+ throw new Error(`WeChat getuploadurl failed: ret=${uploadUrlResp.ret} errcode=${uploadUrlResp.errcode}${uploadUrlResp.errmsg ? ` errmsg=${uploadUrlResp.errmsg}` : ''}`);
1094
+ }
1095
+ const encryptedQueryParam = await this.uploadEncryptedBufferToCdn(binding, plaintext, uploadUrlResp.upload_param, fileKey, aesKey);
1096
+ return {
1097
+ fileKey,
1098
+ encryptedQueryParam,
1099
+ aesKeyHex,
1100
+ fileSize: plaintext.length,
1101
+ cipherSize,
1102
+ };
1103
+ }
1104
+ async uploadEncryptedBufferToCdn(binding, plaintext, uploadParam, fileKey, aesKey) {
1105
+ const cipher = node_crypto_1.default.createCipheriv('aes-128-ecb', aesKey, null);
1106
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
1107
+ const url = `${binding.cdnBaseUrl.replace(/\/$/, '')}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(fileKey)}`;
1108
+ const res = await fetch(url, {
1109
+ method: 'POST',
1110
+ headers: {
1111
+ 'Content-Type': 'application/octet-stream',
1112
+ },
1113
+ body: new Uint8Array(ciphertext),
1114
+ });
1115
+ if (!res.ok) {
1116
+ throw new Error(`WeChat CDN upload failed: HTTP ${res.status}`);
1117
+ }
1118
+ const encryptedQueryParam = res.headers.get('x-encrypted-param') || '';
1119
+ if (!encryptedQueryParam) {
1120
+ throw new Error('WeChat CDN upload response missing x-encrypted-param');
1121
+ }
1122
+ return encryptedQueryParam;
1123
+ }
1124
+ getAesEcbPaddedSize(size) {
1125
+ return Math.ceil((size + 1) / 16) * 16;
1126
+ }
1127
+ resolveContextTokenForFileSend(workspaceId, channelId, participantId) {
1128
+ const preferredParticipantId = String(participantId || '').trim();
1129
+ const direct = preferredParticipantId
1130
+ ? this.options.store.getPlatformThreadBinding(workspaceId, channelId, preferredParticipantId, 'weixin')
1131
+ : undefined;
1132
+ if (direct?.last_platform_message_id) {
1133
+ return direct.last_platform_message_id;
1134
+ }
1135
+ const users = this.options.store.listAuthorizedUsers(workspaceId, 'weixin');
1136
+ const user = users.find((entry) => entry.chatId === channelId || entry.platformUserId === channelId);
1137
+ if (!user) {
1138
+ return '';
1139
+ }
1140
+ return this.options.store.getPlatformThreadBinding(workspaceId, user.chatId, user.platformUserId, 'weixin')?.last_platform_message_id || '';
1141
+ }
955
1142
  // ==================== Private: Attachment Download ====================
956
1143
  sniffExtAndKind(buf) {
957
1144
  if (buf[0] === 0xff && buf[1] === 0xd8)
@@ -10,11 +10,20 @@ async function runCli(argv, env = node_process_1.default.env, io = node_process_
10
10
  try {
11
11
  const { positionals, flags } = parseArgs(argv);
12
12
  const [domain = '', action = '', maybeId = ''] = positionals;
13
+ const json = getBooleanFlag(flags, 'json', false);
14
+ if (domain === 'channel') {
15
+ switch (action) {
16
+ case 'send-file':
17
+ return await handleChannelSendFile(flags, env, io, json);
18
+ default:
19
+ printUsage(io.stderr);
20
+ return 2;
21
+ }
22
+ }
13
23
  if (domain !== 'scheduler') {
14
24
  printUsage(io.stderr);
15
25
  return 2;
16
26
  }
17
- const json = getBooleanFlag(flags, 'json', false);
18
27
  switch (action) {
19
28
  case 'add':
20
29
  return await handleAdd(flags, env, io, json);
@@ -39,6 +48,36 @@ async function runCli(argv, env = node_process_1.default.env, io = node_process_
39
48
  return 1;
40
49
  }
41
50
  }
51
+ async function handleChannelSendFile(flags, env, io, json) {
52
+ const context = resolveContext(flags, env);
53
+ const filePath = getRequiredFlag(flags, 'path');
54
+ const platform = getFlag(flags, 'platform') || context.platform;
55
+ if (!platform) {
56
+ throw new Error('channel send-file requires a platform. Set LOCAL_AI_PLATFORM or pass --platform.');
57
+ }
58
+ if (!context.workspaceId) {
59
+ throw new Error('channel send-file requires a workspace context. Set LOCAL_AI_WORKSPACE_ID or pass --workspace.');
60
+ }
61
+ const target = getFlag(flags, 'target') || getFlag(flags, 'chat-id') || context.chatId;
62
+ if (!target) {
63
+ throw new Error('channel send-file requires a target. Set LOCAL_AI_CHAT_ID or pass --target.');
64
+ }
65
+ const result = await request(context.baseUrl, 'POST', `/platforms/${encodeURIComponent(platform)}/${encodeURIComponent(context.workspaceId)}/messages`, {
66
+ route: {
67
+ type: 'channel.chat',
68
+ channelId: target,
69
+ participantId: getFlag(flags, 'participant-id') || context.platformUserId || undefined,
70
+ },
71
+ parts: [{
72
+ type: 'file',
73
+ path: filePath,
74
+ fileName: getFlag(flags, 'name') || undefined,
75
+ }],
76
+ });
77
+ const file = result.attachments?.[0];
78
+ print(json, io.stdout, result, `Sent file ${file?.fileName || filePath} to ${result.channelId}: ${result.messageIds[0] || ''}`);
79
+ return 0;
80
+ }
42
81
  async function handleAdd(flags, env, io, json) {
43
82
  const cronExpr = getRequiredFlag(flags, 'cron');
44
83
  const promptTemplate = getRequiredFlag(flags, 'message');
@@ -265,6 +304,7 @@ function printUsage(output) {
265
304
  ' lac scheduler edit <job-id> [--cron "<expr>"] [--message "<text>"] [--desc "<label>"] [--enabled true|false] [--execution-mode same-thread|side-thread] [--json]',
266
305
  ' lac scheduler del <job-id> [--json]',
267
306
  ' lac scheduler run <job-id> [--json]',
307
+ ' lac channel send-file --path "<file>" [--target <chat-or-user-id>] [--workspace <id>] [--platform lark] [--name <filename>] [--json]',
268
308
  ].join('\n') + '\n');
269
309
  }
270
310
  function formatJobLine(job) {
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createBuiltinLarkChannelPlugin = createBuiltinLarkChannelPlugin;
4
- const local_core_lark_gateway_js_1 = require("../../gateway/local-core-lark-gateway.js");
4
+ const local_core_lark_gateway_js_1 = require("../../channel/lark/local-core-lark-gateway.js");
5
5
  function createBuiltinLarkChannelPlugin(options) {
6
6
  let runtime = null;
7
7
  let unsubscribeBridgeEvents = null;
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createBuiltinWeixinChannelPlugin = createBuiltinWeixinChannelPlugin;
4
- const local_core_weixin_gateway_js_1 = require("../../gateway/local-core-weixin-gateway.js");
4
+ const local_core_weixin_gateway_js_1 = require("../../channel/weixin/local-core-weixin-gateway.js");
5
5
  function createBuiltinWeixinChannelPlugin(options) {
6
6
  let runtime = null;
7
7
  let unsubscribeBridgeEvents = null;