oh-my-im 0.1.2 → 0.1.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/README.md +2 -2
- package/dist/{codex.js → agents/codex-agent.js} +14 -10
- package/dist/agents/index.js +20 -0
- package/dist/agents/pi-agent.js +148 -0
- package/dist/agents/process-utils.js +40 -0
- package/dist/bot-app.js +349 -0
- package/dist/{omi-bot.js → bot-worker.js} +50 -3
- package/dist/config.js +2 -0
- package/dist/conversation-log.js +31 -0
- package/dist/dingtalk-card.js +3 -3
- package/dist/dingtalk.js +6 -4
- package/dist/dws-client.js +102 -0
- package/dist/dws-dashboard.js +48 -10
- package/dist/dws-listener.js +441 -266
- package/dist/monitor-command.js +44 -6
- package/dist/omi.js +1 -1
- package/package.json +5 -7
- package/dist/cli.js +0 -28
- package/dist/index.js +0 -221
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# oh-my-im
|
|
2
2
|
|
|
3
|
-
独立版钉钉机器人到 Codex CLI 桥接程序。
|
|
3
|
+
独立版钉钉机器人到 Codex CLI / Pi Agent 桥接程序。
|
|
4
4
|
|
|
5
5
|
## 使用
|
|
6
6
|
|
|
@@ -11,6 +11,6 @@ npm link
|
|
|
11
11
|
omi
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
首次运行会在 `~/.oh-my-im`
|
|
14
|
+
首次运行会在 `~/.oh-my-im` 创建本地配置;在管理页填写钉钉应用凭证、钉钉规则和机器人单聊授权人员,并选择 **Codex CLI** 或 **Pi Agent(Pi 默认模型)**。使用 Pi 前请确保本机 `pi` 命令已安装并完成 `/login`;可用 `PI_CLI_PATH` 覆盖命令路径。`omi` 的状态、日志和全部运行配置都固定保存在此目录,因此可在任意目录执行 `omi status`、`omi stop` 或 `omi update`。
|
|
15
15
|
|
|
16
16
|
详见 [docs/development.md](docs/development.md)。
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
-
import { createLogger } from "
|
|
3
|
+
import { createLogger } from "../logger.js";
|
|
4
|
+
import { createAgentEnv } from "./process-utils.js";
|
|
4
5
|
const log = createLogger("Codex");
|
|
5
6
|
function parseJsonLine(line) {
|
|
6
7
|
try {
|
|
@@ -55,15 +56,7 @@ export function runCodex(prompt, sessionId, config, callbacks = {}) {
|
|
|
55
56
|
return new Promise((resolve, reject) => {
|
|
56
57
|
const start = Date.now();
|
|
57
58
|
const args = buildArgs(prompt, config.codexWorkDir, sessionId, config);
|
|
58
|
-
const env =
|
|
59
|
-
if (config.codexProxy) {
|
|
60
|
-
env.HTTP_PROXY = config.codexProxy;
|
|
61
|
-
env.HTTPS_PROXY = config.codexProxy;
|
|
62
|
-
env.http_proxy = config.codexProxy;
|
|
63
|
-
env.https_proxy = config.codexProxy;
|
|
64
|
-
env.ALL_PROXY = config.codexProxy;
|
|
65
|
-
env.all_proxy = config.codexProxy;
|
|
66
|
-
}
|
|
59
|
+
const env = createAgentEnv(config.codexProxy);
|
|
67
60
|
log.info(`spawn ${config.codexCliPath} ${args.join(" ")}`);
|
|
68
61
|
const child = spawn(config.codexCliPath, args, {
|
|
69
62
|
cwd: config.codexWorkDir,
|
|
@@ -84,6 +77,17 @@ export function runCodex(prompt, sessionId, config, callbacks = {}) {
|
|
|
84
77
|
reject(new Error(`Codex CLI timeout after ${Math.round(config.cliTimeoutMs / 1000)}s`));
|
|
85
78
|
}, config.cliTimeoutMs);
|
|
86
79
|
timeout.unref();
|
|
80
|
+
callbacks.onAbortReady?.(() => {
|
|
81
|
+
if (!completed) {
|
|
82
|
+
child.kill("SIGTERM");
|
|
83
|
+
// Some child processes do not exit on SIGTERM. Ensure a user pause
|
|
84
|
+
// actually stops the current Codex task.
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
if (!completed)
|
|
87
|
+
child.kill("SIGKILL");
|
|
88
|
+
}, 1_000).unref();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
87
91
|
child.stdin.write(prompt);
|
|
88
92
|
child.stdin.end();
|
|
89
93
|
child.stderr.on("data", (chunk) => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { runCodex } from "./codex-agent.js";
|
|
2
|
+
import { runPi } from "./pi-agent.js";
|
|
3
|
+
export function agentLabel(agent) {
|
|
4
|
+
return agent === "pi" ? "Pi" : "Codex";
|
|
5
|
+
}
|
|
6
|
+
export function agentSwitchMessage(agent) {
|
|
7
|
+
const label = agentLabel(agent);
|
|
8
|
+
const source = agent === "pi"
|
|
9
|
+
? "Pi Agent(使用本机 Pi CLI)"
|
|
10
|
+
: "Codex Agent(使用本机 Codex CLI)";
|
|
11
|
+
return [
|
|
12
|
+
`当前已切换到 ${label}。\n`,
|
|
13
|
+
"使用方法:直接发送问题或任务即可;发送已配置的暂停指令可暂停当前任务,发送 Agent 切换指令可再次切换。",
|
|
14
|
+
].join("\n");
|
|
15
|
+
}
|
|
16
|
+
export function runAgent(agent, prompt, sessionId, config, callbacks = {}) {
|
|
17
|
+
return agent === "pi"
|
|
18
|
+
? runPi(prompt, sessionId, config, callbacks)
|
|
19
|
+
: runCodex(prompt, sessionId, config, callbacks);
|
|
20
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createLogger } from "../logger.js";
|
|
3
|
+
import { asObject, attachJsonlReader, createAgentEnv } from "./process-utils.js";
|
|
4
|
+
const log = createLogger("Pi");
|
|
5
|
+
function extractAssistantText(message) {
|
|
6
|
+
const value = asObject(message);
|
|
7
|
+
if (!value || value.role !== "assistant" || !Array.isArray(value.content))
|
|
8
|
+
return undefined;
|
|
9
|
+
const text = value.content.flatMap((part) => {
|
|
10
|
+
const item = asObject(part);
|
|
11
|
+
return item?.type === "text" && typeof item.text === "string" ? [item.text] : [];
|
|
12
|
+
}).join("\n");
|
|
13
|
+
return text || undefined;
|
|
14
|
+
}
|
|
15
|
+
export function runPi(prompt, sessionId, config, callbacks = {}) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
const args = ["--mode", "rpc", "--approve"];
|
|
19
|
+
if (sessionId)
|
|
20
|
+
args.push("--session", sessionId);
|
|
21
|
+
const env = createAgentEnv(config.codexProxy);
|
|
22
|
+
const cliPath = config.piCliPath || "pi";
|
|
23
|
+
log.info(`spawn ${cliPath} ${args.join(" ")}`);
|
|
24
|
+
const child = spawn(cliPath, args, {
|
|
25
|
+
cwd: config.codexWorkDir,
|
|
26
|
+
env,
|
|
27
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
28
|
+
});
|
|
29
|
+
let completed = false;
|
|
30
|
+
let accumulated = "";
|
|
31
|
+
let authoritativeText = "";
|
|
32
|
+
let nextSessionId = sessionId;
|
|
33
|
+
let stderr = "";
|
|
34
|
+
const toolStats = {};
|
|
35
|
+
const seenToolCalls = new Set();
|
|
36
|
+
const finish = () => {
|
|
37
|
+
if (completed)
|
|
38
|
+
return;
|
|
39
|
+
completed = true;
|
|
40
|
+
clearTimeout(timeout);
|
|
41
|
+
child.kill("SIGTERM");
|
|
42
|
+
resolve({
|
|
43
|
+
sessionId: nextSessionId,
|
|
44
|
+
text: (authoritativeText || accumulated).trim() || "(无输出)",
|
|
45
|
+
toolStats,
|
|
46
|
+
durationMs: Date.now() - start,
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
const fail = (message) => {
|
|
50
|
+
if (completed)
|
|
51
|
+
return;
|
|
52
|
+
completed = true;
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
child.kill("SIGTERM");
|
|
55
|
+
reject(new Error(message));
|
|
56
|
+
};
|
|
57
|
+
const timeout = setTimeout(() => {
|
|
58
|
+
log.warn(`timeout after ${config.cliTimeoutMs}ms`);
|
|
59
|
+
fail(`Pi Agent timeout after ${Math.round(config.cliTimeoutMs / 1000)}s`);
|
|
60
|
+
}, config.cliTimeoutMs);
|
|
61
|
+
timeout.unref();
|
|
62
|
+
callbacks.onAbortReady?.(() => {
|
|
63
|
+
if (!completed) {
|
|
64
|
+
child.stdin.write(`${JSON.stringify({ type: "abort" })}\n`);
|
|
65
|
+
setTimeout(() => child.kill("SIGTERM"), 1000).unref();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
callbacks.onSteerReady?.((message) => {
|
|
69
|
+
if (completed || child.stdin.destroyed || !child.stdin.writable)
|
|
70
|
+
return false;
|
|
71
|
+
try {
|
|
72
|
+
child.stdin.write(`${JSON.stringify({ type: "steer", message })}\n`);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
child.stderr.on("data", (chunk) => {
|
|
80
|
+
const text = chunk.toString();
|
|
81
|
+
stderr += text;
|
|
82
|
+
for (const line of text.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)) {
|
|
83
|
+
log.debug(`stderr: ${line.slice(0, 1_000)}`);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
attachJsonlReader(child.stdout, (line) => {
|
|
87
|
+
let event;
|
|
88
|
+
try {
|
|
89
|
+
event = JSON.parse(line);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
log.warn(`ignored non-JSON Pi output: ${line.slice(0, 240)}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (event.type === "response" && event.success === false) {
|
|
96
|
+
fail(typeof event.error === "string" ? event.error : "Pi Agent command failed");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (event.type === "response" && event.command === "get_state" && event.success === true) {
|
|
100
|
+
const data = asObject(event.data);
|
|
101
|
+
if (typeof data?.sessionId === "string" && data.sessionId)
|
|
102
|
+
nextSessionId = data.sessionId;
|
|
103
|
+
child.stdin.write(`${JSON.stringify({ id: "prompt", type: "prompt", message: prompt })}\n`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (event.type === "message_update") {
|
|
107
|
+
const update = asObject(event.assistantMessageEvent);
|
|
108
|
+
if (update?.type === "text_delta" && typeof update.delta === "string") {
|
|
109
|
+
accumulated += update.delta;
|
|
110
|
+
callbacks.onText?.(accumulated);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (event.type === "tool_execution_start") {
|
|
115
|
+
const name = typeof event.toolName === "string" ? event.toolName : "tool";
|
|
116
|
+
const callId = typeof event.toolCallId === "string" ? event.toolCallId : `${name}:${seenToolCalls.size}`;
|
|
117
|
+
if (!seenToolCalls.has(callId)) {
|
|
118
|
+
seenToolCalls.add(callId);
|
|
119
|
+
toolStats[name] = (toolStats[name] ?? 0) + 1;
|
|
120
|
+
callbacks.onToolUse?.(name, { ...toolStats });
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (event.type === "message_end") {
|
|
125
|
+
const text = extractAssistantText(event.message);
|
|
126
|
+
if (text)
|
|
127
|
+
authoritativeText = text;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (event.type === "agent_settled")
|
|
131
|
+
finish();
|
|
132
|
+
});
|
|
133
|
+
child.on("error", (err) => {
|
|
134
|
+
log.error("spawn error", err);
|
|
135
|
+
fail(err.message);
|
|
136
|
+
});
|
|
137
|
+
child.on("close", (code) => {
|
|
138
|
+
if (completed)
|
|
139
|
+
return;
|
|
140
|
+
if (code && code !== 0) {
|
|
141
|
+
fail(stderr.trim() || `Pi Agent exited with code ${code}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
finish();
|
|
145
|
+
});
|
|
146
|
+
child.stdin.write(`${JSON.stringify({ id: "state", type: "get_state" })}\n`);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
|
+
export function asObject(value) {
|
|
3
|
+
return value && typeof value === "object" ? value : undefined;
|
|
4
|
+
}
|
|
5
|
+
export function createAgentEnv(proxy) {
|
|
6
|
+
const env = { ...process.env };
|
|
7
|
+
if (!proxy)
|
|
8
|
+
return env;
|
|
9
|
+
env.HTTP_PROXY = proxy;
|
|
10
|
+
env.HTTPS_PROXY = proxy;
|
|
11
|
+
env.http_proxy = proxy;
|
|
12
|
+
env.https_proxy = proxy;
|
|
13
|
+
env.ALL_PROXY = proxy;
|
|
14
|
+
env.all_proxy = proxy;
|
|
15
|
+
return env;
|
|
16
|
+
}
|
|
17
|
+
/** Read strict LF-delimited JSONL without treating Unicode separators as records. */
|
|
18
|
+
export function attachJsonlReader(stream, onLine) {
|
|
19
|
+
const decoder = new StringDecoder("utf8");
|
|
20
|
+
let buffer = "";
|
|
21
|
+
stream.on("data", (chunk) => {
|
|
22
|
+
buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
|
|
23
|
+
while (true) {
|
|
24
|
+
const index = buffer.indexOf("\n");
|
|
25
|
+
if (index < 0)
|
|
26
|
+
break;
|
|
27
|
+
let line = buffer.slice(0, index);
|
|
28
|
+
buffer = buffer.slice(index + 1);
|
|
29
|
+
if (line.endsWith("\r"))
|
|
30
|
+
line = line.slice(0, -1);
|
|
31
|
+
if (line)
|
|
32
|
+
onLine(line);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
stream.on("end", () => {
|
|
36
|
+
buffer += decoder.end();
|
|
37
|
+
if (buffer)
|
|
38
|
+
onLine(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer);
|
|
39
|
+
});
|
|
40
|
+
}
|
package/dist/bot-app.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { loadConfig } from "./config.js";
|
|
2
|
+
import { agentLabel, agentSwitchMessage, runAgent } from "./agents/index.js";
|
|
3
|
+
import { DingTalkBot, isSingleConversation } from "./dingtalk.js";
|
|
4
|
+
import { createLogger } from "./logger.js";
|
|
5
|
+
import { parseAgentControlCommand } from "./monitor-command.js";
|
|
6
|
+
import { appendConversationLog } from "./conversation-log.js";
|
|
7
|
+
const log = createLogger("Main");
|
|
8
|
+
function getState(conversations, conversationId) {
|
|
9
|
+
const existing = conversations.get(conversationId);
|
|
10
|
+
if (existing)
|
|
11
|
+
return existing;
|
|
12
|
+
const created = { sessions: {}, busy: false };
|
|
13
|
+
conversations.set(conversationId, created);
|
|
14
|
+
return created;
|
|
15
|
+
}
|
|
16
|
+
function isAllowed(message, allowedUserIds) {
|
|
17
|
+
// Single-chat authorization is deny-by-default. Only users explicitly added
|
|
18
|
+
// in the dashboard may use the bot; an empty list allows nobody.
|
|
19
|
+
if (allowedUserIds.length === 0)
|
|
20
|
+
return false;
|
|
21
|
+
// DingTalk may provide both a staffId and an open userId. The dashboard
|
|
22
|
+
// stores the openDingTalkId returned by dws, so accept either identifier.
|
|
23
|
+
const senderIds = [message.senderStaffId, message.senderId]
|
|
24
|
+
.filter((id) => Boolean(id?.trim()))
|
|
25
|
+
.map((id) => id.trim());
|
|
26
|
+
return senderIds.some((id) => allowedUserIds.includes(id));
|
|
27
|
+
}
|
|
28
|
+
function formatStats(stats) {
|
|
29
|
+
const entries = Object.entries(stats);
|
|
30
|
+
if (entries.length === 0)
|
|
31
|
+
return "";
|
|
32
|
+
return entries.map(([name, count]) => `${name} x${count}`).join(", ");
|
|
33
|
+
}
|
|
34
|
+
function buildCardContent(content, note) {
|
|
35
|
+
const safeContent = content.trim() || "Codex 正在处理...";
|
|
36
|
+
const safeNote = note?.trim();
|
|
37
|
+
return safeNote ? `${safeContent}\n\n---\n${safeNote}` : safeContent;
|
|
38
|
+
}
|
|
39
|
+
function describeAttachment(attachment) {
|
|
40
|
+
const parts = [
|
|
41
|
+
`类型: ${attachment.type}`,
|
|
42
|
+
`路径: ${attachment.path}`,
|
|
43
|
+
];
|
|
44
|
+
if (attachment.fileName)
|
|
45
|
+
parts.push(`文件名: ${attachment.fileName}`);
|
|
46
|
+
if (attachment.contentType)
|
|
47
|
+
parts.push(`Content-Type: ${attachment.contentType}`);
|
|
48
|
+
if (attachment.size)
|
|
49
|
+
parts.push(`大小: ${attachment.size} bytes`);
|
|
50
|
+
if (attachment.duration)
|
|
51
|
+
parts.push(`时长: ${attachment.duration}ms`);
|
|
52
|
+
if (attachment.recognition)
|
|
53
|
+
parts.push(`语音识别: ${attachment.recognition}`);
|
|
54
|
+
return parts.join("\n");
|
|
55
|
+
}
|
|
56
|
+
async function buildCodexPrompt(bot, message) {
|
|
57
|
+
if (message.msgtype === "text")
|
|
58
|
+
return message.text.trim();
|
|
59
|
+
const downloaded = await bot.downloadAttachments(message);
|
|
60
|
+
const attachmentText = downloaded.map(describeAttachment).join("\n\n");
|
|
61
|
+
const recognizedText = message.attachments
|
|
62
|
+
.map((item) => item.recognition)
|
|
63
|
+
.filter((item) => Boolean(item))
|
|
64
|
+
.join("\n");
|
|
65
|
+
const text = [message.text, recognizedText].filter(Boolean).join("\n").trim();
|
|
66
|
+
if (message.msgtype === "audio" || message.msgtype === "voice") {
|
|
67
|
+
if (text) {
|
|
68
|
+
return [
|
|
69
|
+
"用户发送了一段语音,钉钉识别文本如下:",
|
|
70
|
+
text,
|
|
71
|
+
attachmentText ? `\n语音文件信息:\n${attachmentText}` : "",
|
|
72
|
+
"\n请根据语音识别文本回复用户。如果需要,也可以参考本地语音文件路径。",
|
|
73
|
+
].filter(Boolean).join("\n");
|
|
74
|
+
}
|
|
75
|
+
return [
|
|
76
|
+
"用户发送了一段语音,但钉钉消息里没有提供语音识别文本。",
|
|
77
|
+
attachmentText ? `语音文件已下载:\n${attachmentText}` : "语音文件未能下载。",
|
|
78
|
+
"请尝试根据本地文件分析;如果当前环境不支持音频识别,请明确告诉用户需要文字或可识别语音。",
|
|
79
|
+
].join("\n\n");
|
|
80
|
+
}
|
|
81
|
+
if (message.msgtype === "picture" || message.msgtype === "image") {
|
|
82
|
+
return [
|
|
83
|
+
"用户发送了图片。",
|
|
84
|
+
attachmentText ? `图片已下载到本地:\n${attachmentText}` : "图片未能下载。",
|
|
85
|
+
text ? `随图文字:\n${text}` : "",
|
|
86
|
+
"请分析图片内容并回复用户。",
|
|
87
|
+
].filter(Boolean).join("\n\n");
|
|
88
|
+
}
|
|
89
|
+
if (message.msgtype === "richText") {
|
|
90
|
+
return [
|
|
91
|
+
"用户发送了富文本消息。",
|
|
92
|
+
text ? `文本内容:\n${text}` : "",
|
|
93
|
+
attachmentText ? `附件信息:\n${attachmentText}` : "",
|
|
94
|
+
"请根据以上内容回复用户。",
|
|
95
|
+
].filter(Boolean).join("\n\n");
|
|
96
|
+
}
|
|
97
|
+
if (downloaded.length > 0) {
|
|
98
|
+
return [
|
|
99
|
+
`用户发送了 ${message.msgtype} 消息。`,
|
|
100
|
+
`附件已下载到本地:\n${attachmentText}`,
|
|
101
|
+
text ? `附带文本:\n${text}` : "",
|
|
102
|
+
"请根据附件内容回复用户;如果当前 Codex 环境无法直接读取该类型文件,请说明已收到文件及本地路径。",
|
|
103
|
+
].filter(Boolean).join("\n\n");
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`暂不支持 ${message.msgtype} 消息:钉钉没有提供可处理的文本或附件下载信息。`);
|
|
106
|
+
}
|
|
107
|
+
async function handleCommand(bot, config, conversations, message, text) {
|
|
108
|
+
const state = getState(conversations, message.conversationId);
|
|
109
|
+
if (text === "/help") {
|
|
110
|
+
await bot.sendText(message.conversationId, [
|
|
111
|
+
"oh-my-im commands:",
|
|
112
|
+
"/help - 查看帮助",
|
|
113
|
+
"/status - 查看运行状态",
|
|
114
|
+
"/new - 清空当前会话的 Codex/Pi session",
|
|
115
|
+
].join("\n"));
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (text === "/status") {
|
|
119
|
+
await bot.sendText(message.conversationId, [
|
|
120
|
+
"oh-my-im status:",
|
|
121
|
+
`Agent: ${agentLabel(config.agent)}`,
|
|
122
|
+
`Codex CLI: ${config.codexCliPath}`,
|
|
123
|
+
`Pi CLI: ${config.piCliPath ?? "pi"}`,
|
|
124
|
+
`WorkDir: ${config.codexWorkDir}`,
|
|
125
|
+
`Current session: ${state.sessions[config.agent] ?? "new"}`,
|
|
126
|
+
`Known conversations: ${conversations.size}`,
|
|
127
|
+
].join("\n"));
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
if (text === "/new") {
|
|
131
|
+
state.sessions = {};
|
|
132
|
+
await bot.sendText(message.conversationId, "已清空当前会话的 Agent session。");
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
export async function runApp(configOverride, options = {}) {
|
|
138
|
+
const config = configOverride ?? loadConfig();
|
|
139
|
+
const bot = new DingTalkBot(config);
|
|
140
|
+
const conversations = new Map();
|
|
141
|
+
async function handleMessage(message) {
|
|
142
|
+
log.info(`received message conversation=${message.conversationId} conversationType=${message.conversationType ?? "<none>"} msgtype=${message.msgtype} senderNick=${message.senderNick ?? "<none>"} senderId=${message.senderId} senderStaffId=${message.senderStaffId ?? "<none>"} text=${JSON.stringify(message.text.slice(0, 500))} textLen=${message.text.length} attachmentCount=${message.attachments.length}`);
|
|
143
|
+
if (options.singleChatOnly && !isSingleConversation(message.conversationType)) {
|
|
144
|
+
log.debug(`ignored non-single conversation=${message.conversationId} type=${message.conversationType ?? "unknown"}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const allowedUserIds = options.getAllowedUserIds?.() ?? config.allowedUserIds;
|
|
148
|
+
const allowed = isAllowed(message, allowedUserIds);
|
|
149
|
+
log.info(`permission check conversation=${message.conversationId} senderNick=${message.senderNick ?? "<none>"} senderId=${message.senderId} senderStaffId=${message.senderStaffId ?? "<none>"} allowedCount=${allowedUserIds.length} allowed=[${allowedUserIds.join(",")}] result=${allowed ? "ALLOW" : "DENY"}`);
|
|
150
|
+
if (!allowed) {
|
|
151
|
+
const receivedIds = [message.senderStaffId, message.senderId].filter(Boolean).join(", ");
|
|
152
|
+
await bot.sendText(message.conversationId, `抱歉,您没有访问权限。\n收到的 ID: ${receivedIds}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const text = message.text.trim();
|
|
156
|
+
if (!text && message.attachments.length === 0 && message.msgtype !== "richText")
|
|
157
|
+
return;
|
|
158
|
+
if (message.msgtype === "text" || message.msgtype === "richText") {
|
|
159
|
+
const keywords = options.getCommandKeywords?.();
|
|
160
|
+
const control = keywords ? parseAgentControlCommand(text, keywords) : undefined;
|
|
161
|
+
if (control === "pause") {
|
|
162
|
+
const state = getState(conversations, message.conversationId);
|
|
163
|
+
if (!state.busy || !state.abort) {
|
|
164
|
+
await bot.sendText(message.conversationId, "当前没有正在处理的 Agent 任务。");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
state.paused = true;
|
|
168
|
+
state.abort();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (control && typeof control === "object") {
|
|
172
|
+
await options.setAgent?.(control.agent);
|
|
173
|
+
await bot.sendText(message.conversationId, agentSwitchMessage(control.agent));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (message.msgtype === "text" && text.startsWith("/")) {
|
|
178
|
+
log.info(`command=${text} conversation=${message.conversationId}`);
|
|
179
|
+
}
|
|
180
|
+
const selectedAgent = options.getAgent?.() ?? config.agent;
|
|
181
|
+
const currentConfig = selectedAgent === config.agent ? config : { ...config, agent: selectedAgent };
|
|
182
|
+
if (message.msgtype === "text" && await handleCommand(bot, currentConfig, conversations, message, text))
|
|
183
|
+
return;
|
|
184
|
+
const state = getState(conversations, message.conversationId);
|
|
185
|
+
if (state.busy) {
|
|
186
|
+
if (state.activeAgent === "pi" && state.steer && text) {
|
|
187
|
+
const steered = state.steer(text);
|
|
188
|
+
await bot.sendText(message.conversationId, steered
|
|
189
|
+
? "已将这条消息作为引导发送给当前 Pi 任务。"
|
|
190
|
+
: "当前 Pi 任务暂时无法接收引导,消息已排队等待处理。");
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
await bot.sendText(message.conversationId, "当前 Agent 不支持运行中引导,请等待任务结束,或先发送暂停指令。");
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
state.busy = true;
|
|
198
|
+
state.paused = false;
|
|
199
|
+
state.activeAgent = selectedAgent;
|
|
200
|
+
const label = agentLabel(selectedAgent);
|
|
201
|
+
const taskStartedAt = Date.now();
|
|
202
|
+
const formatElapsed = () => {
|
|
203
|
+
const totalSeconds = Math.max(0, Math.floor((Date.now() - taskStartedAt) / 1000));
|
|
204
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
205
|
+
const seconds = totalSeconds % 60;
|
|
206
|
+
return minutes > 0 ? `${minutes}分${seconds}秒` : `${seconds}秒`;
|
|
207
|
+
};
|
|
208
|
+
const title = `【${label}】`;
|
|
209
|
+
const processingTitle = () => `🔵 ${title}处理中... (${formatElapsed()})`;
|
|
210
|
+
const reply = await bot.sendThinkingCard(message, `${label} 正在处理...`, processingTitle());
|
|
211
|
+
let elapsedTimer;
|
|
212
|
+
let latestCardContent = `${label} 正在处理...`;
|
|
213
|
+
const agent = selectedAgent;
|
|
214
|
+
let prompt = "";
|
|
215
|
+
try {
|
|
216
|
+
let latestStats = {};
|
|
217
|
+
let lastUpdateAt = 0;
|
|
218
|
+
const cardUpdateInterval = selectedAgent === "pi" ? 1_000 : 500;
|
|
219
|
+
let pendingUpdate;
|
|
220
|
+
const updateCard = (title, content, force = false) => {
|
|
221
|
+
latestCardContent = content;
|
|
222
|
+
if (reply.mode !== "card")
|
|
223
|
+
return;
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
const run = () => {
|
|
226
|
+
lastUpdateAt = Date.now();
|
|
227
|
+
pendingUpdate = undefined;
|
|
228
|
+
bot.updateReply(reply, title, content, { fallbackToText: false }).catch((err) => {
|
|
229
|
+
log.warn(`card update skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
if (force || now - lastUpdateAt >= cardUpdateInterval) {
|
|
233
|
+
if (pendingUpdate) {
|
|
234
|
+
clearTimeout(pendingUpdate);
|
|
235
|
+
pendingUpdate = undefined;
|
|
236
|
+
}
|
|
237
|
+
run();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!pendingUpdate) {
|
|
241
|
+
pendingUpdate = setTimeout(run, cardUpdateInterval - (now - lastUpdateAt));
|
|
242
|
+
pendingUpdate.unref();
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
elapsedTimer = setInterval(() => {
|
|
246
|
+
updateCard(processingTitle(), latestCardContent);
|
|
247
|
+
}, 1_000);
|
|
248
|
+
elapsedTimer.unref();
|
|
249
|
+
prompt = await buildCodexPrompt(bot, message);
|
|
250
|
+
const result = await runAgent(agent, prompt, state.sessions[agent], config, {
|
|
251
|
+
onAbortReady: (abort) => { state.abort = abort; },
|
|
252
|
+
onSteerReady: (steer) => { state.steer = steer; },
|
|
253
|
+
onText: (content) => {
|
|
254
|
+
const stats = formatStats(latestStats);
|
|
255
|
+
updateCard(processingTitle(), buildCardContent(content, stats ? `工具:${stats}` : undefined));
|
|
256
|
+
},
|
|
257
|
+
onToolUse: (_toolName, stats) => {
|
|
258
|
+
latestStats = stats;
|
|
259
|
+
const statsText = formatStats(stats);
|
|
260
|
+
updateCard(processingTitle(), buildCardContent(`${label} 正在处理...`, statsText ? `工具:${statsText}` : undefined));
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
if (elapsedTimer) {
|
|
264
|
+
clearInterval(elapsedTimer);
|
|
265
|
+
elapsedTimer = undefined;
|
|
266
|
+
}
|
|
267
|
+
if (state.paused)
|
|
268
|
+
throw new Error("Agent task paused by user");
|
|
269
|
+
if (pendingUpdate) {
|
|
270
|
+
clearTimeout(pendingUpdate);
|
|
271
|
+
}
|
|
272
|
+
state.sessions[agent] = result.sessionId ?? state.sessions[agent];
|
|
273
|
+
const stats = formatStats(result.toolStats);
|
|
274
|
+
const note = [`Agent ${label}`, `耗时 ${(result.durationMs / 1000).toFixed(1)}s`, stats].filter(Boolean).join(" | ");
|
|
275
|
+
await bot.updateReply(reply, `✅ ${title}完成 总耗时 ${formatElapsed()}`, buildCardContent(result.text, note));
|
|
276
|
+
await appendConversationLog({
|
|
277
|
+
id: `${message.conversationId}:${taskStartedAt}`,
|
|
278
|
+
createdAt: new Date().toISOString(),
|
|
279
|
+
conversationType: "personal",
|
|
280
|
+
conversationName: message.senderNick || message.senderId,
|
|
281
|
+
groupId: message.conversationId,
|
|
282
|
+
groupName: message.senderNick || message.senderId,
|
|
283
|
+
status: "completed",
|
|
284
|
+
agent,
|
|
285
|
+
question: prompt,
|
|
286
|
+
senderNames: [message.senderNick || message.senderId],
|
|
287
|
+
senderDetails: [{ senderName: message.senderNick || message.senderId, senderId: message.senderStaffId || message.senderId, content: prompt }],
|
|
288
|
+
content: result.text,
|
|
289
|
+
messageCount: 1,
|
|
290
|
+
}, [{ senderName: message.senderNick || message.senderId, senderId: message.senderStaffId || message.senderId, content: prompt }]);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
if (elapsedTimer) {
|
|
294
|
+
clearInterval(elapsedTimer);
|
|
295
|
+
elapsedTimer = undefined;
|
|
296
|
+
}
|
|
297
|
+
if (state.paused) {
|
|
298
|
+
log.info(`${label} task paused by user`);
|
|
299
|
+
await bot.updateReply(reply, `🔴 ${title}处理暂停 总耗时 ${formatElapsed()}`, latestCardContent);
|
|
300
|
+
await appendConversationLog({
|
|
301
|
+
id: `${message.conversationId}:${taskStartedAt}`,
|
|
302
|
+
createdAt: new Date().toISOString(), conversationType: "personal",
|
|
303
|
+
conversationName: message.senderNick || message.senderId, groupId: message.conversationId,
|
|
304
|
+
groupName: message.senderNick || message.senderId, status: "failed", agent,
|
|
305
|
+
question: prompt, senderNames: [message.senderNick || message.senderId],
|
|
306
|
+
senderDetails: [{ senderName: message.senderNick || message.senderId, senderId: message.senderStaffId || message.senderId, content: prompt }],
|
|
307
|
+
content: latestCardContent, messageCount: 1,
|
|
308
|
+
}, [{ senderName: message.senderNick || message.senderId, senderId: message.senderStaffId || message.senderId, content: prompt }]);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
log.error(`${label} execution failed`, err);
|
|
312
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
313
|
+
await bot.updateReply(reply, `❌ ${title}处理失败 总耗时 ${formatElapsed()}`, `${label} 执行失败:${errorMessage}`);
|
|
314
|
+
await appendConversationLog({
|
|
315
|
+
id: `${message.conversationId}:${taskStartedAt}`,
|
|
316
|
+
createdAt: new Date().toISOString(),
|
|
317
|
+
conversationType: "personal",
|
|
318
|
+
conversationName: message.senderNick || message.senderId,
|
|
319
|
+
groupId: message.conversationId,
|
|
320
|
+
groupName: message.senderNick || message.senderId,
|
|
321
|
+
status: "failed",
|
|
322
|
+
agent,
|
|
323
|
+
question: prompt,
|
|
324
|
+
senderNames: [message.senderNick || message.senderId],
|
|
325
|
+
senderDetails: [{ senderName: message.senderNick || message.senderId, senderId: message.senderStaffId || message.senderId, content: prompt }],
|
|
326
|
+
content: errorMessage,
|
|
327
|
+
messageCount: 1,
|
|
328
|
+
}, [{ senderName: message.senderNick || message.senderId, senderId: message.senderStaffId || message.senderId, content: prompt }]);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
state.busy = false;
|
|
333
|
+
state.abort = undefined;
|
|
334
|
+
state.steer = undefined;
|
|
335
|
+
state.activeAgent = undefined;
|
|
336
|
+
state.paused = false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
process.once("SIGINT", () => {
|
|
340
|
+
bot.stop();
|
|
341
|
+
process.exit(0);
|
|
342
|
+
});
|
|
343
|
+
process.once("SIGTERM", () => {
|
|
344
|
+
bot.stop();
|
|
345
|
+
process.exit(0);
|
|
346
|
+
});
|
|
347
|
+
await bot.start(handleMessage);
|
|
348
|
+
log.info(`ready workDir=${config.codexWorkDir} codex=${config.codexCliPath}`);
|
|
349
|
+
}
|