@wu529778790/open-im 1.6.1-beta.13 → 1.6.1-beta.15
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/channels/capabilities.js +1 -1
- package/dist/channels/capabilities.test.js +2 -0
- package/dist/codex/cli-runner.d.ts +2 -0
- package/dist/codex/cli-runner.js +48 -2
- package/dist/codex/cli-runner.test.d.ts +1 -0
- package/dist/codex/cli-runner.test.js +53 -0
- package/dist/dingtalk/client.d.ts +6 -0
- package/dist/dingtalk/client.js +62 -0
- package/dist/dingtalk/event-handler.js +29 -4
- package/dist/qq/message-sender.d.ts +2 -2
- package/dist/qq/message-sender.js +13 -102
- package/dist/qq/message-sender.test.js +16 -14
- package/package.json +1 -1
|
@@ -17,7 +17,7 @@ export const CHANNEL_CAPABILITIES = {
|
|
|
17
17
|
},
|
|
18
18
|
qq: {
|
|
19
19
|
inbound: { text: "native", image: "fallback", file: "fallback", voice: "fallback", video: "fallback" },
|
|
20
|
-
outbound: { streamEdit: "
|
|
20
|
+
outbound: { streamEdit: "none", streamPush: "none", image: "fallback", card: "fallback", typing: "fallback" },
|
|
21
21
|
},
|
|
22
22
|
wechat: {
|
|
23
23
|
inbound: { text: "native", image: "fallback", file: "fallback", voice: "fallback", video: "fallback" },
|
|
@@ -8,6 +8,8 @@ describe("channel capabilities", () => {
|
|
|
8
8
|
expect(CHANNEL_CAPABILITIES.qq.inbound.image).toBe("fallback");
|
|
9
9
|
expect(CHANNEL_CAPABILITIES.qq.inbound.voice).toBe("fallback");
|
|
10
10
|
expect(CHANNEL_CAPABILITIES.qq.inbound.video).toBe("fallback");
|
|
11
|
+
expect(CHANNEL_CAPABILITIES.qq.outbound.streamEdit).toBe("none");
|
|
12
|
+
expect(CHANNEL_CAPABILITIES.qq.outbound.streamPush).toBe("none");
|
|
11
13
|
expect(CHANNEL_CAPABILITIES.wechat.inbound.image).toBe("fallback");
|
|
12
14
|
expect(CHANNEL_CAPABILITIES.wework.inbound.video).toBe("fallback");
|
|
13
15
|
expect(CHANNEL_CAPABILITIES.wework.outbound.image).toBe("native");
|
|
@@ -31,4 +31,6 @@ export interface CodexRunOptions {
|
|
|
31
31
|
export interface CodexRunHandle {
|
|
32
32
|
abort: () => void;
|
|
33
33
|
}
|
|
34
|
+
export declare function extractPromptImagePaths(prompt: string): string[];
|
|
35
|
+
export declare function buildCodexArgs(prompt: string, sessionId: string | undefined, workDir: string, options?: CodexRunOptions): string[];
|
|
34
36
|
export declare function runCodex(cliPath: string, prompt: string, sessionId: string | undefined, workDir: string, callbacks: CodexRunCallbacks, options?: CodexRunOptions): CodexRunHandle;
|
package/dist/codex/cli-runner.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Codex CLI runner for `codex exec --json` JSONL output.
|
|
3
3
|
*/
|
|
4
4
|
import { execFileSync, spawn } from 'node:child_process';
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { createInterface } from 'node:readline';
|
|
8
8
|
import { createLogger } from '../logger.js';
|
|
@@ -10,6 +10,17 @@ const log = createLogger('CodexCli');
|
|
|
10
10
|
const windowsCodexLaunchCache = new Map();
|
|
11
11
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
12
12
|
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
|
+
const SUPPORTED_IMAGE_EXTENSIONS = new Set([
|
|
14
|
+
'.png',
|
|
15
|
+
'.jpg',
|
|
16
|
+
'.jpeg',
|
|
17
|
+
'.gif',
|
|
18
|
+
'.webp',
|
|
19
|
+
'.bmp',
|
|
20
|
+
'.tif',
|
|
21
|
+
'.tiff',
|
|
22
|
+
'.avif',
|
|
23
|
+
]);
|
|
13
24
|
function getIdleTimeoutMs(totalTimeoutMs) {
|
|
14
25
|
const raw = process.env.CODEX_IDLE_TIMEOUT_MS;
|
|
15
26
|
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
@@ -31,11 +42,42 @@ function parseCodexEvent(line) {
|
|
|
31
42
|
return null;
|
|
32
43
|
}
|
|
33
44
|
}
|
|
34
|
-
function
|
|
45
|
+
function isSupportedImagePath(filePath) {
|
|
46
|
+
const normalized = filePath.trim();
|
|
47
|
+
if (!normalized || !existsSync(normalized))
|
|
48
|
+
return false;
|
|
49
|
+
const lower = normalized.toLowerCase();
|
|
50
|
+
return Array.from(SUPPORTED_IMAGE_EXTENSIONS).some((ext) => lower.endsWith(ext));
|
|
51
|
+
}
|
|
52
|
+
export function extractPromptImagePaths(prompt) {
|
|
53
|
+
const imagePaths = new Set();
|
|
54
|
+
const lines = prompt.split(/\r?\n/);
|
|
55
|
+
for (const rawLine of lines) {
|
|
56
|
+
const line = rawLine.trim();
|
|
57
|
+
if (!line)
|
|
58
|
+
continue;
|
|
59
|
+
const singleMatch = /^Saved local file path:\s*(.+)$/i.exec(line);
|
|
60
|
+
if (singleMatch) {
|
|
61
|
+
const candidate = singleMatch[1].trim();
|
|
62
|
+
if (isSupportedImagePath(candidate))
|
|
63
|
+
imagePaths.add(candidate);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const batchMatch = /^\d+\.\s+(?:.+:\s+)?(.+?)\s+\((image)\)$/i.exec(line);
|
|
67
|
+
if (batchMatch) {
|
|
68
|
+
const candidate = batchMatch[1].trim();
|
|
69
|
+
if (isSupportedImagePath(candidate))
|
|
70
|
+
imagePaths.add(candidate);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return Array.from(imagePaths);
|
|
74
|
+
}
|
|
75
|
+
export function buildCodexArgs(prompt, sessionId, workDir, options) {
|
|
35
76
|
const commonOptions = ['--json', '--skip-git-repo-check'];
|
|
36
77
|
const newSessionOptions = [...commonOptions, '--cd', workDir];
|
|
37
78
|
const resumeOptions = [...commonOptions];
|
|
38
79
|
const canResume = Boolean(sessionId) && options?.permissionMode !== 'plan';
|
|
80
|
+
const imagePaths = extractPromptImagePaths(prompt);
|
|
39
81
|
if (options?.skipPermissions) {
|
|
40
82
|
newSessionOptions.push('--dangerously-bypass-approvals-and-sandbox');
|
|
41
83
|
resumeOptions.push('--dangerously-bypass-approvals-and-sandbox');
|
|
@@ -51,6 +93,10 @@ function buildCodexArgs(_prompt, sessionId, workDir, options) {
|
|
|
51
93
|
newSessionOptions.push('--model', options.model);
|
|
52
94
|
resumeOptions.push('--model', options.model);
|
|
53
95
|
}
|
|
96
|
+
for (const imagePath of imagePaths) {
|
|
97
|
+
newSessionOptions.push('--image', imagePath);
|
|
98
|
+
resumeOptions.push('--image', imagePath);
|
|
99
|
+
}
|
|
54
100
|
if (sessionId && !canResume) {
|
|
55
101
|
log.warn('Codex plan mode does not support resume; starting a new read-only session');
|
|
56
102
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { buildCodexArgs, extractPromptImagePaths } from './cli-runner.js';
|
|
6
|
+
const tempDirs = [];
|
|
7
|
+
function createTempImage(name) {
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), 'open-im-codex-test-'));
|
|
9
|
+
tempDirs.push(dir);
|
|
10
|
+
const path = join(dir, name);
|
|
11
|
+
writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const dir of tempDirs.splice(0)) {
|
|
16
|
+
rmSync(dir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
describe('extractPromptImagePaths', () => {
|
|
20
|
+
it('extracts a single saved image path from a media prompt', () => {
|
|
21
|
+
const imagePath = createTempImage('single.png');
|
|
22
|
+
const prompt = [
|
|
23
|
+
'The user sent a DingTalk image message.',
|
|
24
|
+
`Saved local file path: ${imagePath}`,
|
|
25
|
+
'Use the Read tool to inspect the saved file and describe the relevant visual contents before answering.',
|
|
26
|
+
].join('\n\n');
|
|
27
|
+
expect(extractPromptImagePaths(prompt)).toEqual([imagePath]);
|
|
28
|
+
});
|
|
29
|
+
it('extracts image items from batch media prompts and ignores non-images', () => {
|
|
30
|
+
const imagePath = createTempImage('batch.png');
|
|
31
|
+
const prompt = [
|
|
32
|
+
'Saved local file paths:',
|
|
33
|
+
`1. photo: ${imagePath} (image)`,
|
|
34
|
+
'2. notes.txt (file)',
|
|
35
|
+
].join('\n');
|
|
36
|
+
expect(extractPromptImagePaths(prompt)).toEqual([imagePath]);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('buildCodexArgs', () => {
|
|
40
|
+
it('adds image attachments for new sessions', () => {
|
|
41
|
+
const imagePath = createTempImage('new-session.png');
|
|
42
|
+
const args = buildCodexArgs(`Saved local file path: ${imagePath}`, undefined, 'D:\\coding\\open-im', {});
|
|
43
|
+
expect(args).toContain('--image');
|
|
44
|
+
expect(args).toContain(imagePath);
|
|
45
|
+
});
|
|
46
|
+
it('adds image attachments for resumed sessions', () => {
|
|
47
|
+
const imagePath = createTempImage('resume-session.png');
|
|
48
|
+
const args = buildCodexArgs(`Saved local file path: ${imagePath}`, 'session-123', 'D:\\coding\\open-im', {});
|
|
49
|
+
expect(args.slice(0, 2)).toEqual(['exec', 'resume']);
|
|
50
|
+
expect(args).toContain('--image');
|
|
51
|
+
expect(args).toContain(imagePath);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -8,9 +8,15 @@ export interface DingTalkStreamingTarget {
|
|
|
8
8
|
senderId?: string;
|
|
9
9
|
robotCode?: string;
|
|
10
10
|
}
|
|
11
|
+
export interface DingTalkDownloadedMessageFile {
|
|
12
|
+
buffer: Buffer;
|
|
13
|
+
contentType?: string;
|
|
14
|
+
filename?: string;
|
|
15
|
+
}
|
|
11
16
|
export declare function shouldSuppressDingTalkSocketWarn(args: unknown[]): boolean;
|
|
12
17
|
export declare function registerSessionWebhook(chatId: string, sessionWebhook: string): void;
|
|
13
18
|
export declare function sendText(chatId: string, content: string): Promise<unknown>;
|
|
19
|
+
export declare function downloadRobotMessageFile(downloadCode: string, robotCode: string): Promise<DingTalkDownloadedMessageFile>;
|
|
14
20
|
export declare function sendMarkdown(chatId: string, title: string, text: string): Promise<unknown>;
|
|
15
21
|
export declare function ackMessage(messageId: string, result?: unknown): void;
|
|
16
22
|
export declare function initDingTalk(cfg: Config, eventHandler: (data: DWClientDownStream) => Promise<void>): Promise<void>;
|
package/dist/dingtalk/client.js
CHANGED
|
@@ -78,6 +78,68 @@ export async function sendText(chatId, content) {
|
|
|
78
78
|
text: { content },
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
|
+
export async function downloadRobotMessageFile(downloadCode, robotCode) {
|
|
82
|
+
const accessToken = await getClient().getAccessToken();
|
|
83
|
+
const response = await fetch(`${DINGTALK_OPENAPI_BASE}/v1.0/robot/messageFiles/download`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
'content-type': 'application/json',
|
|
87
|
+
'x-acs-dingtalk-access-token': String(accessToken),
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({ downloadCode, robotCode }),
|
|
90
|
+
signal: AbortSignal.timeout(30000),
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const text = await response.text();
|
|
94
|
+
throw new Error(`DingTalk message file download failed: ${response.status} ${text}`);
|
|
95
|
+
}
|
|
96
|
+
const contentType = response.headers.get('content-type') ?? undefined;
|
|
97
|
+
const contentDisposition = response.headers.get('content-disposition') ?? '';
|
|
98
|
+
const filenameMatch = /filename\*=UTF-8''([^;]+)|filename="?([^\";]+)"?/i.exec(contentDisposition);
|
|
99
|
+
const filename = filenameMatch?.[1] ?? filenameMatch?.[2];
|
|
100
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
101
|
+
if (contentType?.includes('application/json')) {
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(buffer.toString('utf8'));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error(`DingTalk message file download returned JSON payload that could not be parsed`);
|
|
108
|
+
}
|
|
109
|
+
const errorCode = parsed.code ?? parsed.errcode ?? parsed.errorcode;
|
|
110
|
+
if (errorCode !== undefined && errorCode !== 0 && errorCode !== '0') {
|
|
111
|
+
const message = typeof parsed.message === 'string'
|
|
112
|
+
? parsed.message
|
|
113
|
+
: typeof parsed.errmsg === 'string'
|
|
114
|
+
? parsed.errmsg
|
|
115
|
+
: JSON.stringify(parsed);
|
|
116
|
+
throw new Error(`DingTalk message file download business error: ${String(errorCode)} ${message}`);
|
|
117
|
+
}
|
|
118
|
+
const downloadUrl = typeof parsed.downloadUrl === 'string'
|
|
119
|
+
? parsed.downloadUrl
|
|
120
|
+
: typeof parsed.download_url === 'string'
|
|
121
|
+
? parsed.download_url
|
|
122
|
+
: typeof parsed.url === 'string'
|
|
123
|
+
? parsed.url
|
|
124
|
+
: undefined;
|
|
125
|
+
if (!downloadUrl) {
|
|
126
|
+
throw new Error(`DingTalk message file download returned JSON without binary payload or download URL`);
|
|
127
|
+
}
|
|
128
|
+
const redirected = await fetch(downloadUrl, {
|
|
129
|
+
signal: AbortSignal.timeout(30000),
|
|
130
|
+
});
|
|
131
|
+
if (!redirected.ok) {
|
|
132
|
+
const text = await redirected.text();
|
|
133
|
+
throw new Error(`DingTalk redirected file download failed: ${redirected.status} ${text}`);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
buffer: Buffer.from(await redirected.arrayBuffer()),
|
|
137
|
+
contentType: redirected.headers.get('content-type') ?? undefined,
|
|
138
|
+
filename,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { buffer, contentType, filename };
|
|
142
|
+
}
|
|
81
143
|
export async function sendMarkdown(chatId, title, text) {
|
|
82
144
|
return sendByWebhook(chatId, {
|
|
83
145
|
msgtype: 'markdown',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AccessControl } from '../access/access-control.js';
|
|
2
2
|
import { RequestQueue } from '../queue/request-queue.js';
|
|
3
3
|
import { configureDingTalkMessageSender, sendThinkingMessage, updateMessage, sendFinalMessages, sendErrorMessage, sendTextReply, sendImageReply, startTypingLoop, sendPermissionCard, sendModeCard, sendDirectorySelection, } from './message-sender.js';
|
|
4
|
-
import { ackMessage, registerSessionWebhook } from './client.js';
|
|
4
|
+
import { ackMessage, downloadRobotMessageFile, registerSessionWebhook } from './client.js';
|
|
5
5
|
import { registerPermissionSender } from '../hook/permission-server.js';
|
|
6
6
|
import { CommandHandler } from '../commands/handler.js';
|
|
7
7
|
import { getAdapter } from '../adapters/registry.js';
|
|
@@ -14,7 +14,7 @@ import { buildUnsupportedInboundMessage } from '../channels/capabilities.js';
|
|
|
14
14
|
import { buildMediaMetadataPrompt } from '../shared/media-prompt.js';
|
|
15
15
|
import { buildSavedMediaPrompt } from '../shared/media-analysis-prompt.js';
|
|
16
16
|
import { buildMediaContext } from '../shared/media-context.js';
|
|
17
|
-
import { downloadMediaFromUrl } from '../shared/media-storage.js';
|
|
17
|
+
import { downloadMediaFromUrl, inferExtensionFromBuffer, inferExtensionFromContentType, saveBufferMedia, } from '../shared/media-storage.js';
|
|
18
18
|
const log = createLogger('DingTalkHandler');
|
|
19
19
|
const DINGTALK_THROTTLE_MS = 1000;
|
|
20
20
|
function parseRobotMessage(data) {
|
|
@@ -73,7 +73,7 @@ function buildDingTalkMediaContext(text, payload) {
|
|
|
73
73
|
Duration: duration,
|
|
74
74
|
}, text);
|
|
75
75
|
}
|
|
76
|
-
async function buildMediaPrompt(message, kind) {
|
|
76
|
+
async function buildMediaPrompt(message, kind, robotCodeFallback) {
|
|
77
77
|
const payload = extractMediaPayload(message, kind);
|
|
78
78
|
if (!payload)
|
|
79
79
|
return null;
|
|
@@ -85,6 +85,12 @@ async function buildMediaPrompt(message, kind) {
|
|
|
85
85
|
payload.download_url,
|
|
86
86
|
payload.picUrl,
|
|
87
87
|
].find((value) => typeof value === 'string' && value.length > 0);
|
|
88
|
+
const downloadCode = [
|
|
89
|
+
payload.downloadCode,
|
|
90
|
+
payload.download_code,
|
|
91
|
+
payload.pictureDownloadCode,
|
|
92
|
+
payload.picture_download_code,
|
|
93
|
+
].find((value) => typeof value === 'string' && value.length > 0);
|
|
88
94
|
let localPath;
|
|
89
95
|
if (remoteUrl) {
|
|
90
96
|
try {
|
|
@@ -97,6 +103,25 @@ async function buildMediaPrompt(message, kind) {
|
|
|
97
103
|
localPath = undefined;
|
|
98
104
|
}
|
|
99
105
|
}
|
|
106
|
+
if (!localPath && downloadCode) {
|
|
107
|
+
try {
|
|
108
|
+
const robotCode = (typeof message.robotCode === 'string' && message.robotCode.length > 0
|
|
109
|
+
? message.robotCode
|
|
110
|
+
: robotCodeFallback) ?? '';
|
|
111
|
+
if (robotCode) {
|
|
112
|
+
const downloaded = await downloadRobotMessageFile(downloadCode, robotCode);
|
|
113
|
+
const extension = inferExtensionFromContentType(downloaded.contentType ?? '') ||
|
|
114
|
+
inferExtensionFromBuffer(downloaded.buffer) ||
|
|
115
|
+
(kind === 'image' ? '.jpg' : '.bin');
|
|
116
|
+
const basenameHint = downloaded.filename ??
|
|
117
|
+
(typeof payload.fileName === 'string' ? payload.fileName : undefined);
|
|
118
|
+
localPath = await saveBufferMedia(downloaded.buffer, extension, basenameHint);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
localPath = undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
100
125
|
if (localPath) {
|
|
101
126
|
return buildSavedMediaPrompt({
|
|
102
127
|
source: 'DingTalk',
|
|
@@ -221,7 +246,7 @@ export function setupDingTalkHandlers(config, sessionManager) {
|
|
|
221
246
|
};
|
|
222
247
|
if (message.msgtype !== 'text') {
|
|
223
248
|
const kind = toInboundKind(message.msgtype);
|
|
224
|
-
const prompt = await buildMediaPrompt(message, kind);
|
|
249
|
+
const prompt = await buildMediaPrompt(message, kind, config.dingtalkClientId);
|
|
225
250
|
if (!prompt) {
|
|
226
251
|
await sendTextReply(chatId, buildUnsupportedInboundMessage('dingtalk', kind));
|
|
227
252
|
ackMessage(callbackId, { ignored: message.msgtype });
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export declare function sendTextReply(chatId: string, text: string): Promise<void>;
|
|
2
2
|
export declare function sendImageReply(chatId: string, imagePath: string): Promise<void>;
|
|
3
3
|
export declare function sendThinkingMessage(chatId: string, replyToMessageId?: string, _toolId?: string): Promise<string>;
|
|
4
|
-
export declare function updateMessage(
|
|
5
|
-
export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string,
|
|
4
|
+
export declare function updateMessage(_chatId: string, _messageId: string, _content: string, _status: "thinking" | "streaming" | "done" | "error", _note?: string, _toolId?: string): Promise<void>;
|
|
5
|
+
export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string, _note: string, toolId?: string): Promise<void>;
|
|
6
6
|
export declare function sendErrorMessage(chatId: string, messageId: string, error: string, toolId?: string): Promise<void>;
|
|
7
7
|
export declare function sendDirectorySelection(chatId: string, currentDir: string): Promise<void>;
|
|
8
8
|
export declare function sendModeKeyboard(chatId: string, _userId: string, currentMode: string): Promise<void>;
|
|
@@ -3,12 +3,10 @@ import { splitLongContent } from "../shared/utils.js";
|
|
|
3
3
|
import { buildImageFallbackMessage } from "../channels/capabilities.js";
|
|
4
4
|
import { getQQBot } from "./client.js";
|
|
5
5
|
import { buildMessageTitle, OPEN_IM_SYSTEM_TITLE } from "../shared/message-title.js";
|
|
6
|
-
import { buildTextNote } from "../shared/message-note.js";
|
|
7
6
|
import { buildDirectoryMessage, buildModeMessage } from "../shared/system-messages.js";
|
|
8
7
|
const log = createLogger("QQSender");
|
|
9
8
|
const MAX_QQ_MESSAGE_LENGTH = 1500;
|
|
10
|
-
const
|
|
11
|
-
const streamStates = new Map();
|
|
9
|
+
const pendingReplies = new Map();
|
|
12
10
|
function parseChatTarget(chatId) {
|
|
13
11
|
if (chatId.startsWith("group:")) {
|
|
14
12
|
return { kind: "group", id: chatId.slice("group:".length) };
|
|
@@ -32,81 +30,6 @@ async function sendRaw(chatId, text, replyToMessageId) {
|
|
|
32
30
|
}
|
|
33
31
|
return bot.sendPrivateMessage(target.id, text, replyToMessageId);
|
|
34
32
|
}
|
|
35
|
-
function getOrCreateStreamState(messageId, chatId, replyToMessageId) {
|
|
36
|
-
const existing = streamStates.get(messageId);
|
|
37
|
-
if (existing)
|
|
38
|
-
return existing;
|
|
39
|
-
const state = {
|
|
40
|
-
chatId,
|
|
41
|
-
replyToMessageId,
|
|
42
|
-
lastSentLength: 0,
|
|
43
|
-
sentStreamChunk: false,
|
|
44
|
-
pendingText: "",
|
|
45
|
-
};
|
|
46
|
-
streamStates.set(messageId, state);
|
|
47
|
-
return state;
|
|
48
|
-
}
|
|
49
|
-
function buildStreamChunk(toolId, content, note, withHeader = false) {
|
|
50
|
-
const header = withHeader ? buildMessageTitle(toolId, "streaming") : "";
|
|
51
|
-
const noteBlock = note ? `\n\n${buildTextNote(note)}` : "";
|
|
52
|
-
return `${header}${header ? "\n" : ""}${content}${noteBlock}`.trim();
|
|
53
|
-
}
|
|
54
|
-
function findPreferredSplit(text, limit) {
|
|
55
|
-
const normalizedLimit = Math.min(text.length, limit);
|
|
56
|
-
const boundaries = ["\n\n", "\n", "。", ",", ";", ". ", "! ", "? ", ", ", " "];
|
|
57
|
-
const minimumUsefulSplit = Math.min(80, Math.floor(normalizedLimit / 3));
|
|
58
|
-
for (const boundary of boundaries) {
|
|
59
|
-
const index = text.lastIndexOf(boundary, normalizedLimit);
|
|
60
|
-
if (index >= minimumUsefulSplit) {
|
|
61
|
-
return index + boundary.length;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
if (text.length >= minimumUsefulSplit) {
|
|
65
|
-
return normalizedLimit;
|
|
66
|
-
}
|
|
67
|
-
return 0;
|
|
68
|
-
}
|
|
69
|
-
function resetStreamState(state) {
|
|
70
|
-
state.lastSentLength = 0;
|
|
71
|
-
state.lastToolNote = undefined;
|
|
72
|
-
state.pendingText = "";
|
|
73
|
-
state.sentStreamChunk = false;
|
|
74
|
-
}
|
|
75
|
-
async function sendIncrementalContent(state, toolId, content, note, flushAll = false) {
|
|
76
|
-
if (!flushAll && content.length < state.lastSentLength) {
|
|
77
|
-
resetStreamState(state);
|
|
78
|
-
}
|
|
79
|
-
const delta = content.slice(state.lastSentLength);
|
|
80
|
-
const hasNewNote = !!note && note !== state.lastToolNote;
|
|
81
|
-
if (!delta && !hasNewNote)
|
|
82
|
-
return;
|
|
83
|
-
if (delta) {
|
|
84
|
-
state.pendingText += delta;
|
|
85
|
-
let noteSent = false;
|
|
86
|
-
while (state.pendingText.length > 0) {
|
|
87
|
-
const splitAt = flushAll
|
|
88
|
-
? Math.min(state.pendingText.length, STREAM_CHUNK_LENGTH)
|
|
89
|
-
: findPreferredSplit(state.pendingText, STREAM_CHUNK_LENGTH);
|
|
90
|
-
if (splitAt <= 0)
|
|
91
|
-
break;
|
|
92
|
-
const part = state.pendingText.slice(0, splitAt).trim();
|
|
93
|
-
state.pendingText = state.pendingText.slice(splitAt).trimStart();
|
|
94
|
-
if (!part)
|
|
95
|
-
continue;
|
|
96
|
-
const text = buildStreamChunk(toolId, part, state.pendingText.length === 0 && hasNewNote ? note : undefined, !state.sentStreamChunk);
|
|
97
|
-
await sendRaw(state.chatId, text, state.replyToMessageId);
|
|
98
|
-
if (state.pendingText.length === 0 && hasNewNote)
|
|
99
|
-
noteSent = true;
|
|
100
|
-
state.sentStreamChunk = true;
|
|
101
|
-
}
|
|
102
|
-
state.lastSentLength = content.length;
|
|
103
|
-
if (noteSent)
|
|
104
|
-
state.lastToolNote = note;
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
await sendRaw(state.chatId, `[${toolId}] ${note}`, state.replyToMessageId);
|
|
108
|
-
state.lastToolNote = note;
|
|
109
|
-
}
|
|
110
33
|
export async function sendTextReply(chatId, text) {
|
|
111
34
|
try {
|
|
112
35
|
const formatted = `${buildMessageTitle(OPEN_IM_SYSTEM_TITLE, "done")}\n\n${text}`;
|
|
@@ -122,36 +45,24 @@ export async function sendImageReply(chatId, imagePath) {
|
|
|
122
45
|
await sendTextReply(chatId, buildImageFallbackMessage("qq", imagePath));
|
|
123
46
|
}
|
|
124
47
|
export async function sendThinkingMessage(chatId, replyToMessageId, _toolId = "claude") {
|
|
125
|
-
const messageId = `${Date.now()}`;
|
|
126
|
-
|
|
48
|
+
const messageId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
49
|
+
pendingReplies.set(messageId, { replyToMessageId });
|
|
127
50
|
return messageId;
|
|
128
51
|
}
|
|
129
|
-
export async function updateMessage(
|
|
130
|
-
|
|
131
|
-
return;
|
|
132
|
-
const state = getOrCreateStreamState(messageId, chatId);
|
|
133
|
-
await sendIncrementalContent(state, toolId, content, note);
|
|
52
|
+
export async function updateMessage(_chatId, _messageId, _content, _status, _note, _toolId = "claude") {
|
|
53
|
+
// QQ 官方机器人接口不支持单条消息流式更新,这里显式忽略中间增量,只发送最终结果。
|
|
134
54
|
}
|
|
135
|
-
export async function sendFinalMessages(chatId, messageId, fullContent,
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
await sendIncrementalContent(state, toolId, fullContent, note || undefined, true);
|
|
142
|
-
if (!state.sentStreamChunk) {
|
|
143
|
-
const completionText = note
|
|
144
|
-
? buildStreamChunk(toolId, fullContent, note, true)
|
|
145
|
-
: `${buildMessageTitle(toolId, "done")}\n${fullContent}`;
|
|
146
|
-
for (const part of splitLongContent(completionText, MAX_QQ_MESSAGE_LENGTH)) {
|
|
147
|
-
await sendRaw(chatId, part, state.replyToMessageId);
|
|
148
|
-
}
|
|
55
|
+
export async function sendFinalMessages(chatId, messageId, fullContent, _note, toolId = "claude") {
|
|
56
|
+
const replyToMessageId = pendingReplies.get(messageId)?.replyToMessageId;
|
|
57
|
+
pendingReplies.delete(messageId);
|
|
58
|
+
const completionText = `${buildMessageTitle(toolId, "done")}\n${fullContent}`;
|
|
59
|
+
for (const part of splitLongContent(completionText, MAX_QQ_MESSAGE_LENGTH)) {
|
|
60
|
+
await sendRaw(chatId, part, replyToMessageId);
|
|
149
61
|
}
|
|
150
|
-
streamStates.delete(messageId);
|
|
151
62
|
}
|
|
152
63
|
export async function sendErrorMessage(chatId, messageId, error, toolId = "claude") {
|
|
153
|
-
const replyToMessageId =
|
|
154
|
-
|
|
64
|
+
const replyToMessageId = pendingReplies.get(messageId)?.replyToMessageId;
|
|
65
|
+
pendingReplies.delete(messageId);
|
|
155
66
|
await sendRaw(chatId, `${buildMessageTitle(toolId, "error")}\n${error}`, replyToMessageId);
|
|
156
67
|
}
|
|
157
68
|
export async function sendDirectorySelection(chatId, currentDir) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
const sendPrivateMessageMock = vi.fn();
|
|
3
3
|
const sendGroupMessageMock = vi.fn();
|
|
4
4
|
const sendChannelMessageMock = vi.fn();
|
|
@@ -11,10 +11,17 @@ vi.mock("./client.js", () => ({
|
|
|
11
11
|
}));
|
|
12
12
|
describe("QQ message sender", () => {
|
|
13
13
|
beforeEach(() => {
|
|
14
|
+
vi.useFakeTimers();
|
|
14
15
|
vi.resetModules();
|
|
15
16
|
sendPrivateMessageMock.mockReset();
|
|
16
17
|
sendGroupMessageMock.mockReset();
|
|
17
18
|
sendChannelMessageMock.mockReset();
|
|
19
|
+
sendPrivateMessageMock.mockResolvedValue(undefined);
|
|
20
|
+
sendGroupMessageMock.mockResolvedValue(undefined);
|
|
21
|
+
sendChannelMessageMock.mockResolvedValue(undefined);
|
|
22
|
+
});
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.useRealTimers();
|
|
18
25
|
});
|
|
19
26
|
it("routes image replies through the fallback text sender", async () => {
|
|
20
27
|
const sender = await import("./message-sender.js");
|
|
@@ -24,21 +31,16 @@ describe("QQ message sender", () => {
|
|
|
24
31
|
expect(sendGroupMessageMock.mock.calls[0][1]).toContain("open-im");
|
|
25
32
|
expect(sendGroupMessageMock.mock.calls[0][1]).toContain("C:\\images\\out.png");
|
|
26
33
|
});
|
|
27
|
-
it("
|
|
34
|
+
it("ignores intermediate stream updates and sends only the final reply", async () => {
|
|
28
35
|
const sender = await import("./message-sender.js");
|
|
29
36
|
const messageId = await sender.sendThinkingMessage("private:user-1", "reply-1", "codex");
|
|
30
|
-
await sender.updateMessage("private:user-1", messageId, "
|
|
31
|
-
await sender.
|
|
37
|
+
await sender.updateMessage("private:user-1", messageId, "第一段", "streaming", undefined, "codex");
|
|
38
|
+
await sender.updateMessage("private:user-1", messageId, "第一段\n第二段", "streaming", "耗时 1.2s", "codex");
|
|
39
|
+
await sender.sendFinalMessages("private:user-1", messageId, "最终答案", "耗时 1.2s", "codex");
|
|
32
40
|
expect(sendPrivateMessageMock).toHaveBeenCalledTimes(1);
|
|
33
|
-
expect(sendPrivateMessageMock.mock.calls[0][
|
|
34
|
-
expect(sendPrivateMessageMock.mock.calls[0][1]).
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const sender = await import("./message-sender.js");
|
|
38
|
-
const messageId = await sender.sendThinkingMessage("private:user-1", undefined, "codex");
|
|
39
|
-
await sender.updateMessage("private:user-1", messageId, "这是比较长的前置内容,用来模拟思考流。", "streaming", undefined, "codex");
|
|
40
|
-
await sender.updateMessage("private:user-1", messageId, "短答案", "streaming", undefined, "codex");
|
|
41
|
-
expect(sendPrivateMessageMock).toHaveBeenCalledTimes(2);
|
|
42
|
-
expect(sendPrivateMessageMock.mock.calls[1][1]).toContain("短答案");
|
|
41
|
+
expect(sendPrivateMessageMock.mock.calls[0][0]).toBe("user-1");
|
|
42
|
+
expect(sendPrivateMessageMock.mock.calls[0][1]).toContain("最终答案");
|
|
43
|
+
expect(sendPrivateMessageMock.mock.calls[0][1]).not.toContain("第一段");
|
|
44
|
+
expect(sendPrivateMessageMock.mock.calls[0][2]).toBe("reply-1");
|
|
43
45
|
});
|
|
44
46
|
});
|