@cydm/pie 2.7.12 → 2.7.14
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 +13 -14
- package/dist/builtin/extensions/ask-user/index.js +3 -3
- package/dist/builtin/extensions/document-attachments/index.js +3 -0
- package/dist/builtin/extensions/im-forward/index.js +980 -0
- package/dist/builtin/extensions/im-forward/package.json +11 -0
- package/dist/builtin/extensions/pie-ipc/index.js +1671 -0
- package/dist/builtin/extensions/pie-ipc/package.json +11 -0
- package/dist/builtin/extensions/plan-mode/index.js +3 -3
- package/dist/builtin/extensions/subagent/index.js +4 -4
- package/dist/builtin/extensions/todo/index.js +3 -3
- package/dist/builtin/skills/computer-use-axi/computer-use-axi-broker.js +13 -11
- package/dist/builtin/skills/computer-use-axi/computer-use-axi-runtime.js +1 -1
- package/dist/chunks/{chunk-2NBUFVUQ.js → chunk-5YU6MU2N.js} +24 -1
- package/dist/chunks/chunk-F7EBZTZO.js +26 -0
- package/dist/chunks/{chunk-YI3CSQGD.js → chunk-FOE2AW4L.js} +2 -2
- package/dist/chunks/{chunk-EN47TIBG.js → chunk-N4252GH3.js} +126 -99
- package/dist/chunks/{chunk-AFPRX5XI.js → chunk-VPTLDLI6.js} +38 -18
- package/dist/chunks/{src-XDNJ3PHL.js → src-4PG3KZUS.js} +6 -2
- package/dist/chunks/{test-stream-LUUGOJMP.js → test-stream-OTKTTZ72.js} +1 -1
- package/dist/cli.js +400 -125
- package/package.json +3 -2
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
|
|
2
|
+
import {
|
|
3
|
+
detectImageMime,
|
|
4
|
+
readImageFormat
|
|
5
|
+
} from "../../../chunks/chunk-F7EBZTZO.js";
|
|
6
|
+
import "../../../chunks/chunk-LZQXQOPJ.js";
|
|
7
|
+
|
|
8
|
+
// builtin/extensions/im-forward/index.ts
|
|
9
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
// builtin/extensions/im-forward/lark-cli.ts
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
async function runLarkCli(binary, args, timeoutMs = 3e4, cwd, options = {}) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let child;
|
|
19
|
+
try {
|
|
20
|
+
child = (options.spawn ?? spawn)(binary, args, {
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
detached: process.platform !== "win32",
|
|
23
|
+
...cwd ? { cwd } : {}
|
|
24
|
+
});
|
|
25
|
+
} catch (error) {
|
|
26
|
+
resolve({ stdout: "", error: `spawn failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
let stdout = "";
|
|
30
|
+
let stderr = "";
|
|
31
|
+
let settled = false;
|
|
32
|
+
let timedOut = false;
|
|
33
|
+
let killTimer;
|
|
34
|
+
let closeTimer;
|
|
35
|
+
const finish = (result) => {
|
|
36
|
+
if (settled) return;
|
|
37
|
+
settled = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
if (killTimer) clearTimeout(killTimer);
|
|
40
|
+
if (closeTimer) clearTimeout(closeTimer);
|
|
41
|
+
resolve(result);
|
|
42
|
+
};
|
|
43
|
+
const stopTree = (signal) => {
|
|
44
|
+
try {
|
|
45
|
+
if (process.platform === "win32" && child.pid) {
|
|
46
|
+
const killer = spawn("taskkill", ["/F", "/T", "/PID", String(child.pid)], { stdio: "ignore" });
|
|
47
|
+
killer.on("error", () => {
|
|
48
|
+
child.kill(signal);
|
|
49
|
+
});
|
|
50
|
+
killer.unref();
|
|
51
|
+
} else if (child.pid) process.kill(-child.pid, signal);
|
|
52
|
+
else child.kill(signal);
|
|
53
|
+
} catch {
|
|
54
|
+
try {
|
|
55
|
+
child.kill(signal);
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const timeoutResult = () => ({ stdout, error: `timeout after ${timeoutMs}ms` });
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
timedOut = true;
|
|
63
|
+
stopTree("SIGTERM");
|
|
64
|
+
if (settled) return;
|
|
65
|
+
killTimer = setTimeout(() => {
|
|
66
|
+
stopTree("SIGKILL");
|
|
67
|
+
if (settled) return;
|
|
68
|
+
closeTimer = setTimeout(() => {
|
|
69
|
+
child.stdout?.destroy();
|
|
70
|
+
child.stderr?.destroy();
|
|
71
|
+
finish(timeoutResult());
|
|
72
|
+
}, options.closeGraceMs ?? 1e3);
|
|
73
|
+
}, options.killGraceMs ?? 1e3);
|
|
74
|
+
}, timeoutMs);
|
|
75
|
+
child.stdout?.on("data", (chunk) => {
|
|
76
|
+
if (!settled) stdout += chunk.toString("utf8");
|
|
77
|
+
});
|
|
78
|
+
child.stderr?.on("data", (chunk) => {
|
|
79
|
+
if (!settled) stderr += chunk.toString("utf8");
|
|
80
|
+
});
|
|
81
|
+
child.on("error", (error) => finish({ stdout, error: `spawn error: ${error.message}` }));
|
|
82
|
+
child.on("close", (code) => {
|
|
83
|
+
if (timedOut) finish(timeoutResult());
|
|
84
|
+
else if (code === 0 && stdout.trim()) finish({ stdout });
|
|
85
|
+
else finish({ stdout, error: `exit ${code}${stderr ? `: ${stderr.slice(-300).trim()}` : ""}` });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// builtin/extensions/im-forward/index.ts
|
|
91
|
+
var CLI_CALL_TIMEOUT_MS = 3e4;
|
|
92
|
+
var ROUTE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
93
|
+
var MAX_ROUTES = 500;
|
|
94
|
+
var MAX_SEEN_EVENTS = 1e3;
|
|
95
|
+
var MAX_BODY_CHARS = 4e3;
|
|
96
|
+
var REPLY_CHAIN_MAX_DEPTH = 5;
|
|
97
|
+
var MAX_INPUT_HEADER_CHARS = 200;
|
|
98
|
+
var RESTART_BACKOFF_MIN_MS = 5e3;
|
|
99
|
+
var RESTART_BACKOFF_MAX_MS = 6e4;
|
|
100
|
+
var SEND_FAILURE_NOTIFY_THROTTLE_MS = 5 * 60 * 1e3;
|
|
101
|
+
var MAX_INBOUND_RETRIES = 3;
|
|
102
|
+
var DEFAULT_INBOUND_RETRY_BACKOFF_MS = 5e3;
|
|
103
|
+
var MAX_PENDING_IM_INPUTS = 50;
|
|
104
|
+
var PENDING_IM_INPUT_TTL_MS = 60 * 60 * 1e3;
|
|
105
|
+
var IM_INPUT_FALLBACK_CAPTION = "\u8BF7\u67E5\u770B\u8FD9\u4E9B\u56FE\u7247\u5E76\u5904\u7406\u3002";
|
|
106
|
+
var IM_INPUT_PREFIX = "[Feishu]";
|
|
107
|
+
function isRecord(value) {
|
|
108
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
109
|
+
}
|
|
110
|
+
function readConfigLayer(filePath) {
|
|
111
|
+
if (!fs.existsSync(filePath)) return { config: {} };
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
114
|
+
if (!isRecord(parsed)) return { config: {}, problem: `${filePath}: root is not an object` };
|
|
115
|
+
return { config: parsed };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return { config: {}, problem: `${filePath}: ${error instanceof Error ? error.message : String(error)}` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function resolveImForwardConfig(cwd, configDir) {
|
|
121
|
+
const merged = {};
|
|
122
|
+
const problems = [];
|
|
123
|
+
let anyLayer = false;
|
|
124
|
+
for (const layer of [path.join(configDir, "im-forward.json"), path.join(cwd, ".pie", "im-forward.json")]) {
|
|
125
|
+
if (fs.existsSync(layer)) anyLayer = true;
|
|
126
|
+
const { config, problem } = readConfigLayer(layer);
|
|
127
|
+
if (problem) problems.push(problem);
|
|
128
|
+
Object.assign(merged, config);
|
|
129
|
+
}
|
|
130
|
+
if (!anyLayer) return { config: null, problems };
|
|
131
|
+
const rawChatId = merged.chatId ?? merged.receiveId;
|
|
132
|
+
const chatId = typeof rawChatId === "string" ? rawChatId.trim() : "";
|
|
133
|
+
if (!chatId) {
|
|
134
|
+
if (merged.enabled === true) problems.push('im-forward: "enabled": true requires "chatId" (oc_...)');
|
|
135
|
+
return { config: null, problems };
|
|
136
|
+
}
|
|
137
|
+
if (!chatId.startsWith("oc_")) problems.push(`im-forward: chatId "${chatId}" does not look like a chat id (oc_...)`);
|
|
138
|
+
const allowUsers = Array.isArray(merged.allowUsers) ? merged.allowUsers.filter((id) => typeof id === "string" && id.trim().length > 0) : void 0;
|
|
139
|
+
const larkCli = typeof merged.larkCli === "string" && merged.larkCli.trim() ? merged.larkCli.trim() : void 0;
|
|
140
|
+
const retryBackoffRaw = typeof merged.retryBackoffMs === "number" && Number.isFinite(merged.retryBackoffMs) ? merged.retryBackoffMs : NaN;
|
|
141
|
+
const retryBackoffMs = Number.isNaN(retryBackoffRaw) ? void 0 : Math.min(6e4, Math.max(20, Math.round(retryBackoffRaw)));
|
|
142
|
+
return { config: { enabled: merged.enabled === true, chatId, allowUsers: allowUsers?.length ? allowUsers : void 0, larkCli, retryBackoffMs }, problems };
|
|
143
|
+
}
|
|
144
|
+
function readLiveEnabled(cwd, configDir, cache) {
|
|
145
|
+
const layers = [path.join(configDir, "im-forward.json"), path.join(cwd, ".pie", "im-forward.json")];
|
|
146
|
+
let enabled = false;
|
|
147
|
+
let present = false;
|
|
148
|
+
for (const layer of layers) {
|
|
149
|
+
let mtime = 0;
|
|
150
|
+
try {
|
|
151
|
+
mtime = fs.statSync(layer).mtimeMs;
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
present = true;
|
|
156
|
+
if (cache && cache.path === layer && cache.mtime === mtime) {
|
|
157
|
+
enabled = cache.enabled ?? false;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const raw = readConfigLayer(layer).config;
|
|
161
|
+
enabled = raw.enabled === true;
|
|
162
|
+
if (cache) {
|
|
163
|
+
cache.path = layer;
|
|
164
|
+
cache.mtime = mtime;
|
|
165
|
+
cache.enabled = enabled;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return present ? enabled : false;
|
|
169
|
+
}
|
|
170
|
+
function assistantForwardText(message) {
|
|
171
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) return "";
|
|
172
|
+
let text = "";
|
|
173
|
+
let hasToolCalls = false;
|
|
174
|
+
for (const block of message.content) {
|
|
175
|
+
if (!isRecord(block)) continue;
|
|
176
|
+
if (block.type === "text" && typeof block.text === "string") text += block.text;
|
|
177
|
+
else if (block.type === "toolCall") hasToolCalls = true;
|
|
178
|
+
}
|
|
179
|
+
if (hasToolCalls) return "";
|
|
180
|
+
return text.trim();
|
|
181
|
+
}
|
|
182
|
+
function sessionLabel(sessionId, sessionName) {
|
|
183
|
+
if (sessionName && sessionName.trim()) return sessionName.trim();
|
|
184
|
+
return sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
|
|
185
|
+
}
|
|
186
|
+
function inputHeaderLine(userInput) {
|
|
187
|
+
if (!userInput) return null;
|
|
188
|
+
const collapsed = userInput.replace(/\s+/g, " ").trim();
|
|
189
|
+
if (!collapsed) return null;
|
|
190
|
+
const clipped = collapsed.length > MAX_INPUT_HEADER_CHARS ? `${collapsed.slice(0, MAX_INPUT_HEADER_CHARS)}\u2026` : collapsed;
|
|
191
|
+
return `[UserInput: ${clipped}]`;
|
|
192
|
+
}
|
|
193
|
+
function composeForwardBody(sessionId, sessionName, userInput, text) {
|
|
194
|
+
const lines = [`[Pie][Session: ${sessionLabel(sessionId, sessionName)}]`];
|
|
195
|
+
const input = inputHeaderLine(userInput);
|
|
196
|
+
if (input) lines.push(input);
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push(text);
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
function failureReason(message) {
|
|
202
|
+
const detail = message.errorMessage?.trim();
|
|
203
|
+
if (detail) {
|
|
204
|
+
const collapsed = detail.replace(/\s+/g, " ");
|
|
205
|
+
return collapsed.length > 400 ? `${collapsed.slice(0, 400)}\u2026` : collapsed;
|
|
206
|
+
}
|
|
207
|
+
switch (message.stopReason) {
|
|
208
|
+
case "aborted":
|
|
209
|
+
return "turn aborted";
|
|
210
|
+
case "error":
|
|
211
|
+
return "model/provider error (no detail)";
|
|
212
|
+
case "length":
|
|
213
|
+
return "output length limit reached";
|
|
214
|
+
default:
|
|
215
|
+
return message.stopReason ? `stop reason: ${message.stopReason}` : "unknown failure";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function parseLarkCliEnvelope(stdout) {
|
|
219
|
+
let parsed;
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(stdout);
|
|
222
|
+
} catch {
|
|
223
|
+
return { errorText: `unparseable lark-cli output: ${stdout.slice(0, 200)}` };
|
|
224
|
+
}
|
|
225
|
+
if (!isRecord(parsed)) return { errorText: "lark-cli output is not an object" };
|
|
226
|
+
if (parsed.ok === true && isRecord(parsed.data)) return { ok: true, data: parsed.data };
|
|
227
|
+
if (parsed.code === 0 && isRecord(parsed.data)) return { ok: true, data: parsed.data };
|
|
228
|
+
if (parsed.ok === false) {
|
|
229
|
+
const error = isRecord(parsed.error) ? JSON.stringify(parsed.error) : "unknown";
|
|
230
|
+
return { ok: false, errorText: `lark-cli error: ${error}` };
|
|
231
|
+
}
|
|
232
|
+
if (typeof parsed.code === "number" && parsed.code !== 0) {
|
|
233
|
+
return { ok: false, errorText: `lark-cli error code ${String(parsed.code)}: ${String(parsed.msg ?? "unknown")}` };
|
|
234
|
+
}
|
|
235
|
+
return { errorText: "lark-cli output missing ok field" };
|
|
236
|
+
}
|
|
237
|
+
function extractInboundText(content) {
|
|
238
|
+
if (typeof content !== "string") return "";
|
|
239
|
+
const trimmed = content.trim();
|
|
240
|
+
if (!trimmed) return "";
|
|
241
|
+
if (trimmed.startsWith("{")) {
|
|
242
|
+
try {
|
|
243
|
+
const parsed = JSON.parse(trimmed);
|
|
244
|
+
if (isRecord(parsed) && typeof parsed.text === "string" && Object.keys(parsed).length === 1) {
|
|
245
|
+
return parsed.text.trim();
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return trimmed;
|
|
251
|
+
}
|
|
252
|
+
function hashText(input) {
|
|
253
|
+
let hash = 0;
|
|
254
|
+
for (let i = 0; i < input.length; i++) {
|
|
255
|
+
hash = hash * 31 + input.charCodeAt(i) | 0;
|
|
256
|
+
}
|
|
257
|
+
return Math.abs(hash).toString(36);
|
|
258
|
+
}
|
|
259
|
+
function parseInboundLine(line) {
|
|
260
|
+
let parsed;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(line);
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
return isRecord(parsed) ? parsed : null;
|
|
267
|
+
}
|
|
268
|
+
function extractImageKeys(content) {
|
|
269
|
+
if (typeof content !== "string") return [];
|
|
270
|
+
return [...new Set(Array.from(content.matchAll(/\[Image:\s*(img_[A-Za-z0-9_-]+)\]/g), (match) => match[1]))];
|
|
271
|
+
}
|
|
272
|
+
function extractImageKey(content) {
|
|
273
|
+
return extractImageKeys(content)[0] ?? null;
|
|
274
|
+
}
|
|
275
|
+
function imageExtensionFromKey(imageKey) {
|
|
276
|
+
if (imageKey.includes(".gif")) return ".gif";
|
|
277
|
+
if (/\.jpe?g/i.test(imageKey)) return ".jpg";
|
|
278
|
+
return ".png";
|
|
279
|
+
}
|
|
280
|
+
function resolveLarkBinary(explicit) {
|
|
281
|
+
if (explicit) {
|
|
282
|
+
try {
|
|
283
|
+
fs.accessSync(explicit, fs.constants.X_OK);
|
|
284
|
+
return explicit;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const suffixes = process.platform === "win32" ? ["lark-cli.exe", "lark-cli.cmd", "lark-cli"] : ["lark-cli"];
|
|
290
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
291
|
+
if (!dir) continue;
|
|
292
|
+
for (const suffix of suffixes) {
|
|
293
|
+
const candidate = path.join(dir, suffix);
|
|
294
|
+
try {
|
|
295
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
296
|
+
return candidate;
|
|
297
|
+
} catch {
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
function imForward(ctx) {
|
|
304
|
+
if (!ctx.hasUI) return;
|
|
305
|
+
const configDir = resolveConfigDir();
|
|
306
|
+
const { config, problems } = resolveImForwardConfig(ctx.cwd, configDir);
|
|
307
|
+
for (const problem of problems) ctx.log(`im-forward: ${problem}`);
|
|
308
|
+
if (!config) return;
|
|
309
|
+
const cfg = config;
|
|
310
|
+
const resolvedBinary = resolveLarkBinary(cfg.larkCli);
|
|
311
|
+
if (!resolvedBinary) {
|
|
312
|
+
const message = 'im-forward: lark-cli not found on PATH. Install it (npm i -g @larksuite/cli) or set "larkCli" in ~/.pie/im-forward.json.';
|
|
313
|
+
ctx.log(message);
|
|
314
|
+
ctx.ui.notify(message, "warning");
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const larkBinary = resolvedBinary;
|
|
318
|
+
const enabledCache = {};
|
|
319
|
+
let forwardingEnabled = cfg.enabled === true;
|
|
320
|
+
function isForwardingEnabled() {
|
|
321
|
+
const live = readLiveEnabled(ctx.cwd, configDir, enabledCache);
|
|
322
|
+
if (live !== forwardingEnabled) {
|
|
323
|
+
forwardingEnabled = live;
|
|
324
|
+
updateFooterTag();
|
|
325
|
+
}
|
|
326
|
+
return forwardingEnabled;
|
|
327
|
+
}
|
|
328
|
+
const STATUS_KEY = "im-forward";
|
|
329
|
+
function updateFooterTag() {
|
|
330
|
+
ctx.ui.setStatus?.(STATUS_KEY, forwardingEnabled ? "[IM]" : void 0);
|
|
331
|
+
}
|
|
332
|
+
function writeEnabledToConfig(next) {
|
|
333
|
+
const userLayer = path.join(configDir, "im-forward.json");
|
|
334
|
+
const projectLayer = path.join(ctx.cwd, ".pie", "im-forward.json");
|
|
335
|
+
let target = userLayer;
|
|
336
|
+
try {
|
|
337
|
+
const projectParsed = JSON.parse(fs.readFileSync(projectLayer, "utf8"));
|
|
338
|
+
if (isRecord(projectParsed) && "enabled" in projectParsed) target = projectLayer;
|
|
339
|
+
} catch {
|
|
340
|
+
}
|
|
341
|
+
let parsed = {};
|
|
342
|
+
try {
|
|
343
|
+
parsed = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
344
|
+
if (!isRecord(parsed)) parsed = {};
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
parsed.enabled = next;
|
|
348
|
+
try {
|
|
349
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
350
|
+
fs.writeFileSync(target, `${JSON.stringify(parsed, null, " ")}
|
|
351
|
+
`);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
ctx.log(`im-forward: failed to persist enabled=${next}: ${error instanceof Error ? error.message : String(error)}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
let inputEpoch = 0;
|
|
357
|
+
function toggleForwarding() {
|
|
358
|
+
inputEpoch++;
|
|
359
|
+
isForwardingEnabled();
|
|
360
|
+
forwardingEnabled = !forwardingEnabled;
|
|
361
|
+
writeEnabledToConfig(forwardingEnabled);
|
|
362
|
+
enabledCache.enabled = forwardingEnabled;
|
|
363
|
+
updateFooterTag();
|
|
364
|
+
if (forwardingEnabled) {
|
|
365
|
+
ctx.log("im-forward: enabled");
|
|
366
|
+
ctx.ui.notify("IM forwarding enabled", "info");
|
|
367
|
+
} else {
|
|
368
|
+
ctx.log("im-forward: disabled");
|
|
369
|
+
ctx.ui.notify("IM forwarding disabled", "info");
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
updateFooterTag();
|
|
373
|
+
ctx.registerShortcut("ctrl+i", {
|
|
374
|
+
description: "Toggle IM forwarding (Feishu)",
|
|
375
|
+
handler: toggleForwarding
|
|
376
|
+
});
|
|
377
|
+
let currentSessionId = null;
|
|
378
|
+
const pendingInputs = [];
|
|
379
|
+
const replyAnchor = /* @__PURE__ */ new Map();
|
|
380
|
+
const routes = /* @__PURE__ */ new Map();
|
|
381
|
+
const threadRoutes = /* @__PURE__ */ new Map();
|
|
382
|
+
const seenEventIds = /* @__PURE__ */ new Map();
|
|
383
|
+
const retryTimers = /* @__PURE__ */ new Map();
|
|
384
|
+
let forwardCounter = 0;
|
|
385
|
+
let anchorFallbacks = 0;
|
|
386
|
+
let lastSendError;
|
|
387
|
+
let lastSendErrorAt = 0;
|
|
388
|
+
let lastSendFailureNotifyAt = 0;
|
|
389
|
+
function notifySendFailure(error) {
|
|
390
|
+
lastSendError = error;
|
|
391
|
+
lastSendErrorAt = Date.now();
|
|
392
|
+
ctx.log(`im-forward: ${error}`);
|
|
393
|
+
if (Date.now() - lastSendFailureNotifyAt > SEND_FAILURE_NOTIFY_THROTTLE_MS) {
|
|
394
|
+
lastSendFailureNotifyAt = Date.now();
|
|
395
|
+
ctx.ui.notify(`im-forward: send failed \u2014 ${error}`, "warning");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function pruneState(now = Date.now()) {
|
|
399
|
+
for (const [id, route] of routes) {
|
|
400
|
+
if (now - route.sentAt > ROUTE_TTL_MS) {
|
|
401
|
+
routes.delete(id);
|
|
402
|
+
if (route.threadId) threadRoutes.delete(route.threadId);
|
|
403
|
+
const anchor = replyAnchor.get(route.sessionId);
|
|
404
|
+
if (anchor?.messageId === id) replyAnchor.delete(route.sessionId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
while (routes.size > MAX_ROUTES) {
|
|
408
|
+
let oldest;
|
|
409
|
+
for (const route of routes.values()) {
|
|
410
|
+
if (!oldest || route.sentAt < oldest.sentAt) oldest = route;
|
|
411
|
+
}
|
|
412
|
+
if (!oldest) break;
|
|
413
|
+
routes.delete(oldest.larkMessageId);
|
|
414
|
+
if (oldest.threadId) threadRoutes.delete(oldest.threadId);
|
|
415
|
+
}
|
|
416
|
+
const cutoff = now - PENDING_IM_INPUT_TTL_MS;
|
|
417
|
+
while (pendingInputs.length && pendingInputs[0].at <= cutoff) pendingInputs.shift();
|
|
418
|
+
while (pendingInputs.length > MAX_PENDING_IM_INPUTS) pendingInputs.shift();
|
|
419
|
+
for (const [eventId, at] of seenEventIds) {
|
|
420
|
+
if (now - at > ROUTE_TTL_MS) seenEventIds.delete(eventId);
|
|
421
|
+
}
|
|
422
|
+
if (seenEventIds.size > MAX_SEEN_EVENTS) {
|
|
423
|
+
const excess = seenEventIds.size - MAX_SEEN_EVENTS;
|
|
424
|
+
let removed = 0;
|
|
425
|
+
for (const eventId of seenEventIds.keys()) {
|
|
426
|
+
seenEventIds.delete(eventId);
|
|
427
|
+
if (++removed >= excess) break;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function resolveOutboundAttribution(sessionId) {
|
|
432
|
+
const messages = ctx.getMessages?.() ?? [];
|
|
433
|
+
let lastUserText;
|
|
434
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
435
|
+
const message = messages[i];
|
|
436
|
+
if (!isRecord(message) || message.role !== "user") continue;
|
|
437
|
+
const joined = (typeof message.content === "string" ? message.content : Array.isArray(message.content) ? message.content.map((block) => isRecord(block) && typeof block.text === "string" ? block.text : "").join("") : "").trim();
|
|
438
|
+
if (joined) {
|
|
439
|
+
lastUserText = joined;
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (lastUserText === void 0) return void 0;
|
|
444
|
+
for (let j = 0; j < pendingInputs.length; j++) {
|
|
445
|
+
const pending = pendingInputs[j];
|
|
446
|
+
if (pending.sessionId !== sessionId) continue;
|
|
447
|
+
const expected = (pending.larkMessageId ? `${IM_INPUT_PREFIX}
|
|
448
|
+
${pending.caption || IM_INPUT_FALLBACK_CAPTION}` : pending.caption).trim();
|
|
449
|
+
if (lastUserText === expected) {
|
|
450
|
+
pendingInputs.splice(j, 1);
|
|
451
|
+
return { caption: pending.caption || `[Feishu images]`, replyTo: pending.larkMessageId || void 0, threadId: pending.threadId };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return void 0;
|
|
455
|
+
}
|
|
456
|
+
async function acknowledgeMessage(messageId) {
|
|
457
|
+
const { error } = await runLarkCli(larkBinary, [
|
|
458
|
+
"im",
|
|
459
|
+
"reactions",
|
|
460
|
+
"create",
|
|
461
|
+
"--as",
|
|
462
|
+
"bot",
|
|
463
|
+
"--params",
|
|
464
|
+
JSON.stringify({ message_id: messageId }),
|
|
465
|
+
"--data",
|
|
466
|
+
JSON.stringify({ reaction_type: { emoji_type: "THUMBSUP" } })
|
|
467
|
+
]);
|
|
468
|
+
if (error) ctx.log(`im-forward: acknowledge react failed: ${error}`);
|
|
469
|
+
}
|
|
470
|
+
ctx.on("terminal:input", (event) => {
|
|
471
|
+
const text = event.text?.trim() ?? "";
|
|
472
|
+
if (text && !text.startsWith(IM_INPUT_PREFIX) && !text.startsWith("/") && !text.startsWith("!!") && !/@\[[^\]]+\]\(file:/.test(text) && currentSessionId) {
|
|
473
|
+
pendingInputs.push({ sessionId: currentSessionId, caption: text, larkMessageId: "", threadId: null, at: Date.now() });
|
|
474
|
+
pruneState();
|
|
475
|
+
}
|
|
476
|
+
return { action: "continue" };
|
|
477
|
+
});
|
|
478
|
+
ctx.on("message:end", (event) => {
|
|
479
|
+
if (!isForwardingEnabled()) return;
|
|
480
|
+
const message = event.message;
|
|
481
|
+
const stop = message.role === "assistant" ? message.stopReason : void 0;
|
|
482
|
+
if (stop === "aborted" || stop === "error") return;
|
|
483
|
+
const text = assistantForwardText(message);
|
|
484
|
+
if (!text) return;
|
|
485
|
+
const sessionId = currentSessionId;
|
|
486
|
+
if (!sessionId) return;
|
|
487
|
+
const sequence = ++forwardCounter;
|
|
488
|
+
const attribution = resolveOutboundAttribution(sessionId);
|
|
489
|
+
void forwardToLark(text, sessionId, sequence, attribution?.caption, attribution?.replyTo);
|
|
490
|
+
});
|
|
491
|
+
ctx.on("turn:failed", (event) => {
|
|
492
|
+
if (!isForwardingEnabled()) return;
|
|
493
|
+
const sessionId = currentSessionId;
|
|
494
|
+
if (!sessionId) return;
|
|
495
|
+
const reason = failureReason(event.message ?? {});
|
|
496
|
+
const sequence = ++forwardCounter;
|
|
497
|
+
const attribution = resolveOutboundAttribution(sessionId);
|
|
498
|
+
void forwardToLark(`\u26A0\uFE0F \u4EFB\u52A1\u5931\u8D25\uFF1A${reason}`, sessionId, sequence, attribution?.caption, attribution?.replyTo);
|
|
499
|
+
});
|
|
500
|
+
ctx.on("turn:cancelled", (event) => {
|
|
501
|
+
if (!isForwardingEnabled()) return;
|
|
502
|
+
const sessionId = currentSessionId;
|
|
503
|
+
if (!sessionId) return;
|
|
504
|
+
const sequence = ++forwardCounter;
|
|
505
|
+
const attribution = resolveOutboundAttribution(sessionId);
|
|
506
|
+
void forwardToLark(`\u26D4\uFE0F \u4EFB\u52A1\u5DF2\u53D6\u6D88\uFF1A${event.reason}`, sessionId, sequence, attribution?.caption, attribution?.replyTo);
|
|
507
|
+
});
|
|
508
|
+
async function forwardToLark(text, sessionId, sequence, userInput, replyTo) {
|
|
509
|
+
pruneState();
|
|
510
|
+
const sessionName = ctx.getSessionName?.();
|
|
511
|
+
const composed = composeForwardBody(sessionId, sessionName, userInput, text);
|
|
512
|
+
const body = composed.length > MAX_BODY_CHARS ? `${composed.slice(0, MAX_BODY_CHARS)}
|
|
513
|
+
\u2026(truncated)` : composed;
|
|
514
|
+
const idempotencyKey = `pie-im-forward-${sessionId}-${sequence}-${hashText(composed)}`;
|
|
515
|
+
let anchor = replyTo ?? replyAnchor.get(sessionId)?.messageId;
|
|
516
|
+
let args = anchor ? ["im", "+messages-reply", "--as", "bot", "--message-id", anchor, "--text", body, "--idempotency-key", idempotencyKey] : ["im", "+messages-send", "--as", "bot", "--chat-id", cfg.chatId, "--text", body, "--idempotency-key", idempotencyKey];
|
|
517
|
+
let parsed;
|
|
518
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
519
|
+
const { stdout, error } = await runLarkCli(larkBinary, args);
|
|
520
|
+
if (error) {
|
|
521
|
+
if (attempt === 0 && anchor) {
|
|
522
|
+
if (replyAnchor.get(sessionId)?.messageId === anchor) replyAnchor.delete(sessionId);
|
|
523
|
+
anchorFallbacks++;
|
|
524
|
+
anchor = void 0;
|
|
525
|
+
args = ["im", "+messages-send", "--as", "bot", "--chat-id", cfg.chatId, "--text", body, "--idempotency-key", idempotencyKey];
|
|
526
|
+
ctx.log(`im-forward: reply send failed (${error}); falling back to chat root`);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
notifySendFailure(`send failed: ${error}`);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
parsed = parseLarkCliEnvelope(stdout);
|
|
533
|
+
if (parsed.ok && parsed.data) break;
|
|
534
|
+
if (attempt === 0 && anchor) {
|
|
535
|
+
if (replyAnchor.get(sessionId)?.messageId === anchor) replyAnchor.delete(sessionId);
|
|
536
|
+
anchor = void 0;
|
|
537
|
+
args = ["im", "+messages-send", "--as", "bot", "--chat-id", cfg.chatId, "--text", body, "--idempotency-key", idempotencyKey];
|
|
538
|
+
ctx.log(`im-forward: reply rejected (${parsed.errorText ?? "no data"}); falling back to chat root`);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
notifySendFailure(`send rejected: ${parsed.errorText ?? "no data"}`);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (!parsed || !parsed.ok || !parsed.data) return;
|
|
545
|
+
const messageId = parsed.data.message_id;
|
|
546
|
+
if (typeof messageId === "string" && messageId) {
|
|
547
|
+
const threadId = typeof parsed.data.thread_id === "string" ? parsed.data.thread_id : null;
|
|
548
|
+
const route = { larkMessageId: messageId, threadId, sessionId, sentAt: Date.now() };
|
|
549
|
+
routes.set(messageId, route);
|
|
550
|
+
if (threadId) threadRoutes.set(threadId, route);
|
|
551
|
+
ctx.log(`im-forward: forwarded session=${sessionId} as ${messageId}${threadId ? ` (thread ${threadId})` : ""}`);
|
|
552
|
+
} else {
|
|
553
|
+
ctx.log("im-forward: send ok but no message_id in response; reply routing disabled for this message");
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
ctx.on("session:changed", (event) => {
|
|
557
|
+
inputEpoch++;
|
|
558
|
+
currentSessionId = event.sessionId;
|
|
559
|
+
if (event.sessionId && !consumer && !terminated) {
|
|
560
|
+
ctx.log("im-forward: session bound, starting event consumer");
|
|
561
|
+
spawnConsumer();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
async function handleInboundEvent(event, viaRetry = false) {
|
|
565
|
+
if (!event) return;
|
|
566
|
+
if (event.type !== "im.message.receive_v1") return;
|
|
567
|
+
if (!event.event_id) return;
|
|
568
|
+
if (!viaRetry && inFlightRetries.has(event.event_id)) return;
|
|
569
|
+
if (!viaRetry && retryTimers.has(event.event_id)) return;
|
|
570
|
+
if (!viaRetry && seenEventIds.has(event.event_id)) return;
|
|
571
|
+
seenEventIds.set(event.event_id, Date.now());
|
|
572
|
+
pruneState();
|
|
573
|
+
let scheduledNextRetry = false;
|
|
574
|
+
const retry = () => {
|
|
575
|
+
scheduledNextRetry = true;
|
|
576
|
+
scheduleInboundRetry(event, retryDepth(event.event_id) + 1);
|
|
577
|
+
};
|
|
578
|
+
const terminalCleanup = () => {
|
|
579
|
+
if (!scheduledNextRetry) {
|
|
580
|
+
retryAttempts.delete(event.event_id);
|
|
581
|
+
inFlightRetries.delete(event.event_id);
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
if (!isForwardingEnabled()) {
|
|
585
|
+
terminalCleanup();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
await handleInboundEventInner(event, retry);
|
|
590
|
+
} finally {
|
|
591
|
+
terminalCleanup();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
async function handleInboundEventInner(event, retry) {
|
|
595
|
+
if (event.chat_id !== cfg.chatId) return;
|
|
596
|
+
if (cfg.allowUsers && (!event.sender_id || !cfg.allowUsers.includes(event.sender_id))) return;
|
|
597
|
+
const messageId = event.message_id ?? event.id;
|
|
598
|
+
if (!messageId) return;
|
|
599
|
+
const sessionId = currentSessionId;
|
|
600
|
+
const epoch = inputEpoch;
|
|
601
|
+
const scope = ctx.beginUserInput?.(() => !terminated && inputEpoch === epoch && currentSessionId === sessionId && isForwardingEnabled());
|
|
602
|
+
if (!scope) {
|
|
603
|
+
retry();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
const meta = await fetchMessageMeta(messageId);
|
|
608
|
+
if (!meta) {
|
|
609
|
+
retry();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (!scope.isCurrent()) return;
|
|
613
|
+
const route = await resolveRouteForInbound(messageId, meta);
|
|
614
|
+
if (!route) {
|
|
615
|
+
const resolution = await routeResolutionOutcome(messageId, meta);
|
|
616
|
+
if (resolution === "transient") retry();
|
|
617
|
+
else ctx.log(`im-forward: no route for ${messageId}; ignoring`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (route.sessionId !== sessionId || !scope.isCurrent()) return;
|
|
621
|
+
const text = extractInboundText(event.content);
|
|
622
|
+
const imageKeys = extractImageKeys(event.content);
|
|
623
|
+
const caption = imageKeys.length ? text.replace(/\[Image:\s*img_[A-Za-z0-9_-]+\]/g, "").trim() : text;
|
|
624
|
+
if (!caption && !imageKeys.length) return;
|
|
625
|
+
const submitText = `${IM_INPUT_PREFIX}
|
|
626
|
+
${caption || IM_INPUT_FALLBACK_CAPTION}`;
|
|
627
|
+
const accepted = imageKeys.length ? await injectImageMessage(messageId, imageKeys, submitText, scope) : await scope.submit(submitText);
|
|
628
|
+
if (!accepted || !scope.isCurrent()) {
|
|
629
|
+
if (scope.isCurrent()) {
|
|
630
|
+
ctx.ui.notify("im-forward: message was not accepted; no input submitted.", "warning");
|
|
631
|
+
await forwardToLark(`\u26A0\uFE0F \u6D88\u606F\u63A5\u6536\u5931\u8D25\uFF0C\u672A\u63D0\u4EA4\u4EFB\u52A1\u3002${imageKeys.length ? "\u8BF7\u68C0\u67E5\u9644\u4EF6\u540E\u91CD\u8BD5\u3002" : "\u8BF7\u91CD\u8BD5\u3002"}`, route.sessionId, ++forwardCounter, caption, messageId);
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
replyAnchor.set(route.sessionId, { messageId, at: Date.now() });
|
|
636
|
+
pendingInputs.push({ sessionId: route.sessionId, caption, larkMessageId: messageId, threadId: route.threadId, at: Date.now() });
|
|
637
|
+
pruneState();
|
|
638
|
+
ctx.log(`im-forward: accepted ${messageId} into session ${route.sessionId} (${imageKeys.length} images)`);
|
|
639
|
+
void acknowledgeMessage(messageId);
|
|
640
|
+
} finally {
|
|
641
|
+
scope.dispose();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const retryAttempts = /* @__PURE__ */ new Map();
|
|
645
|
+
function retryDepth(eventId) {
|
|
646
|
+
return eventId ? retryAttempts.get(eventId) ?? 0 : 0;
|
|
647
|
+
}
|
|
648
|
+
const inFlightRetries = /* @__PURE__ */ new Set();
|
|
649
|
+
function scheduleInboundRetry(event, attempt) {
|
|
650
|
+
if (!event.event_id) return;
|
|
651
|
+
if (terminated || attempt > MAX_INBOUND_RETRIES) {
|
|
652
|
+
if (attempt > MAX_INBOUND_RETRIES) {
|
|
653
|
+
ctx.log(`im-forward: giving up on event ${event.event_id} after ${MAX_INBOUND_RETRIES} retries`);
|
|
654
|
+
retryAttempts.delete(event.event_id);
|
|
655
|
+
void forwardToLark(`\u26A0\uFE0F \u6D88\u606F\u63A5\u6536\u5931\u8D25\uFF08\u91CD\u8BD5\u7528\u5C3D\uFF09\u3002\u8BF7\u5728 Pie \u7EC8\u7AEF\u67E5\u770B\u65E5\u5FD7\u3002`, currentSessionId ?? "", ++forwardCounter, void 0, event.message_id ?? event.id);
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const backoff = (cfg.retryBackoffMs ?? DEFAULT_INBOUND_RETRY_BACKOFF_MS) * 2 ** (attempt - 1);
|
|
660
|
+
if (retryTimers.has(event.event_id)) return;
|
|
661
|
+
retryAttempts.set(event.event_id, attempt);
|
|
662
|
+
const timer = setTimeout(() => {
|
|
663
|
+
retryTimers.delete(event.event_id);
|
|
664
|
+
inFlightRetries.add(event.event_id);
|
|
665
|
+
void handleInboundEvent(event, true).finally(() => {
|
|
666
|
+
inFlightRetries.delete(event.event_id);
|
|
667
|
+
});
|
|
668
|
+
}, backoff);
|
|
669
|
+
timer.unref?.();
|
|
670
|
+
retryTimers.set(event.event_id, { attempt, timer, event });
|
|
671
|
+
ctx.log(`im-forward: transient inbound failure; retrying ${event.event_id} in ${backoff}ms (attempt ${attempt}/${MAX_INBOUND_RETRIES})`);
|
|
672
|
+
}
|
|
673
|
+
ctx.__forceInboundRetriesForTest = () => {
|
|
674
|
+
for (const [eventId, entry] of retryTimers) {
|
|
675
|
+
clearTimeout(entry.timer);
|
|
676
|
+
retryTimers.delete(eventId);
|
|
677
|
+
void handleInboundEvent({ ...entry.event ?? {} }, true);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
async function routeResolutionOutcome(messageId, meta) {
|
|
681
|
+
if (!meta.threadId) {
|
|
682
|
+
if (!meta.replyTo) return "not-ours";
|
|
683
|
+
let current = meta.replyTo;
|
|
684
|
+
const visited = /* @__PURE__ */ new Set();
|
|
685
|
+
for (let depth = 0; depth < REPLY_CHAIN_MAX_DEPTH && current && !visited.has(current); depth++) {
|
|
686
|
+
visited.add(current);
|
|
687
|
+
const { stdout: stdout2, error: error2 } = await runLarkCli(larkBinary, ["im", "+messages-mget", "--as", "bot", "--message-ids", current]);
|
|
688
|
+
if (error2) return "transient";
|
|
689
|
+
const parsed = parseLarkCliEnvelope(stdout2);
|
|
690
|
+
if (!parsed.ok || !parsed.data) return "transient";
|
|
691
|
+
const messages = parsed.data.messages;
|
|
692
|
+
if (!Array.isArray(messages) || !isRecord(messages[0])) return "transient";
|
|
693
|
+
const next = messages[0].reply_to;
|
|
694
|
+
if (typeof next !== "string" || !next) return "not-ours";
|
|
695
|
+
current = next;
|
|
696
|
+
}
|
|
697
|
+
return "not-ours";
|
|
698
|
+
}
|
|
699
|
+
const { stdout, error } = await runLarkCli(larkBinary, [
|
|
700
|
+
"im",
|
|
701
|
+
"+threads-messages-list",
|
|
702
|
+
"--as",
|
|
703
|
+
"bot",
|
|
704
|
+
"--thread",
|
|
705
|
+
meta.threadId
|
|
706
|
+
]);
|
|
707
|
+
if (error) return "transient";
|
|
708
|
+
return parseLarkCliEnvelope(stdout).ok ? "not-ours" : "transient";
|
|
709
|
+
}
|
|
710
|
+
async function injectImageMessage(messageId, imageKeys, submitText, scope) {
|
|
711
|
+
if (imageKeys.length > 10) {
|
|
712
|
+
ctx.log("im-forward: refusing more than 10 images in one message");
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
let tempDir;
|
|
716
|
+
try {
|
|
717
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pie-im-forward-"));
|
|
718
|
+
const files = [];
|
|
719
|
+
for (const [index, imageKey] of imageKeys.entries()) {
|
|
720
|
+
if (!scope.isCurrent()) return false;
|
|
721
|
+
const ext = imageExtensionFromKey(imageKey);
|
|
722
|
+
const fileName = `image-${index + 1}${ext}`;
|
|
723
|
+
const { stdout, error } = await runLarkCli(larkBinary, [
|
|
724
|
+
"im",
|
|
725
|
+
"+messages-resources-download",
|
|
726
|
+
"--as",
|
|
727
|
+
"bot",
|
|
728
|
+
"--message-id",
|
|
729
|
+
messageId,
|
|
730
|
+
"--file-key",
|
|
731
|
+
imageKey,
|
|
732
|
+
"--type",
|
|
733
|
+
"image",
|
|
734
|
+
"--output",
|
|
735
|
+
fileName
|
|
736
|
+
], CLI_CALL_TIMEOUT_MS, tempDir);
|
|
737
|
+
if (error || !parseLarkCliEnvelope(stdout).ok) throw new Error(error || "image download rejected");
|
|
738
|
+
if (!scope.isCurrent()) return false;
|
|
739
|
+
let target = path.join(tempDir, fileName);
|
|
740
|
+
if (fs.statSync(target).size > 10 * 1024 * 1024) throw new Error("image exceeds 10 MB inline limit");
|
|
741
|
+
const detected = readImageFormat(target);
|
|
742
|
+
if (detected.ext === ".bin") throw new Error("download is not a supported image");
|
|
743
|
+
if (detected.ext !== ext) {
|
|
744
|
+
const corrected = path.join(tempDir, `image-${index + 1}${detected.ext}`);
|
|
745
|
+
fs.renameSync(target, corrected);
|
|
746
|
+
target = corrected;
|
|
747
|
+
}
|
|
748
|
+
files.push({ path: target, displayName: `feishu-image-${index + 1}${detected.ext}` });
|
|
749
|
+
}
|
|
750
|
+
return await scope.submit(submitText, files);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
ctx.log(`im-forward: image input failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
753
|
+
return false;
|
|
754
|
+
} finally {
|
|
755
|
+
if (tempDir) {
|
|
756
|
+
try {
|
|
757
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
758
|
+
} catch (error) {
|
|
759
|
+
ctx.log(`im-forward: temporary image cleanup failed: ${String(error)}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
async function fetchMessageMeta(messageId) {
|
|
765
|
+
const { stdout, error } = await runLarkCli(larkBinary, [
|
|
766
|
+
"im",
|
|
767
|
+
"+messages-mget",
|
|
768
|
+
"--as",
|
|
769
|
+
"bot",
|
|
770
|
+
"--message-ids",
|
|
771
|
+
messageId
|
|
772
|
+
]);
|
|
773
|
+
if (error) {
|
|
774
|
+
ctx.log(`im-forward: mget failed: ${error}`);
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
const parsed = parseLarkCliEnvelope(stdout);
|
|
778
|
+
if (!parsed.ok || !parsed.data) return null;
|
|
779
|
+
const messages = parsed.data.messages;
|
|
780
|
+
if (!Array.isArray(messages) || !isRecord(messages[0])) return null;
|
|
781
|
+
const message = messages[0];
|
|
782
|
+
const threadId = typeof message.thread_id === "string" && message.thread_id ? message.thread_id : null;
|
|
783
|
+
const replyTo = typeof message.reply_to === "string" && message.reply_to ? message.reply_to : null;
|
|
784
|
+
return { threadId, replyTo };
|
|
785
|
+
}
|
|
786
|
+
async function resolveRouteForInbound(messageId, meta) {
|
|
787
|
+
if (meta.threadId) {
|
|
788
|
+
const byThread = threadRoutes.get(meta.threadId);
|
|
789
|
+
if (byThread) return byThread;
|
|
790
|
+
}
|
|
791
|
+
const direct = routes.get(messageId);
|
|
792
|
+
if (direct) return direct;
|
|
793
|
+
if (meta.replyTo) {
|
|
794
|
+
const viaChain = await resolveRouteForReply(meta.replyTo);
|
|
795
|
+
if (viaChain) return viaChain;
|
|
796
|
+
}
|
|
797
|
+
if (meta.threadId) return await resolveRouteByThreadRoot(meta.threadId);
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
async function resolveRouteByThreadRoot(threadId) {
|
|
801
|
+
const { stdout, error } = await runLarkCli(larkBinary, [
|
|
802
|
+
"im",
|
|
803
|
+
"+threads-messages-list",
|
|
804
|
+
"--as",
|
|
805
|
+
"bot",
|
|
806
|
+
"--thread",
|
|
807
|
+
threadId
|
|
808
|
+
]);
|
|
809
|
+
if (error) {
|
|
810
|
+
ctx.log(`im-forward: thread list failed: ${error}`);
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
const parsed = parseLarkCliEnvelope(stdout);
|
|
814
|
+
if (!parsed.ok || !parsed.data) return null;
|
|
815
|
+
const messages = parsed.data.messages;
|
|
816
|
+
if (!Array.isArray(messages)) return null;
|
|
817
|
+
for (const message of messages) {
|
|
818
|
+
if (!isRecord(message)) continue;
|
|
819
|
+
const id = message.message_id;
|
|
820
|
+
if (typeof id !== "string") continue;
|
|
821
|
+
const route = routes.get(id);
|
|
822
|
+
if (route) {
|
|
823
|
+
threadRoutes.set(threadId, route);
|
|
824
|
+
return route;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
async function resolveRouteForReply(startId) {
|
|
830
|
+
let current = startId;
|
|
831
|
+
const visited = /* @__PURE__ */ new Set();
|
|
832
|
+
for (let depth = 0; depth < REPLY_CHAIN_MAX_DEPTH; depth++) {
|
|
833
|
+
const direct = routes.get(current);
|
|
834
|
+
if (direct) return direct;
|
|
835
|
+
if (visited.has(current)) return null;
|
|
836
|
+
visited.add(current);
|
|
837
|
+
const parent = await fetchReplyTo(current);
|
|
838
|
+
if (!parent) return null;
|
|
839
|
+
current = parent;
|
|
840
|
+
}
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
async function fetchReplyTo(messageId) {
|
|
844
|
+
const { stdout, error } = await runLarkCli(larkBinary, [
|
|
845
|
+
"im",
|
|
846
|
+
"+messages-mget",
|
|
847
|
+
"--as",
|
|
848
|
+
"bot",
|
|
849
|
+
"--message-ids",
|
|
850
|
+
messageId
|
|
851
|
+
]);
|
|
852
|
+
if (error) {
|
|
853
|
+
ctx.log(`im-forward: mget failed: ${error}`);
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
const parsed = parseLarkCliEnvelope(stdout);
|
|
857
|
+
if (!parsed.ok || !parsed.data) return null;
|
|
858
|
+
const messages = parsed.data.messages;
|
|
859
|
+
if (!Array.isArray(messages) || !isRecord(messages[0])) return null;
|
|
860
|
+
const replyTo = messages[0].reply_to;
|
|
861
|
+
return typeof replyTo === "string" && replyTo ? replyTo : null;
|
|
862
|
+
}
|
|
863
|
+
let consumer = null;
|
|
864
|
+
let consumerReady = false;
|
|
865
|
+
let consumerRestarts = 0;
|
|
866
|
+
let consumerLastExit;
|
|
867
|
+
let terminated = false;
|
|
868
|
+
let restartTimer;
|
|
869
|
+
let notifiedUnreadyExit = false;
|
|
870
|
+
function spawnConsumer() {
|
|
871
|
+
if (terminated) return;
|
|
872
|
+
const child = spawn2(larkBinary, ["event", "consume", "im.message.receive_v1", "--as", "bot"], {
|
|
873
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
874
|
+
});
|
|
875
|
+
const state = { child, stopped: false };
|
|
876
|
+
consumer = state;
|
|
877
|
+
consumerReady = false;
|
|
878
|
+
let buffer = "";
|
|
879
|
+
child.stdin?.on("error", () => void 0);
|
|
880
|
+
child.stdout?.on("data", (chunk) => {
|
|
881
|
+
buffer += chunk.toString("utf8");
|
|
882
|
+
let newline = buffer.indexOf("\n");
|
|
883
|
+
while (newline >= 0) {
|
|
884
|
+
const line = buffer.slice(0, newline).trim();
|
|
885
|
+
buffer = buffer.slice(newline + 1);
|
|
886
|
+
if (line) void handleInboundEvent(parseInboundLine(line)).catch((error) => {
|
|
887
|
+
ctx.log(`im-forward: inbound handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
888
|
+
});
|
|
889
|
+
newline = buffer.indexOf("\n");
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
const stderrTail = [];
|
|
893
|
+
child.stderr?.on("data", (chunk) => {
|
|
894
|
+
for (const raw of chunk.toString("utf8").split("\n")) {
|
|
895
|
+
const line = raw.trim();
|
|
896
|
+
if (!line) continue;
|
|
897
|
+
stderrTail.push(line);
|
|
898
|
+
if (stderrTail.length > 20) stderrTail.shift();
|
|
899
|
+
if (line.startsWith("[event] ready")) {
|
|
900
|
+
consumerReady = true;
|
|
901
|
+
consumerRestarts = 0;
|
|
902
|
+
ctx.log("im-forward: event consumer ready");
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
child.on("exit", (code, signal) => {
|
|
907
|
+
if (state.stopped || terminated) return;
|
|
908
|
+
consumerLastExit = `code=${code} signal=${signal}`;
|
|
909
|
+
consumerRestarts += 1;
|
|
910
|
+
ctx.log(`im-forward: consumer exited (${consumerLastExit})`);
|
|
911
|
+
if (!consumerReady && !notifiedUnreadyExit) {
|
|
912
|
+
notifiedUnreadyExit = true;
|
|
913
|
+
ctx.ui.notify(
|
|
914
|
+
`im-forward: lark-cli event consumer exited before becoming ready (${consumerLastExit}). Check lark-cli config/auth; retries continue in the background. ${stderrTail.slice(-3).join(" | ")}`,
|
|
915
|
+
"warning"
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
const backoff = Math.min(RESTART_BACKOFF_MIN_MS * 2 ** Math.min(consumerRestarts, 4), RESTART_BACKOFF_MAX_MS);
|
|
919
|
+
restartTimer = setTimeout(spawnConsumer, backoff);
|
|
920
|
+
restartTimer.unref?.();
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
ctx.registerCleanup?.(() => {
|
|
924
|
+
inputEpoch++;
|
|
925
|
+
terminated = true;
|
|
926
|
+
for (const { timer } of retryTimers.values()) clearTimeout(timer);
|
|
927
|
+
retryTimers.clear();
|
|
928
|
+
retryAttempts.clear();
|
|
929
|
+
inFlightRetries.clear();
|
|
930
|
+
if (restartTimer) clearTimeout(restartTimer);
|
|
931
|
+
if (consumer && !consumer.child.killed) {
|
|
932
|
+
consumer.stopped = true;
|
|
933
|
+
consumer.child.kill("SIGTERM");
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
ctx.registerCommand({
|
|
937
|
+
name: "im-forward",
|
|
938
|
+
description: "IM forwarding status (Feishu via lark-cli)",
|
|
939
|
+
handler: (commandCtx) => {
|
|
940
|
+
const lines = [
|
|
941
|
+
"IM forwarding (Feishu via lark-cli)",
|
|
942
|
+
`- chat: ${cfg.chatId}`,
|
|
943
|
+
`- allowUsers: ${cfg.allowUsers?.join(", ") ?? "(anyone in chat)"}`,
|
|
944
|
+
`- binary: ${larkBinary}`,
|
|
945
|
+
`- consumer: ${terminated ? "stopped" : consumer ? consumerReady ? "ready" : "starting" : "idle (waits for first session)"}${consumerLastExit ? ` (last exit: ${consumerLastExit})` : ""}, restarts=${consumerRestarts}`,
|
|
946
|
+
`- routes: ${routes.size} forwarded message(s) routable (${threadRoutes.size} thread(s), ${replyAnchor.size} anchor(s))`,
|
|
947
|
+
`- current session: ${currentSessionId ?? "(none)"}`
|
|
948
|
+
];
|
|
949
|
+
if (lastSendError) lines.push(`- last send error: ${lastSendError} (at ${new Date(lastSendErrorAt).toISOString()})`);
|
|
950
|
+
if (anchorFallbacks) lines.push(`- anchor fallbacks to chat root: ${anchorFallbacks}`);
|
|
951
|
+
if (pendingInputs.length) lines.push(`- pending turn attributions: ${pendingInputs.length}`);
|
|
952
|
+
if (retryAttempts.size) lines.push(`- inbound retries pending: ${retryTimers.size} (attempts so far: ${Math.max(...retryAttempts.values())})`);
|
|
953
|
+
commandCtx.ui.notify(lines.join("\n"), "info");
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
function resolveConfigDir() {
|
|
957
|
+
const envDir = process.env.PIE_CODING_AGENT_DIR?.trim();
|
|
958
|
+
if (envDir) {
|
|
959
|
+
if (envDir === "~") return os.homedir();
|
|
960
|
+
if (envDir.startsWith("~/")) return os.homedir() + envDir.slice(1);
|
|
961
|
+
return envDir;
|
|
962
|
+
}
|
|
963
|
+
return path.join(os.homedir(), ".pie");
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
export {
|
|
967
|
+
assistantForwardText,
|
|
968
|
+
composeForwardBody,
|
|
969
|
+
imForward as default,
|
|
970
|
+
detectImageMime,
|
|
971
|
+
extractImageKey,
|
|
972
|
+
extractImageKeys,
|
|
973
|
+
extractInboundText,
|
|
974
|
+
failureReason,
|
|
975
|
+
inputHeaderLine,
|
|
976
|
+
parseLarkCliEnvelope,
|
|
977
|
+
readLiveEnabled,
|
|
978
|
+
resolveImForwardConfig,
|
|
979
|
+
sessionLabel
|
|
980
|
+
};
|