@openai-lite/codex-feishu 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 +164 -0
- package/bin/codex-feishu.js +8 -0
- package/docs/ARCHITECTURE.md +118 -0
- package/docs/FEISHU_SETUP.zh-CN.md +46 -0
- package/docs/README.zh-CN.md +130 -0
- package/docs/assets/feishu-preview-image-reply.png +0 -0
- package/docs/assets/feishu-preview-image-reply1.jpg +0 -0
- package/docs/assets/feishu-preview-session.png +0 -0
- package/package.json +23 -0
- package/src/cli.js +158 -0
- package/src/commands/daemon.js +6037 -0
- package/src/commands/doctor.js +126 -0
- package/src/commands/inbound.js +27 -0
- package/src/commands/init.js +224 -0
- package/src/commands/mcp.js +295 -0
- package/src/commands/qrcode.js +163 -0
- package/src/lib/app_server_client.js +831 -0
- package/src/lib/daemon_control.js +190 -0
- package/src/lib/feishu_bridge.js +461 -0
- package/src/lib/fs_utils.js +41 -0
- package/src/lib/paths.js +59 -0
- package/src/lib/state_store.js +146 -0
- package/src/lib/uds_rpc.js +195 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readTextIfExists } from "../lib/fs_utils.js";
|
|
3
|
+
import {
|
|
4
|
+
getBridgeConfigPath,
|
|
5
|
+
getBridgeRpcEndpoint,
|
|
6
|
+
getCodexConfigPath,
|
|
7
|
+
} from "../lib/paths.js";
|
|
8
|
+
import { callJsonRpc } from "../lib/uds_rpc.js";
|
|
9
|
+
|
|
10
|
+
function checkCommand(cmd, args = ["--version"]) {
|
|
11
|
+
try {
|
|
12
|
+
const output = execFileSync(cmd, args, { encoding: "utf8" }).trim();
|
|
13
|
+
return { ok: true, output };
|
|
14
|
+
} catch {
|
|
15
|
+
return { ok: false, output: "" };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function existsNonEmpty(filePath) {
|
|
20
|
+
const content = await readTextIfExists(filePath);
|
|
21
|
+
return Boolean(content && content.trim().length > 0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mark(ok) {
|
|
25
|
+
return ok ? "OK " : "ERR";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runDoctor() {
|
|
29
|
+
const codexCheck = checkCommand("codex");
|
|
30
|
+
const codexFeishuCheck = checkCommand("codex-feishu");
|
|
31
|
+
const invokedFromLocalScript = (process.argv[1] || "").includes("codex-feishu");
|
|
32
|
+
const codexFeishuAvailable = codexFeishuCheck.ok || invokedFromLocalScript;
|
|
33
|
+
const codexConfigPath = getCodexConfigPath();
|
|
34
|
+
const bridgeConfigPath = getBridgeConfigPath();
|
|
35
|
+
|
|
36
|
+
const codexConfig = (await readTextIfExists(codexConfigPath)) ?? "";
|
|
37
|
+
const mcpConfigured =
|
|
38
|
+
codexConfig.includes("[mcp_servers.codex_feishu]") &&
|
|
39
|
+
codexConfig.includes('command = "codex-feishu"');
|
|
40
|
+
const bridgeConfigReady = await existsNonEmpty(bridgeConfigPath);
|
|
41
|
+
const endpoint = getBridgeRpcEndpoint();
|
|
42
|
+
let daemonRunning = false;
|
|
43
|
+
let daemonInfo = "not running";
|
|
44
|
+
let bridgeStatus = null;
|
|
45
|
+
let recentEvents = [];
|
|
46
|
+
try {
|
|
47
|
+
const pong = await callJsonRpc(endpoint, "bridge/ping", {}, { timeoutMs: 800 });
|
|
48
|
+
daemonRunning = Boolean(pong?.ok);
|
|
49
|
+
daemonInfo = daemonRunning ? `running (version=${pong?.version || "unknown"})` : "not running";
|
|
50
|
+
if (daemonRunning) {
|
|
51
|
+
try {
|
|
52
|
+
bridgeStatus = await callJsonRpc(endpoint, "bridge/status", {}, { timeoutMs: 1200 });
|
|
53
|
+
} catch {
|
|
54
|
+
bridgeStatus = null;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const eventsResp = await callJsonRpc(endpoint, "feishu/events/recent", { limit: 80 }, { timeoutMs: 1200 });
|
|
58
|
+
if (Array.isArray(eventsResp?.items)) {
|
|
59
|
+
recentEvents = eventsResp.items;
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
recentEvents = [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
daemonRunning = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// eslint-disable-next-line no-console
|
|
70
|
+
console.log("codex-feishu doctor\n");
|
|
71
|
+
// eslint-disable-next-line no-console
|
|
72
|
+
console.log(`[${mark(codexCheck.ok)}] codex binary ${codexCheck.ok ? codexCheck.output : "not found"}`);
|
|
73
|
+
// eslint-disable-next-line no-console
|
|
74
|
+
console.log(
|
|
75
|
+
`[${mark(codexFeishuAvailable)}] codex-feishu binary ${
|
|
76
|
+
codexFeishuCheck.ok
|
|
77
|
+
? codexFeishuCheck.output
|
|
78
|
+
: invokedFromLocalScript
|
|
79
|
+
? "running from local script (not on PATH)"
|
|
80
|
+
: "not found"
|
|
81
|
+
}`,
|
|
82
|
+
);
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.log(`[${mark(mcpConfigured)}] mcp config in ${codexConfigPath}`);
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
console.log(`[${mark(bridgeConfigReady)}] bridge config ${bridgeConfigPath}`);
|
|
87
|
+
// eslint-disable-next-line no-console
|
|
88
|
+
console.log(`[${mark(daemonRunning)}] daemon rpc ${endpoint} ${daemonInfo}`);
|
|
89
|
+
if (bridgeStatus) {
|
|
90
|
+
const feishuRunning = Boolean(bridgeStatus?.feishu?.running);
|
|
91
|
+
const bindings = Number(bridgeStatus?.bindings ?? 0);
|
|
92
|
+
const activeThread = bridgeStatus?.active_thread_id ?? "(none)";
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.log(`[${mark(feishuRunning)}] feishu runtime ${feishuRunning ? "running" : "stopped"}`);
|
|
95
|
+
// eslint-disable-next-line no-console
|
|
96
|
+
console.log(`[INFO] bindings=${bindings}, active_thread=${activeThread}`);
|
|
97
|
+
}
|
|
98
|
+
if (recentEvents.length > 0) {
|
|
99
|
+
const inbound = recentEvents.filter((ev) => ev?.source === "feishu" && ev?.type === "feishu_message_inbound");
|
|
100
|
+
const inboundCount = inbound.length;
|
|
101
|
+
const groupInboundCount = inbound.filter((ev) => {
|
|
102
|
+
const t = String(ev?.chat_type ?? "").toLowerCase();
|
|
103
|
+
return t && t !== "p2p" && t !== "single";
|
|
104
|
+
}).length;
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.log(
|
|
107
|
+
`[INFO] recent inbound events=${inboundCount}, group_inbound=${groupInboundCount} (last ${recentEvents.length} events)`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ok =
|
|
112
|
+
codexCheck.ok &&
|
|
113
|
+
codexFeishuAvailable &&
|
|
114
|
+
mcpConfigured &&
|
|
115
|
+
bridgeConfigReady &&
|
|
116
|
+
daemonRunning;
|
|
117
|
+
// eslint-disable-next-line no-console
|
|
118
|
+
console.log(`\nOverall: ${ok ? "READY" : "NOT READY"}`);
|
|
119
|
+
if (!ok) {
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
console.log("Run: codex-feishu init");
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.log("Then start daemon: codex-feishu daemon");
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getBridgeRpcEndpoint } from "../lib/paths.js";
|
|
2
|
+
import { callJsonRpc } from "../lib/uds_rpc.js";
|
|
3
|
+
|
|
4
|
+
export async function runInbound(flags) {
|
|
5
|
+
const chatId = flags["chat-id"] || flags.chat;
|
|
6
|
+
const userId = flags["user-id"] || flags.user || null;
|
|
7
|
+
const text = flags.text;
|
|
8
|
+
if (!chatId || !text) {
|
|
9
|
+
throw new Error("usage: codex-feishu inbound --chat-id <chat_id> --text <message> [--user-id <id>]");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const endpoint = getBridgeRpcEndpoint();
|
|
13
|
+
const result = await callJsonRpc(
|
|
14
|
+
endpoint,
|
|
15
|
+
"feishu/inbound_text",
|
|
16
|
+
{
|
|
17
|
+
chat_id: chatId,
|
|
18
|
+
user_id: userId,
|
|
19
|
+
text,
|
|
20
|
+
},
|
|
21
|
+
{ timeoutMs: 30_000 },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// eslint-disable-next-line no-console
|
|
25
|
+
console.log(JSON.stringify(result, null, 2));
|
|
26
|
+
}
|
|
27
|
+
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import { ensureDir, readJsonIfExists, readTextIfExists, writeText } from "../lib/fs_utils.js";
|
|
4
|
+
import { getBridgeConfigPath, getBridgeHome, getCodexConfigPath, getPromptsDir } from "../lib/paths.js";
|
|
5
|
+
|
|
6
|
+
const CODEX_FEISHU_MARK_BEGIN = "# BEGIN codex-feishu";
|
|
7
|
+
const CODEX_FEISHU_MARK_END = "# END codex-feishu";
|
|
8
|
+
|
|
9
|
+
const MCP_BLOCK = `${CODEX_FEISHU_MARK_BEGIN}
|
|
10
|
+
[mcp_servers.codex_feishu]
|
|
11
|
+
command = "codex-feishu"
|
|
12
|
+
args = ["mcp"]
|
|
13
|
+
${CODEX_FEISHU_MARK_END}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
function buildBridgeConfig(flags, existing = {}) {
|
|
17
|
+
return {
|
|
18
|
+
version: 1,
|
|
19
|
+
app_id: flags["app-id"] || process.env.FEISHU_APP_ID || existing.app_id || "",
|
|
20
|
+
app_secret: flags["app-secret"] || process.env.FEISHU_APP_SECRET || existing.app_secret || "",
|
|
21
|
+
bot_open_id: flags["bot-open-id"] || process.env.FEISHU_BOT_OPEN_ID || existing.bot_open_id || "",
|
|
22
|
+
encrypt_key: flags["encrypt-key"] || process.env.FEISHU_ENCRYPT_KEY || existing.encrypt_key || "",
|
|
23
|
+
verify_token: flags["verify-token"] || process.env.FEISHU_VERIFY_TOKEN || existing.verify_token || "",
|
|
24
|
+
codex_bin:
|
|
25
|
+
flags["codex-bin"] ||
|
|
26
|
+
process.env.CODEX_FEISHU_CODEX_BIN ||
|
|
27
|
+
process.env.CODEX_BIN ||
|
|
28
|
+
existing.codex_bin ||
|
|
29
|
+
"",
|
|
30
|
+
event_mode: "long_connection",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readJsonSafe(response) {
|
|
35
|
+
try {
|
|
36
|
+
return await response.json();
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatReason(prefix, code, msg, status) {
|
|
43
|
+
if (typeof code === "number" || typeof code === "string") {
|
|
44
|
+
return `${prefix} code=${code}${msg ? ` msg=${msg}` : ""}`;
|
|
45
|
+
}
|
|
46
|
+
if (status) {
|
|
47
|
+
return `${prefix} http=${status}`;
|
|
48
|
+
}
|
|
49
|
+
return prefix;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function tryFetchBotOpenId(appId, appSecret) {
|
|
53
|
+
try {
|
|
54
|
+
const authResp = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "content-type": "application/json" },
|
|
57
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
58
|
+
signal: AbortSignal.timeout(6000),
|
|
59
|
+
});
|
|
60
|
+
const authJson = await readJsonSafe(authResp);
|
|
61
|
+
if (!authResp.ok) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
reason: formatReason("tenant_access_token failed", authJson?.code, authJson?.msg, authResp.status),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (!authJson || authJson.code !== 0 || !authJson.tenant_access_token) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reason: formatReason(
|
|
71
|
+
"tenant_access_token invalid response",
|
|
72
|
+
authJson?.code,
|
|
73
|
+
authJson?.msg,
|
|
74
|
+
authResp.status,
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const botResp = await fetch("https://open.feishu.cn/open-apis/bot/v3/info/", {
|
|
80
|
+
method: "GET",
|
|
81
|
+
headers: { authorization: `Bearer ${authJson.tenant_access_token}` },
|
|
82
|
+
signal: AbortSignal.timeout(6000),
|
|
83
|
+
});
|
|
84
|
+
const botJson = await readJsonSafe(botResp);
|
|
85
|
+
if (!botResp.ok) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: formatReason("bot_info failed", botJson?.code, botJson?.msg, botResp.status),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!botJson || botJson.code !== 0) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
reason: formatReason("bot_info invalid response", botJson?.code, botJson?.msg, botResp.status),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const botOpenId = botJson?.bot?.open_id || botJson?.data?.bot?.open_id || "";
|
|
98
|
+
if (!botOpenId) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
reason: "bot_info success but open_id is empty (enable bot ability and publish app first)",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, botOpenId };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: `auto detect request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function ensureCodexConfigHasMcpBlock() {
|
|
114
|
+
const configPath = getCodexConfigPath();
|
|
115
|
+
const configDir = path.dirname(configPath);
|
|
116
|
+
await ensureDir(configDir);
|
|
117
|
+
const current = (await readTextIfExists(configPath)) ?? "";
|
|
118
|
+
if (current.includes(CODEX_FEISHU_MARK_BEGIN)) {
|
|
119
|
+
return { updated: false, configPath };
|
|
120
|
+
}
|
|
121
|
+
const joiner = current.endsWith("\n") || current.length === 0 ? "" : "\n";
|
|
122
|
+
await writeText(configPath, `${current}${joiner}\n${MCP_BLOCK}`);
|
|
123
|
+
return { updated: true, configPath };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function cleanupLegacyPromptFiles() {
|
|
127
|
+
const promptsDir = getPromptsDir();
|
|
128
|
+
await ensureDir(promptsDir);
|
|
129
|
+
|
|
130
|
+
const promptFq = path.join(promptsDir, "fq.md");
|
|
131
|
+
const promptLegacy = path.join(promptsDir, "feishu-qrcode.md");
|
|
132
|
+
try {
|
|
133
|
+
await fs.unlink(promptFq);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (!err || err.code !== "ENOENT") {
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
await fs.unlink(promptLegacy);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (!err || err.code !== "ENOENT") {
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { promptsDir, removed: [promptFq, promptLegacy] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function writeBridgeConfig(bridgeConfig) {
|
|
150
|
+
const bridgeHome = getBridgeHome();
|
|
151
|
+
const bridgeConfigPath = getBridgeConfigPath();
|
|
152
|
+
await ensureDir(bridgeHome);
|
|
153
|
+
await writeText(`${bridgeConfigPath}`, `${JSON.stringify(bridgeConfig, null, 2)}\n`);
|
|
154
|
+
return { bridgeConfigPath, bridgeConfig };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function runInit(flags, options = {}) {
|
|
158
|
+
const existingBridgeConfig = (await readJsonIfExists(getBridgeConfigPath())) ?? {};
|
|
159
|
+
const bridgeConfig = buildBridgeConfig(flags, existingBridgeConfig);
|
|
160
|
+
const hasManualBotOpenId =
|
|
161
|
+
Boolean(flags["bot-open-id"] && flags["bot-open-id"].trim()) ||
|
|
162
|
+
Boolean(process.env.FEISHU_BOT_OPEN_ID && process.env.FEISHU_BOT_OPEN_ID.trim());
|
|
163
|
+
let autoDetectBotOpenIdResult = null;
|
|
164
|
+
if (!bridgeConfig.bot_open_id && !hasManualBotOpenId && bridgeConfig.app_id && bridgeConfig.app_secret) {
|
|
165
|
+
autoDetectBotOpenIdResult = await tryFetchBotOpenId(bridgeConfig.app_id, bridgeConfig.app_secret);
|
|
166
|
+
if (autoDetectBotOpenIdResult.ok) {
|
|
167
|
+
bridgeConfig.bot_open_id = autoDetectBotOpenIdResult.botOpenId;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { updated, configPath } = await ensureCodexConfigHasMcpBlock();
|
|
172
|
+
await cleanupLegacyPromptFiles();
|
|
173
|
+
const { bridgeConfigPath } = await writeBridgeConfig(bridgeConfig);
|
|
174
|
+
|
|
175
|
+
// eslint-disable-next-line no-console
|
|
176
|
+
console.log("codex-feishu init completed");
|
|
177
|
+
// eslint-disable-next-line no-console
|
|
178
|
+
console.log(`- Codex config: ${configPath}${updated ? " (updated)" : " (already configured)"}`);
|
|
179
|
+
// eslint-disable-next-line no-console
|
|
180
|
+
console.log(`- Bridge config: ${bridgeConfigPath}`);
|
|
181
|
+
// eslint-disable-next-line no-console
|
|
182
|
+
console.log(`- codex_bin: ${bridgeConfig.codex_bin ? bridgeConfig.codex_bin : "(default: codex in PATH)"}`);
|
|
183
|
+
|
|
184
|
+
if (!bridgeConfig.app_id || !bridgeConfig.app_secret) {
|
|
185
|
+
// eslint-disable-next-line no-console
|
|
186
|
+
console.log(
|
|
187
|
+
"- Missing app_id/app_secret. Set FEISHU_APP_ID and FEISHU_APP_SECRET, then re-run init.",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (!bridgeConfig.bot_open_id) {
|
|
191
|
+
// eslint-disable-next-line no-console
|
|
192
|
+
console.log(
|
|
193
|
+
"- Optional bot_open_id is empty. QR will encode /bind CODE instead of opening bot chat directly.",
|
|
194
|
+
);
|
|
195
|
+
if (autoDetectBotOpenIdResult && !autoDetectBotOpenIdResult.ok) {
|
|
196
|
+
// eslint-disable-next-line no-console
|
|
197
|
+
console.log(`- bot_open_id auto-detect failed: ${autoDetectBotOpenIdResult.reason}`);
|
|
198
|
+
// eslint-disable-next-line no-console
|
|
199
|
+
console.log(
|
|
200
|
+
"- Degraded gracefully: binding and chat sync still work; one-tap open-chat QR is disabled.",
|
|
201
|
+
);
|
|
202
|
+
// eslint-disable-next-line no-console
|
|
203
|
+
console.log("- To set manually: codex-feishu init --app-id <...> --app-secret <...> --bot-open-id <...>");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// eslint-disable-next-line no-console
|
|
208
|
+
console.log("\nNext:");
|
|
209
|
+
if (options.startDaemon) {
|
|
210
|
+
// eslint-disable-next-line no-console
|
|
211
|
+
console.log("1) Daemon will be restarted in background (see Daemon section below).");
|
|
212
|
+
// eslint-disable-next-line no-console
|
|
213
|
+
console.log("2) Start Codex normally: codex");
|
|
214
|
+
// eslint-disable-next-line no-console
|
|
215
|
+
console.log("3) Bind info will be printed automatically below (or refresh with: codex-feishu qrcode).");
|
|
216
|
+
} else {
|
|
217
|
+
// eslint-disable-next-line no-console
|
|
218
|
+
console.log("1) Start daemon: codex-feishu daemon");
|
|
219
|
+
// eslint-disable-next-line no-console
|
|
220
|
+
console.log("2) Start Codex normally: codex");
|
|
221
|
+
// eslint-disable-next-line no-console
|
|
222
|
+
console.log("3) Get bind info: codex-feishu qrcode");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { getBridgeRpcEndpoint } from "../lib/paths.js";
|
|
4
|
+
import { callJsonRpc } from "../lib/uds_rpc.js";
|
|
5
|
+
|
|
6
|
+
const TOOLS = [
|
|
7
|
+
{
|
|
8
|
+
name: "feishu_qrcode",
|
|
9
|
+
description: "Generate a Feishu binding QR code for codex-feishu.",
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
purpose: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description: "Optional reason for QR code generation.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "feishu_status",
|
|
23
|
+
description: "Get codex-feishu bridge status.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {},
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "feishu_new_thread",
|
|
32
|
+
description: "Create/switch to a new synced conversation thread on Feishu side.",
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
title: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "Optional thread title",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function out(msg) {
|
|
47
|
+
process.stdout.write(`${JSON.stringify(msg)}\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ok(id, result) {
|
|
51
|
+
out({ jsonrpc: "2.0", id, result });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function err(id, code, message) {
|
|
55
|
+
out({
|
|
56
|
+
jsonrpc: "2.0",
|
|
57
|
+
id,
|
|
58
|
+
error: {
|
|
59
|
+
code,
|
|
60
|
+
message,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toolText(text) {
|
|
66
|
+
return {
|
|
67
|
+
content: [{ type: "text", text }],
|
|
68
|
+
isError: false,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatInShanghai(value) {
|
|
73
|
+
const date = new Date(value);
|
|
74
|
+
if (!Number.isFinite(date.getTime())) {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
const parts = new Intl.DateTimeFormat("zh-CN", {
|
|
78
|
+
timeZone: "Asia/Shanghai",
|
|
79
|
+
year: "numeric",
|
|
80
|
+
month: "2-digit",
|
|
81
|
+
day: "2-digit",
|
|
82
|
+
hour: "2-digit",
|
|
83
|
+
minute: "2-digit",
|
|
84
|
+
second: "2-digit",
|
|
85
|
+
hour12: false,
|
|
86
|
+
}).formatToParts(date);
|
|
87
|
+
const read = (type) => parts.find((item) => item.type === type)?.value ?? "";
|
|
88
|
+
const y = read("year");
|
|
89
|
+
const mon = read("month");
|
|
90
|
+
const d = read("day");
|
|
91
|
+
const h = read("hour");
|
|
92
|
+
const min = read("minute");
|
|
93
|
+
const s = read("second");
|
|
94
|
+
if (!y || !mon || !d || !h || !min || !s) {
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
return `${y}-${mon}-${d} ${h}:${min}:${s}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function renderAsciiQr(value) {
|
|
101
|
+
try {
|
|
102
|
+
const mod = await import("qrcode");
|
|
103
|
+
const QRCode = mod?.default ?? mod;
|
|
104
|
+
if (!QRCode || typeof QRCode.toString !== "function") {
|
|
105
|
+
throw new Error("qrcode.toString unavailable");
|
|
106
|
+
}
|
|
107
|
+
return await QRCode.toString(value, { type: "terminal", small: true, margin: 1 });
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.error(`[codex-feishu] qr render failed: ${err?.message ?? String(err)}`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function maybeAutostartDaemon() {
|
|
116
|
+
const auto = process.env.CODEX_FEISHU_AUTOSTART ?? "true";
|
|
117
|
+
if (auto === "0" || auto.toLowerCase() === "false") {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const trySpawn = (cmd, args) =>
|
|
122
|
+
new Promise((resolve) => {
|
|
123
|
+
const child = spawn(cmd, args, {
|
|
124
|
+
detached: true,
|
|
125
|
+
stdio: "ignore",
|
|
126
|
+
});
|
|
127
|
+
child.once("error", () => resolve(false));
|
|
128
|
+
child.once("spawn", () => {
|
|
129
|
+
child.unref();
|
|
130
|
+
resolve(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const fromPath = await trySpawn("codex-feishu", ["daemon"]);
|
|
135
|
+
if (!fromPath) {
|
|
136
|
+
const currentEntry = process.argv[1];
|
|
137
|
+
if (currentEntry) {
|
|
138
|
+
const fromCurrentNode = await trySpawn(process.execPath, [currentEntry, "daemon"]);
|
|
139
|
+
if (fromCurrentNode) {
|
|
140
|
+
await new Promise((resolve) => {
|
|
141
|
+
setTimeout(resolve, 500);
|
|
142
|
+
});
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await new Promise((resolve) => {
|
|
150
|
+
setTimeout(resolve, 500);
|
|
151
|
+
});
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function callDaemon(method, params) {
|
|
156
|
+
const endpoint = getBridgeRpcEndpoint();
|
|
157
|
+
try {
|
|
158
|
+
return await callJsonRpc(endpoint, method, params, { timeoutMs: 4000 });
|
|
159
|
+
} catch (firstErr) {
|
|
160
|
+
await maybeAutostartDaemon();
|
|
161
|
+
try {
|
|
162
|
+
return await callJsonRpc(endpoint, method, params, { timeoutMs: 6000 });
|
|
163
|
+
} catch (secondErr) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`daemon unavailable. Start it with \`codex-feishu daemon\`. Last error: ${secondErr.message}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function handleToolCall(id, params) {
|
|
172
|
+
const toolName = params?.name;
|
|
173
|
+
const args = params?.arguments ?? {};
|
|
174
|
+
|
|
175
|
+
if (toolName === "feishu_qrcode") {
|
|
176
|
+
const result = await callDaemon("feishu/qrcode", {
|
|
177
|
+
purpose: args.purpose ?? null,
|
|
178
|
+
cwd_hint: process.cwd(),
|
|
179
|
+
});
|
|
180
|
+
const asciiQr = await renderAsciiQr(result.qr_text);
|
|
181
|
+
const expireAtShanghai = formatInShanghai(result.expires_at);
|
|
182
|
+
const text = [
|
|
183
|
+
"飞书绑定码已生成。",
|
|
184
|
+
result.reused ? "- note: 本次复用了最近一次未过期绑定码(避免重复调用生成新码)" : "",
|
|
185
|
+
`- code: ${result.code}`,
|
|
186
|
+
`- expire_at(上海): ${expireAtShanghai || "unknown"}`,
|
|
187
|
+
`- qrcode_mode: ${result.qrcode_mode ?? "unknown"}`,
|
|
188
|
+
`- qr_payload: ${result.qr_text}`,
|
|
189
|
+
result.open_chat_link ? `- open_chat_link: ${result.open_chat_link}` : "",
|
|
190
|
+
`- 在飞书里发送: ${result.bind_command_hint}`,
|
|
191
|
+
result.open_chat_link
|
|
192
|
+
? "\n可扫码二维码(内容是 打开机器人聊天页;进入后仍需发送 /bind 命令)。"
|
|
193
|
+
: "\n可扫码二维码(内容是 /bind 命令)。",
|
|
194
|
+
"如二维码显示被截断,请直接使用上面的 open_chat_link 或 /bind 指令。",
|
|
195
|
+
asciiQr ? `\n\`\`\`\n${asciiQr}\n\`\`\`` : "",
|
|
196
|
+
asciiQr ? "" : "\n二维码渲染失败:请先执行 `npm install` 后重启 codex/codex-feishu。",
|
|
197
|
+
].join("\n");
|
|
198
|
+
ok(id, toolText(text));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (toolName === "feishu_status") {
|
|
203
|
+
const status = await callDaemon("feishu/status", {});
|
|
204
|
+
const text = [
|
|
205
|
+
"codex-feishu 状态:",
|
|
206
|
+
`- app_server_running: ${status.app_server?.running ? "yes" : "no"}`,
|
|
207
|
+
`- app_server_initialized: ${status.app_server?.initialized ? "yes" : "no"}`,
|
|
208
|
+
`- feishu_enabled: ${status.feishu?.enabled ? "yes" : "no"}`,
|
|
209
|
+
`- feishu_running: ${status.feishu?.running ? "yes" : "no"}`,
|
|
210
|
+
`- feishu_last_error: ${status.feishu?.last_error ?? "none"}`,
|
|
211
|
+
`- active_thread_id: ${status.active_thread_id ?? "none"}`,
|
|
212
|
+
`- bindings: ${status.bindings}`,
|
|
213
|
+
`- pending_bind_codes: ${status.pending_bind_codes}`,
|
|
214
|
+
`- pending_requests: ${status.pending?.count ?? 0}`,
|
|
215
|
+
`- latest_event: ${status.latest_event?.type || status.latest_event?.method || "none"}`,
|
|
216
|
+
].join("\n");
|
|
217
|
+
ok(id, toolText(text));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (toolName === "feishu_new_thread") {
|
|
222
|
+
const created = await callDaemon("feishu/new_thread", {
|
|
223
|
+
title: args.title ?? null,
|
|
224
|
+
});
|
|
225
|
+
ok(id, toolText(`已创建并切换到新线程: ${created.thread_id}`));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
err(id, -32601, `unknown tool: ${toolName}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function handleRequest(msg) {
|
|
233
|
+
const { id, method, params } = msg;
|
|
234
|
+
if (method === "initialize") {
|
|
235
|
+
ok(id, {
|
|
236
|
+
protocolVersion: "2024-11-05",
|
|
237
|
+
serverInfo: {
|
|
238
|
+
name: "codex-feishu",
|
|
239
|
+
version: "0.1.0",
|
|
240
|
+
},
|
|
241
|
+
capabilities: {
|
|
242
|
+
tools: {},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (method === "tools/list") {
|
|
249
|
+
ok(id, { tools: TOOLS });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (method === "tools/call") {
|
|
254
|
+
try {
|
|
255
|
+
await handleToolCall(id, params);
|
|
256
|
+
} catch (toolErr) {
|
|
257
|
+
ok(
|
|
258
|
+
id,
|
|
259
|
+
{
|
|
260
|
+
content: [{ type: "text", text: toolErr.message }],
|
|
261
|
+
isError: true,
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (method === "notifications/initialized") {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (id !== undefined) {
|
|
273
|
+
err(id, -32601, `method not found: ${method}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function runMcp() {
|
|
278
|
+
const rl = readline.createInterface({
|
|
279
|
+
input: process.stdin,
|
|
280
|
+
crlfDelay: Infinity,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
for await (const line of rl) {
|
|
284
|
+
if (!line.trim()) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
let msg;
|
|
288
|
+
try {
|
|
289
|
+
msg = JSON.parse(line);
|
|
290
|
+
} catch {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
await handleRequest(msg);
|
|
294
|
+
}
|
|
295
|
+
}
|