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 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
- const processingTitle = () => options.getShowElapsed?.() === false ? `🔵 ${title}处理中...` : `🔵 ${title}处理中... (${formatElapsed()})`;
538
- const finishedTitle = (icon, state) => options.getShowElapsed?.() === false ? `${icon} ${title}${state}` : `${icon} ${title}${state} 总耗时 ${formatElapsed()}`;
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: (_toolName, stats) => {
597
+ onToolUse: (toolName, stats) => {
589
598
  latestStats = stats;
590
599
  const statsText = formatStats(stats);
591
600
  updateCard(processingTitle(), buildCardContent(`${label} 正在处理...`, statsText ? `工具:${statsText}` : undefined));
@@ -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
- throw new Error("请先在管理页配置机器人单聊授权人员");
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
- writeBotStatus(true, false);
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
+ });
@@ -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
- return (result.messages ?? []).map((message) => ({
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
- return { userId: result.userId?.trim(), openDingTalkId: (result.openDingTalkId || result.openDingtalkId)?.trim(), name: result.name?.trim(), email: result.email?.trim(), dept: result.dept?.trim(), org: result.org?.trim() };
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
- const result = await runDwsJson(["contact", "+search-user", "--query", query]);
105
- return (result.users ?? result.items ?? []).flatMap((user) => {
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", "10",
158
+ "chat", "+chat-list", "--types", "group", "--page-size", "20",
150
159
  ]);
151
- return result.conversations ?? result.chats?.map((chat) => ({ openConversationId: chat.openConversationId, conversationName: chat.name })) ?? [];
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"];
@@ -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
- if (botAllowedUserIds.length === 0)
65
- throw new Error("请至少配置一名机器人单聊授权人员");
66
- if (typeof source.robotName !== "string" || !source.robotName.trim())
67
- throw new Error("请填写机器人名称");
68
- if (typeof source.clientId !== "string" || !source.clientId.trim())
69
- throw new Error("请填写钉钉应用 Client ID");
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 });
@@ -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
- log.debug(`current DWS user for history name=${currentName || "<none>"}`);
32
+ lastUserResolveFailure = "";
33
+ log.info(`current DWS user for history name=${currentName || "<none>"}`);
36
34
  }
37
35
  catch (err) {
38
- log.warn(`unable to resolve current DWS user for history: ${String(err)}`);
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) ? config.historyPollIntervalSeconds : DEFAULT_POLL_INTERVAL_SECONDS;
50
- if (intervalSeconds <= 0)
50
+ const intervalSeconds = Number.isFinite(config.historyPollIntervalSeconds)
51
+ ? config.historyPollIntervalSeconds : DEFAULT_POLL_INTERVAL_SECONDS;
52
+ if (intervalSeconds <= 0 || !currentName)
51
53
  return;
52
- const conversations = await listConversations();
53
- const allGroups = conversations
54
- .map((item) => item.openConversationId?.trim())
55
- .filter((id) => Boolean(id));
56
- const groups = [...new Set([...options.configuredGroupIds(config), ...allGroups])].slice(0, config.historyGroupLimit || DEFAULT_GROUP_LIMIT);
57
- log.debug(`history poll groups=${groups.length}/${config.historyGroupLimit} messages=${config.historyMessageLimit} interval=${intervalSeconds}s currentUser=${currentName || "<none>"}`);
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)).forEach(options.acceptEvent);
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(() => { void poll(); }, Math.max(1, Number(options.getConfig().historyPollIntervalSeconds || DEFAULT_POLL_INTERVAL_SECONDS)) * 1_000);
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
  }
@@ -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 { startDashboard, } from "./dws-dashboard.js";
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
- const targets = createDefaultTargets();
331
- return {
332
- privateChatEnabled: true,
333
- cardUpdateIntervalMs: 3_000,
334
- showElapsed: true,
335
- historyGroupLimit: 10,
336
- historyMessageLimit: 20,
337
- historyPollIntervalSeconds: 5,
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
- botSuperAdminUserIds: [],
342
- botSuperAdminUserNames: {},
343
- robotSenderOpenDingTalkId: "",
344
- replyFormat: "markdown",
345
- agent: "codex",
346
- commandKeywords: structuredClone(EMPTY_COMMAND_KEYWORDS),
347
- groupPromptPrefix: "",
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
- const processingTitle = () => getDashboardConfig().showElapsed ? `🔵 ${title}处理中... (${formatElapsed()})` : `🔵 ${title}处理中...`;
730
- const finishedTitle = (icon, state) => getDashboardConfig().showElapsed ? `${icon} ${title}${state} 总耗时 ${formatElapsed()}` : `${icon} ${title}${state}`;
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(), `正在调用工具:${toolName}`);
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(replyReloadTimer);
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-im",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "author": {
5
5
  "name": "杜振训",
6
6
  "email": "duzhenxun@126.com"