oh-my-im 0.1.0

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 ADDED
@@ -0,0 +1,14 @@
1
+ # oh-my-im
2
+
3
+ 独立版钉钉机器人到 Codex CLI 桥接程序。
4
+
5
+ ## 使用
6
+
7
+ ```bash
8
+ npm install
9
+ cp .env.example .env
10
+ # 编辑 .env
11
+ npm run dev
12
+ ```
13
+
14
+ 详见 [docs/development.md](docs/development.md)。
package/dist/cli.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ import { runApp } from "./index.js";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ const command = process.argv[2] ?? "start";
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ if (command === "--help" || command === "-h" || command === "help") {
9
+ console.log([
10
+ "oh-my-im",
11
+ "",
12
+ "Commands:",
13
+ " start start the DingTalk to Codex bridge (default)",
14
+ " version print version",
15
+ ].join("\n"));
16
+ process.exit(0);
17
+ }
18
+ if (command === "--version" || command === "-v" || command === "version") {
19
+ const pkgPath = join(here, "..", "package.json");
20
+ const version = JSON.parse(readFileSync(pkgPath, "utf8")).version ?? "0.1.0";
21
+ console.log(version);
22
+ process.exit(0);
23
+ }
24
+ if (command !== "start") {
25
+ console.error(`Unknown command: ${command}`);
26
+ process.exit(1);
27
+ }
28
+ await runApp();
package/dist/codex.js ADDED
@@ -0,0 +1,168 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { createLogger } from "./logger.js";
4
+ const log = createLogger("Codex");
5
+ function parseJsonLine(line) {
6
+ try {
7
+ return JSON.parse(line);
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ function extractText(item) {
14
+ const itemType = item.type;
15
+ if (itemType === "agent_message") {
16
+ return typeof item.message === "string"
17
+ ? item.message
18
+ : typeof item.text === "string"
19
+ ? item.text
20
+ : undefined;
21
+ }
22
+ if (itemType === "message") {
23
+ const content = item.content;
24
+ if (!Array.isArray(content))
25
+ return undefined;
26
+ return content
27
+ .map((part) => {
28
+ if (!part || typeof part !== "object")
29
+ return "";
30
+ const p = part;
31
+ return p.type === "output_text" && p.text ? p.text : "";
32
+ })
33
+ .filter(Boolean)
34
+ .join("\n");
35
+ }
36
+ return undefined;
37
+ }
38
+ function buildArgs(prompt, workDir, sessionId, config) {
39
+ const common = ["--json", "--skip-git-repo-check"];
40
+ if (config.codexPermissionMode === "bypass") {
41
+ common.push("--dangerously-bypass-approvals-and-sandbox");
42
+ }
43
+ else {
44
+ common.push("--full-auto");
45
+ }
46
+ return sessionId
47
+ ? ["exec", "resume", ...common, sessionId, "-"]
48
+ : ["exec", ...common, "--cd", workDir, "-"];
49
+ }
50
+ export function runCodex(prompt, sessionId, config) {
51
+ return new Promise((resolve, reject) => {
52
+ const start = Date.now();
53
+ const args = buildArgs(prompt, config.codexWorkDir, sessionId, config);
54
+ const env = { ...process.env };
55
+ if (config.codexProxy) {
56
+ env.HTTP_PROXY = config.codexProxy;
57
+ env.HTTPS_PROXY = config.codexProxy;
58
+ env.http_proxy = config.codexProxy;
59
+ env.https_proxy = config.codexProxy;
60
+ env.ALL_PROXY = config.codexProxy;
61
+ env.all_proxy = config.codexProxy;
62
+ }
63
+ log.info(`spawn ${config.codexCliPath} ${args.join(" ")}`);
64
+ const child = spawn(config.codexCliPath, args, {
65
+ cwd: config.codexWorkDir,
66
+ env,
67
+ stdio: ["pipe", "pipe", "pipe"],
68
+ });
69
+ let completed = false;
70
+ let nextSessionId = sessionId;
71
+ let accumulated = "";
72
+ let stderr = "";
73
+ const toolStats = {};
74
+ const timeout = setTimeout(() => {
75
+ if (completed)
76
+ return;
77
+ completed = true;
78
+ child.kill("SIGTERM");
79
+ log.warn(`timeout after ${config.cliTimeoutMs}ms`);
80
+ reject(new Error(`Codex CLI timeout after ${Math.round(config.cliTimeoutMs / 1000)}s`));
81
+ }, config.cliTimeoutMs);
82
+ timeout.unref();
83
+ child.stdin.write(prompt);
84
+ child.stdin.end();
85
+ child.stderr.on("data", (chunk) => {
86
+ stderr += chunk.toString();
87
+ });
88
+ const rl = createInterface({ input: child.stdout });
89
+ rl.on("line", (line) => {
90
+ const event = parseJsonLine(line);
91
+ if (!event)
92
+ return;
93
+ const payload = event.payload ?? event;
94
+ const type = payload.type ?? event.type;
95
+ if (type === "thread.started" || type === "session_meta") {
96
+ const id = payload.thread_id ?? payload.session_id ?? event.thread_id;
97
+ if (typeof id === "string" && id) {
98
+ nextSessionId = id;
99
+ log.debug(`session=${id}`);
100
+ }
101
+ return;
102
+ }
103
+ if (type === "turn.failed" || type === "error") {
104
+ const err = payload.error;
105
+ const message = err?.message ?? (typeof payload.message === "string" ? payload.message : "Codex failed");
106
+ log.error(message);
107
+ completed = true;
108
+ clearTimeout(timeout);
109
+ reject(new Error(message));
110
+ return;
111
+ }
112
+ if (event.type === "response_item" || type === "item.started" || type === "item.updated" || type === "item.completed") {
113
+ const item = payload.item ?? payload;
114
+ const itemType = item.type;
115
+ if (itemType === "function_call" || itemType === "custom_tool_call") {
116
+ const name = typeof item.name === "string" ? item.name : "tool";
117
+ toolStats[name] = (toolStats[name] ?? 0) + 1;
118
+ log.debug(`tool=${name}`);
119
+ return;
120
+ }
121
+ const text = extractText(item);
122
+ if (text)
123
+ accumulated += (accumulated ? "\n\n" : "") + text;
124
+ return;
125
+ }
126
+ if (type === "task_complete" || type === "turn.completed") {
127
+ const last = payload.last_agent_message;
128
+ if (!accumulated && typeof last === "string")
129
+ accumulated = last;
130
+ completed = true;
131
+ clearTimeout(timeout);
132
+ log.info(`completed in ${Date.now() - start}ms`);
133
+ resolve({
134
+ sessionId: nextSessionId,
135
+ text: accumulated.trim() || "(无输出)",
136
+ toolStats,
137
+ durationMs: Date.now() - start,
138
+ });
139
+ }
140
+ });
141
+ child.on("error", (err) => {
142
+ if (completed)
143
+ return;
144
+ completed = true;
145
+ clearTimeout(timeout);
146
+ log.error("spawn error", err);
147
+ reject(err);
148
+ });
149
+ child.on("close", (code) => {
150
+ if (completed)
151
+ return;
152
+ completed = true;
153
+ clearTimeout(timeout);
154
+ if (code && code !== 0) {
155
+ log.error(`exit code ${code}`);
156
+ reject(new Error(stderr.trim() || `Codex CLI exited with code ${code}`));
157
+ return;
158
+ }
159
+ log.info(`closed cleanly in ${Date.now() - start}ms`);
160
+ resolve({
161
+ sessionId: nextSessionId,
162
+ text: accumulated.trim() || "(无输出)",
163
+ toolStats,
164
+ durationMs: Date.now() - start,
165
+ });
166
+ });
167
+ });
168
+ }
package/dist/config.js ADDED
@@ -0,0 +1,39 @@
1
+ import "dotenv/config";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ function requireEnv(name) {
5
+ const value = process.env[name]?.trim();
6
+ if (!value)
7
+ throw new Error(`Missing required env: ${name}`);
8
+ return value;
9
+ }
10
+ function splitCsv(value) {
11
+ if (!value)
12
+ return [];
13
+ return value
14
+ .split(",")
15
+ .map((item) => item.trim())
16
+ .filter(Boolean);
17
+ }
18
+ function resolveWorkDir() {
19
+ const configured = process.env.CODEX_WORK_DIR?.trim();
20
+ const dir = configured ? resolve(configured) : process.cwd();
21
+ if (!existsSync(dir))
22
+ throw new Error(`CODEX_WORK_DIR does not exist: ${dir}`);
23
+ return dir;
24
+ }
25
+ export function loadConfig() {
26
+ const permissionMode = process.env.CODEX_PERMISSION_MODE?.trim().toLowerCase();
27
+ const timeoutRaw = process.env.OPEN_IM_CLI_TIMEOUT_MS?.trim();
28
+ const timeout = timeoutRaw ? Number.parseInt(timeoutRaw, 10) : 30 * 60 * 1000;
29
+ return {
30
+ dingtalkClientId: requireEnv("DINGTALK_CLIENT_ID"),
31
+ dingtalkClientSecret: requireEnv("DINGTALK_CLIENT_SECRET"),
32
+ codexCliPath: process.env.CODEX_CLI_PATH?.trim() || "codex",
33
+ codexWorkDir: resolveWorkDir(),
34
+ codexProxy: process.env.CODEX_PROXY?.trim() || undefined,
35
+ codexPermissionMode: permissionMode === "bypass" ? "bypass" : undefined,
36
+ allowedUserIds: splitCsv(process.env.ALLOWED_USER_IDS),
37
+ cliTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : 30 * 60 * 1000,
38
+ };
39
+ }
@@ -0,0 +1,112 @@
1
+ import { DWClient, TOPIC_ROBOT } from "dingtalk-stream";
2
+ import { createLogger } from "./logger.js";
3
+ const log = createLogger("DingTalk");
4
+ export class DingTalkBot {
5
+ config;
6
+ client = null;
7
+ webhooks = new Map();
8
+ constructor(config) {
9
+ this.config = config;
10
+ }
11
+ async start(onMessage) {
12
+ this.client = new DWClient({
13
+ clientId: this.config.dingtalkClientId,
14
+ clientSecret: this.config.dingtalkClientSecret,
15
+ keepAlive: true,
16
+ debug: false,
17
+ });
18
+ this.client.registerCallbackListener(TOPIC_ROBOT, async (data) => {
19
+ const message = this.parseMessage(data);
20
+ if (!message) {
21
+ log.warn("Ignored invalid DingTalk payload");
22
+ this.ack(data.headers.messageId, { ignored: true });
23
+ return;
24
+ }
25
+ try {
26
+ await onMessage(message);
27
+ this.ack(message.callbackId, { handled: true });
28
+ }
29
+ catch (err) {
30
+ console.error("[DingTalk] message handling failed:", err);
31
+ this.ack(message.callbackId, { error: String(err) });
32
+ }
33
+ });
34
+ await this.client.connect();
35
+ console.log("[DingTalk] stream connected");
36
+ }
37
+ stop() {
38
+ this.client?.disconnect();
39
+ this.client = null;
40
+ this.webhooks.clear();
41
+ }
42
+ async sendText(conversationId, content) {
43
+ const webhook = this.webhooks.get(conversationId);
44
+ if (!webhook)
45
+ throw new Error(`No sessionWebhook for conversation: ${conversationId}`);
46
+ const accessToken = await this.getAccessToken();
47
+ const res = await fetch(webhook, {
48
+ method: "POST",
49
+ headers: {
50
+ "content-type": "application/json",
51
+ "x-acs-dingtalk-access-token": accessToken,
52
+ },
53
+ body: JSON.stringify({
54
+ msgtype: "text",
55
+ text: { content },
56
+ }),
57
+ signal: AbortSignal.timeout(30_000),
58
+ });
59
+ if (!res.ok) {
60
+ const text = await res.text();
61
+ throw new Error(`DingTalk reply failed: ${res.status} ${text}`);
62
+ }
63
+ log.debug(`Sent DingTalk text to conversation=${conversationId}`);
64
+ }
65
+ parseMessage(data) {
66
+ let payload;
67
+ try {
68
+ payload = JSON.parse(data.data);
69
+ }
70
+ catch {
71
+ log.warn("Failed to parse DingTalk payload");
72
+ return null;
73
+ }
74
+ const conversationId = payload.conversationId;
75
+ const sessionWebhook = payload.sessionWebhook;
76
+ const senderStaffId = payload.senderStaffId;
77
+ const senderId = payload.senderId;
78
+ const msgtype = payload.msgtype;
79
+ const textPayload = payload.text;
80
+ const text = msgtype === "text" ? textPayload?.content?.trim() ?? "" : "";
81
+ if (typeof conversationId !== "string" ||
82
+ typeof sessionWebhook !== "string" ||
83
+ typeof senderId !== "string" ||
84
+ typeof msgtype !== "string") {
85
+ return null;
86
+ }
87
+ this.webhooks.set(conversationId, sessionWebhook);
88
+ return {
89
+ callbackId: data.headers.messageId,
90
+ conversationId,
91
+ sessionWebhook,
92
+ senderId,
93
+ senderStaffId: typeof senderStaffId === "string" ? senderStaffId : undefined,
94
+ senderNick: typeof payload.senderNick === "string" ? payload.senderNick : undefined,
95
+ text,
96
+ msgtype,
97
+ };
98
+ }
99
+ async getAccessToken() {
100
+ if (!this.client)
101
+ throw new Error("DingTalk client is not initialized");
102
+ return this.client.getAccessToken();
103
+ }
104
+ ack(messageId, result) {
105
+ try {
106
+ this.client?.socketCallBackResponse(messageId, result);
107
+ }
108
+ catch (err) {
109
+ log.warn("DingTalk ack failed:", err);
110
+ }
111
+ }
112
+ }
package/dist/index.js ADDED
@@ -0,0 +1,103 @@
1
+ import { loadConfig } from "./config.js";
2
+ import { runCodex } from "./codex.js";
3
+ import { DingTalkBot } from "./dingtalk.js";
4
+ import { createLogger } from "./logger.js";
5
+ const log = createLogger("Main");
6
+ function getState(conversations, conversationId) {
7
+ const existing = conversations.get(conversationId);
8
+ if (existing)
9
+ return existing;
10
+ const created = { busy: false };
11
+ conversations.set(conversationId, created);
12
+ return created;
13
+ }
14
+ function isAllowed(message, allowedUserIds) {
15
+ if (allowedUserIds.length === 0)
16
+ return true;
17
+ return allowedUserIds.includes(message.senderStaffId ?? message.senderId);
18
+ }
19
+ function formatStats(stats) {
20
+ const entries = Object.entries(stats);
21
+ if (entries.length === 0)
22
+ return "";
23
+ return entries.map(([name, count]) => `${name} x${count}`).join(", ");
24
+ }
25
+ async function handleCommand(bot, config, conversations, message, text) {
26
+ const state = getState(conversations, message.conversationId);
27
+ if (text === "/help") {
28
+ await bot.sendText(message.conversationId, [
29
+ "oh-my-im commands:",
30
+ "/help - 查看帮助",
31
+ "/status - 查看运行状态",
32
+ "/new - 清空当前会话的 Codex session",
33
+ ].join("\n"));
34
+ return true;
35
+ }
36
+ if (text === "/status") {
37
+ await bot.sendText(message.conversationId, [
38
+ "oh-my-im status:",
39
+ `Codex: ${config.codexCliPath}`,
40
+ `WorkDir: ${config.codexWorkDir}`,
41
+ `Current session: ${state.sessionId ?? "new"}`,
42
+ `Known conversations: ${conversations.size}`,
43
+ ].join("\n"));
44
+ return true;
45
+ }
46
+ if (text === "/new") {
47
+ state.sessionId = undefined;
48
+ await bot.sendText(message.conversationId, "已清空当前会话的 Codex session。");
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+ export async function runApp() {
54
+ const config = loadConfig();
55
+ const bot = new DingTalkBot(config);
56
+ const conversations = new Map();
57
+ async function handleMessage(message) {
58
+ if (!isAllowed(message, config.allowedUserIds)) {
59
+ await bot.sendText(message.conversationId, `抱歉,您没有访问权限。\n您的 ID: ${message.senderStaffId ?? message.senderId}`);
60
+ return;
61
+ }
62
+ if (message.msgtype !== "text") {
63
+ await bot.sendText(message.conversationId, `暂不支持 ${message.msgtype} 消息,请发送文本。`);
64
+ return;
65
+ }
66
+ const text = message.text.trim();
67
+ if (!text)
68
+ return;
69
+ if (await handleCommand(bot, config, conversations, message, text))
70
+ return;
71
+ const state = getState(conversations, message.conversationId);
72
+ if (state.busy) {
73
+ await bot.sendText(message.conversationId, "当前会话已有 Codex 任务在执行,请稍后再发。");
74
+ return;
75
+ }
76
+ state.busy = true;
77
+ await bot.sendText(message.conversationId, "Codex 正在处理...");
78
+ try {
79
+ const result = await runCodex(text, state.sessionId, config);
80
+ state.sessionId = result.sessionId ?? state.sessionId;
81
+ const stats = formatStats(result.toolStats);
82
+ const note = [`耗时 ${(result.durationMs / 1000).toFixed(1)}s`, stats].filter(Boolean).join(" | ");
83
+ await bot.sendText(message.conversationId, `${result.text}\n\n${note}`);
84
+ }
85
+ catch (err) {
86
+ log.error("Codex execution failed", err);
87
+ await bot.sendText(message.conversationId, `Codex 执行失败:${err instanceof Error ? err.message : String(err)}`);
88
+ }
89
+ finally {
90
+ state.busy = false;
91
+ }
92
+ }
93
+ process.once("SIGINT", () => {
94
+ bot.stop();
95
+ process.exit(0);
96
+ });
97
+ process.once("SIGTERM", () => {
98
+ bot.stop();
99
+ process.exit(0);
100
+ });
101
+ await bot.start(handleMessage);
102
+ log.info(`ready workDir=${config.codexWorkDir} codex=${config.codexCliPath}`);
103
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,34 @@
1
+ const LEVEL_WEIGHT = {
2
+ debug: 10,
3
+ info: 20,
4
+ warn: 30,
5
+ error: 40,
6
+ };
7
+ function parseLevel(value) {
8
+ const normalized = value?.trim().toLowerCase();
9
+ if (normalized === "debug" || normalized === "info" || normalized === "warn" || normalized === "error") {
10
+ return normalized;
11
+ }
12
+ return "info";
13
+ }
14
+ export function createLogger(scope) {
15
+ const minLevel = parseLevel(process.env.LOG_LEVEL);
16
+ function emit(level, message, ...args) {
17
+ if (LEVEL_WEIGHT[level] < LEVEL_WEIGHT[minLevel])
18
+ return;
19
+ const prefix = `[${new Date().toISOString()}] [${scope}] [${level.toUpperCase()}]`;
20
+ const line = `${prefix} ${message}`;
21
+ if (level === "error")
22
+ console.error(line, ...args);
23
+ else if (level === "warn")
24
+ console.warn(line, ...args);
25
+ else
26
+ console.log(line, ...args);
27
+ }
28
+ return {
29
+ debug: (message, ...args) => emit("debug", message, ...args),
30
+ info: (message, ...args) => emit("info", message, ...args),
31
+ warn: (message, ...args) => emit("warn", message, ...args),
32
+ error: (message, ...args) => emit("error", message, ...args),
33
+ };
34
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "oh-my-im",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "files": [
7
+ "dist",
8
+ "README.md"
9
+ ],
10
+ "bin": {
11
+ "oh-my-im": "dist/cli.js"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git@github.com:duzhenxun/oh-my-im.git"
16
+ },
17
+ "homepage": "https://github.com/duzhenxun/oh-my-im#readme",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "dev": "tsx src/cli.ts",
23
+ "build": "tsc",
24
+ "start": "node dist/cli.js",
25
+ "help": "node dist/cli.js help"
26
+ },
27
+ "dependencies": {
28
+ "dingtalk-stream": "^2.1.4",
29
+ "dotenv": "^17.3.1"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^20.0.0",
33
+ "tsx": "^4.0.0",
34
+ "typescript": "^5.0.0"
35
+ },
36
+ "engines": {
37
+ "node": ">=20"
38
+ }
39
+ }