@wu529778790/open-im 1.6.1-beta.2 → 1.6.1-beta.4

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.
@@ -6,6 +6,9 @@ import { splitLongContent, getAIToolDisplayName } from '../shared/utils.js';
6
6
  import { listDirectories, buildDirectoryKeyboard } from '../commands/handler.js';
7
7
  import { MAX_DINGTALK_MESSAGE_LENGTH } from '../constants.js';
8
8
  import { buildImageFallbackMessage } from '../channels/capabilities.js';
9
+ import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from '../shared/message-title.js';
10
+ import { buildTextNote } from '../shared/message-note.js';
11
+ import { buildDirectoryMessage, buildModeMessage, buildPermissionRequestMessage, } from '../shared/system-messages.js';
9
12
  const log = createLogger('DingTalkSender');
10
13
  const STATUS_ICONS = {
11
14
  thinking: '🔵',
@@ -45,18 +48,11 @@ function formatMessage(content, status, note, toolId = 'claude') {
45
48
  : toolName;
46
49
  let text = `${icon} ${title}\n\n${content}`;
47
50
  if (note)
48
- text += `\n\n─────────\n${note}`;
51
+ text += `\n\n${buildTextNote(note)}`;
49
52
  return text;
50
53
  }
51
54
  function getToolTitle(toolId, status) {
52
- const toolName = getAIToolDisplayName(toolId);
53
- if (status === 'done')
54
- return toolName;
55
- if (status === 'thinking')
56
- return `${toolName} - 思考中`;
57
- if (status === 'streaming')
58
- return `${toolName} - 执行中`;
59
- return `${toolName} - 错误`;
55
+ return buildMessageTitle(toolId, status);
60
56
  }
61
57
  /**
62
58
  * 适配钉钉官方「搜索结果卡片」模板变量结构
@@ -337,58 +333,39 @@ export async function sendErrorMessage(chatId, messageId, error, toolId = 'claud
337
333
  await sendTextWithRetry(chatId, formatMessage(`错误:${error}`, 'error', '执行失败', toolId));
338
334
  }
339
335
  export async function sendTextReply(chatId, text, _threadCtx) {
340
- await sendTextWithRetry(chatId, text);
336
+ await sendTextWithRetry(chatId, formatMessage(text, 'done', undefined, OPEN_IM_SYSTEM_TITLE));
341
337
  log.info(`Text reply sent to DingTalk chat ${chatId}`);
342
338
  }
343
339
  export async function sendImageReply(chatId, imagePath) {
344
340
  await sendTextReply(chatId, buildImageFallbackMessage('dingtalk', imagePath));
345
341
  }
346
342
  export async function sendProactiveTextReply(target, text) {
347
- await sendProactiveText(target, text);
343
+ await sendProactiveText(target, formatMessage(text, 'done', undefined, OPEN_IM_SYSTEM_TITLE));
348
344
  const targetId = typeof target === 'string' ? target : target.chatId;
349
345
  log.info(`Proactive text sent to DingTalk chat ${targetId}`);
350
346
  }
351
347
  export async function sendPermissionCard(chatId, requestId, toolName, toolInput) {
352
- const message = `🔐 权限请求
353
-
354
- 工具: ${toolName}
355
-
356
- 参数:
357
- ${toolInput.length > 300 ? toolInput.slice(0, 300) + '...' : toolInput}
358
-
359
- 请回复以下命令进行操作:
360
- /allow - 允许
361
- /deny - 拒绝
362
-
363
- 请求 ID: ${requestId.slice(-8)}`;
348
+ const message = buildPermissionRequestMessage(toolName, toolInput, requestId);
364
349
  await sendTextWithRetry(chatId, message);
365
350
  }
366
351
  export async function sendModeCard(chatId, _userId, currentMode) {
367
352
  const { MODE_LABELS } = await import('../permission-mode/types.js');
368
- const message = `🔐 权限模式
369
-
370
- 当前模式: ${MODE_LABELS[currentMode] || currentMode}
371
-
372
- 发送命令切换模式:
373
- /mode ask - 每次询问
374
- /mode accept-edits - 自动批准编辑
375
- /mode plan - 仅分析
376
- /mode yolo - 跳过所有权限`;
353
+ const message = buildModeMessage(MODE_LABELS[currentMode] || currentMode);
377
354
  await sendTextWithRetry(chatId, message);
378
355
  }
379
356
  export async function sendDirectorySelection(chatId, currentDir, userId) {
380
357
  const directories = listDirectories(currentDir);
381
358
  const dirName = basename(currentDir) || currentDir;
382
359
  if (directories.length === 0) {
383
- await sendTextWithRetry(chatId, `📁 当前目录: ${dirName}\n\n没有可访问的子目录`);
360
+ await sendTextWithRetry(chatId, buildDirectoryMessage(dirName));
384
361
  return;
385
362
  }
386
363
  const keyboard = buildDirectoryKeyboard(directories, userId);
387
364
  const entries = keyboard.inline_keyboard
388
365
  .flat()
389
366
  .map((item) => item.text)
390
- .join('\n');
391
- await sendTextWithRetry(chatId, `📁 当前目录: ${dirName}\n\n可用目录:\n${entries}\n\n请使用 /cd <路径> 切换目录`);
367
+ .map((item) => `- ${item}`);
368
+ await sendTextWithRetry(chatId, buildDirectoryMessage(dirName, entries));
392
369
  }
393
370
  export function startTypingLoop(_chatId) {
394
371
  return () => { };
@@ -17,6 +17,7 @@ import { createLogger } from '../logger.js';
17
17
  import { createMediaTargetPath } from '../shared/media-storage.js';
18
18
  import { buildSavedMediaPrompt } from '../shared/media-analysis-prompt.js';
19
19
  import { buildMediaContext } from '../shared/media-context.js';
20
+ import { buildProgressNote } from '../shared/message-note.js';
20
21
  const log = createLogger('FeishuHandler');
21
22
  async function downloadFeishuMessageResource(client, messageId, fileKey, type, options) {
22
23
  const targetPath = createMediaTargetPath(options?.fallbackExtension ?? 'bin', options?.basenameHint ?? fileKey);
@@ -51,10 +52,12 @@ async function sendPermissionCard(chatId, requestId, toolName, toolInput) {
51
52
  ${formattedInput}
52
53
  \`\`\`
53
54
 
54
- **请求 ID:** \`${requestId.slice(-8)}\``;
55
+ **请求 ID:** \`${requestId.slice(-8)}\`
56
+
57
+ 请点击下方按钮进行处理。`;
55
58
  const cardContent = createFeishuButtonCard('权限请求', content, [
56
- { label: '允许', value: `allow_${requestId}`, type: 'primary' },
57
- { label: '拒绝', value: `deny_${requestId}`, type: 'default' },
59
+ { label: '允许', value: `allow_${requestId}`, type: 'primary' },
60
+ { label: '拒绝', value: `deny_${requestId}`, type: 'default' },
58
61
  ]);
59
62
  try {
60
63
  await client.im.message.create({
@@ -111,7 +114,7 @@ export function setupFeishuHandlers(config, sessionManager) {
111
114
  const stopTyping = startTypingLoop(chatId);
112
115
  const taskKey = `${userId}:${cardId}`;
113
116
  const streamUpdate = (content, toolNote) => {
114
- const note = toolNote ? '输出中...\n' + toolNote : '输出中...';
117
+ const note = buildProgressNote(toolNote);
115
118
  streamContentUpdate(cardId, content, note).catch((e) => log.debug('Stream update failed (will retry on next update):', e?.message ?? e));
116
119
  };
117
120
  await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'feishu', taskKey }, prompt, toolAdapter, {
@@ -4,7 +4,9 @@ import { createLogger } from '../logger.js';
4
4
  import { splitLongContent } from '../shared/utils.js';
5
5
  import { MAX_FEISHU_MESSAGE_LENGTH } from '../constants.js';
6
6
  import { buildCardV2, splitLongContent as cardSplitLongContent, truncateForStreaming } from './card-builder.js';
7
- import { getAIToolDisplayName, OPEN_IM_BRAND_SUFFIX } from '../shared/utils.js';
7
+ import { getAIToolDisplayName } from '../shared/utils.js';
8
+ import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from '../shared/message-title.js';
9
+ import { buildTextNote } from '../shared/message-note.js';
8
10
  import { createCard, enableStreaming, sendCardMessage, streamContent as cardkitStreamContent, updateCardFull, markCompleted, disableStreaming, destroySession, } from './cardkit-manager.js';
9
11
  const log = createLogger('FeishuSender');
10
12
  const STATUS_CONFIG = {
@@ -14,10 +16,15 @@ const STATUS_CONFIG = {
14
16
  error: { icon: '❌', template: 'red', title: '错误' },
15
17
  };
16
18
  function getToolTitle(toolId, status) {
17
- const name = getAIToolDisplayName(toolId);
18
- const statusText = STATUS_CONFIG[status].title;
19
- const base = status === 'done' ? name : `${name} - ${statusText}`;
20
- return `${base}${OPEN_IM_BRAND_SUFFIX}`;
19
+ return buildMessageTitle(toolId, status, {
20
+ brandSuffix: true,
21
+ statusTitles: {
22
+ thinking: STATUS_CONFIG.thinking.title,
23
+ streaming: STATUS_CONFIG.streaming.title,
24
+ done: STATUS_CONFIG.done.title,
25
+ error: STATUS_CONFIG.error.title,
26
+ },
27
+ });
21
28
  }
22
29
  /**
23
30
  * Create Feishu interactive card with native lark_md support
@@ -41,7 +48,7 @@ function createFeishuCard(title, content, status, note) {
41
48
  tag: 'div',
42
49
  text: {
43
50
  tag: 'lark_md',
44
- content: `**💡 ${note}**`,
51
+ content: buildTextNote(note),
45
52
  },
46
53
  });
47
54
  }
@@ -118,7 +125,7 @@ export function createFeishuModeCardReadOnly(currentMode) {
118
125
  return {
119
126
  config: { wide_screen_mode: true },
120
127
  header: {
121
- template: 'green',
128
+ template: 'blue',
122
129
  title: { content: '🔐 权限模式', tag: 'plain_text' },
123
130
  },
124
131
  elements: [
@@ -126,7 +133,7 @@ export function createFeishuModeCardReadOnly(currentMode) {
126
133
  tag: 'div',
127
134
  text: {
128
135
  tag: 'lark_md',
129
- content: `**当前模式:** ${currentMode}\n\n✅ 已切换成功,发送 \`/mode\` 可再次切换。`,
136
+ content: `**当前模式:** ${currentMode}\n\n✅ 已切换成功。\n\n发送 \`/mode\` 可再次切换。`,
130
137
  },
131
138
  },
132
139
  ],
@@ -171,7 +178,7 @@ function createFeishuModeCard(currentMode, buttons) {
171
178
  tag: 'div',
172
179
  text: {
173
180
  tag: 'lark_md',
174
- content: `**当前模式:** ${currentMode}\n\n点击下方按钮切换模式:\n\n_💡 若点击报错:开放平台 → 事件与回调 → 切到「回调」Tab → 添加「卡片回传交互」。或直接用 \`/mode ask\` 等命令切换。_`,
181
+ content: `**当前模式:** ${currentMode}\n\n点击下方按钮切换模式。\n\n_💡 若点击报错:开放平台 → 事件与回调 → 切到「回调」Tab → 添加「卡片回传交互」。也可直接发送 \`/mode ask\` 等命令切换。_`,
175
182
  },
176
183
  });
177
184
  elements.push({ tag: 'hr' });
@@ -419,7 +426,7 @@ export async function sendFinalMessages(chatId, messageId, fullContent, note, to
419
426
  }
420
427
  export async function sendTextReply(chatId, text) {
421
428
  const client = getClient();
422
- const cardContent = createFeishuCard('📢 open-im', text, 'done');
429
+ const cardContent = createFeishuCard(OPEN_IM_SYSTEM_TITLE, text, 'done');
423
430
  try {
424
431
  await client.im.message.create({
425
432
  data: {
@@ -437,7 +444,7 @@ export async function sendTextReply(chatId, text) {
437
444
  /** 使用 open_id 发送(私聊时 context 可能只有 open_id) */
438
445
  export async function sendTextReplyByOpenId(openId, text) {
439
446
  const client = getClient();
440
- const cardContent = createFeishuCard('📢 open-im', text, 'done');
447
+ const cardContent = createFeishuCard(OPEN_IM_SYSTEM_TITLE, text, 'done');
441
448
  try {
442
449
  await client.im.message.create({
443
450
  data: {
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ import { APP_HOME, SHUTDOWN_PORT } from "./constants.js";
31
31
  import { startPermissionServer, stopPermissionServer } from "./hook/permission-server.js";
32
32
  import { initPermissionModes } from "./permission-mode/session-mode.js";
33
33
  import { createRequire } from "node:module";
34
+ import { escapePathForMarkdown } from "./shared/utils.js";
34
35
  const require = createRequire(import.meta.url);
35
36
  const { version: APP_VERSION } = require("../package.json");
36
37
  const log = createLogger("Main");
@@ -85,20 +86,30 @@ function buildStartupMessage(platform, appVersion, aiCommand, defaultWorkDir, su
85
86
  sessionDir = sessionManager.getWorkDir(activeChatId);
86
87
  }
87
88
  }
89
+ const platformList = successfulPlatforms.map((item) => `\`${item}\``).join("、");
88
90
  const lines = [
89
- `🟢 open-im v${appVersion} 服务已启动`,
91
+ `**服务已启动**`,
90
92
  "",
91
- `AI 工具: ${aiCommand}`,
92
- `成功启动平台: ${successfulPlatforms.join(", ")}`,
93
+ `- 版本: \`open-im v${appVersion}\``,
94
+ `- AI 工具: \`${aiCommand}\``,
95
+ `- 成功启动平台: ${platformList}`,
93
96
  ];
94
97
  if (sessionDir) {
95
- lines.push(`会话目录: ${sessionDir}`);
98
+ lines.push(`- 会话目录: ${escapePathForMarkdown(sessionDir)}`);
96
99
  }
97
100
  else {
98
- lines.push(`会话目录: 请发送 /pwd 查看`);
101
+ lines.push(`- 会话目录: 发送 \`/pwd\` 查看`);
99
102
  }
100
103
  return lines.join("\n");
101
104
  }
105
+ function buildShutdownMessage(uptimeMinutes) {
106
+ return [
107
+ `**服务正在关闭**`,
108
+ "",
109
+ `- 服务: \`open-im\``,
110
+ `- 运行时长: \`${uptimeMinutes} 分钟\``,
111
+ ].join("\n");
112
+ }
102
113
  export async function main() {
103
114
  if (needsSetup()) {
104
115
  const saved = process.stdin.isTTY
@@ -238,7 +249,7 @@ export async function main() {
238
249
  log.info("Shutting down...");
239
250
  const uptimeSec = Math.floor((Date.now() - startedAt) / 1000);
240
251
  const m = Math.floor(uptimeSec / 60);
241
- const shutdownMsg = `🔴 open-im 服务正在关闭...\n运行时长: ${m}分钟`;
252
+ const shutdownMsg = buildShutdownMessage(m);
242
253
  // Send notification only to successfully initialized platforms
243
254
  for (const platform of successfulPlatforms) {
244
255
  await sendLifecycleNotification(platform, shutdownMsg).catch((err) => {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  export type PermissionMode = 'ask' | 'accept-edits' | 'plan' | 'yolo';
7
7
  export declare const PERMISSION_MODES: PermissionMode[];
8
- /** Claude Code 官方模式名(用于显示) */
8
+ /** 用户侧展示名 */
9
9
  export declare const MODE_LABELS: Record<PermissionMode, string>;
10
10
  export declare const MODE_DESCRIPTIONS: Record<PermissionMode, string>;
11
11
  export declare function parsePermissionMode(raw: string): PermissionMode | null;
@@ -1,10 +1,10 @@
1
1
  export const PERMISSION_MODES = ['ask', 'accept-edits', 'plan', 'yolo'];
2
- /** Claude Code 官方模式名(用于显示) */
2
+ /** 用户侧展示名 */
3
3
  export const MODE_LABELS = {
4
- ask: 'default',
5
- 'accept-edits': 'acceptEdits',
6
- plan: 'plan',
7
- yolo: 'bypassPermissions',
4
+ ask: '每次询问',
5
+ 'accept-edits': '自动批准编辑',
6
+ plan: '仅分析',
7
+ yolo: '跳过所有权限',
8
8
  };
9
9
  export const MODE_DESCRIPTIONS = {
10
10
  ask: '首次使用每个工具时提示确认',
@@ -2,6 +2,9 @@ import { createLogger } from "../logger.js";
2
2
  import { splitLongContent } from "../shared/utils.js";
3
3
  import { buildImageFallbackMessage } from "../channels/capabilities.js";
4
4
  import { getQQBot } from "./client.js";
5
+ import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from "../shared/message-title.js";
6
+ import { buildTextNote } from "../shared/message-note.js";
7
+ import { buildDirectoryMessage, buildModeMessage } from "../shared/system-messages.js";
5
8
  const log = createLogger("QQSender");
6
9
  const MAX_QQ_MESSAGE_LENGTH = 1500;
7
10
  const STREAM_CHUNK_LENGTH = 1200;
@@ -44,8 +47,8 @@ function getOrCreateStreamState(messageId, chatId, replyToMessageId) {
44
47
  return state;
45
48
  }
46
49
  function buildStreamChunk(toolId, content, note, withHeader = false) {
47
- const header = withHeader ? `[${toolId}]` : "";
48
- const noteBlock = note ? `\n\n${note}` : "";
50
+ const header = withHeader ? buildMessageTitle(toolId, "streaming") : "";
51
+ const noteBlock = note ? `\n\n${buildTextNote(note)}` : "";
49
52
  return `${header}${header ? "\n" : ""}${content}${noteBlock}`.trim();
50
53
  }
51
54
  function findPreferredSplit(text, limit) {
@@ -109,7 +112,8 @@ async function sendIncrementalContent(state, toolId, content, note, flushAll = f
109
112
  }
110
113
  export async function sendTextReply(chatId, text) {
111
114
  try {
112
- for (const part of splitLongContent(text, MAX_QQ_MESSAGE_LENGTH)) {
115
+ const formatted = `${buildMessageTitle(OPEN_IM_SYSTEM_TITLE, "done")}\n\n${text}`;
116
+ for (const part of splitLongContent(formatted, MAX_QQ_MESSAGE_LENGTH)) {
113
117
  await sendRaw(chatId, part);
114
118
  }
115
119
  }
@@ -135,10 +139,10 @@ export async function sendFinalMessages(chatId, messageId, fullContent, note, to
135
139
  const state = getOrCreateStreamState(messageId, chatId);
136
140
  await sendIncrementalContent(state, toolId, fullContent, undefined, true);
137
141
  const completionText = note
138
- ? `[${toolId}] done\n${note}`
142
+ ? `${buildMessageTitle(toolId, "done")}\n${note}`
139
143
  : state.sentStreamChunk
140
- ? `[${toolId}] done`
141
- : `[${toolId}]\n${fullContent}`;
144
+ ? buildMessageTitle(toolId, "done")
145
+ : `${buildMessageTitle(toolId, "done")}\n${fullContent}`;
142
146
  for (const part of splitLongContent(completionText, MAX_QQ_MESSAGE_LENGTH)) {
143
147
  await sendRaw(chatId, part, state.replyToMessageId);
144
148
  }
@@ -147,13 +151,15 @@ export async function sendFinalMessages(chatId, messageId, fullContent, note, to
147
151
  export async function sendErrorMessage(chatId, messageId, error, toolId = "claude") {
148
152
  const replyToMessageId = streamStates.get(messageId)?.replyToMessageId;
149
153
  streamStates.delete(messageId);
150
- await sendRaw(chatId, `[${toolId}] error\n${error}`, replyToMessageId);
154
+ await sendRaw(chatId, `${buildMessageTitle(toolId, "error")}\n${error}`, replyToMessageId);
151
155
  }
152
156
  export async function sendDirectorySelection(chatId, currentDir) {
153
- await sendTextReply(chatId, `Current directory: ${currentDir}\nUse /cd <path> to switch.`);
157
+ await sendTextReply(chatId, buildDirectoryMessage(currentDir));
154
158
  }
155
159
  export async function sendModeKeyboard(chatId, _userId, currentMode) {
156
- await sendTextReply(chatId, `Current mode: ${currentMode}\nUse /mode ask|accept-edits|plan|yolo to switch.`);
160
+ const { MODE_LABELS } = await import("../permission-mode/types.js");
161
+ const label = MODE_LABELS[currentMode] || currentMode;
162
+ await sendTextReply(chatId, buildModeMessage(label));
157
163
  }
158
164
  export function startTypingLoop() {
159
165
  return () => { };
@@ -21,6 +21,7 @@ describe("QQ message sender", () => {
21
21
  await sender.sendImageReply("group:group-1", "C:\\images\\out.png");
22
22
  expect(sendGroupMessageMock).toHaveBeenCalledTimes(1);
23
23
  expect(sendGroupMessageMock.mock.calls[0][0]).toBe("group-1");
24
+ expect(sendGroupMessageMock.mock.calls[0][1]).toContain("open-im");
24
25
  expect(sendGroupMessageMock.mock.calls[0][1]).toContain("C:\\images\\out.png");
25
26
  });
26
27
  });
@@ -1,5 +1,8 @@
1
1
  export declare function inferExtensionFromContentType(contentType: string): string;
2
+ export declare function inferExtensionFromBuffer(buffer: Buffer): string;
2
3
  export declare function createMediaTargetPath(extension: string, basenameHint?: string): string;
4
+ export declare function saveBufferMedia(buffer: Buffer, extension: string, basenameHint?: string): Promise<string>;
5
+ export declare function decryptAes256CbcMedia(buffer: Buffer, aesKey: string): Buffer;
3
6
  export declare function saveBase64Media(base64: string, extension: string, basenameHint?: string): Promise<string>;
4
7
  export declare function downloadMediaFromUrl(url: string, options?: {
5
8
  basenameHint?: string;
@@ -1,3 +1,4 @@
1
+ import { createDecipheriv } from "node:crypto";
1
2
  import { mkdir, writeFile } from "node:fs/promises";
2
3
  import { extname, join } from "node:path";
3
4
  import { IMAGE_DIR } from "../constants.js";
@@ -22,6 +23,30 @@ export function inferExtensionFromContentType(contentType) {
22
23
  return ".json";
23
24
  return "";
24
25
  }
26
+ export function inferExtensionFromBuffer(buffer) {
27
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
28
+ return ".jpg";
29
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
30
+ return ".png";
31
+ if (buffer.length >= 6) {
32
+ const gifHeader = buffer.subarray(0, 6).toString("ascii");
33
+ if (gifHeader === "GIF87a" || gifHeader === "GIF89a")
34
+ return ".gif";
35
+ }
36
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP")
37
+ return ".webp";
38
+ if (buffer.length >= 2 && buffer.subarray(0, 2).toString("ascii") === "BM")
39
+ return ".bmp";
40
+ if (buffer.length >= 4 && (buffer.subarray(0, 4).toString("ascii") === "II*\0" || buffer.subarray(0, 4).toString("ascii") === "MM\0*"))
41
+ return ".tif";
42
+ if (buffer.length >= 4 && buffer.subarray(0, 4).toString("ascii") === "%PDF")
43
+ return ".pdf";
44
+ if (buffer.length >= 4 && buffer.subarray(0, 4).toString("ascii") === "OggS")
45
+ return ".ogg";
46
+ if (buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp")
47
+ return ".mp4";
48
+ return "";
49
+ }
25
50
  export function createMediaTargetPath(extension, basenameHint) {
26
51
  const safeExtension = extension.startsWith(".") ? extension : `.${extension}`;
27
52
  const safeBasename = basenameHint ? sanitizeName(basenameHint) : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -29,12 +54,32 @@ export function createMediaTargetPath(extension, basenameHint) {
29
54
  const filename = existingExtension ? safeBasename : `${safeBasename}${safeExtension}`;
30
55
  return join(IMAGE_DIR, filename);
31
56
  }
32
- export async function saveBase64Media(base64, extension, basenameHint) {
57
+ export async function saveBufferMedia(buffer, extension, basenameHint) {
33
58
  await mkdir(IMAGE_DIR, { recursive: true });
34
59
  const path = createMediaTargetPath(extension, basenameHint);
35
- await writeFile(path, Buffer.from(base64, "base64"));
60
+ await writeFile(path, buffer);
36
61
  return path;
37
62
  }
63
+ function decodeAesKey(aesKey) {
64
+ const normalized = aesKey.trim().replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/");
65
+ const withPadding = normalized.length % 4 === 0 ? normalized : normalized.padEnd(normalized.length + (4 - (normalized.length % 4)), "=");
66
+ const decoded = Buffer.from(withPadding, "base64");
67
+ if (decoded.length === 32)
68
+ return decoded;
69
+ const utf8 = Buffer.from(aesKey, "utf8");
70
+ if (utf8.length === 32)
71
+ return utf8;
72
+ throw new Error(`Invalid AES key length: expected 32 bytes, got ${decoded.length || utf8.length}`);
73
+ }
74
+ export function decryptAes256CbcMedia(buffer, aesKey) {
75
+ const key = decodeAesKey(aesKey);
76
+ const iv = key.subarray(0, 16);
77
+ const decipher = createDecipheriv("aes-256-cbc", key, iv);
78
+ return Buffer.concat([decipher.update(buffer), decipher.final()]);
79
+ }
80
+ export async function saveBase64Media(base64, extension, basenameHint) {
81
+ return saveBufferMedia(Buffer.from(base64, "base64"), extension, basenameHint);
82
+ }
38
83
  export async function downloadMediaFromUrl(url, options) {
39
84
  await mkdir(IMAGE_DIR, { recursive: true });
40
85
  const response = await fetch(url, { signal: AbortSignal.timeout(30000) });
@@ -1,5 +1,6 @@
1
+ import { createCipheriv, randomBytes } from "node:crypto";
1
2
  import { describe, expect, it } from "vitest";
2
- import { createMediaTargetPath, inferExtensionFromContentType } from "./media-storage.js";
3
+ import { createMediaTargetPath, decryptAes256CbcMedia, inferExtensionFromBuffer, inferExtensionFromContentType, } from "./media-storage.js";
3
4
  describe("createMediaTargetPath", () => {
4
5
  it("does not append a fallback extension when basename already has one", () => {
5
6
  const path = createMediaTargetPath("bin", "report.pdf");
@@ -19,3 +20,20 @@ describe("inferExtensionFromContentType", () => {
19
20
  expect(inferExtensionFromContentType("application/pdf")).toBe(".pdf");
20
21
  });
21
22
  });
23
+ describe("inferExtensionFromBuffer", () => {
24
+ it("detects jpeg files from the magic header", () => {
25
+ const buffer = Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x43]);
26
+ expect(inferExtensionFromBuffer(buffer)).toBe(".jpg");
27
+ });
28
+ });
29
+ describe("decryptAes256CbcMedia", () => {
30
+ it("decrypts WeWork-style AES-256-CBC media buffers", () => {
31
+ const key = randomBytes(32);
32
+ const iv = key.subarray(0, 16);
33
+ const plaintext = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]);
34
+ const cipher = createCipheriv("aes-256-cbc", key, iv);
35
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
36
+ const aesKey = key.toString("base64").replace(/=+$/g, "");
37
+ expect(decryptAes256CbcMedia(encrypted, aesKey)).toEqual(plaintext);
38
+ });
39
+ });
@@ -0,0 +1,3 @@
1
+ export declare function buildProgressNote(toolNote?: string): string;
2
+ export declare function buildErrorNote(): string;
3
+ export declare function buildTextNote(note: string): string;
@@ -0,0 +1,10 @@
1
+ export function buildProgressNote(toolNote) {
2
+ const detail = toolNote?.trim();
3
+ return detail ? `输出中...\n${detail}` : "输出中...";
4
+ }
5
+ export function buildErrorNote() {
6
+ return "执行失败";
7
+ }
8
+ export function buildTextNote(note) {
9
+ return `─────────\n💡 ${note.trim()}`;
10
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildErrorNote, buildProgressNote, buildTextNote } from "./message-note.js";
3
+ describe("message note helpers", () => {
4
+ it("builds a consistent progress note", () => {
5
+ expect(buildProgressNote()).toBe("输出中...");
6
+ expect(buildProgressNote("Read x.ts")).toBe("输出中...\nRead x.ts");
7
+ });
8
+ it("builds a consistent error note", () => {
9
+ expect(buildErrorNote()).toBe("执行失败");
10
+ });
11
+ it("builds a consistent rendered note block", () => {
12
+ expect(buildTextNote("输出中...")).toBe("─────────\n💡 输出中...");
13
+ });
14
+ });
@@ -0,0 +1,8 @@
1
+ export type SharedMessageStatus = "thinking" | "streaming" | "done" | "error";
2
+ export declare const OPEN_IM_SYSTEM_TITLE = "open-im";
3
+ interface BuildMessageTitleOptions {
4
+ brandSuffix?: boolean;
5
+ statusTitles?: Partial<Record<SharedMessageStatus, string>>;
6
+ }
7
+ export declare function buildMessageTitle(toolId: string, status: SharedMessageStatus, options?: BuildMessageTitleOptions): string;
8
+ export {};
@@ -0,0 +1,14 @@
1
+ import { getAIToolDisplayName, OPEN_IM_BRAND_SUFFIX } from "./utils.js";
2
+ export const OPEN_IM_SYSTEM_TITLE = "open-im";
3
+ const DEFAULT_STATUS_TITLES = {
4
+ thinking: "思考中",
5
+ streaming: "执行中",
6
+ done: "完成",
7
+ error: "错误",
8
+ };
9
+ export function buildMessageTitle(toolId, status, options = {}) {
10
+ const toolName = getAIToolDisplayName(toolId);
11
+ const statusTitle = options.statusTitles?.[status] ?? DEFAULT_STATUS_TITLES[status];
12
+ const title = status === "done" ? toolName : `${toolName} - ${statusTitle}`;
13
+ return options.brandSuffix ? `${title}${OPEN_IM_BRAND_SUFFIX}` : title;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from "./message-title.js";
3
+ describe("buildMessageTitle", () => {
4
+ it("uses a consistent title format for non-final statuses", () => {
5
+ expect(buildMessageTitle("codex", "thinking")).toBe("Codex - 思考中");
6
+ expect(buildMessageTitle("codex", "streaming")).toBe("Codex - 执行中");
7
+ expect(buildMessageTitle("codex", "error")).toBe("Codex - 错误");
8
+ });
9
+ it("keeps done titles as the bare tool name", () => {
10
+ expect(buildMessageTitle("claude", "done")).toBe("Claude Code");
11
+ });
12
+ it("can append the Feishu brand suffix", () => {
13
+ expect(buildMessageTitle("cursor", "done", { brandSuffix: true })).toBe("Cursor · 通过 open-im 控制");
14
+ });
15
+ it("exposes a shared system title", () => {
16
+ expect(OPEN_IM_SYSTEM_TITLE).toBe("open-im");
17
+ });
18
+ });
@@ -0,0 +1,3 @@
1
+ export declare function buildPermissionRequestMessage(toolName: string, toolInput: string, requestId: string): string;
2
+ export declare function buildModeMessage(currentModeLabel: string): string;
3
+ export declare function buildDirectoryMessage(currentDir: string, directories?: string[]): string;
@@ -0,0 +1,51 @@
1
+ export function buildPermissionRequestMessage(toolName, toolInput, requestId) {
2
+ const preview = toolInput.length > 300 ? `${toolInput.slice(0, 300)}...` : toolInput;
3
+ return [
4
+ "🔐 权限请求",
5
+ "",
6
+ `工具: ${toolName}`,
7
+ "",
8
+ "参数:",
9
+ "```",
10
+ preview,
11
+ "```",
12
+ "",
13
+ "请回复以下命令:",
14
+ "- /allow",
15
+ "- /deny",
16
+ "",
17
+ `请求 ID: ${requestId.slice(-8)}`,
18
+ ].join("\n");
19
+ }
20
+ export function buildModeMessage(currentModeLabel) {
21
+ return [
22
+ "🔐 权限模式",
23
+ "",
24
+ `当前模式: ${currentModeLabel}`,
25
+ "",
26
+ "发送以下命令切换:",
27
+ "- /mode ask",
28
+ "- /mode accept-edits",
29
+ "- /mode plan",
30
+ "- /mode yolo",
31
+ ].join("\n");
32
+ }
33
+ export function buildDirectoryMessage(currentDir, directories) {
34
+ if (!directories || directories.length === 0) {
35
+ return [
36
+ `📁 当前目录: ${currentDir}`,
37
+ "",
38
+ "没有可访问的子目录。",
39
+ "",
40
+ "可发送 /cd <路径> 切换目录。",
41
+ ].join("\n");
42
+ }
43
+ return [
44
+ `📁 当前目录: ${currentDir}`,
45
+ "",
46
+ "可用目录:",
47
+ ...directories,
48
+ "",
49
+ "请发送 /cd <路径> 切换目录。",
50
+ ].join("\n");
51
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildDirectoryMessage, buildModeMessage, buildPermissionRequestMessage, } from "./system-messages.js";
3
+ describe("system message builders", () => {
4
+ it("builds a permission request message", () => {
5
+ expect(buildPermissionRequestMessage("Read", "file.txt", "abcdef123456")).toContain("权限请求");
6
+ expect(buildPermissionRequestMessage("Read", "file.txt", "abcdef123456")).toContain("123456");
7
+ });
8
+ it("builds a mode message", () => {
9
+ expect(buildModeMessage("计划模式")).toContain("当前模式: 计划模式");
10
+ });
11
+ it("builds a directory message", () => {
12
+ expect(buildDirectoryMessage("D:/coding/open-im", ["- src", "- dist"])).toContain("可用目录:");
13
+ expect(buildDirectoryMessage("D:/coding/open-im")).toContain("没有可访问的子目录");
14
+ });
15
+ });
@@ -16,6 +16,7 @@ import { createLogger } from "../logger.js";
16
16
  import { downloadMediaFromUrl } from "../shared/media-storage.js";
17
17
  import { buildSavedMediaPrompt } from "../shared/media-analysis-prompt.js";
18
18
  import { buildMediaContext } from "../shared/media-context.js";
19
+ import { buildErrorNote, buildProgressNote } from "../shared/message-note.js";
19
20
  const log = createLogger("TgHandler");
20
21
  class DynamicThrottle {
21
22
  lastUpdate = 0;
@@ -173,7 +174,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
173
174
  ? `...\n\n${content.slice(-STREAM_PREVIEW_LENGTH)}`
174
175
  : content;
175
176
  }
176
- const note = toolNote ? "输出中...\n" + toolNote : "输出中...";
177
+ const note = buildProgressNote(toolNote);
177
178
  await updateMessage(chatId, msgId, displayContent, "streaming", note, toolId);
178
179
  throttle.recordSuccess();
179
180
  lastUpdateTime = Date.now();
@@ -240,7 +241,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
240
241
  },
241
242
  sendError: async (error) => {
242
243
  throttle.reset();
243
- await updateMessage(chatId, msgId, `错误:${error}`, "error", "执行失败", toolId);
244
+ await updateMessage(chatId, msgId, `错误:${error}`, "error", buildErrorNote(), toolId);
244
245
  },
245
246
  extraCleanup: () => {
246
247
  throttle.reset();
@@ -2,7 +2,9 @@ import { getBot } from "./client.js";
2
2
  import { createReadStream } from "node:fs";
3
3
  import { basename } from "node:path";
4
4
  import { createLogger } from "../logger.js";
5
- import { splitLongContent, truncateText, getAIToolDisplayName, } from "../shared/utils.js";
5
+ import { splitLongContent, truncateText, } from "../shared/utils.js";
6
+ import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from "../shared/message-title.js";
7
+ import { buildTextNote } from "../shared/message-note.js";
6
8
  import { MAX_TELEGRAM_MESSAGE_LENGTH } from "../constants.js";
7
9
  import { listDirectories, buildDirectoryKeyboard, } from "../commands/handler.js";
8
10
  const log = createLogger("TgSender");
@@ -14,12 +16,7 @@ const STATUS_ICONS = {
14
16
  error: "🔴",
15
17
  };
16
18
  function getToolTitle(toolId, status) {
17
- const name = getAIToolDisplayName(toolId);
18
- if (status === "thinking")
19
- return `${name} - 思考中...`;
20
- if (status === "error")
21
- return `${name} - 错误`;
22
- return name;
19
+ return buildMessageTitle(toolId, status);
23
20
  }
24
21
  const TG_MAX_LENGTH = 4096;
25
22
  const RESERVED_LENGTH = 150;
@@ -27,20 +24,19 @@ function formatMessage(content, status, note, toolId = "claude") {
27
24
  const icon = STATUS_ICONS[status];
28
25
  const title = getToolTitle(toolId, status);
29
26
  const headerLength = `${icon} ${title}\n\n`.length;
30
- const noteLength = note ? `\n\n─────────\n${note}`.length : 0;
27
+ const noteBlock = note ? `\n\n${buildTextNote(note)}` : "";
28
+ const noteLength = noteBlock.length;
31
29
  const maxContentLength = TG_MAX_LENGTH - headerLength - noteLength - RESERVED_LENGTH;
32
30
  const text = truncateText(content, Math.max(100, maxContentLength));
33
31
  let out = `${icon} ${title}\n\n${text}`;
34
- if (note)
35
- out += `\n\n─────────\n${note}`;
32
+ out += noteBlock;
36
33
  if (out.length > TG_MAX_LENGTH) {
37
34
  const keepLen = TG_MAX_LENGTH - 50;
38
35
  const tail = text.slice(text.length - keepLen);
39
36
  const lineBreak = tail.indexOf("\n");
40
37
  const clean = lineBreak > 0 && lineBreak < 200 ? tail.slice(lineBreak + 1) : tail;
41
38
  out = `${icon} ${title}\n\n...(前文已省略)...\n${clean}`;
42
- if (note)
43
- out += `\n\n─────────\n${note}`;
39
+ out += noteBlock;
44
40
  }
45
41
  return out;
46
42
  }
@@ -111,7 +107,7 @@ export async function sendFinalMessages(chatId, messageId, fullContent, note, to
111
107
  export async function sendTextReply(chatId, text) {
112
108
  const bot = getBot();
113
109
  try {
114
- await bot.telegram.sendMessage(Number(chatId), text, {
110
+ await bot.telegram.sendMessage(Number(chatId), formatMessage(text, "done", undefined, OPEN_IM_SYSTEM_TITLE), {
115
111
  parse_mode: "Markdown",
116
112
  });
117
113
  }
@@ -129,12 +125,12 @@ export async function sendDirectorySelection(chatId, currentDir, userId) {
129
125
  const bot = getBot();
130
126
  const directories = listDirectories(currentDir);
131
127
  if (directories.length === 0) {
132
- await bot.telegram.sendMessage(Number(chatId), `📁 当前目录: \`${currentDir}\`\n\n没有可访问的子目录`, { parse_mode: "Markdown" });
128
+ await bot.telegram.sendMessage(Number(chatId), `📁 当前目录: \`${currentDir}\`\n\n没有可访问的子目录。\n\n可发送 \`/cd <路径>\` 切换目录。`, { parse_mode: "Markdown" });
133
129
  return;
134
130
  }
135
131
  const keyboard = buildDirectoryKeyboard(directories, userId);
136
132
  const dirName = basename(currentDir) || currentDir;
137
- await bot.telegram.sendMessage(Number(chatId), `📁 当前目录: \`${dirName}\`\n\n选择要切换到的目录:`, {
133
+ await bot.telegram.sendMessage(Number(chatId), `📁 当前目录: \`${dirName}\`\n\n请选择要切换到的目录:`, {
138
134
  parse_mode: "Markdown",
139
135
  reply_markup: keyboard,
140
136
  });
@@ -151,7 +147,7 @@ export async function sendModeKeyboard(chatId, userId, currentMode) {
151
147
  })),
152
148
  ],
153
149
  };
154
- await bot.telegram.sendMessage(Number(chatId), `🔐 **权限模式** (当前: ${MODE_LABELS[currentMode] ?? currentMode})\n\n点击切换:`, { parse_mode: "Markdown", reply_markup: keyboard });
150
+ await bot.telegram.sendMessage(Number(chatId), `🔐 **权限模式**\n\n当前模式: ${MODE_LABELS[currentMode] ?? currentMode}\n\n点击下方按钮切换:`, { parse_mode: "Markdown", reply_markup: keyboard });
155
151
  }
156
152
  export function startTypingLoop(chatId) {
157
153
  const bot = getBot();
@@ -19,6 +19,7 @@ import { buildSavedMediaPrompt } from '../shared/media-analysis-prompt.js';
19
19
  import { buildMediaMetadataPrompt } from '../shared/media-prompt.js';
20
20
  import { buildMediaContext } from '../shared/media-context.js';
21
21
  import { downloadMediaFromUrl } from '../shared/media-storage.js';
22
+ import { buildErrorNote, buildProgressNote } from '../shared/message-note.js';
22
23
  const log = createLogger('WeChatHandler');
23
24
  function getWeChatFilename(message) {
24
25
  return message.filename || message.file_name;
@@ -138,7 +139,7 @@ export function setupWeChatHandlers(config, sessionManager) {
138
139
  await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'wechat', taskKey }, prompt, toolAdapter, {
139
140
  throttleMs: WECHAT_THROTTLE_MS,
140
141
  streamUpdate: async (content, toolNote) => {
141
- const note = toolNote ? `Working...\n${toolNote}` : 'Working...';
142
+ const note = buildProgressNote(toolNote);
142
143
  try {
143
144
  await updateMessage(chatId, msgId, content, 'streaming', note, toolId);
144
145
  }
@@ -150,7 +151,7 @@ export function setupWeChatHandlers(config, sessionManager) {
150
151
  await sendFinalMessages(chatId, msgId, content, note ?? '', toolId);
151
152
  },
152
153
  sendError: async (error) => {
153
- await updateMessage(chatId, msgId, `Error: ${error}`, 'error', 'Execution failed', toolId);
154
+ await updateMessage(chatId, msgId, `Error: ${error}`, 'error', buildErrorNote(), toolId);
154
155
  },
155
156
  extraCleanup: () => {
156
157
  stopTyping();
@@ -3,8 +3,11 @@
3
3
  */
4
4
  import { sendAGPMessage } from './client.js';
5
5
  import { createLogger } from '../logger.js';
6
- import { splitLongContent, getAIToolDisplayName } from '../shared/utils.js';
6
+ import { splitLongContent } from '../shared/utils.js';
7
7
  import { buildImageFallbackMessage } from '../channels/capabilities.js';
8
+ import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from '../shared/message-title.js';
9
+ import { buildTextNote } from '../shared/message-note.js';
10
+ import { buildModeMessage, buildPermissionRequestMessage, } from '../shared/system-messages.js';
8
11
  const log = createLogger('WeChatSender');
9
12
  const MAX_WECHAT_MESSAGE_LENGTH = 2048;
10
13
  const STATUS_CONFIG = {
@@ -14,9 +17,14 @@ const STATUS_CONFIG = {
14
17
  error: { icon: '❌', title: '错误' },
15
18
  };
16
19
  function getToolTitle(toolId, status) {
17
- const name = getAIToolDisplayName(toolId);
18
- const statusText = STATUS_CONFIG[status].title;
19
- return status === 'done' ? name : `${name} - ${statusText}`;
20
+ return buildMessageTitle(toolId, status, {
21
+ statusTitles: {
22
+ thinking: STATUS_CONFIG.thinking.title,
23
+ streaming: STATUS_CONFIG.streaming.title,
24
+ done: STATUS_CONFIG.done.title,
25
+ error: STATUS_CONFIG.error.title,
26
+ },
27
+ });
20
28
  }
21
29
  /**
22
30
  * Format message for WeChat (simple text format for AGP)
@@ -31,7 +39,7 @@ function formatWeChatMessage(title, content, status, note) {
31
39
  message += `_正在思考,请稍候..._\n\n💭 **准备中**\n\n`;
32
40
  }
33
41
  if (note) {
34
- message += `---\n\n💡 **${note}**`;
42
+ message += buildTextNote(note);
35
43
  }
36
44
  return message;
37
45
  }
@@ -108,7 +116,7 @@ export async function sendFinalMessages(chatId, _messageId, fullContent, note, t
108
116
  * Send simple text reply to WeChat
109
117
  */
110
118
  export async function sendTextReply(chatId, text) {
111
- const message = formatWeChatMessage('📢 open-im', text, 'done');
119
+ const message = formatWeChatMessage(OPEN_IM_SYSTEM_TITLE, text, 'done');
112
120
  try {
113
121
  sendAGPMessage('session.promptResponse', {
114
122
  session_id: chatId,
@@ -131,20 +139,7 @@ export async function sendImageReply(chatId, imagePath) {
131
139
  * Send permission card with action buttons (for permission prompts)
132
140
  */
133
141
  export async function sendPermissionCard(chatId, requestId, toolName, toolInput) {
134
- const message = `🔐 **权限请求**
135
-
136
- **工具:** \`${toolName}\`
137
-
138
- **参数:**
139
- \`\`\`
140
- ${toolInput}
141
- \`\`\`
142
-
143
- 请回复以下命令进行操作:
144
- • \`/allow\` - 允许
145
- • \`/deny\` - 拒绝
146
-
147
- **请求 ID:** \`${requestId}\``;
142
+ const message = buildPermissionRequestMessage(toolName, toolInput, requestId);
148
143
  try {
149
144
  sendAGPMessage('session.promptResponse', {
150
145
  session_id: chatId,
@@ -162,15 +157,7 @@ ${toolInput}
162
157
  * Send mode switch card
163
158
  */
164
159
  export async function sendModeCard(chatId, _userId, currentMode) {
165
- const message = `🔐 **权限模式**
166
-
167
- **当前模式:** \`${currentMode}\`
168
-
169
- 点击下方按钮或发送命令切换模式:
170
- • \`/mode ask\` - 每次询问
171
- • \`/mode accept-edits\` - 自动批准编辑
172
- • \`/mode plan\` - 仅分析
173
- • \`/mode yolo\` - 跳过所有权限`;
160
+ const message = buildModeMessage(currentMode);
174
161
  try {
175
162
  sendAGPMessage('session.promptResponse', {
176
163
  session_id: chatId,
@@ -15,7 +15,8 @@ describe("WeChat message sender", () => {
15
15
  expect(sendAGPMessageMock).toHaveBeenCalledWith("session.promptResponse", expect.objectContaining({
16
16
  session_id: "session-1",
17
17
  status: "success",
18
- content: expect.stringContaining("C:\\images\\out.png"),
18
+ content: expect.stringContaining("open-im"),
19
19
  }));
20
+ expect(sendAGPMessageMock.mock.calls[0][1].content).toContain("C:\\images\\out.png");
20
21
  });
21
22
  });
@@ -15,9 +15,10 @@ import { setChatUser } from '../shared/chat-user-map.js';
15
15
  import { createLogger } from '../logger.js';
16
16
  import { buildUnsupportedInboundMessage } from '../channels/capabilities.js';
17
17
  import { buildMediaMetadataPrompt } from '../shared/media-prompt.js';
18
- import { downloadMediaFromUrl, saveBase64Media } from '../shared/media-storage.js';
18
+ import { decryptAes256CbcMedia, downloadMediaFromUrl, inferExtensionFromBuffer, inferExtensionFromContentType, saveBase64Media, saveBufferMedia, } from '../shared/media-storage.js';
19
19
  import { buildSavedMediaPrompt } from '../shared/media-analysis-prompt.js';
20
20
  import { buildMediaContext } from '../shared/media-context.js';
21
+ import { buildErrorNote, buildProgressNote } from '../shared/message-note.js';
21
22
  const log = createLogger('WeWorkHandler');
22
23
  function extractTextContent(data) {
23
24
  const body = data.body;
@@ -87,10 +88,26 @@ async function buildMediaPrompt(data, kind) {
87
88
  }
88
89
  else if (typeof imagePayload.url === 'string' && imagePayload.url.length > 0) {
89
90
  try {
90
- const savedPath = await downloadMediaFromUrl(imagePayload.url, {
91
- basenameHint: imagePayload.md5,
92
- fallbackExtension: 'jpg',
93
- });
91
+ let savedPath = '';
92
+ if (typeof imagePayload.aeskey === 'string' && imagePayload.aeskey.trim().length > 0) {
93
+ const response = await fetch(imagePayload.url, { signal: AbortSignal.timeout(30000) });
94
+ if (!response.ok) {
95
+ throw new Error(`Failed to download media: HTTP ${response.status}`);
96
+ }
97
+ const encryptedBuffer = Buffer.from(await response.arrayBuffer());
98
+ const decryptedBuffer = decryptAes256CbcMedia(encryptedBuffer, imagePayload.aeskey);
99
+ const extension = inferExtensionFromBuffer(decryptedBuffer) ||
100
+ inferExtensionFromContentType(response.headers.get('content-type') ?? '') ||
101
+ '.jpg';
102
+ savedPath = await saveBufferMedia(decryptedBuffer, extension, imagePayload.md5);
103
+ log.info(`Downloaded and decrypted WeWork image: ${savedPath}`);
104
+ }
105
+ else {
106
+ savedPath = await downloadMediaFromUrl(imagePayload.url, {
107
+ basenameHint: imagePayload.md5,
108
+ fallbackExtension: 'jpg',
109
+ });
110
+ }
94
111
  return buildSavedMediaPrompt({
95
112
  source: 'WeWork',
96
113
  kind: 'image',
@@ -173,7 +190,7 @@ export function setupWeWorkHandlers(config, sessionManager) {
173
190
  await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'wework', taskKey }, prompt, toolAdapter, {
174
191
  throttleMs: WEWORK_THROTTLE_MS,
175
192
  streamUpdate: async (content, toolNote) => {
176
- const note = toolNote ? `Working...\n${toolNote}` : 'Working...';
193
+ const note = buildProgressNote(toolNote);
177
194
  try {
178
195
  await updateMessage(chatId, msgId, content, 'streaming', note, toolId, reqId);
179
196
  }
@@ -185,7 +202,7 @@ export function setupWeWorkHandlers(config, sessionManager) {
185
202
  await sendFinalMessages(chatId, msgId, content, note ?? '', toolId, reqId);
186
203
  },
187
204
  sendError: async (error) => {
188
- await updateMessage(chatId, msgId, `Error: ${error}`, 'error', 'Execution failed', toolId, reqId);
205
+ await updateMessage(chatId, msgId, `Error: ${error}`, 'error', buildErrorNote(), toolId, reqId);
189
206
  },
190
207
  extraCleanup: () => {
191
208
  stopTyping();
@@ -4,7 +4,10 @@
4
4
  */
5
5
  import { sendText, sendStream, sendStreamWithItems, sendProactiveMessage } from './client.js';
6
6
  import { createLogger } from '../logger.js';
7
- import { splitLongContent, getAIToolDisplayName } from '../shared/utils.js';
7
+ import { splitLongContent } from '../shared/utils.js';
8
+ import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from '../shared/message-title.js';
9
+ import { buildTextNote } from '../shared/message-note.js';
10
+ import { buildDirectoryMessage, buildModeMessage, buildPermissionRequestMessage, } from '../shared/system-messages.js';
8
11
  import { MAX_WEWORK_MESSAGE_LENGTH } from '../constants.js';
9
12
  import { createHash, randomBytes } from 'node:crypto';
10
13
  import { readFile } from 'node:fs/promises';
@@ -31,9 +34,14 @@ const STATUS_CONFIG = {
31
34
  error: { icon: '[error]', title: '错误' },
32
35
  };
33
36
  function getToolTitle(toolId, status) {
34
- const name = getAIToolDisplayName(toolId);
35
- const statusText = STATUS_CONFIG[status].title;
36
- return status === 'done' ? name : `${name} - ${statusText}`;
37
+ return buildMessageTitle(toolId, status, {
38
+ statusTitles: {
39
+ thinking: STATUS_CONFIG.thinking.title,
40
+ streaming: STATUS_CONFIG.streaming.title,
41
+ done: STATUS_CONFIG.done.title,
42
+ error: STATUS_CONFIG.error.title,
43
+ },
44
+ });
37
45
  }
38
46
  function generateReqId() {
39
47
  return `${Date.now()}-${randomBytes(8).toString('hex')}`;
@@ -51,7 +59,7 @@ function formatWeWorkMessage(title, content, status, note) {
51
59
  message += `_正在思考,请稍候..._\n\n[thinking] **准备中**\n\n`;
52
60
  }
53
61
  if (note) {
54
- message += `---\n\n[note] **${note}**`;
62
+ message += buildTextNote(note);
55
63
  }
56
64
  return message;
57
65
  }
@@ -198,7 +206,7 @@ export async function sendFinalMessages(chatId, streamId, fullContent, note, too
198
206
  * 主动推送文本,用于启动/关闭通知等场景,无需 req_id。
199
207
  */
200
208
  export async function sendProactiveTextReply(chatId, text) {
201
- const message = formatWeWorkMessage('[open-im]', text, 'done');
209
+ const message = formatWeWorkMessage(OPEN_IM_SYSTEM_TITLE, text, 'done');
202
210
  try {
203
211
  sendProactiveMessage(chatId, message);
204
212
  log.info(`Proactive text sent to user ${chatId}`);
@@ -212,7 +220,7 @@ export async function sendProactiveTextReply(chatId, text) {
212
220
  * @param threadCtxOrReqId 兼容 MessageSender 的 threadCtx;若为 string 则作为 reqId 使用
213
221
  */
214
222
  export async function sendTextReply(chatId, text, threadCtxOrReqId) {
215
- const message = formatWeWorkMessage('[open-im]', text, 'done');
223
+ const message = formatWeWorkMessage(OPEN_IM_SYSTEM_TITLE, text, 'done');
216
224
  const explicitReqId = typeof threadCtxOrReqId === 'string' ? threadCtxOrReqId : undefined;
217
225
  const effectiveReqId = explicitReqId ?? currentReqId;
218
226
  try {
@@ -224,21 +232,7 @@ export async function sendTextReply(chatId, text, threadCtxOrReqId) {
224
232
  }
225
233
  }
226
234
  export async function sendPermissionCard(chatId, requestId, toolName, toolInput, reqId) {
227
- const message = [
228
- '[Permission Request]',
229
- '',
230
- `Tool: ${toolName}`,
231
- 'Arguments:',
232
- '```',
233
- toolInput,
234
- '```',
235
- '',
236
- 'Reply with one of the following commands:',
237
- '- /allow',
238
- '- /deny',
239
- '',
240
- `Request ID: ${requestId.slice(-8)}`,
241
- ].join('\n');
235
+ const message = buildPermissionRequestMessage(toolName, toolInput, requestId);
242
236
  try {
243
237
  sendText(getReqId(reqId), message);
244
238
  log.info(`Permission card sent to user ${chatId}`);
@@ -250,17 +244,7 @@ export async function sendPermissionCard(chatId, requestId, toolName, toolInput,
250
244
  export async function sendModeCard(chatId, _userId, currentMode, reqId) {
251
245
  const { MODE_LABELS } = await import('../permission-mode/types.js');
252
246
  const label = MODE_LABELS[currentMode] || currentMode;
253
- const message = [
254
- '[Permission Mode]',
255
- '',
256
- `Current mode: ${label}`,
257
- '',
258
- 'Send one of the following commands to switch:',
259
- '- /mode ask',
260
- '- /mode accept-edits',
261
- '- /mode plan',
262
- '- /mode yolo',
263
- ].join('\n');
247
+ const message = buildModeMessage(label);
264
248
  try {
265
249
  sendText(getReqId(reqId), message);
266
250
  log.info(`Mode card sent to user ${chatId}`);
@@ -292,7 +276,7 @@ export async function sendImageReply(chatId, imagePath) {
292
276
  }
293
277
  }
294
278
  export async function sendDirectorySelection(chatId, currentDir, _userId) {
295
- await sendTextReply(chatId, `Current directory: ${currentDir}\n\nUse \`/cd <directory>\` to switch.`);
279
+ await sendTextReply(chatId, buildDirectoryMessage(currentDir));
296
280
  }
297
281
  export function startTypingLoop(_chatId) {
298
282
  return () => { };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.6.1-beta.2",
3
+ "version": "1.6.1-beta.4",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, Cursor)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",