oh-my-im 0.1.5 → 0.1.6

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));
@@ -92,7 +92,17 @@ process.once("exit", () => {
92
92
  });
93
93
  try {
94
94
  const config = loadBotConfig();
95
- writeBotStatus(true, false);
95
+ // Publish the configured state immediately. On startup the stream is not
96
+ // connected yet, and a disabled stream must not briefly appear enabled.
97
+ const initiallyEnabled = (() => {
98
+ try {
99
+ return JSON.parse(readFileSync(dashboardConfigFile, "utf8")).privateChatEnabled !== false;
100
+ }
101
+ catch {
102
+ return true;
103
+ }
104
+ })();
105
+ writeBotStatus(initiallyEnabled, false);
96
106
  await runApp(config, {
97
107
  singleChatOnly: true,
98
108
  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"]);
@@ -146,9 +148,13 @@ export async function listGroupMembers(groupId) {
146
148
  }
147
149
  export async function listConversations() {
148
150
  const result = await runDwsJson([
149
- "chat", "+chat-list", "--types", "group", "--page-size", "10",
151
+ "chat", "+chat-list", "--types", "group", "--page-size", "20",
150
152
  ]);
151
- return result.conversations ?? result.chats?.map((chat) => ({ openConversationId: chat.openConversationId, conversationName: chat.name })) ?? [];
153
+ const data = result.data;
154
+ const nested = data?.result;
155
+ const chats = result.chats ?? data?.chats ?? nested?.chats;
156
+ return result.conversations ?? data?.conversations ?? nested?.conversations
157
+ ?? chats?.map((chat) => ({ openConversationId: chat.openConversationId, conversationName: chat.name })) ?? [];
152
158
  }
153
159
  export function startGroupEventStream(eventName, maxEvents, groupId) {
154
160
  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
  }
@@ -7,8 +7,7 @@ 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";
@@ -726,8 +725,17 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
726
725
  const seconds = totalSeconds % 60;
727
726
  return minutes > 0 ? `${minutes}分${seconds}秒` : `${seconds}秒`;
728
727
  };
729
- const processingTitle = () => getDashboardConfig().showElapsed ? `🔵 ${title}处理中... (${formatElapsed()})` : `🔵 ${title}处理中...`;
730
- const finishedTitle = (icon, state) => getDashboardConfig().showElapsed ? `${icon} ${title}${state} 总耗时 ${formatElapsed()}` : `${icon} ${title}${state}`;
728
+ // Keep the title lightweight. Detailed phases are already shown in the
729
+ // card body; only alternate the icon as a heartbeat to avoid duplicate text.
730
+ let heartbeat = 0;
731
+ const processingTitle = () => {
732
+ const heartbeatIcon = heartbeat++ % 2 === 0 ? "🔵" : "🔷";
733
+ const elapsed = getDashboardConfig().showElapsed ? ` ${formatElapsed()}` : "";
734
+ return `${heartbeatIcon} ${title}处理中...${elapsed}`;
735
+ };
736
+ // The setting only controls the live processing title. Completion always
737
+ // includes the elapsed time as a useful final result summary.
738
+ const finishedTitle = (icon, state) => `${icon} ${title}${state} 总耗时 ${formatElapsed()}`;
731
739
  try {
732
740
  const storedCard = cardState.cards[groupId];
733
741
  if (storedCard?.status === "processing") {
@@ -832,7 +840,7 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
832
840
  log.info(`codex tool=${toolName} count=${stats[toolName] ?? 1}`);
833
841
  liveReply.content = `正在调用工具:${toolName}`;
834
842
  latestVisibleContent = liveReply.content;
835
- updateCard(processingTitle(), `正在调用工具:${toolName}`);
843
+ updateCard(processingTitle(), liveReply.content);
836
844
  },
837
845
  onText: (text) => {
838
846
  const delta = text.startsWith(lastText) ? text.slice(lastText.length) : text;
@@ -1238,63 +1246,34 @@ async function main() {
1238
1246
  log.info(`dashboard config applied: ${configuredGroupIds(config).length} group(s), ${config.targets.length} rule(s)`);
1239
1247
  };
1240
1248
  await saveDashboardConfig(dashboardConfig);
1241
- const dashboardServerConfig = await loadDashboardServerConfig();
1242
1249
  cardClient.setCredentials(dashboardConfig.clientId, dashboardConfig.clientSecret);
1243
1250
  cardClient.setRobotCode(dashboardConfig.clientId);
1244
1251
  const replies = await loadReplyHistory();
1245
1252
  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
1253
  const runtime = {
1259
1254
  startedAt: new Date().toISOString(),
1260
1255
  eventConnected: false,
1261
1256
  activeBatches: 0,
1262
1257
  };
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
1258
  log.info(`monitoring ${configuredGroupIds(dashboardConfig).length} group(s) with ${dashboardConfig.targets.length} rule(s)`);
1259
+ // The dashboard is a separate process. Reload its file so changes made in
1260
+ // the web UI take effect in the listener without requiring a restart.
1261
+ const configReloadTimer = setInterval(() => {
1262
+ void loadDashboardConfig().then((latest) => {
1263
+ if (JSON.stringify(latest) === JSON.stringify(dashboardConfig))
1264
+ return;
1265
+ dashboardConfig = latest;
1266
+ cardClient.setCredentials(latest.clientId, latest.clientSecret);
1267
+ cardClient.setRobotCode(latest.clientId);
1268
+ log.info(`dashboard config reloaded: ${configuredGroupIds(latest).length} group(s), ${latest.targets.length} rule(s)`);
1269
+ }).catch((err) => log.warn(`dashboard config reload failed: ${String(err)}`));
1270
+ }, 1_000);
1271
+ configReloadTimer.unref();
1293
1272
  try {
1294
1273
  await startGroupListener(seen, queues, cardState, sessions, cardClient, () => dashboardConfig, applyDashboardConfig, replies, liveReplies, runtime);
1295
1274
  }
1296
1275
  finally {
1297
- clearInterval(replyReloadTimer);
1276
+ clearInterval(configReloadTimer);
1298
1277
  }
1299
1278
  }
1300
1279
  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.6",
4
4
  "author": {
5
5
  "name": "杜振训",
6
6
  "email": "duzhenxun@126.com"