oh-my-im 0.1.5 → 0.1.7
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/bot-app.js +12 -3
- package/dist/bot-worker.js +18 -3
- package/dist/dashboard-worker.js +118 -0
- package/dist/dws-client.js +19 -6
- package/dist/dws-dashboard.js +7 -8
- package/dist/dws-history.js +41 -24
- package/dist/dws-listener.js +55 -70
- package/dist/omi.js +5 -1
- package/package.json +1 -1
package/dist/bot-app.js
CHANGED
|
@@ -534,8 +534,17 @@ export async function runApp(configOverride, options = {}) {
|
|
|
534
534
|
return minutes > 0 ? `${minutes}分${seconds}秒` : `${seconds}秒`;
|
|
535
535
|
};
|
|
536
536
|
const title = `【${label}】`;
|
|
537
|
-
|
|
538
|
-
|
|
537
|
+
// Keep the title lightweight. Detailed phases are already shown in the
|
|
538
|
+
// card body; only alternate the icon as a heartbeat to avoid duplicate text.
|
|
539
|
+
let heartbeat = 0;
|
|
540
|
+
const processingTitle = () => {
|
|
541
|
+
const heartbeatIcon = heartbeat++ % 2 === 0 ? "🔵" : "🔷";
|
|
542
|
+
const elapsed = options.getShowElapsed?.() === false ? "" : ` ${formatElapsed()}`;
|
|
543
|
+
return `${heartbeatIcon} ${title}处理中...${elapsed}`;
|
|
544
|
+
};
|
|
545
|
+
// The setting only controls the live processing title. Completion always
|
|
546
|
+
// includes the elapsed time as a useful final result summary.
|
|
547
|
+
const finishedTitle = (icon, state) => `${icon} ${title}${state} 总耗时 ${formatElapsed()}`;
|
|
539
548
|
const reply = await bot.sendThinkingCard(message, `${label} 正在处理...`, processingTitle());
|
|
540
549
|
let elapsedTimer;
|
|
541
550
|
let latestCardContent = `${label} 正在处理...`;
|
|
@@ -585,7 +594,7 @@ export async function runApp(configOverride, options = {}) {
|
|
|
585
594
|
const stats = formatStats(latestStats);
|
|
586
595
|
updateCard(processingTitle(), buildCardContent(content, stats ? `工具:${stats}` : undefined));
|
|
587
596
|
},
|
|
588
|
-
onToolUse: (
|
|
597
|
+
onToolUse: (toolName, stats) => {
|
|
589
598
|
latestStats = stats;
|
|
590
599
|
const statsText = formatStats(stats);
|
|
591
600
|
updateCard(processingTitle(), buildCardContent(`${label} 正在处理...`, statsText ? `工具:${statsText}` : undefined));
|
package/dist/bot-worker.js
CHANGED
|
@@ -47,9 +47,14 @@ function loadBotConfig() {
|
|
|
47
47
|
const configuredNames = credentials.botAllowedUserNames ?? {};
|
|
48
48
|
const allowedUserIds = [...new Set(configuredIds.map((id) => id.trim()).filter(Boolean))];
|
|
49
49
|
if (allowedUserIds.length === 0) {
|
|
50
|
-
|
|
50
|
+
// A fresh installation should still start so the management page can be
|
|
51
|
+
// used to configure the first authorized person. All incoming private
|
|
52
|
+
// messages remain denied by runApp until an ID is added.
|
|
53
|
+
console.log("[OmiBot] no private-chat authorization users configured; worker started in deny-all mode");
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.log(`[OmiBot] loaded authorization users=${allowedUserIds.length} ids=[${allowedUserIds.join(",")}] names=${JSON.stringify(configuredNames)}`);
|
|
51
57
|
}
|
|
52
|
-
console.log(`[OmiBot] loaded authorization users=${allowedUserIds.length} ids=[${allowedUserIds.join(",")}] names=${JSON.stringify(configuredNames)}`);
|
|
53
58
|
return {
|
|
54
59
|
dingtalkClientId: clientId,
|
|
55
60
|
dingtalkClientSecret: clientSecret,
|
|
@@ -92,7 +97,17 @@ process.once("exit", () => {
|
|
|
92
97
|
});
|
|
93
98
|
try {
|
|
94
99
|
const config = loadBotConfig();
|
|
95
|
-
|
|
100
|
+
// Publish the configured state immediately. On startup the stream is not
|
|
101
|
+
// connected yet, and a disabled stream must not briefly appear enabled.
|
|
102
|
+
const initiallyEnabled = (() => {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(readFileSync(dashboardConfigFile, "utf8")).privateChatEnabled !== false;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
writeBotStatus(initiallyEnabled, false);
|
|
96
111
|
await runApp(config, {
|
|
97
112
|
singleChatOnly: true,
|
|
98
113
|
getAgent: () => {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, writeFile, rename, readdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { startDashboard, } from "./dws-dashboard.js";
|
|
6
|
+
import { getDwsAuthStatus, getCurrentDwsUser, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, searchGroups, listGroupMembers, searchUsers, searchBots, } from "./dws-client.js";
|
|
7
|
+
const dataDir = join(homedir(), ".oh-my-im");
|
|
8
|
+
const configFile = join(dataDir, "dws-dashboard.json");
|
|
9
|
+
const serverFile = join(dataDir, "dws-dashboard-server.json");
|
|
10
|
+
const botStatusFile = join(dataDir, "omi-bot-status.json");
|
|
11
|
+
const repliesDir = join(dataDir, "replies");
|
|
12
|
+
const legacyRepliesFile = join(dataDir, "dws-replies.json");
|
|
13
|
+
const defaultConfig = () => ({
|
|
14
|
+
privateChatEnabled: true,
|
|
15
|
+
cardUpdateIntervalMs: 3_000,
|
|
16
|
+
showElapsed: true,
|
|
17
|
+
historyGroupLimit: 10,
|
|
18
|
+
historyMessageLimit: 20,
|
|
19
|
+
historyPollIntervalSeconds: 5,
|
|
20
|
+
targets: [], botAllowedUserIds: [], botAllowedUserNames: {},
|
|
21
|
+
botSuperAdminUserIds: [], botSuperAdminUserNames: {}, robotSenderOpenDingTalkId: "",
|
|
22
|
+
commandKeywords: { pause: [], monitorOpen: [], monitorStop: [], switchPi: [], switchCodex: [] },
|
|
23
|
+
groupPromptPrefix: "", replyFormat: "markdown", robotName: "AI Agent",
|
|
24
|
+
clientId: "", clientSecret: "", agent: "codex",
|
|
25
|
+
});
|
|
26
|
+
async function loadConfig() {
|
|
27
|
+
try {
|
|
28
|
+
const value = JSON.parse(await readFile(configFile, "utf8"));
|
|
29
|
+
return { ...defaultConfig(), ...value, commandKeywords: { ...defaultConfig().commandKeywords, ...(value.commandKeywords ?? {}) } };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return defaultConfig();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function saveConfig(config) {
|
|
36
|
+
await mkdir(dataDir, { recursive: true });
|
|
37
|
+
const temporary = `${configFile}.${process.pid}.tmp`;
|
|
38
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
39
|
+
await rename(temporary, configFile);
|
|
40
|
+
}
|
|
41
|
+
async function loadServerConfig() {
|
|
42
|
+
try {
|
|
43
|
+
const value = JSON.parse(await readFile(serverFile, "utf8"));
|
|
44
|
+
if (Number.isInteger(value.port) && value.port > 0 && typeof value.host === "string")
|
|
45
|
+
return { port: value.port, host: value.host };
|
|
46
|
+
}
|
|
47
|
+
catch { /* use defaults */ }
|
|
48
|
+
const config = { port: 12525, host: "127.0.0.1" };
|
|
49
|
+
await mkdir(dataDir, { recursive: true });
|
|
50
|
+
await writeFile(serverFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
function validReplies(value) {
|
|
54
|
+
if (!Array.isArray(value))
|
|
55
|
+
return [];
|
|
56
|
+
return value.filter((item) => Boolean(item && typeof item === "object" &&
|
|
57
|
+
typeof item.id === "string" && typeof item.content === "string" &&
|
|
58
|
+
(item.status === "completed" || item.status === "failed")));
|
|
59
|
+
}
|
|
60
|
+
async function loadReplies() {
|
|
61
|
+
const records = [];
|
|
62
|
+
try {
|
|
63
|
+
const files = (await readdir(repliesDir)).filter((file) => /^\\d{4}-\\d{2}-\\d{2}\\.json$/.test(file));
|
|
64
|
+
for (const file of files) {
|
|
65
|
+
try {
|
|
66
|
+
records.push(...validReplies(JSON.parse(await readFile(join(repliesDir, file), "utf8"))));
|
|
67
|
+
}
|
|
68
|
+
catch { /* ignore a file being written concurrently */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch { /* the directory does not exist on first run */ }
|
|
72
|
+
try {
|
|
73
|
+
records.push(...validReplies(JSON.parse(await readFile(legacyRepliesFile, "utf8"))));
|
|
74
|
+
}
|
|
75
|
+
catch { /* legacy file is optional */ }
|
|
76
|
+
return [...new Map(records.map((item) => [item.id, item])).values()]
|
|
77
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 100);
|
|
78
|
+
}
|
|
79
|
+
async function botStatus() {
|
|
80
|
+
const config = await loadConfig();
|
|
81
|
+
try {
|
|
82
|
+
const value = JSON.parse(await readFile(botStatusFile, "utf8"));
|
|
83
|
+
return { enabled: config.privateChatEnabled !== false, connected: config.privateChatEnabled !== false && value.connected !== false, updatedAt: value.updatedAt };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { enabled: config.privateChatEnabled !== false, connected: false };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function main() {
|
|
90
|
+
// Do not create a partial config here. The listener owns initialization and
|
|
91
|
+
// migration; this process only reads it and writes complete configurations
|
|
92
|
+
// submitted by the dashboard.
|
|
93
|
+
let config = await loadConfig();
|
|
94
|
+
const serverConfig = await loadServerConfig();
|
|
95
|
+
const runtime = { startedAt: new Date().toISOString(), eventConnected: false, activeBatches: 0 };
|
|
96
|
+
let replies = await loadReplies();
|
|
97
|
+
const replyReloadTimer = setInterval(() => {
|
|
98
|
+
void loadReplies().then((latest) => { replies = latest; }).catch(() => undefined);
|
|
99
|
+
}, 1_000);
|
|
100
|
+
replyReloadTimer.unref();
|
|
101
|
+
startDashboard(serverConfig.port, {
|
|
102
|
+
getConfig: () => config,
|
|
103
|
+
updateConfig: async (next) => { await saveConfig(next); config = next; },
|
|
104
|
+
getStatus: () => ({ ...runtime }),
|
|
105
|
+
// Replies are persisted by the listener/bot. The dashboard remains usable
|
|
106
|
+
// even when those workers are absent or have not been configured yet.
|
|
107
|
+
getReplies: () => replies,
|
|
108
|
+
searchGroups, listGroupMembers, searchUsers, searchBots,
|
|
109
|
+
getCurrentDwsUser: async () => ({ ...(await getCurrentDwsUser()), auth: await getDwsAuthStatus() }),
|
|
110
|
+
getDwsAuthStatus, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, getBotStatus: botStatus,
|
|
111
|
+
}, { host: serverConfig.host });
|
|
112
|
+
console.log(`[OmiDashboard] dashboard started at http://${serverConfig.host}:${serverConfig.port}`);
|
|
113
|
+
}
|
|
114
|
+
main().catch((err) => {
|
|
115
|
+
console.error(`[OmiDashboard] ${err instanceof Error ? err.stack || err.message : String(err)}`);
|
|
116
|
+
// The web process must not take down the manager because an optional status
|
|
117
|
+
// provider (for example dws on a fresh machine) is unavailable.
|
|
118
|
+
});
|
package/dist/dws-client.js
CHANGED
|
@@ -29,7 +29,8 @@ export async function listGroupMessages(groupId, from, limit = 20) {
|
|
|
29
29
|
"chat", "message", "list", "--group", groupId, "--time", formatDwsTime(from),
|
|
30
30
|
"--direction", "newer", "--limit", String(limit),
|
|
31
31
|
]);
|
|
32
|
-
|
|
32
|
+
const messages = result.messages ?? result.result?.messages ?? result.data?.messages ?? result.data?.result?.messages ?? [];
|
|
33
|
+
return messages.map((message) => ({
|
|
33
34
|
conversation_id: message.conversationId || message.openConversationId,
|
|
34
35
|
create_time: message.createTime,
|
|
35
36
|
message_id: message.messageId || message.openMessageId,
|
|
@@ -43,7 +44,8 @@ export async function listGroupMessages(groupId, from, limit = 20) {
|
|
|
43
44
|
}
|
|
44
45
|
export async function getCurrentDwsUser() {
|
|
45
46
|
const result = await runDwsJson(["contact", "+me"]);
|
|
46
|
-
|
|
47
|
+
const user = result.data ?? result;
|
|
48
|
+
return { userId: user.userId?.trim(), openDingTalkId: (user.openDingTalkId || user.openDingtalkId)?.trim(), name: user.name?.trim(), email: user.email?.trim(), dept: user.dept?.trim(), org: user.org?.trim() };
|
|
47
49
|
}
|
|
48
50
|
export async function getDwsAuthStatus() {
|
|
49
51
|
const result = await runDwsJson(["auth", "status"]);
|
|
@@ -101,8 +103,15 @@ export async function searchGroups(query) {
|
|
|
101
103
|
});
|
|
102
104
|
}
|
|
103
105
|
export async function searchUsers(query) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
+
// Keep this as the DWS contact search command. Do not use group members or
|
|
107
|
+
// any DingTalk HTTP API here; the value saved for robot authorization must be
|
|
108
|
+
// the contact userId returned by this command.
|
|
109
|
+
const result = await runDwsJson([
|
|
110
|
+
"contact", "+search-user", "--query", query,
|
|
111
|
+
]);
|
|
112
|
+
const users = result.users ?? result.items ?? result.data?.users ?? result.data?.items
|
|
113
|
+
?? result.data?.result?.users ?? result.data?.result?.items ?? [];
|
|
114
|
+
return users.flatMap((user) => {
|
|
106
115
|
// Robot callbacks expose userId as senderStaffId. Prefer it for one-to-one
|
|
107
116
|
// authorization; openDingTalkId belongs to a different identifier namespace.
|
|
108
117
|
const senderId = (user.userId || user.openDingtalkId || user.openDingTalkId)?.trim();
|
|
@@ -146,9 +155,13 @@ export async function listGroupMembers(groupId) {
|
|
|
146
155
|
}
|
|
147
156
|
export async function listConversations() {
|
|
148
157
|
const result = await runDwsJson([
|
|
149
|
-
"chat", "+chat-list", "--types", "group", "--page-size", "
|
|
158
|
+
"chat", "+chat-list", "--types", "group", "--page-size", "20",
|
|
150
159
|
]);
|
|
151
|
-
|
|
160
|
+
const data = result.data;
|
|
161
|
+
const nested = data?.result;
|
|
162
|
+
const chats = result.chats ?? data?.chats ?? nested?.chats;
|
|
163
|
+
return result.conversations ?? data?.conversations ?? nested?.conversations
|
|
164
|
+
?? chats?.map((chat) => ({ openConversationId: chat.openConversationId, conversationName: chat.name })) ?? [];
|
|
152
165
|
}
|
|
153
166
|
export function startGroupEventStream(eventName, maxEvents, groupId) {
|
|
154
167
|
const args = ["event", "consume", eventName, "--flatten", "--format", "ndjson"];
|
package/dist/dws-dashboard.js
CHANGED
|
@@ -61,12 +61,13 @@ function normalizeConfig(value) {
|
|
|
61
61
|
if (!Array.isArray(source.botAllowedUserIds))
|
|
62
62
|
throw new Error("请至少配置一名机器人单聊授权人员");
|
|
63
63
|
const botAllowedUserIds = [...new Set(source.botAllowedUserIds.filter((id) => typeof id === "string").map((id) => id.trim()).filter(Boolean))];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
// The dashboard is also the first-run setup screen. Allow an incomplete
|
|
65
|
+
// draft to be saved so a fresh machine can be configured before the bot
|
|
66
|
+
// worker has credentials or DWS login.
|
|
67
|
+
if (typeof source.robotName !== "string")
|
|
68
|
+
throw new Error("机器人名称格式无效");
|
|
69
|
+
if (typeof source.clientId !== "string")
|
|
70
|
+
throw new Error("Client ID 格式无效");
|
|
70
71
|
if (typeof source.clientSecret !== "string")
|
|
71
72
|
throw new Error("Client Secret 格式无效");
|
|
72
73
|
const botAllowedUserNames = source.botAllowedUserNames && typeof source.botAllowedUserNames === "object"
|
|
@@ -215,8 +216,6 @@ export function startDashboard(port, hooks, options = {}) {
|
|
|
215
216
|
if (!config.clientSecret)
|
|
216
217
|
config.clientSecret = hooks.getConfig().clientSecret;
|
|
217
218
|
console.log(`[Dashboard] saving config agent=${config.agent} targets=${config.targets.length} botUsers=${config.botAllowedUserIds.length}`);
|
|
218
|
-
if (!config.clientSecret)
|
|
219
|
-
throw new Error("请填写钉钉应用 Client Secret");
|
|
220
219
|
void hooks.updateConfig(config).then(() => {
|
|
221
220
|
console.log(`[Dashboard] config saved agent=${config.agent} targets=${config.targets.length}`);
|
|
222
221
|
sendJson(response, 200, { config: publicConfig(config), saved: true });
|
package/dist/dws-history.js
CHANGED
|
@@ -5,37 +5,38 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
|
|
5
5
|
const DEFAULT_GROUP_LIMIT = 10;
|
|
6
6
|
function senderName(event) {
|
|
7
7
|
const raw = event.sender;
|
|
8
|
-
if (typeof raw === "string")
|
|
9
|
-
return raw.trim();
|
|
10
8
|
if (!raw || typeof raw !== "object")
|
|
11
9
|
return "";
|
|
12
10
|
const sender = raw;
|
|
13
11
|
return [sender.name, sender.nick, sender.displayName]
|
|
14
|
-
.find((value) => typeof value === "string" && Boolean(value.trim()))
|
|
15
|
-
?.trim() || "";
|
|
12
|
+
.find((value) => typeof value === "string" && Boolean(value.trim()))?.trim() || "";
|
|
16
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Historical messages intentionally use one and only one identity rule:
|
|
16
|
+
* the sender name must equal `dws contact +me`'s name. Do not mix in the
|
|
17
|
+
* configured monitor sender ID, because that can accidentally accept another
|
|
18
|
+
* person's message when DWS returns different ID namespaces.
|
|
19
|
+
*/
|
|
17
20
|
function isCurrentUser(event, currentName) {
|
|
18
21
|
return Boolean(currentName && senderName(event) === currentName);
|
|
19
22
|
}
|
|
20
|
-
/**
|
|
21
|
-
* Single historical-message compensation loop.
|
|
22
|
-
* It reads only the first ten group chats, keeps at most twenty messages per
|
|
23
|
-
* group (the message client applies --limit 20), and only forwards messages
|
|
24
|
-
* authored by the current DWS login account. All command classification,
|
|
25
|
-
* de-duplication and permission checks remain in the listener's acceptEvent().
|
|
26
|
-
*/
|
|
27
23
|
export function startDwsHistoryPolling(options) {
|
|
28
24
|
let stopped = false;
|
|
29
25
|
let polling = false;
|
|
30
26
|
let currentName = "";
|
|
27
|
+
let lastUserResolveFailure = "";
|
|
31
28
|
const refreshCurrentUser = async () => {
|
|
32
29
|
try {
|
|
33
30
|
const user = await getCurrentDwsUser();
|
|
34
31
|
currentName = user.name?.trim() || "";
|
|
35
|
-
|
|
32
|
+
lastUserResolveFailure = "";
|
|
33
|
+
log.info(`current DWS user for history name=${currentName || "<none>"}`);
|
|
36
34
|
}
|
|
37
35
|
catch (err) {
|
|
38
|
-
|
|
36
|
+
const message = String(err);
|
|
37
|
+
if (message !== lastUserResolveFailure)
|
|
38
|
+
log.warn(`unable to resolve current DWS user for history: ${message}`);
|
|
39
|
+
lastUserResolveFailure = message;
|
|
39
40
|
}
|
|
40
41
|
};
|
|
41
42
|
const poll = async () => {
|
|
@@ -46,21 +47,35 @@ export function startDwsHistoryPolling(options) {
|
|
|
46
47
|
if (!currentName)
|
|
47
48
|
await refreshCurrentUser();
|
|
48
49
|
const config = options.getConfig();
|
|
49
|
-
const intervalSeconds = Number.isFinite(config.historyPollIntervalSeconds)
|
|
50
|
-
|
|
50
|
+
const intervalSeconds = Number.isFinite(config.historyPollIntervalSeconds)
|
|
51
|
+
? config.historyPollIntervalSeconds : DEFAULT_POLL_INTERVAL_SECONDS;
|
|
52
|
+
if (intervalSeconds <= 0 || !currentName)
|
|
51
53
|
return;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// Group discovery is deliberately kept to the DWS command requested by
|
|
55
|
+
// the user: dws chat +chat-list --types group --page-size 20.
|
|
56
|
+
let conversations = [];
|
|
57
|
+
try {
|
|
58
|
+
conversations = await listConversations();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
log.warn(`history group list failed: ${String(err)}`);
|
|
62
|
+
}
|
|
63
|
+
const groups = [...new Set(conversations
|
|
64
|
+
.map((item) => item.openConversationId?.trim())
|
|
65
|
+
.filter((id) => Boolean(id)))]
|
|
66
|
+
.slice(0, config.historyGroupLimit || DEFAULT_GROUP_LIMIT);
|
|
67
|
+
log.info(`history poll groups=${groups.length} messages=${config.historyMessageLimit} currentUserName=${currentName}`);
|
|
58
68
|
for (const groupId of groups) {
|
|
59
69
|
if (stopped)
|
|
60
70
|
break;
|
|
61
71
|
try {
|
|
72
|
+
// The second and only message command is dws chat message list.
|
|
62
73
|
const messages = await listGroupMessages(groupId, new Date(Date.now() - 20_000), config.historyMessageLimit || 20);
|
|
63
|
-
messages.filter((event) => isCurrentUser(event, currentName))
|
|
74
|
+
const currentUserMessages = messages.filter((event) => isCurrentUser(event, currentName));
|
|
75
|
+
if (messages.length > 0) {
|
|
76
|
+
log.debug(`history poll group=${groupId} received=${messages.length} matchedCurrentUserName=${currentUserMessages.length}`);
|
|
77
|
+
}
|
|
78
|
+
currentUserMessages.forEach(options.acceptEvent);
|
|
64
79
|
}
|
|
65
80
|
catch (err) {
|
|
66
81
|
log.warn(`history poll group failed group=${groupId}: ${String(err)}`);
|
|
@@ -71,8 +86,10 @@ export function startDwsHistoryPolling(options) {
|
|
|
71
86
|
polling = false;
|
|
72
87
|
}
|
|
73
88
|
};
|
|
74
|
-
void poll();
|
|
75
|
-
const timer = setInterval(() => {
|
|
89
|
+
void poll().catch((err) => log.warn(`history poll failed: ${String(err)}`));
|
|
90
|
+
const timer = setInterval(() => {
|
|
91
|
+
void poll().catch((err) => log.warn(`history poll failed: ${String(err)}`));
|
|
92
|
+
}, Math.max(1, Number(options.getConfig().historyPollIntervalSeconds || DEFAULT_POLL_INTERVAL_SECONDS)) * 1_000);
|
|
76
93
|
timer.unref();
|
|
77
94
|
return () => { stopped = true; clearInterval(timer); };
|
|
78
95
|
}
|
package/dist/dws-listener.js
CHANGED
|
@@ -2,13 +2,12 @@ import { mkdir, open, readdir, readFile, rename, unlink, writeFile } from "node:
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { unlinkSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
6
|
import { createInterface } from "node:readline";
|
|
7
7
|
import { agentLabel, agentSwitchMessage, runAgent } from "./agents/index.js";
|
|
8
8
|
import { DingTalkCardClient } from "./dingtalk-card.js";
|
|
9
9
|
import { applyMonitorCommand, parseMonitorCommand } from "./monitor-command.js";
|
|
10
|
-
import {
|
|
11
|
-
import { dwsPath, listConversations, getCurrentDwsUser, getDwsAuthStatus, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, listGroupBots, addBotToGroup, listGroupMembers, searchGroups, searchUsers, searchBots, startGroupEventStream, } from "./dws-client.js";
|
|
10
|
+
import { dwsPath, listConversations, getCurrentDwsUser, listGroupBots, addBotToGroup, listGroupMembers, searchBots, startGroupEventStream, } from "./dws-client.js";
|
|
12
11
|
import { createLogger } from "./logger.js";
|
|
13
12
|
import { sendRobotGroupText } from "./dingtalk-robot.js";
|
|
14
13
|
import { runDwsJson } from "./dws-client.js";
|
|
@@ -37,6 +36,7 @@ const CONFIG_MIGRATION_FILE = join(DATA_DIR, ".config-location-v1");
|
|
|
37
36
|
const LISTENER_LOCK_FILE = join(DATA_DIR, "dws-listener.lock");
|
|
38
37
|
const CARD_STATE_FILE = join(DATA_DIR, "dws-cards.json");
|
|
39
38
|
const DASHBOARD_CONFIG_FILE = join(DATA_DIR, "dws-dashboard.json");
|
|
39
|
+
const DEFAULT_DASHBOARD_CONFIG_FILE = join(dirname(new URL(import.meta.url).pathname), "..", "default-dashboard-config.json");
|
|
40
40
|
const DASHBOARD_SERVER_CONFIG_FILE = join(DATA_DIR, "dws-dashboard-server.json");
|
|
41
41
|
const REPLY_HISTORY_DIR = join(DATA_DIR, "replies");
|
|
42
42
|
const LEGACY_REPLY_HISTORY_FILE = join(DATA_DIR, "dws-replies.json");
|
|
@@ -327,28 +327,33 @@ async function loadDashboardConfig() {
|
|
|
327
327
|
const stored = await readStoredDashboardConfig(DASHBOARD_CONFIG_FILE);
|
|
328
328
|
if (stored)
|
|
329
329
|
return stored;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
330
|
+
let defaults = {};
|
|
331
|
+
try {
|
|
332
|
+
defaults = JSON.parse(await readFile(DEFAULT_DASHBOARD_CONFIG_FILE, "utf8"));
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
log.warn(`default dashboard config unavailable: ${String(err)}`);
|
|
336
|
+
}
|
|
337
|
+
const targets = Array.isArray(defaults.targets) ? defaults.targets : createDefaultTargets();
|
|
338
|
+
return normalizeDashboardConfig({
|
|
339
|
+
...defaults,
|
|
338
340
|
targets,
|
|
339
|
-
botAllowedUserIds: defaultBotAllowedUserIds(targets),
|
|
340
|
-
botAllowedUserNames: Object.fromEntries(targets.map((target) => [target.senderId, target.senderName])),
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
robotName: DEFAULT_ROBOT_NAME,
|
|
349
|
-
clientId: DEFAULT_DINGTALK_CLIENT_ID,
|
|
350
|
-
clientSecret: DEFAULT_DINGTALK_CLIENT_SECRET,
|
|
351
|
-
|
|
341
|
+
botAllowedUserIds: Array.isArray(defaults.botAllowedUserIds) ? defaults.botAllowedUserIds : defaultBotAllowedUserIds(targets),
|
|
342
|
+
botAllowedUserNames: defaults.botAllowedUserNames ?? Object.fromEntries(targets.map((target) => [target.senderId, target.senderName])),
|
|
343
|
+
commandKeywords: defaults.commandKeywords ?? structuredClone(EMPTY_COMMAND_KEYWORDS),
|
|
344
|
+
privateChatEnabled: defaults.privateChatEnabled ?? true,
|
|
345
|
+
cardUpdateIntervalMs: defaults.cardUpdateIntervalMs ?? 3_000,
|
|
346
|
+
showElapsed: defaults.showElapsed ?? true,
|
|
347
|
+
historyGroupLimit: defaults.historyGroupLimit ?? 10,
|
|
348
|
+
historyMessageLimit: defaults.historyMessageLimit ?? 20,
|
|
349
|
+
historyPollIntervalSeconds: defaults.historyPollIntervalSeconds ?? 5,
|
|
350
|
+
robotName: defaults.robotName ?? DEFAULT_ROBOT_NAME,
|
|
351
|
+
clientId: defaults.clientId ?? DEFAULT_DINGTALK_CLIENT_ID,
|
|
352
|
+
clientSecret: defaults.clientSecret ?? DEFAULT_DINGTALK_CLIENT_SECRET,
|
|
353
|
+
replyFormat: defaults.replyFormat ?? "markdown",
|
|
354
|
+
agent: defaults.agent ?? "codex",
|
|
355
|
+
groupPromptPrefix: defaults.groupPromptPrefix ?? "",
|
|
356
|
+
});
|
|
352
357
|
}
|
|
353
358
|
function normalizeDashboardServerConfig(value) {
|
|
354
359
|
if (!value || typeof value !== "object")
|
|
@@ -726,8 +731,17 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
|
|
|
726
731
|
const seconds = totalSeconds % 60;
|
|
727
732
|
return minutes > 0 ? `${minutes}分${seconds}秒` : `${seconds}秒`;
|
|
728
733
|
};
|
|
729
|
-
|
|
730
|
-
|
|
734
|
+
// Keep the title lightweight. Detailed phases are already shown in the
|
|
735
|
+
// card body; only alternate the icon as a heartbeat to avoid duplicate text.
|
|
736
|
+
let heartbeat = 0;
|
|
737
|
+
const processingTitle = () => {
|
|
738
|
+
const heartbeatIcon = heartbeat++ % 2 === 0 ? "🔵" : "🔷";
|
|
739
|
+
const elapsed = getDashboardConfig().showElapsed ? ` ${formatElapsed()}` : "";
|
|
740
|
+
return `${heartbeatIcon} ${title}处理中...${elapsed}`;
|
|
741
|
+
};
|
|
742
|
+
// The setting only controls the live processing title. Completion always
|
|
743
|
+
// includes the elapsed time as a useful final result summary.
|
|
744
|
+
const finishedTitle = (icon, state) => `${icon} ${title}${state} 总耗时 ${formatElapsed()}`;
|
|
731
745
|
try {
|
|
732
746
|
const storedCard = cardState.cards[groupId];
|
|
733
747
|
if (storedCard?.status === "processing") {
|
|
@@ -832,7 +846,7 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
|
|
|
832
846
|
log.info(`codex tool=${toolName} count=${stats[toolName] ?? 1}`);
|
|
833
847
|
liveReply.content = `正在调用工具:${toolName}`;
|
|
834
848
|
latestVisibleContent = liveReply.content;
|
|
835
|
-
updateCard(processingTitle(),
|
|
849
|
+
updateCard(processingTitle(), liveReply.content);
|
|
836
850
|
},
|
|
837
851
|
onText: (text) => {
|
|
838
852
|
const delta = text.startsWith(lastText) ? text.slice(lastText.length) : text;
|
|
@@ -1238,63 +1252,34 @@ async function main() {
|
|
|
1238
1252
|
log.info(`dashboard config applied: ${configuredGroupIds(config).length} group(s), ${config.targets.length} rule(s)`);
|
|
1239
1253
|
};
|
|
1240
1254
|
await saveDashboardConfig(dashboardConfig);
|
|
1241
|
-
const dashboardServerConfig = await loadDashboardServerConfig();
|
|
1242
1255
|
cardClient.setCredentials(dashboardConfig.clientId, dashboardConfig.clientSecret);
|
|
1243
1256
|
cardClient.setRobotCode(dashboardConfig.clientId);
|
|
1244
1257
|
const replies = await loadReplyHistory();
|
|
1245
1258
|
const liveReplies = new Map();
|
|
1246
|
-
// The bot worker writes personal-chat logs in the same daily files. Reload
|
|
1247
|
-
// them periodically so the dashboard can show personal and group exchanges
|
|
1248
|
-
// without requiring a listener restart.
|
|
1249
|
-
const reloadReplyHistory = async () => {
|
|
1250
|
-
const latest = await loadReplyHistory();
|
|
1251
|
-
const byId = new Map([...replies, ...latest].map((reply) => [reply.id, reply]));
|
|
1252
|
-
replies.splice(0, replies.length, ...Array.from(byId.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 100));
|
|
1253
|
-
};
|
|
1254
|
-
const replyReloadTimer = setInterval(() => {
|
|
1255
|
-
void reloadReplyHistory().catch((err) => log.warn(`reply history reload failed: ${String(err)}`));
|
|
1256
|
-
}, 1_000);
|
|
1257
|
-
replyReloadTimer.unref();
|
|
1258
1259
|
const runtime = {
|
|
1259
1260
|
startedAt: new Date().toISOString(),
|
|
1260
1261
|
eventConnected: false,
|
|
1261
1262
|
activeBatches: 0,
|
|
1262
1263
|
};
|
|
1263
|
-
startDashboard(dashboardServerConfig.port, {
|
|
1264
|
-
getConfig: () => dashboardConfig,
|
|
1265
|
-
updateConfig: applyDashboardConfig,
|
|
1266
|
-
getStatus: () => ({ ...runtime }),
|
|
1267
|
-
getReplies: () => [...liveReplies.values(), ...replies],
|
|
1268
|
-
searchGroups,
|
|
1269
|
-
listGroupMembers,
|
|
1270
|
-
searchUsers,
|
|
1271
|
-
searchBots,
|
|
1272
|
-
getCurrentDwsUser: async () => {
|
|
1273
|
-
const [user, auth] = await Promise.all([getCurrentDwsUser(), getDwsAuthStatus()]);
|
|
1274
|
-
return { ...user, auth };
|
|
1275
|
-
},
|
|
1276
|
-
getDwsAuthStatus,
|
|
1277
|
-
startDwsDeviceLogin,
|
|
1278
|
-
getDwsDeviceLoginOutput,
|
|
1279
|
-
logoutDws,
|
|
1280
|
-
getBotStatus: async () => {
|
|
1281
|
-
const enabled = dashboardConfig.privateChatEnabled !== false;
|
|
1282
|
-
try {
|
|
1283
|
-
const value = JSON.parse(await readFile(join(DATA_DIR, "omi-bot-status.json"), "utf8"));
|
|
1284
|
-
return { enabled, connected: enabled && value.connected !== false, updatedAt: value.updatedAt };
|
|
1285
|
-
}
|
|
1286
|
-
catch {
|
|
1287
|
-
return { enabled, connected: false };
|
|
1288
|
-
}
|
|
1289
|
-
},
|
|
1290
|
-
}, { host: dashboardServerConfig.host });
|
|
1291
|
-
log.info(`dashboard started at http://${dashboardServerConfig.host}:${dashboardServerConfig.port}`);
|
|
1292
1264
|
log.info(`monitoring ${configuredGroupIds(dashboardConfig).length} group(s) with ${dashboardConfig.targets.length} rule(s)`);
|
|
1265
|
+
// The dashboard is a separate process. Reload its file so changes made in
|
|
1266
|
+
// the web UI take effect in the listener without requiring a restart.
|
|
1267
|
+
const configReloadTimer = setInterval(() => {
|
|
1268
|
+
void loadDashboardConfig().then((latest) => {
|
|
1269
|
+
if (JSON.stringify(latest) === JSON.stringify(dashboardConfig))
|
|
1270
|
+
return;
|
|
1271
|
+
dashboardConfig = latest;
|
|
1272
|
+
cardClient.setCredentials(latest.clientId, latest.clientSecret);
|
|
1273
|
+
cardClient.setRobotCode(latest.clientId);
|
|
1274
|
+
log.info(`dashboard config reloaded: ${configuredGroupIds(latest).length} group(s), ${latest.targets.length} rule(s)`);
|
|
1275
|
+
}).catch((err) => log.warn(`dashboard config reload failed: ${String(err)}`));
|
|
1276
|
+
}, 1_000);
|
|
1277
|
+
configReloadTimer.unref();
|
|
1293
1278
|
try {
|
|
1294
1279
|
await startGroupListener(seen, queues, cardState, sessions, cardClient, () => dashboardConfig, applyDashboardConfig, replies, liveReplies, runtime);
|
|
1295
1280
|
}
|
|
1296
1281
|
finally {
|
|
1297
|
-
clearInterval(
|
|
1282
|
+
clearInterval(configReloadTimer);
|
|
1298
1283
|
}
|
|
1299
1284
|
}
|
|
1300
1285
|
main().catch((err) => {
|
package/dist/omi.js
CHANGED
|
@@ -15,6 +15,7 @@ const logFile = join(stateDir, "omi.log");
|
|
|
15
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
16
|
const listenerPath = join(here, "dws-listener.js");
|
|
17
17
|
const botPath = join(here, "bot-worker.js");
|
|
18
|
+
const dashboardPath = join(here, "dashboard-worker.js");
|
|
18
19
|
const packageFile = join(here, "..", "package.json");
|
|
19
20
|
const packageName = "oh-my-im";
|
|
20
21
|
const repositoryUrl = "https://github.com/duzhenxun/oh-my-im";
|
|
@@ -115,7 +116,7 @@ function readState() {
|
|
|
115
116
|
const value = JSON.parse(readFileSync(stateFile, "utf8"));
|
|
116
117
|
if ((value.mode !== "bot" && value.mode !== "listen") || !Array.isArray(value.processes))
|
|
117
118
|
return undefined;
|
|
118
|
-
const processes = value.processes.filter((item) => Boolean(item && (item.role === "bot" || item.role === "listener") && Number.isInteger(item.pid) && item.pid > 0 && isRunning(item.pid)));
|
|
119
|
+
const processes = value.processes.filter((item) => Boolean(item && (item.role === "bot" || item.role === "listener" || item.role === "dashboard") && Number.isInteger(item.pid) && item.pid > 0 && isRunning(item.pid)));
|
|
119
120
|
if (processes.length === 0) {
|
|
120
121
|
unlinkSync(stateFile);
|
|
121
122
|
return undefined;
|
|
@@ -169,6 +170,9 @@ function start(mode, workspace = launchWorkspace) {
|
|
|
169
170
|
}
|
|
170
171
|
mkdirSync(stateDir, { recursive: true });
|
|
171
172
|
const processes = [];
|
|
173
|
+
// Keep the management UI independent: missing DWS login or bot credentials
|
|
174
|
+
// must never prevent users from opening the configuration page.
|
|
175
|
+
processes.push(startProcess("dashboard", dashboardPath, workspace));
|
|
172
176
|
if (mode === "listen")
|
|
173
177
|
processes.push(startProcess("listener", listenerPath, workspace));
|
|
174
178
|
processes.push(startProcess("bot", botPath, workspace));
|