oh-my-im 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,6 @@ npm link
11
11
  omi
12
12
  ```
13
13
 
14
- 首次运行会创建 `.oh-my-im` 本地配置;在管理页填写钉钉应用凭证、钉钉规则和机器人单聊授权人员即可。
14
+ 首次运行会在 `~/.oh-my-im` 创建本地配置;在管理页填写钉钉应用凭证、钉钉规则和机器人单聊授权人员即可。`omi` 的状态、日志和全部运行配置都固定保存在此目录,因此可在任意目录执行 `omi status`、`omi stop` 或 `omi update`。
15
15
 
16
16
  详见 [docs/development.md](docs/development.md)。
@@ -11,8 +11,8 @@ function normalizeConfig(value) {
11
11
  if (!value || typeof value !== "object")
12
12
  throw new Error("配置必须是 JSON 对象");
13
13
  const source = value;
14
- if (!Array.isArray(source.targets) || source.targets.length === 0)
15
- throw new Error("至少保留一条钉钉规则");
14
+ if (!Array.isArray(source.targets))
15
+ throw new Error("钉钉规则格式无效");
16
16
  const keys = new Set();
17
17
  const targets = source.targets.map((item, index) => {
18
18
  if (!item || typeof item !== "object")
@@ -2,10 +2,12 @@ import { spawn } from "node:child_process";
2
2
  import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { unlinkSync } from "node:fs";
5
+ import { homedir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import { createInterface } from "node:readline";
7
8
  import { runCodex } from "./codex.js";
8
9
  import { DingTalkCardClient } from "./dingtalk-card.js";
10
+ import { applyMonitorCommand, DU_ZHENXUN_OPEN_DINGTALK_ID, parseMonitorCommand } from "./monitor-command.js";
9
11
  import { startDashboard, } from "./dws-dashboard.js";
10
12
  import { createLogger } from "./logger.js";
11
13
  const log = createLogger("DwsCodexListener");
@@ -13,7 +15,7 @@ const DEFAULT_DWS_GROUP_IDS = [
13
15
  "cid4SeVl1pUe7Z3wiI9IUxtfA==", // 公司自测专用
14
16
  ];
15
17
  const DEFAULT_ALLOWED_SENDER_IDS = [
16
- "DZyRu3o9aXvmiSh7BJa5S4EQiEiE", // 杜振训
18
+ DU_ZHENXUN_OPEN_DINGTALK_ID, // 杜振训
17
19
  ];
18
20
  const IGNORED_ROBOT_SENDER_IDS = new Set([
19
21
  "DZyRu3o9aXvl2Ve04YPzcrrTokgiSOyA9A", // 映客活动AI in 公司自测专用
@@ -22,11 +24,14 @@ const IGNORED_ROBOT_SENDER_IDS = new Set([
22
24
  const DEFAULT_CODEX_WORK_DIR = "/Users/dds/go/src/git.inke.cn/opd/activitys/inke.activity.service";
23
25
  const DEFAULT_DWS_CODEX_TIMEOUT_MS = 300_000;
24
26
  const DWS_GROUP_MESSAGE_EVENT = "user_im_message_receive_group_all";
25
- const LISTENER_LOCK_FILE = join(process.cwd(), ".oh-my-im-dws-listener.lock");
26
- const CARD_STATE_FILE = join(process.cwd(), ".oh-my-im", "dws-cards.json");
27
- const DASHBOARD_CONFIG_FILE = join(process.cwd(), ".oh-my-im", "dws-dashboard.json");
28
- const DASHBOARD_SERVER_CONFIG_FILE = join(process.cwd(), ".oh-my-im", "dws-dashboard-server.json");
29
- const REPLY_HISTORY_FILE = join(process.cwd(), ".oh-my-im", "dws-replies.json");
27
+ const DATA_DIR = join(homedir(), ".oh-my-im");
28
+ const LEGACY_DATA_DIR = join(process.cwd(), ".oh-my-im");
29
+ const CONFIG_MIGRATION_FILE = join(DATA_DIR, ".config-location-v1");
30
+ const LISTENER_LOCK_FILE = join(DATA_DIR, "dws-listener.lock");
31
+ const CARD_STATE_FILE = join(DATA_DIR, "dws-cards.json");
32
+ const DASHBOARD_CONFIG_FILE = join(DATA_DIR, "dws-dashboard.json");
33
+ const DASHBOARD_SERVER_CONFIG_FILE = join(DATA_DIR, "dws-dashboard-server.json");
34
+ const REPLY_HISTORY_FILE = join(DATA_DIR, "dws-replies.json");
30
35
  const DEFAULT_DWS_CARD_ROBOT_CODE = "dingn9wrup8mqq1ptabn";
31
36
  const DEFAULT_DINGTALK_CLIENT_ID = "";
32
37
  const DEFAULT_DINGTALK_CLIENT_SECRET = "";
@@ -35,13 +40,18 @@ const dwsPath = process.env.DWS_CLI_PATH?.trim() || "dws";
35
40
  const maxReplyLength = 8_000;
36
41
  const cardUpdateIntervalMs = 1_200;
37
42
  const historyPollIntervalMs = 3_000;
43
+ const commandPollIntervalMs = 5_000;
38
44
  function eventKey(event, groupId) {
39
- if (event.message_id?.trim())
40
- return `${groupId}:message:${event.message_id.trim()}`;
45
+ const messageId = event.message_id || event.messageId || event.openMessageId;
46
+ if (messageId?.trim())
47
+ return `${groupId}:message:${messageId.trim()}`;
41
48
  if (event.event_id?.trim())
42
49
  return `${groupId}:event:${event.event_id.trim()}`;
43
50
  return undefined;
44
51
  }
52
+ function eventGroupId(event) {
53
+ return event.conversation_id?.trim() || event.conversationId?.trim() || event.openConversationId?.trim();
54
+ }
45
55
  function formatDwsTime(date) {
46
56
  const part = (value) => String(value).padStart(2, "0");
47
57
  return `${date.getFullYear()}-${part(date.getMonth() + 1)}-${part(date.getDate())} ${part(date.getHours())}:${part(date.getMinutes())}:${part(date.getSeconds())}`;
@@ -69,9 +79,9 @@ function listGroupMessages(groupId, from) {
69
79
  conversation_id: message.conversationId,
70
80
  create_time: message.createTime,
71
81
  message_id: message.messageId,
72
- sender_open_dingtalk_id: message.senderId,
82
+ sender_open_dingtalk_id: message.senderOpenDingTalkId || message.senderId,
73
83
  sender: message.sender ? { name: message.sender } : undefined,
74
- content: message.text,
84
+ content: message.text || message.content,
75
85
  })));
76
86
  }
77
87
  catch (err) {
@@ -80,6 +90,37 @@ function listGroupMessages(groupId, from) {
80
90
  });
81
91
  });
82
92
  }
93
+ async function searchMonitorCommands(from) {
94
+ const result = await runDwsJson([
95
+ "chat", "+search-msg",
96
+ "--senders", DU_ZHENXUN_OPEN_DINGTALK_ID,
97
+ "--start", from.toISOString(),
98
+ "--end", new Date().toISOString(),
99
+ "--order", "asc",
100
+ "--limit", "50",
101
+ ]);
102
+ return (result.messages ?? []).map((message) => ({
103
+ conversation_id: message.conversationId,
104
+ create_time: message.createTime,
105
+ message_id: message.messageId,
106
+ sender_open_dingtalk_id: message.senderOpenDingTalkId || message.senderId,
107
+ sender: message.sender ? { name: message.sender } : undefined,
108
+ content: message.text || message.content,
109
+ }));
110
+ }
111
+ async function sendRobotText(groupId, robotCode, content) {
112
+ const result = await runDwsJson([
113
+ "chat", "+messages-send",
114
+ "--as", "bot",
115
+ "--robot-code", robotCode,
116
+ "--groups", groupId,
117
+ "--text", content,
118
+ "--yes",
119
+ ]);
120
+ if (result.success === false || (result.failedCount ?? 0) > 0) {
121
+ throw new Error("机器人普通消息发送失败");
122
+ }
123
+ }
83
124
  function runDwsJson(args) {
84
125
  return new Promise((resolve, reject) => {
85
126
  const child = spawn(dwsPath, [...args, "--format", "json"], { stdio: ["ignore", "pipe", "pipe"] });
@@ -168,7 +209,7 @@ async function loadCardState() {
168
209
  }
169
210
  }
170
211
  async function saveCardState(state) {
171
- await mkdir(join(process.cwd(), ".oh-my-im"), { recursive: true });
212
+ await mkdir(DATA_DIR, { recursive: true });
172
213
  const temporary = `${CARD_STATE_FILE}.${process.pid}.tmp`;
173
214
  await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, "utf8");
174
215
  await rename(temporary, CARD_STATE_FILE);
@@ -193,48 +234,88 @@ function createDefaultTargets() {
193
234
  function defaultBotAllowedUserIds(targets) {
194
235
  return [...new Set(targets.map((target) => target.senderId).filter(Boolean))];
195
236
  }
196
- async function loadDashboardConfig() {
237
+ function normalizeDashboardConfig(parsed) {
238
+ if (!Array.isArray(parsed.targets))
239
+ throw new Error("targets is invalid");
240
+ if (parsed.replyFormat !== "markdown" && parsed.replyFormat !== "plain")
241
+ throw new Error("replyFormat is invalid");
242
+ const targets = parsed.targets;
243
+ return {
244
+ ...parsed,
245
+ targets,
246
+ botAllowedUserIds: Array.isArray(parsed.botAllowedUserIds) && parsed.botAllowedUserIds.length > 0
247
+ ? [...new Set(parsed.botAllowedUserIds.map((id) => id.trim()).filter(Boolean))]
248
+ : defaultBotAllowedUserIds(targets),
249
+ robotName: parsed.robotName?.trim() || DEFAULT_ROBOT_NAME,
250
+ clientId: parsed.clientId?.trim() || DEFAULT_DINGTALK_CLIENT_ID,
251
+ clientSecret: parsed.clientSecret?.trim() || DEFAULT_DINGTALK_CLIENT_SECRET,
252
+ robotCode: parsed.robotCode?.trim() || DEFAULT_DWS_CARD_ROBOT_CODE,
253
+ };
254
+ }
255
+ async function readStoredDashboardConfig(file) {
197
256
  try {
198
- const parsed = JSON.parse(await readFile(DASHBOARD_CONFIG_FILE, "utf8"));
199
- if (!Array.isArray(parsed.targets) || parsed.targets.length === 0)
200
- throw new Error("targets is empty");
201
- if (parsed.replyFormat !== "markdown" && parsed.replyFormat !== "plain")
202
- throw new Error("replyFormat is invalid");
203
- const targets = parsed.targets;
204
- return {
205
- ...parsed,
206
- targets,
207
- botAllowedUserIds: Array.isArray(parsed.botAllowedUserIds) && parsed.botAllowedUserIds.length > 0
208
- ? [...new Set(parsed.botAllowedUserIds.map((id) => id.trim()).filter(Boolean))]
209
- : defaultBotAllowedUserIds(targets),
210
- robotName: parsed.robotName?.trim() || DEFAULT_ROBOT_NAME,
211
- clientId: parsed.clientId?.trim() || DEFAULT_DINGTALK_CLIENT_ID,
212
- clientSecret: parsed.clientSecret?.trim() || DEFAULT_DINGTALK_CLIENT_SECRET,
213
- robotCode: parsed.robotCode?.trim() || DEFAULT_DWS_CARD_ROBOT_CODE,
214
- };
257
+ return normalizeDashboardConfig(JSON.parse(await readFile(file, "utf8")));
215
258
  }
216
259
  catch (err) {
217
260
  const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
218
261
  if (code !== "ENOENT")
219
- log.warn(`dashboard config ignored: ${String(err)}`);
220
- const targets = createDefaultTargets();
221
- return {
222
- targets,
223
- botAllowedUserIds: defaultBotAllowedUserIds(targets),
224
- replyFormat: "markdown",
225
- robotName: DEFAULT_ROBOT_NAME,
226
- clientId: DEFAULT_DINGTALK_CLIENT_ID,
227
- clientSecret: DEFAULT_DINGTALK_CLIENT_SECRET,
228
- robotCode: DEFAULT_DWS_CARD_ROBOT_CODE,
229
- };
262
+ log.warn(`dashboard config ignored (${file}): ${String(err)}`);
263
+ return undefined;
230
264
  }
231
265
  }
232
266
  async function saveDashboardConfig(config) {
233
- await mkdir(join(process.cwd(), ".oh-my-im"), { recursive: true });
267
+ await mkdir(DATA_DIR, { recursive: true });
234
268
  const temporary = `${DASHBOARD_CONFIG_FILE}.${process.pid}.tmp`;
235
269
  await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, "utf8");
236
270
  await rename(temporary, DASHBOARD_CONFIG_FILE);
237
271
  }
272
+ function mergeDashboardConfigs(primary, legacy) {
273
+ const targets = [...primary.targets, ...legacy.targets].filter((target, index, all) => all.findIndex((item) => item.groupId === target.groupId && item.senderId === target.senderId) === index);
274
+ return {
275
+ ...legacy,
276
+ ...primary,
277
+ targets,
278
+ botAllowedUserIds: [...new Set([...primary.botAllowedUserIds, ...legacy.botAllowedUserIds])],
279
+ clientId: primary.clientId || legacy.clientId,
280
+ clientSecret: primary.clientSecret || legacy.clientSecret,
281
+ robotCode: primary.robotCode || legacy.robotCode,
282
+ };
283
+ }
284
+ async function migrateDashboardConfig() {
285
+ try {
286
+ await readFile(CONFIG_MIGRATION_FILE, "utf8");
287
+ return;
288
+ }
289
+ catch {
290
+ // A single migration avoids resurrecting deleted legacy rules after future restarts.
291
+ }
292
+ const primary = await readStoredDashboardConfig(DASHBOARD_CONFIG_FILE);
293
+ const legacy = LEGACY_DATA_DIR === DATA_DIR
294
+ ? undefined
295
+ : await readStoredDashboardConfig(join(LEGACY_DATA_DIR, "dws-dashboard.json"));
296
+ if (primary && legacy)
297
+ await saveDashboardConfig(mergeDashboardConfigs(primary, legacy));
298
+ else if (legacy)
299
+ await saveDashboardConfig(legacy);
300
+ await mkdir(DATA_DIR, { recursive: true });
301
+ await writeFile(CONFIG_MIGRATION_FILE, "migrated\n", "utf8");
302
+ }
303
+ async function loadDashboardConfig() {
304
+ await migrateDashboardConfig();
305
+ const stored = await readStoredDashboardConfig(DASHBOARD_CONFIG_FILE);
306
+ if (stored)
307
+ return stored;
308
+ const targets = createDefaultTargets();
309
+ return {
310
+ targets,
311
+ botAllowedUserIds: defaultBotAllowedUserIds(targets),
312
+ replyFormat: "markdown",
313
+ robotName: DEFAULT_ROBOT_NAME,
314
+ clientId: DEFAULT_DINGTALK_CLIENT_ID,
315
+ clientSecret: DEFAULT_DINGTALK_CLIENT_SECRET,
316
+ robotCode: DEFAULT_DWS_CARD_ROBOT_CODE,
317
+ };
318
+ }
238
319
  function normalizeDashboardServerConfig(value) {
239
320
  if (!value || typeof value !== "object")
240
321
  throw new Error("dashboard server config is invalid");
@@ -248,7 +329,7 @@ function normalizeDashboardServerConfig(value) {
248
329
  return { port, host };
249
330
  }
250
331
  async function saveDashboardServerConfig(config) {
251
- await mkdir(join(process.cwd(), ".oh-my-im"), { recursive: true });
332
+ await mkdir(DATA_DIR, { recursive: true });
252
333
  const temporary = `${DASHBOARD_SERVER_CONFIG_FILE}.${process.pid}.tmp`;
253
334
  await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, "utf8");
254
335
  await rename(temporary, DASHBOARD_SERVER_CONFIG_FILE);
@@ -289,7 +370,7 @@ async function recordReply(replies, reply) {
289
370
  replies.unshift(reply);
290
371
  if (replies.length > 100)
291
372
  replies.length = 100;
292
- await mkdir(join(process.cwd(), ".oh-my-im"), { recursive: true });
373
+ await mkdir(DATA_DIR, { recursive: true });
293
374
  const temporary = `${REPLY_HISTORY_FILE}.${process.pid}.tmp`;
294
375
  await writeFile(temporary, `${JSON.stringify(replies, null, 2)}\n`, "utf8");
295
376
  await rename(temporary, REPLY_HISTORY_FILE);
@@ -298,6 +379,53 @@ function groupTitle(groupId, config) {
298
379
  const target = config.targets.find((item) => item.groupId === groupId);
299
380
  return `钉钉群 ${target?.groupName || knownGroupName(groupId)}`;
300
381
  }
382
+ function eventGroupName(event, groupId, config) {
383
+ const configuredName = config.targets.find((item) => item.groupId === groupId)?.groupName;
384
+ const eventName = [event.conversation_title, event.conversation_name, event.conversationTitle, event.conversationName]
385
+ .find((value) => typeof value === "string" && Boolean(value.trim()));
386
+ return configuredName || eventName?.trim() || knownGroupName(groupId);
387
+ }
388
+ const conversationNameCache = new Map();
389
+ async function resolveCommandGroupName(groupId, config) {
390
+ const configuredName = config.targets.find((item) => item.groupId === groupId)?.groupName?.trim();
391
+ if (configuredName && configuredName !== groupId && !configuredName.startsWith("cid"))
392
+ return configuredName;
393
+ const cached = conversationNameCache.get(groupId);
394
+ if (cached)
395
+ return cached;
396
+ try {
397
+ const result = await runDwsJson([
398
+ "chat", "+conversation-list", "--page-all", "--page-limit", "5", "--max-items", "500",
399
+ ]);
400
+ for (const conversation of result.conversations ?? []) {
401
+ const id = conversation.openConversationId?.trim();
402
+ const name = conversation.conversationName?.trim();
403
+ if (id && name)
404
+ conversationNameCache.set(id, name);
405
+ }
406
+ }
407
+ catch (err) {
408
+ log.warn(`unable to resolve group name for monitor command: ${String(err)}`);
409
+ }
410
+ return conversationNameCache.get(groupId) || eventGroupName({}, groupId, config);
411
+ }
412
+ async function handleMonitorCommand(command, groupId, getDashboardConfig, updateDashboardConfig) {
413
+ const config = getDashboardConfig();
414
+ if (!config.robotCode.trim())
415
+ throw new Error("无法执行 AI 管理命令:请先在管理页配置机器人 Robot Code");
416
+ const target = {
417
+ groupId,
418
+ groupName: await resolveCommandGroupName(groupId, config),
419
+ senderId: DU_ZHENXUN_OPEN_DINGTALK_ID,
420
+ senderName: "杜振训",
421
+ };
422
+ const result = applyMonitorCommand(config, command, target);
423
+ if (result.changed)
424
+ await updateDashboardConfig(result.config);
425
+ const detail = command === "open" ? "注意啦~本群 映客活动AI 解锁🔓" : "注意啦~本群 映客活动AI 已休眠💤";
426
+ await sendRobotText(groupId, config.robotCode, detail);
427
+ log.info(`monitor command=${command} group=${groupId} changed=${result.changed}`);
428
+ }
301
429
  function acceptsTarget(event, config) {
302
430
  const groupId = event.conversation_id?.trim();
303
431
  if (!groupId)
@@ -333,6 +461,7 @@ function isMissingCardError(err) {
333
461
  function getSenderId(event) {
334
462
  const sender = event.sender ?? {};
335
463
  return event.sender_open_dingtalk_id?.trim() ||
464
+ event.senderOpenDingTalkId?.trim() ||
336
465
  event.sender_user_id?.trim() ||
337
466
  (typeof sender.openDingtalkId === "string" ? sender.openDingtalkId.trim() : "") ||
338
467
  (typeof sender.open_dingtalk_id === "string" ? sender.open_dingtalk_id.trim() : "") ||
@@ -555,7 +684,7 @@ function resolveGroupIds() {
555
684
  throw new Error("No DWS group configured");
556
685
  return Array.from(new Set(groupIds));
557
686
  }
558
- function startGroupListener(seen, queues, cardState, sessions, cardClient, getDashboardConfig, replies, liveReplies, runtime) {
687
+ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDashboardConfig, updateDashboardConfig, replies, liveReplies, runtime) {
559
688
  const args = [
560
689
  "event", "consume", DWS_GROUP_MESSAGE_EVENT,
561
690
  "--flatten", "--format", "ndjson",
@@ -566,6 +695,7 @@ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDa
566
695
  log.info(`starting ${dwsPath} ${args.join(" ")}`);
567
696
  const listener = spawn(dwsPath, args, { stdio: ["pipe", "pipe", "pipe"] });
568
697
  runtime.eventConnected = true;
698
+ let monitorCommandChain = Promise.resolve();
569
699
  const rl = createInterface({ input: listener.stdout });
570
700
  listener.stderr.on("data", (chunk) => {
571
701
  const message = chunk.toString().trim();
@@ -573,17 +703,13 @@ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDa
573
703
  log.debug(`dws: ${message}`);
574
704
  });
575
705
  const acceptEvent = (event) => {
576
- const groupId = event.conversation_id?.trim();
706
+ const groupId = eventGroupId(event);
577
707
  if (!groupId)
578
708
  return;
579
709
  if (isIgnoredRobotEvent(event)) {
580
710
  log.debug(`ignored robot message event=${event.event_id || "unknown"}`);
581
711
  return;
582
712
  }
583
- if (!acceptsTarget(event, getDashboardConfig())) {
584
- log.debug(`ignored unmonitored sender=${getSenderId(event)} event=${event.event_id || "unknown"}`);
585
- return;
586
- }
587
713
  const key = eventKey(event, groupId);
588
714
  if (key && seen.has(key))
589
715
  return;
@@ -593,6 +719,19 @@ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDa
593
719
  seen.delete(seen.values().next().value);
594
720
  }
595
721
  runtime.lastEventAt = new Date().toISOString();
722
+ const command = parseMonitorCommand({ senderId: getSenderId(event), content: event.content || event.text });
723
+ if (command) {
724
+ if (command === "stop")
725
+ queues.get(groupId)?.pending.splice(0);
726
+ monitorCommandChain = monitorCommandChain
727
+ .then(() => handleMonitorCommand(command, groupId, getDashboardConfig, updateDashboardConfig))
728
+ .catch((err) => log.error(`monitor command failed: ${String(err)}`));
729
+ return;
730
+ }
731
+ if (!acceptsTarget(event, getDashboardConfig())) {
732
+ log.debug(`ignored unmonitored sender=${getSenderId(event)} event=${event.event_id || "unknown"}`);
733
+ return;
734
+ }
596
735
  void enqueueGroupEvent(event, groupId, queues, cardState, sessions, cardClient, getDashboardConfig, replies, liveReplies, runtime)
597
736
  .catch((err) => log.error(String(err)));
598
737
  };
@@ -618,11 +757,33 @@ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDa
618
757
  void pollHistory().catch((err) => log.warn(`history poll failed: ${String(err)}`));
619
758
  }, historyPollIntervalMs);
620
759
  pollTimer.unref();
760
+ let lastCommandPollAt = new Date(Date.now() - 60_000);
761
+ let commandPollInFlight = false;
762
+ const pollCommands = async () => {
763
+ if (commandPollInFlight)
764
+ return;
765
+ commandPollInFlight = true;
766
+ const queryStartedAt = new Date();
767
+ try {
768
+ (await searchMonitorCommands(lastCommandPollAt)).forEach(acceptEvent);
769
+ // Overlap protects messages created during the request; message ID de-duplication is shared with Stream events.
770
+ lastCommandPollAt = new Date(queryStartedAt.getTime() - 1_000);
771
+ }
772
+ finally {
773
+ commandPollInFlight = false;
774
+ }
775
+ };
776
+ void pollCommands().catch((err) => log.warn(`monitor command poll failed: ${String(err)}`));
777
+ const commandPollTimer = setInterval(() => {
778
+ void pollCommands().catch((err) => log.warn(`monitor command poll failed: ${String(err)}`));
779
+ }, commandPollIntervalMs);
780
+ commandPollTimer.unref();
621
781
  return new Promise((resolve, reject) => {
622
782
  listener.on("error", reject);
623
783
  listener.on("close", (code) => {
624
784
  runtime.eventConnected = false;
625
785
  clearInterval(pollTimer);
786
+ clearInterval(commandPollTimer);
626
787
  const close = code === 0
627
788
  ? Promise.resolve()
628
789
  : Promise.reject(new Error(`dws event consume exited with code ${code}`));
@@ -661,6 +822,13 @@ async function main() {
661
822
  const sessions = new Map();
662
823
  const cardClient = new DingTalkCardClient();
663
824
  let dashboardConfig = await loadDashboardConfig();
825
+ const applyDashboardConfig = async (config) => {
826
+ await saveDashboardConfig(config);
827
+ dashboardConfig = config;
828
+ cardClient.setCredentials(config.clientId, config.clientSecret);
829
+ cardClient.setRobotCode(config.robotCode);
830
+ log.info(`dashboard config applied: ${configuredGroupIds(config).length} group(s), ${config.targets.length} rule(s)`);
831
+ };
664
832
  await saveDashboardConfig(dashboardConfig);
665
833
  const dashboardServerConfig = await loadDashboardServerConfig();
666
834
  cardClient.setCredentials(dashboardConfig.clientId, dashboardConfig.clientSecret);
@@ -674,13 +842,7 @@ async function main() {
674
842
  };
675
843
  startDashboard(dashboardServerConfig.port, {
676
844
  getConfig: () => dashboardConfig,
677
- updateConfig: async (config) => {
678
- await saveDashboardConfig(config);
679
- dashboardConfig = config;
680
- cardClient.setCredentials(config.clientId, config.clientSecret);
681
- cardClient.setRobotCode(config.robotCode);
682
- log.info(`dashboard config applied: ${configuredGroupIds(config).length} group(s), ${config.targets.length} rule(s)`);
683
- },
845
+ updateConfig: applyDashboardConfig,
684
846
  getStatus: () => ({ ...runtime }),
685
847
  getReplies: () => [...liveReplies.values(), ...replies],
686
848
  searchGroups,
@@ -688,7 +850,7 @@ async function main() {
688
850
  }, { host: dashboardServerConfig.host });
689
851
  log.info(`dashboard started at http://${dashboardServerConfig.host}:${dashboardServerConfig.port}`);
690
852
  log.info(`monitoring ${configuredGroupIds(dashboardConfig).length} group(s) with ${dashboardConfig.targets.length} rule(s)`);
691
- await startGroupListener(seen, queues, cardState, sessions, cardClient, () => dashboardConfig, replies, liveReplies, runtime);
853
+ await startGroupListener(seen, queues, cardState, sessions, cardClient, () => dashboardConfig, applyDashboardConfig, replies, liveReplies, runtime);
692
854
  }
693
855
  main().catch((err) => {
694
856
  log.error(err instanceof Error ? err.message : String(err));
@@ -0,0 +1,37 @@
1
+ export const DU_ZHENXUN_OPEN_DINGTALK_ID = "DZyRu3o9aXvmiSh7BJa5S4EQiEiE";
2
+ export function parseMonitorCommand(event) {
3
+ if (event.senderId !== DU_ZHENXUN_OPEN_DINGTALK_ID)
4
+ return undefined;
5
+ const content = event.content?.replace(/\s+/g, "").toLowerCase();
6
+ if (content === "打开ai" || content === "启动ai" || content === "醒醒")
7
+ return "open";
8
+ if (content === "停止ai" || content === "关闭ai" || content === "睡吧")
9
+ return "stop";
10
+ return undefined;
11
+ }
12
+ export function applyMonitorCommand(config, command, target) {
13
+ const existing = config.targets.find((item) => item.groupId === target.groupId && item.senderId === target.senderId);
14
+ if (command === "open") {
15
+ if (existing) {
16
+ if (existing.groupName === target.groupName && existing.senderName === target.senderName)
17
+ return { config, changed: false };
18
+ return {
19
+ changed: true,
20
+ config: {
21
+ ...config,
22
+ targets: config.targets.map((item) => item === existing ? { ...item, groupName: target.groupName, senderName: target.senderName } : item),
23
+ },
24
+ };
25
+ }
26
+ return { config: { ...config, targets: [...config.targets, target] }, changed: true };
27
+ }
28
+ if (!existing)
29
+ return { config, changed: false };
30
+ return {
31
+ config: {
32
+ ...config,
33
+ targets: config.targets.filter((item) => item.groupId !== target.groupId || item.senderId !== target.senderId),
34
+ },
35
+ changed: true,
36
+ };
37
+ }
package/dist/omi-bot.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { open, unlink } from "node:fs/promises";
3
+ import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { runApp } from "./index.js";
5
- const workspace = process.cwd();
6
- const stateDir = join(workspace, ".oh-my-im");
6
+ const stateDir = join(homedir(), ".oh-my-im");
7
7
  const dashboardConfigFile = join(stateDir, "dws-dashboard.json");
8
- const lockFile = join(workspace, ".oh-my-im-bot.lock");
8
+ const lockFile = join(stateDir, "omi-bot.lock");
9
9
  const defaultWorkDir = "/Users/dds/go/src/git.inke.cn/opd/activitys/inke.activity.service";
10
10
  async function acquireLock() {
11
11
  let lock;
package/dist/omi.js CHANGED
@@ -1,16 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
+ import { homedir } from "node:os";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { dirname, join } from "node:path";
6
7
  const args = process.argv.slice(2);
7
8
  const command = args.find((argument) => !argument.startsWith("-")) ?? "start";
8
9
  const noListen = args.includes("--no-listen");
9
- const workspace = process.cwd();
10
- const stateDir = join(workspace, ".oh-my-im");
10
+ const launchWorkspace = process.cwd();
11
+ const stateDir = join(homedir(), ".oh-my-im");
11
12
  const stateFile = join(stateDir, "omi-state.json");
12
- const listenerLockFile = join(workspace, ".oh-my-im-dws-listener.lock");
13
- const serverConfigFile = join(stateDir, "dws-dashboard-server.json");
14
13
  const logFile = join(stateDir, "omi.log");
15
14
  const here = dirname(fileURLToPath(import.meta.url));
16
15
  const listenerPath = join(here, "dws-listener.js");
@@ -49,7 +48,7 @@ function pidFromFile(path) {
49
48
  }
50
49
  }
51
50
  function unmanagedListenerPid() {
52
- const pid = pidFromFile(listenerLockFile);
51
+ const pid = pidFromFile(join(stateDir, "dws-listener.lock"));
53
52
  return pid && isRunning(pid) ? pid : undefined;
54
53
  }
55
54
  function readState() {
@@ -72,8 +71,9 @@ function writeState(state) {
72
71
  mkdirSync(stateDir, { recursive: true });
73
72
  writeFileSync(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
74
73
  }
75
- function dashboardAddress() {
74
+ function dashboardAddress(state) {
76
75
  try {
76
+ const serverConfigFile = join(stateDir, "dws-dashboard-server.json");
77
77
  const config = JSON.parse(readFileSync(serverConfigFile, "utf8"));
78
78
  if (!config.port || !config.host)
79
79
  return undefined;
@@ -84,7 +84,7 @@ function dashboardAddress() {
84
84
  return undefined;
85
85
  }
86
86
  }
87
- function startProcess(role, path) {
87
+ function startProcess(role, path, workspace) {
88
88
  if (!existsSync(path))
89
89
  throw new Error("omi is not built. Run npm run build first.");
90
90
  const logFd = openSync(logFile, "a");
@@ -98,7 +98,7 @@ function startProcess(role, path) {
98
98
  throw new Error(`Unable to start omi ${role}`);
99
99
  return { role, pid: child.pid };
100
100
  }
101
- function start(mode) {
101
+ function start(mode, workspace = launchWorkspace) {
102
102
  const current = readState();
103
103
  if (current) {
104
104
  console.log(`omi is already running in ${current.mode} mode`);
@@ -111,9 +111,9 @@ function start(mode) {
111
111
  mkdirSync(stateDir, { recursive: true });
112
112
  const processes = [];
113
113
  if (mode === "listen")
114
- processes.push(startProcess("listener", listenerPath));
115
- processes.push(startProcess("bot", botPath));
116
- writeState({ mode, startedAt: new Date().toISOString(), processes });
114
+ processes.push(startProcess("listener", listenerPath, workspace));
115
+ processes.push(startProcess("bot", botPath, workspace));
116
+ writeState({ mode, startedAt: new Date().toISOString(), workspace, processes });
117
117
  console.log(`omi started in ${mode === "listen" ? "group listening + one-to-one" : "one-to-one"} mode`);
118
118
  console.log(`log: ${logFile}`);
119
119
  }
@@ -148,9 +148,10 @@ function status() {
148
148
  return;
149
149
  }
150
150
  console.log(`omi: running (${current.mode === "listen" ? "group listening + one-to-one" : "one-to-one"})`);
151
+ console.log(`workspace: ${current.workspace || homedir()}`);
151
152
  for (const managed of current.processes)
152
153
  console.log(`${managed.role}: running (pid ${managed.pid})`);
153
- const address = dashboardAddress();
154
+ const address = dashboardAddress(current);
154
155
  if (address)
155
156
  console.log(`dashboard: ${address}`);
156
157
  console.log(`log: ${logFile}`);
@@ -171,7 +172,7 @@ else if (command === "update") {
171
172
  const current = readState();
172
173
  const mode = current?.mode ?? "listen";
173
174
  await stop();
174
- start(mode);
175
+ start(mode, current?.workspace || launchWorkspace);
175
176
  }
176
177
  else {
177
178
  console.error(`Unknown command: ${command}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-im",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "repository": {
15
15
  "type": "git",
16
- "url": "git@github.com:duzhenxun/oh-my-im.git"
16
+ "url": "git+ssh://git@github.com/duzhenxun/oh-my-im.git"
17
17
  },
18
18
  "homepage": "https://github.com/duzhenxun/oh-my-im#readme",
19
19
  "publishConfig": {