@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.
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { ensureDir, readTextIfExists, writeText } from "./fs_utils.js";
4
+ import { getDaemonLogPath, getDaemonPidPath, getRunDir } from "./paths.js";
5
+
6
+ function sleep(ms) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
9
+
10
+ function toPid(value) {
11
+ const n = Number.parseInt(String(value || "").trim(), 10);
12
+ return Number.isFinite(n) && n > 0 ? n : null;
13
+ }
14
+
15
+ function isPidAlive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ } catch (err) {
20
+ if (err && typeof err === "object" && err.code === "ESRCH") {
21
+ return false;
22
+ }
23
+ return true;
24
+ }
25
+ }
26
+
27
+ async function readPidFile() {
28
+ const pidPath = getDaemonPidPath();
29
+ const raw = await readTextIfExists(pidPath);
30
+ return {
31
+ pidPath,
32
+ pid: toPid(raw),
33
+ };
34
+ }
35
+
36
+ async function removePidFile() {
37
+ const { pidPath } = await readPidFile();
38
+ try {
39
+ fs.unlinkSync(pidPath);
40
+ } catch (err) {
41
+ if (!err || err.code !== "ENOENT") {
42
+ throw err;
43
+ }
44
+ }
45
+ }
46
+
47
+ async function stopByPid(pid, timeoutMs = 3000) {
48
+ if (!isPidAlive(pid)) {
49
+ return { action: "already_stopped", pid };
50
+ }
51
+
52
+ try {
53
+ process.kill(pid, "SIGTERM");
54
+ } catch {
55
+ return { action: "already_stopped", pid };
56
+ }
57
+
58
+ const deadline = Date.now() + timeoutMs;
59
+ while (Date.now() < deadline) {
60
+ if (!isPidAlive(pid)) {
61
+ return { action: "stopped", pid, signal: "SIGTERM" };
62
+ }
63
+ await sleep(100);
64
+ }
65
+
66
+ try {
67
+ process.kill(pid, "SIGKILL");
68
+ } catch {
69
+ return { action: "stopped", pid, signal: "SIGTERM" };
70
+ }
71
+ return { action: "killed", pid, signal: "SIGKILL" };
72
+ }
73
+
74
+ function listDaemonPids() {
75
+ if (process.platform === "win32") {
76
+ // Keep Windows path simple and rely on pid file + explicit restart flow.
77
+ // `ps` is not guaranteed to exist on native Windows environments.
78
+ return [];
79
+ }
80
+ try {
81
+ const ps = spawnSync("ps", ["-axo", "pid=,command="], {
82
+ encoding: "utf8",
83
+ });
84
+ if (ps.status !== 0 || !ps.stdout) {
85
+ return [];
86
+ }
87
+ const pids = [];
88
+ for (const line of ps.stdout.split(/\r?\n/)) {
89
+ const trimmed = line.trim();
90
+ if (!trimmed) {
91
+ continue;
92
+ }
93
+ const match = trimmed.match(/^(\d+)\s+(.+)$/);
94
+ if (!match) {
95
+ continue;
96
+ }
97
+ const pid = toPid(match[1]);
98
+ const command = match[2] || "";
99
+ if (!pid || pid === process.pid) {
100
+ continue;
101
+ }
102
+ if (/\bcodex-feishu\s+daemon(?:\s|$)/.test(command)) {
103
+ pids.push(pid);
104
+ }
105
+ }
106
+ return [...new Set(pids)];
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ async function trySpawnDetached(cmd, args, logPath) {
113
+ await ensureDir(getRunDir());
114
+ const fd = fs.openSync(logPath, "a");
115
+ const child = spawn(cmd, args, {
116
+ detached: true,
117
+ stdio: ["ignore", fd, fd],
118
+ env: process.env,
119
+ });
120
+
121
+ return await new Promise((resolve) => {
122
+ let settled = false;
123
+ const finish = (value) => {
124
+ if (settled) {
125
+ return;
126
+ }
127
+ settled = true;
128
+ try {
129
+ fs.closeSync(fd);
130
+ } catch {
131
+ // noop
132
+ }
133
+ resolve(value);
134
+ };
135
+
136
+ child.once("error", (err) => {
137
+ finish({ ok: false, error: err?.message ?? String(err) });
138
+ });
139
+
140
+ child.once("spawn", () => {
141
+ child.unref();
142
+ finish({ ok: true, pid: child.pid });
143
+ });
144
+ });
145
+ }
146
+
147
+ export async function restartDaemonDetached() {
148
+ await ensureDir(getRunDir());
149
+ const logPath = getDaemonLogPath();
150
+ const { pidPath, pid } = await readPidFile();
151
+
152
+ const stopTargets = new Set([...listDaemonPids(), ...(pid ? [pid] : [])]);
153
+ const stopResults = [];
154
+ for (const targetPid of stopTargets) {
155
+ // eslint-disable-next-line no-await-in-loop
156
+ const result = await stopByPid(targetPid);
157
+ stopResults.push(result);
158
+ }
159
+
160
+ let stopResult = { action: "no_previous_pid", pid: null };
161
+ if (pid) {
162
+ const matched = stopResults.find((item) => item.pid === pid);
163
+ if (matched) {
164
+ stopResult = matched;
165
+ }
166
+ } else if (stopResults.length > 0) {
167
+ stopResult = { action: "cleaned_stale", pid: null, count: stopResults.length };
168
+ }
169
+ await removePidFile();
170
+
171
+ let startResult = await trySpawnDetached("codex-feishu", ["daemon"], logPath);
172
+ if (!startResult.ok) {
173
+ const entry = process.argv[1];
174
+ if (entry) {
175
+ startResult = await trySpawnDetached(process.execPath, [entry, "daemon"], logPath);
176
+ }
177
+ }
178
+ if (!startResult.ok) {
179
+ throw new Error(`failed to start daemon in background: ${startResult.error}`);
180
+ }
181
+
182
+ await writeText(pidPath, `${startResult.pid}\n`);
183
+ return {
184
+ pidPath,
185
+ logPath,
186
+ stopResult,
187
+ stopResults,
188
+ startResult,
189
+ };
190
+ }
@@ -0,0 +1,461 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ function safeJsonParse(text, fallback = null) {
6
+ try {
7
+ return JSON.parse(text);
8
+ } catch {
9
+ return fallback;
10
+ }
11
+ }
12
+
13
+ function parseMessageContent(messageContent) {
14
+ if (typeof messageContent !== "string" || messageContent.length === 0) {
15
+ return {};
16
+ }
17
+ const parsed = safeJsonParse(messageContent, {});
18
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
19
+ return parsed;
20
+ }
21
+ return {};
22
+ }
23
+
24
+ function findFirstStringValue(source, keys, maxDepth = 4) {
25
+ if (!source || typeof source !== "object") {
26
+ return null;
27
+ }
28
+ const wanted = new Set(keys);
29
+ const queue = [{ value: source, depth: 0 }];
30
+ while (queue.length > 0) {
31
+ const current = queue.shift();
32
+ if (!current || !current.value || typeof current.value !== "object") {
33
+ continue;
34
+ }
35
+ for (const [key, value] of Object.entries(current.value)) {
36
+ if (wanted.has(key) && typeof value === "string" && value.trim()) {
37
+ return value.trim();
38
+ }
39
+ if (current.depth < maxDepth && value && typeof value === "object") {
40
+ queue.push({ value, depth: current.depth + 1 });
41
+ }
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function formatInShanghai(value) {
48
+ const date = new Date(value);
49
+ if (!Number.isFinite(date.getTime())) {
50
+ return "";
51
+ }
52
+ const parts = new Intl.DateTimeFormat("zh-CN", {
53
+ timeZone: "Asia/Shanghai",
54
+ year: "numeric",
55
+ month: "2-digit",
56
+ day: "2-digit",
57
+ hour: "2-digit",
58
+ minute: "2-digit",
59
+ second: "2-digit",
60
+ hour12: false,
61
+ }).formatToParts(date);
62
+ const read = (type) => parts.find((item) => item.type === type)?.value ?? "";
63
+ const y = read("year");
64
+ const mon = read("month");
65
+ const d = read("day");
66
+ const h = read("hour");
67
+ const min = read("minute");
68
+ const s = read("second");
69
+ if (!y || !mon || !d || !h || !min || !s) {
70
+ return "";
71
+ }
72
+ return `${y}-${mon}-${d} ${h}:${min}:${s}`;
73
+ }
74
+
75
+ function normalizeMessageEvent(payload) {
76
+ const event = payload?.event ?? payload ?? {};
77
+ const message = event.message ?? {};
78
+ const sender = event.sender ?? {};
79
+ const content = parseMessageContent(message.content);
80
+ const text = typeof content?.text === "string" ? content.text : "";
81
+ const imageKey = findFirstStringValue(content, ["image_key", "imageKey"]);
82
+ const fileKey = findFirstStringValue(content, ["file_key", "fileKey"]);
83
+ const fileName = findFirstStringValue(content, ["file_name", "fileName", "name"]);
84
+ const chatId = message.chat_id ?? null;
85
+ const messageId = message.message_id ?? null;
86
+ const userId =
87
+ sender?.sender_id?.open_id ??
88
+ sender?.sender_id?.union_id ??
89
+ sender?.sender_id?.user_id ??
90
+ null;
91
+ const senderType = sender?.sender_type ?? null;
92
+ const messageType = message?.message_type ?? null;
93
+ const chatType = message?.chat_type ?? null;
94
+
95
+ return {
96
+ text,
97
+ chatId,
98
+ messageId,
99
+ userId,
100
+ senderType,
101
+ messageType,
102
+ chatType,
103
+ imageKey,
104
+ fileKey,
105
+ fileName,
106
+ content,
107
+ raw: payload,
108
+ };
109
+ }
110
+
111
+ function sanitizeCardMarkdown(markdown) {
112
+ const raw = typeof markdown === "string" ? markdown : "";
113
+ if (!raw) {
114
+ return "";
115
+ }
116
+ // Feishu interactive card markdown does not accept Markdown image syntax
117
+ // unless image_key resources are explicitly provided.
118
+ return raw
119
+ .replace(/!\[[^\]]*]\(([^)\n]+)\)/g, (_m, link) => `图片:\`${String(link).trim()}\``)
120
+ .replace(/<img[^>]*>/gi, "[图片]");
121
+ }
122
+
123
+ function buildInteractiveCardContent(payload = {}) {
124
+ const title = payload?.title || "Codex";
125
+ const markdown = sanitizeCardMarkdown(payload?.markdown || "");
126
+ const template = payload?.template || "blue";
127
+ // Hard-disable interactive card actions to avoid callback/button failures in Feishu.
128
+ const actions = [];
129
+ const note = typeof payload?.note === "string" ? payload.note.trim() : "";
130
+ const elements = [
131
+ {
132
+ tag: "markdown",
133
+ content: markdown,
134
+ },
135
+ ];
136
+ if (note) {
137
+ elements.push({
138
+ tag: "note",
139
+ elements: [
140
+ {
141
+ tag: "plain_text",
142
+ content: note,
143
+ },
144
+ ],
145
+ });
146
+ }
147
+ if (actions.length > 0) {
148
+ elements.push({
149
+ tag: "action",
150
+ layout: "flow",
151
+ actions,
152
+ });
153
+ }
154
+ return JSON.stringify({
155
+ config: {
156
+ wide_screen_mode: true,
157
+ update_multi: Boolean(payload?.updatable),
158
+ },
159
+ header: {
160
+ template,
161
+ title: { tag: "plain_text", content: title },
162
+ },
163
+ elements,
164
+ });
165
+ }
166
+
167
+ export class FeishuBridge {
168
+ constructor(options) {
169
+ this.appId = options.appId;
170
+ this.appSecret = options.appSecret;
171
+ this.onText = options.onText || null;
172
+ this.onMessage = options.onMessage || options.onText || null;
173
+ this.onEvent = options.onEvent || (() => {});
174
+ this.running = false;
175
+ this.client = null;
176
+ this.wsClient = null;
177
+ this.sdk = null;
178
+ this.lastError = null;
179
+ }
180
+
181
+ status() {
182
+ return {
183
+ enabled: Boolean(this.appId && this.appSecret),
184
+ running: this.running,
185
+ has_sdk: Boolean(this.sdk),
186
+ last_error: this.lastError,
187
+ };
188
+ }
189
+
190
+ async start() {
191
+ if (!this.appId || !this.appSecret) {
192
+ this.lastError = "missing app_id/app_secret";
193
+ return false;
194
+ }
195
+
196
+ let Lark;
197
+ try {
198
+ Lark = await import("@larksuiteoapi/node-sdk");
199
+ } catch (err) {
200
+ this.lastError =
201
+ "missing @larksuiteoapi/node-sdk. Run: npm i -g @openai-lite/codex-feishu (with dependencies)";
202
+ this.onEvent({
203
+ type: "feishu_sdk_missing",
204
+ error: err?.message ?? String(err),
205
+ });
206
+ return false;
207
+ }
208
+
209
+ this.sdk = Lark;
210
+ this.client = new Lark.Client({
211
+ appId: this.appId,
212
+ appSecret: this.appSecret,
213
+ });
214
+
215
+ const eventDispatcher = new Lark.EventDispatcher({});
216
+ const handler = this.onMessage || this.onText;
217
+ const dispatchNormalizedMessage = async (normalized) => {
218
+ if (!handler) {
219
+ return;
220
+ }
221
+ try {
222
+ await handler(normalized);
223
+ } catch (err) {
224
+ this.onEvent({
225
+ type: "feishu_message_inbound_error",
226
+ error: err?.message ?? String(err),
227
+ });
228
+ }
229
+ };
230
+
231
+ eventDispatcher.register({
232
+ "im.message.receive_v1": async (payload) => {
233
+ const normalized = normalizeMessageEvent(payload);
234
+ if (normalized.senderType === "app") {
235
+ return;
236
+ }
237
+ if (!normalized.chatId) {
238
+ return;
239
+ }
240
+ if (normalized.messageType === "text" && !normalized.text) {
241
+ return;
242
+ }
243
+
244
+ this.onEvent({
245
+ type: "feishu_message_inbound",
246
+ chat_id: normalized.chatId,
247
+ user_id: normalized.userId,
248
+ message_type: normalized.messageType ?? "unknown",
249
+ chat_type: normalized.chatType ?? "unknown",
250
+ });
251
+ await dispatchNormalizedMessage({
252
+ chatId: normalized.chatId,
253
+ userId: normalized.userId,
254
+ messageId: normalized.messageId,
255
+ text: normalized.text,
256
+ chatType: normalized.chatType,
257
+ messageType: normalized.messageType,
258
+ imageKey: normalized.imageKey,
259
+ fileKey: normalized.fileKey,
260
+ fileName: normalized.fileName,
261
+ content: normalized.content,
262
+ raw: normalized.raw,
263
+ source: "message_receive",
264
+ });
265
+ },
266
+ });
267
+
268
+ this.wsClient = new Lark.WSClient({
269
+ appId: this.appId,
270
+ appSecret: this.appSecret,
271
+ });
272
+
273
+ try {
274
+ await this.wsClient.start({
275
+ eventDispatcher,
276
+ });
277
+ this.running = true;
278
+ this.lastError = null;
279
+ this.onEvent({
280
+ type: "feishu_ws_started",
281
+ });
282
+ return true;
283
+ } catch (err) {
284
+ this.running = false;
285
+ this.lastError = err?.message ?? String(err);
286
+ this.onEvent({
287
+ type: "feishu_ws_start_failed",
288
+ error: this.lastError,
289
+ });
290
+ return false;
291
+ }
292
+ }
293
+
294
+ async stop() {
295
+ this.running = false;
296
+ if (this.wsClient && typeof this.wsClient.stop === "function") {
297
+ try {
298
+ await this.wsClient.stop();
299
+ } catch {
300
+ // noop
301
+ }
302
+ }
303
+ }
304
+
305
+ async sendText(chatId, text) {
306
+ if (!this.client || !this.running) {
307
+ throw new Error("feishu bridge not running");
308
+ }
309
+ await this.client.im.v1.message.create({
310
+ params: {
311
+ receive_id_type: "chat_id",
312
+ },
313
+ data: {
314
+ receive_id: chatId,
315
+ msg_type: "text",
316
+ content: JSON.stringify({ text }),
317
+ },
318
+ });
319
+ }
320
+
321
+ async sendImage(chatId, imagePath) {
322
+ if (!this.client || !this.running) {
323
+ throw new Error("feishu bridge not running");
324
+ }
325
+ if (!imagePath || typeof imagePath !== "string") {
326
+ throw new Error("imagePath is required");
327
+ }
328
+ const uploaded = await this.client.im.v1.image.create({
329
+ data: {
330
+ image_type: "message",
331
+ image: fs.createReadStream(imagePath),
332
+ },
333
+ });
334
+ const imageKey = uploaded?.image_key ?? null;
335
+ if (!imageKey) {
336
+ throw new Error("upload image failed: missing image_key");
337
+ }
338
+ await this.client.im.v1.message.create({
339
+ params: {
340
+ receive_id_type: "chat_id",
341
+ },
342
+ data: {
343
+ receive_id: chatId,
344
+ msg_type: "image",
345
+ content: JSON.stringify({ image_key: imageKey }),
346
+ },
347
+ });
348
+ return imageKey;
349
+ }
350
+
351
+ async saveIncomingImage(messageId, imageKey, filePath) {
352
+ if (!this.client || !this.running) {
353
+ throw new Error("feishu bridge not running");
354
+ }
355
+ if (!messageId || !imageKey || !filePath) {
356
+ throw new Error("messageId, imageKey and filePath are required");
357
+ }
358
+
359
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
360
+
361
+ let resource = null;
362
+ try {
363
+ resource = await this.client.im.v1.messageResource.get({
364
+ params: { type: "image" },
365
+ path: {
366
+ message_id: messageId,
367
+ file_key: imageKey,
368
+ },
369
+ });
370
+ } catch {
371
+ // Fallback for cases where image belongs to app-uploaded resources.
372
+ resource = await this.client.im.v1.image.get({
373
+ path: {
374
+ image_key: imageKey,
375
+ },
376
+ });
377
+ }
378
+ await resource.writeFile(filePath);
379
+ return filePath;
380
+ }
381
+
382
+ async sendBindCard(chatId, payload) {
383
+ if (!this.client || !this.running) {
384
+ throw new Error("feishu bridge not running");
385
+ }
386
+ const bindCommand = payload?.bindCommand || "";
387
+ const code = payload?.code || "";
388
+ const openChatLink =
389
+ typeof payload?.openChatLink === "string" && payload.openChatLink.trim()
390
+ ? payload.openChatLink.trim()
391
+ : null;
392
+ const expiresAtShanghai = payload?.expiresAt ? formatInShanghai(payload.expiresAt) : "";
393
+ const groupHint = Boolean(payload?.groupHint);
394
+ const lines = [
395
+ `请先绑定当前飞书会话到 Codex。`,
396
+ code ? `绑定码:${code}` : "",
397
+ bindCommand ? `绑定指令:\`${bindCommand}\`` : "",
398
+ expiresAtShanghai ? `过期时间(上海):${expiresAtShanghai}` : "",
399
+ groupHint ? "群聊建议:完成绑定后优先 `@机器人` 提问。" : "",
400
+ "",
401
+ "发送上面的绑定指令后,即可开始双端同步对话。",
402
+ "提示:直接复制并发送绑定指令即可。",
403
+ ].filter(Boolean);
404
+ const content = buildInteractiveCardContent({
405
+ title: "Codex 会话绑定",
406
+ markdown: lines.join("\n"),
407
+ template: "orange",
408
+ note: bindCommand ? `请手动发送:${bindCommand}${openChatLink ? `\n机器人会话:${openChatLink}` : ""}` : "",
409
+ });
410
+
411
+ await this.client.im.v1.message.create({
412
+ params: {
413
+ receive_id_type: "chat_id",
414
+ },
415
+ data: {
416
+ receive_id: chatId,
417
+ msg_type: "interactive",
418
+ content,
419
+ },
420
+ });
421
+ }
422
+
423
+ async sendMarkdownCard(chatId, payload) {
424
+ if (!this.client || !this.running) {
425
+ throw new Error("feishu bridge not running");
426
+ }
427
+ const content = buildInteractiveCardContent(payload);
428
+ const resp = await this.client.im.v1.message.create({
429
+ params: {
430
+ receive_id_type: "chat_id",
431
+ },
432
+ data: {
433
+ receive_id: chatId,
434
+ msg_type: "interactive",
435
+ content,
436
+ },
437
+ });
438
+ return resp?.data?.message_id ?? null;
439
+ }
440
+
441
+ async patchMarkdownCard(messageId, payload) {
442
+ if (!this.client || !this.running) {
443
+ throw new Error("feishu bridge not running");
444
+ }
445
+ if (!messageId) {
446
+ throw new Error("messageId is required");
447
+ }
448
+ const content = buildInteractiveCardContent({
449
+ ...payload,
450
+ updatable: true,
451
+ });
452
+ await this.client.im.v1.message.patch({
453
+ path: {
454
+ message_id: messageId,
455
+ },
456
+ data: {
457
+ content,
458
+ },
459
+ });
460
+ }
461
+ }
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function ensureDir(dirPath) {
5
+ await fs.mkdir(dirPath, { recursive: true });
6
+ }
7
+
8
+ export async function readTextIfExists(filePath) {
9
+ try {
10
+ return await fs.readFile(filePath, "utf8");
11
+ } catch (err) {
12
+ if (err && typeof err === "object" && err.code === "ENOENT") {
13
+ return null;
14
+ }
15
+ throw err;
16
+ }
17
+ }
18
+
19
+ export async function writeText(filePath, content) {
20
+ const dir = path.dirname(filePath);
21
+ await ensureDir(dir);
22
+ const base = path.basename(filePath);
23
+ const tmpPath = path.join(
24
+ dir,
25
+ `.${base}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
26
+ );
27
+ await fs.writeFile(tmpPath, content, "utf8");
28
+ await fs.rename(tmpPath, filePath);
29
+ }
30
+
31
+ export async function readJsonIfExists(filePath) {
32
+ const text = await readTextIfExists(filePath);
33
+ if (!text) {
34
+ return null;
35
+ }
36
+ return JSON.parse(text);
37
+ }
38
+
39
+ export async function writeJson(filePath, value) {
40
+ await writeText(filePath, `${JSON.stringify(value, null, 2)}\n`);
41
+ }