@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.
- package/dist/dingtalk/message-sender.js +12 -35
- package/dist/feishu/event-handler.js +7 -4
- package/dist/feishu/message-sender.js +18 -11
- package/dist/index.js +17 -6
- package/dist/permission-mode/types.d.ts +1 -1
- package/dist/permission-mode/types.js +5 -5
- package/dist/qq/message-sender.js +15 -9
- package/dist/qq/message-sender.test.js +1 -0
- package/dist/shared/media-storage.d.ts +3 -0
- package/dist/shared/media-storage.js +47 -2
- package/dist/shared/media-storage.test.js +19 -1
- package/dist/shared/message-note.d.ts +3 -0
- package/dist/shared/message-note.js +10 -0
- package/dist/shared/message-note.test.d.ts +1 -0
- package/dist/shared/message-note.test.js +14 -0
- package/dist/shared/message-title.d.ts +8 -0
- package/dist/shared/message-title.js +14 -0
- package/dist/shared/message-title.test.d.ts +1 -0
- package/dist/shared/message-title.test.js +18 -0
- package/dist/shared/system-messages.d.ts +3 -0
- package/dist/shared/system-messages.js +51 -0
- package/dist/shared/system-messages.test.d.ts +1 -0
- package/dist/shared/system-messages.test.js +15 -0
- package/dist/telegram/event-handler.js +3 -2
- package/dist/telegram/message-sender.js +12 -16
- package/dist/wechat/event-handler.js +3 -2
- package/dist/wechat/message-sender.js +16 -29
- package/dist/wechat/message-sender.test.js +2 -1
- package/dist/wework/event-handler.js +24 -7
- package/dist/wework/message-sender.js +18 -34
- package/package.json +1 -1
|
@@ -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
|
|
51
|
+
text += `\n\n${buildTextNote(note)}`;
|
|
49
52
|
return text;
|
|
50
53
|
}
|
|
51
54
|
function getToolTitle(toolId, status) {
|
|
52
|
-
|
|
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,
|
|
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
|
-
.
|
|
391
|
-
await sendTextWithRetry(chatId,
|
|
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: '
|
|
57
|
-
{ label: '
|
|
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
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
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: '
|
|
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✅
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
91
|
+
`**服务已启动**`,
|
|
90
92
|
"",
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
`- 版本: \`open-im v${appVersion}\``,
|
|
94
|
+
`- AI 工具: \`${aiCommand}\``,
|
|
95
|
+
`- 成功启动平台: ${platformList}`,
|
|
93
96
|
];
|
|
94
97
|
if (sessionDir) {
|
|
95
|
-
lines.push(
|
|
98
|
+
lines.push(`- 会话目录: ${escapePathForMarkdown(sessionDir)}`);
|
|
96
99
|
}
|
|
97
100
|
else {
|
|
98
|
-
lines.push(
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
2
|
+
/** 用户侧展示名 */
|
|
3
3
|
export const MODE_LABELS = {
|
|
4
|
-
ask: '
|
|
5
|
-
'accept-edits': '
|
|
6
|
-
plan: '
|
|
7
|
-
yolo: '
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
?
|
|
142
|
+
? `${buildMessageTitle(toolId, "done")}\n${note}`
|
|
139
143
|
: state.sentStreamChunk
|
|
140
|
-
?
|
|
141
|
-
:
|
|
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,
|
|
154
|
+
await sendRaw(chatId, `${buildMessageTitle(toolId, "error")}\n${error}`, replyToMessageId);
|
|
151
155
|
}
|
|
152
156
|
export async function sendDirectorySelection(chatId, currentDir) {
|
|
153
|
-
await sendTextReply(chatId,
|
|
157
|
+
await sendTextReply(chatId, buildDirectoryMessage(currentDir));
|
|
154
158
|
}
|
|
155
159
|
export async function sendModeKeyboard(chatId, _userId, currentMode) {
|
|
156
|
-
|
|
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
|
|
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,
|
|
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,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
|
|
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",
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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), `🔐
|
|
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
|
|
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',
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 +=
|
|
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(
|
|
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("
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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',
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 +=
|
|
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(
|
|
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(
|
|
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,
|
|
279
|
+
await sendTextReply(chatId, buildDirectoryMessage(currentDir));
|
|
296
280
|
}
|
|
297
281
|
export function startTypingLoop(_chatId) {
|
|
298
282
|
return () => { };
|