@cydm/pie 2.7.13 → 2.7.15
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 +16 -0
- package/dist/builtin/extensions/ask-user/index.js +3 -3
- package/dist/builtin/extensions/cron/index.js +669 -0
- package/dist/builtin/extensions/cron/package.json +11 -0
- package/dist/builtin/extensions/document-attachments/index.js +3 -0
- package/dist/builtin/extensions/im-forward/index.js +2278 -0
- package/dist/builtin/extensions/im-forward/package.json +11 -0
- package/dist/builtin/extensions/pie-ipc/index.js +1969 -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/chunks/{chunk-CIFC6QVB.js → chunk-C5UTDL3S.js} +339 -47
- package/dist/chunks/{chunk-D7RMNFVM.js → chunk-CLEPSTWH.js} +2 -2
- package/dist/chunks/chunk-F7EBZTZO.js +26 -0
- package/dist/chunks/{chunk-R46YPPTU.js → chunk-NM2JLPJ6.js} +156 -101
- package/dist/chunks/{chunk-HGUYS5SF.js → chunk-TDHVB2FH.js} +1 -1
- package/dist/chunks/{src-5JJZZ5H7.js → src-IY6TEIL7.js} +2 -2
- package/dist/chunks/{test-stream-ZBTSPU7J.js → test-stream-EXSL5TIO.js} +1 -1
- package/dist/cli.js +498 -136
- package/package.json +6 -2
|
@@ -0,0 +1,2278 @@
|
|
|
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/config.ts
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
// builtin/extensions/im-forward/helpers.ts
|
|
15
|
+
var MAX_INPUT_HEADER_CHARS = 200;
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function assistantForwardText(message) {
|
|
20
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) return "";
|
|
21
|
+
let text = "";
|
|
22
|
+
let hasToolCalls = false;
|
|
23
|
+
for (const block of message.content) {
|
|
24
|
+
if (!isRecord(block)) continue;
|
|
25
|
+
if (block.type === "text" && typeof block.text === "string") text += block.text;
|
|
26
|
+
else if (block.type === "toolCall") hasToolCalls = true;
|
|
27
|
+
}
|
|
28
|
+
if (hasToolCalls) return "";
|
|
29
|
+
return text.trim();
|
|
30
|
+
}
|
|
31
|
+
function sessionLabel(sessionId, sessionName) {
|
|
32
|
+
if (sessionName && sessionName.trim()) return sessionName.trim();
|
|
33
|
+
return sessionId.length > 8 ? sessionId.slice(0, 8) : sessionId;
|
|
34
|
+
}
|
|
35
|
+
function escapeMarkdownInline(text) {
|
|
36
|
+
return text.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
|
|
37
|
+
}
|
|
38
|
+
function inputHeaderLine(userInput) {
|
|
39
|
+
if (!userInput) return null;
|
|
40
|
+
const collapsed = userInput.replace(/\s+/g, " ").trim();
|
|
41
|
+
if (!collapsed) return null;
|
|
42
|
+
let escaped = escapeMarkdownInline(collapsed);
|
|
43
|
+
if (escaped.length > MAX_INPUT_HEADER_CHARS) {
|
|
44
|
+
let clipped = stripDanglingHighSurrogate(escaped.slice(0, MAX_INPUT_HEADER_CHARS));
|
|
45
|
+
if (/(?:^|[^\\])(?:\\\\)*\\$/.test(clipped)) clipped = clipped.slice(0, -1);
|
|
46
|
+
escaped = clipped + "\u2026";
|
|
47
|
+
}
|
|
48
|
+
return `> [UserInput] ${escaped}`;
|
|
49
|
+
}
|
|
50
|
+
function composeForwardBody(sessionId, sessionName, userInput, text) {
|
|
51
|
+
const lines = [`**[Pie] \xB7 ${sessionLabel(sessionId, sessionName)}**`];
|
|
52
|
+
const input = inputHeaderLine(userInput);
|
|
53
|
+
if (input) lines.push(input);
|
|
54
|
+
lines.push("");
|
|
55
|
+
lines.push(text);
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
var FEISHU_POST_REQUEST_LIMIT_BYTES = 3e4;
|
|
59
|
+
var MAX_BODY_BYTES = FEISHU_POST_REQUEST_LIMIT_BYTES - 6e3;
|
|
60
|
+
var MAX_BODY_UTF16_CHARS = 3e4;
|
|
61
|
+
var POST_PARAGRAPH_OVERHEAD_BYTES = 150;
|
|
62
|
+
var POST_LINE_OVERHEAD_BYTES = 12;
|
|
63
|
+
var POST_ENVELOPE_OVERHEAD_BYTES = 256;
|
|
64
|
+
var textEncoder = new TextEncoder();
|
|
65
|
+
function utf8Bytes(text) {
|
|
66
|
+
return textEncoder.encode(text).length;
|
|
67
|
+
}
|
|
68
|
+
function utf16LengthBudget(text, maxChars) {
|
|
69
|
+
if (text.length <= maxChars) return text;
|
|
70
|
+
return stripDanglingHighSurrogate(text.slice(0, maxChars));
|
|
71
|
+
}
|
|
72
|
+
function stripDanglingHighSurrogate(text) {
|
|
73
|
+
return /[\uD800-\uDBFF]$/.test(text) ? text.slice(0, -1) : text;
|
|
74
|
+
}
|
|
75
|
+
function estimatePostRequestBytes(markdown) {
|
|
76
|
+
const escaped = utf8Bytes(JSON.stringify(markdown).slice(1, -1));
|
|
77
|
+
const paragraphs = markdown.split(/\n{2,}/).filter((part) => part.trim().length > 0).length;
|
|
78
|
+
const lines = markdown.length === 0 ? 0 : markdown.split("\n").length;
|
|
79
|
+
return escaped + paragraphs * POST_PARAGRAPH_OVERHEAD_BYTES + lines * POST_LINE_OVERHEAD_BYTES + POST_ENVELOPE_OVERHEAD_BYTES;
|
|
80
|
+
}
|
|
81
|
+
function truncateMarkdown(text, maxBytes = MAX_BODY_BYTES) {
|
|
82
|
+
const bounded = utf16LengthBudget(text, MAX_BODY_UTF16_CHARS);
|
|
83
|
+
const charCapped = bounded !== text;
|
|
84
|
+
if (!charCapped && estimatePostRequestBytes(text) <= maxBytes) return text;
|
|
85
|
+
const suffix = "\n\n\u2026(truncated)";
|
|
86
|
+
const suffixCost = estimatePostRequestBytes(suffix) - POST_ENVELOPE_OVERHEAD_BYTES;
|
|
87
|
+
const budget = charCapped ? Math.min(maxBytes - suffixCost, bounded.length - suffix.length) : maxBytes - suffixCost;
|
|
88
|
+
const snapForward = (candidate) => {
|
|
89
|
+
const newline = candidate.lastIndexOf("\n");
|
|
90
|
+
if (newline < Math.floor(candidate.length * 0.85)) return candidate;
|
|
91
|
+
const snapped = candidate.slice(0, newline);
|
|
92
|
+
return snapped.trim().length > 0 ? snapped : candidate;
|
|
93
|
+
};
|
|
94
|
+
const fit = (limit) => {
|
|
95
|
+
let lo = 0;
|
|
96
|
+
let hi = text.length;
|
|
97
|
+
while (lo < hi) {
|
|
98
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
99
|
+
if (estimatePostRequestBytes(text.slice(0, mid)) <= limit) lo = mid;
|
|
100
|
+
else hi = mid - 1;
|
|
101
|
+
}
|
|
102
|
+
return text.slice(0, lo);
|
|
103
|
+
};
|
|
104
|
+
let cut = snapForward(stripDanglingHighSurrogate(fit(Math.max(0, budget))));
|
|
105
|
+
if ((cut.match(/```/g)?.length ?? 0) % 2 === 1) {
|
|
106
|
+
const closeCost = estimatePostRequestBytes("\n```") - POST_ENVELOPE_OVERHEAD_BYTES;
|
|
107
|
+
const refit = snapForward(stripDanglingHighSurrogate(fit(Math.max(0, budget - closeCost))));
|
|
108
|
+
cut = (refit.match(/```/g)?.length ?? 0) % 2 === 1 ? refit + "\n```" : refit;
|
|
109
|
+
}
|
|
110
|
+
return cut + suffix;
|
|
111
|
+
}
|
|
112
|
+
function failureReason(message) {
|
|
113
|
+
const detail = message.errorMessage?.trim();
|
|
114
|
+
if (detail) {
|
|
115
|
+
const collapsed = detail.replace(/\s+/g, " ");
|
|
116
|
+
return collapsed.length > 400 ? `${collapsed.slice(0, 400)}\u2026` : collapsed;
|
|
117
|
+
}
|
|
118
|
+
switch (message.stopReason) {
|
|
119
|
+
case "aborted":
|
|
120
|
+
return "turn aborted";
|
|
121
|
+
case "error":
|
|
122
|
+
return "model/provider error (no detail)";
|
|
123
|
+
case "length":
|
|
124
|
+
return "output length limit reached";
|
|
125
|
+
default:
|
|
126
|
+
return message.stopReason ? `stop reason: ${message.stopReason}` : "unknown failure";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// builtin/extensions/im-forward/config.ts
|
|
131
|
+
function resolveConfigDir() {
|
|
132
|
+
const value = process.env.PIE_CODING_AGENT_DIR?.trim();
|
|
133
|
+
return value === "~" ? os.homedir() : value?.startsWith("~/") ? os.homedir() + value.slice(1) : value || path.join(os.homedir(), ".pie");
|
|
134
|
+
}
|
|
135
|
+
function configPaths(cwd, dir) {
|
|
136
|
+
return [.../* @__PURE__ */ new Set([path.join(dir, "im-forward.json"), path.join(cwd, ".pie", "im-forward.json")])];
|
|
137
|
+
}
|
|
138
|
+
var configCache = /* @__PURE__ */ new Map();
|
|
139
|
+
function layerStamp(file) {
|
|
140
|
+
try {
|
|
141
|
+
const stat = fs.statSync(file);
|
|
142
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
143
|
+
} catch {
|
|
144
|
+
return "-";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function cachedResolveConfig(cwd, dir) {
|
|
148
|
+
const cacheKey = `${cwd}\0${dir}`;
|
|
149
|
+
const stamp = JSON.stringify(configPaths(cwd, dir).map(layerStamp));
|
|
150
|
+
const cached = configCache.get(cacheKey);
|
|
151
|
+
if (cached && cached.stamp === stamp) return cached.result;
|
|
152
|
+
const result = resolveConfig(cwd, dir);
|
|
153
|
+
configCache.set(cacheKey, { stamp, result });
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
function invalidateConfigCache() {
|
|
157
|
+
configCache.clear();
|
|
158
|
+
}
|
|
159
|
+
function readLayer(file) {
|
|
160
|
+
let text;
|
|
161
|
+
try {
|
|
162
|
+
text = fs.readFileSync(file, "utf8");
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error.code === "ENOENT") return {};
|
|
165
|
+
throw new Error(`Cannot read IM configuration: ${file}`);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const value = JSON.parse(text);
|
|
169
|
+
if (isRecord(value)) return value;
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
throw new Error(`Invalid IM configuration: ${file}. Repair this file before setup; it will not be overwritten.`);
|
|
173
|
+
}
|
|
174
|
+
function resolveConfig(cwd, dir) {
|
|
175
|
+
try {
|
|
176
|
+
let present = false;
|
|
177
|
+
let merged = {};
|
|
178
|
+
const providers = {};
|
|
179
|
+
for (const file of configPaths(cwd, dir)) {
|
|
180
|
+
const layer = readLayer(file);
|
|
181
|
+
if (Object.keys(layer).length) present = true;
|
|
182
|
+
const legacy = ["chatId", "receiveId", "openId", "larkCli", "allowUsers"].some((key) => key in layer);
|
|
183
|
+
if (legacy && !layer.provider) {
|
|
184
|
+
const previous = providers.feishu ?? {};
|
|
185
|
+
providers.feishu = { ...previous, ...Object.fromEntries(["chatId", "openId", "larkCli", "allowUsers"].filter((key) => key in layer).map((key) => [key, layer[key]])), ...layer.receiveId && !layer.chatId ? { chatId: layer.receiveId } : {} };
|
|
186
|
+
merged.provider = "feishu";
|
|
187
|
+
}
|
|
188
|
+
if (isRecord(layer.providers)) {
|
|
189
|
+
for (const [id, settings] of Object.entries(layer.providers)) if (isRecord(settings)) providers[id] = { ...providers[id], ...settings };
|
|
190
|
+
}
|
|
191
|
+
merged = { ...merged, ...layer };
|
|
192
|
+
}
|
|
193
|
+
if (!present) return { config: null, problems: [] };
|
|
194
|
+
if ("provider" in merged && (typeof merged.provider !== "string" || !merged.provider.trim())) throw new Error("Invalid IM provider field. Run setup or repair the configuration; no provider fallback was used.");
|
|
195
|
+
const provider = typeof merged.provider === "string" ? merged.provider : "feishu";
|
|
196
|
+
const retry = merged.retryBackoffMs;
|
|
197
|
+
return { config: { enabled: merged.enabled === true, provider, settings: providers[provider] ?? {}, retryBackoffMs: typeof retry === "number" && Number.isFinite(retry) ? Math.min(6e4, Math.max(20, Math.round(retry))) : void 0 }, problems: [] };
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return { config: null, problems: [error.message] };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function configurationIdentity(config) {
|
|
203
|
+
return JSON.stringify([config.provider, config.settings, config.retryBackoffMs]);
|
|
204
|
+
}
|
|
205
|
+
function configSnapshot(cwd, dir) {
|
|
206
|
+
return JSON.stringify(configPaths(cwd, dir).map((file) => {
|
|
207
|
+
try {
|
|
208
|
+
return fs.readFileSync(file, "utf8");
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error.code === "ENOENT") return null;
|
|
211
|
+
throw new Error(`Cannot read IM configuration: ${file}`);
|
|
212
|
+
}
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
function saveLocation(cwd, dir) {
|
|
216
|
+
const files = configPaths(cwd, dir);
|
|
217
|
+
return fs.existsSync(files[files.length - 1]) ? files[files.length - 1] : files[0];
|
|
218
|
+
}
|
|
219
|
+
function writeConfig(cwd, dir, update, expected) {
|
|
220
|
+
for (const file of configPaths(cwd, dir)) readLayer(file);
|
|
221
|
+
if (expected !== void 0 && configSnapshot(cwd, dir) !== expected) throw new Error("IM configuration changed during setup. Please retry.");
|
|
222
|
+
const target = saveLocation(cwd, dir);
|
|
223
|
+
const value = readLayer(target);
|
|
224
|
+
value.enabled = update.enabled;
|
|
225
|
+
if (update.provider) {
|
|
226
|
+
value.provider = update.provider;
|
|
227
|
+
value.providers = { ...isRecord(value.providers) ? value.providers : {}, [update.provider]: update.settings ?? {} };
|
|
228
|
+
}
|
|
229
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
230
|
+
invalidateConfigCache();
|
|
231
|
+
const temporary = path.join(path.dirname(target), `.im-forward-${randomUUID()}.tmp`);
|
|
232
|
+
try {
|
|
233
|
+
const fd = fs.openSync(temporary, "wx", 384);
|
|
234
|
+
try {
|
|
235
|
+
fs.writeFileSync(fd, JSON.stringify(value, null, " ") + "\n");
|
|
236
|
+
fs.fsyncSync(fd);
|
|
237
|
+
} finally {
|
|
238
|
+
fs.closeSync(fd);
|
|
239
|
+
}
|
|
240
|
+
fs.renameSync(temporary, target);
|
|
241
|
+
} finally {
|
|
242
|
+
try {
|
|
243
|
+
fs.unlinkSync(temporary);
|
|
244
|
+
} catch {
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function hasConfigLayers(cwd, dir) {
|
|
249
|
+
return configPaths(cwd, dir).some((file) => fs.existsSync(file));
|
|
250
|
+
}
|
|
251
|
+
async function clearImConfig(cwd, dir, confirm) {
|
|
252
|
+
const files = configPaths(cwd, dir).filter((file) => fs.existsSync(file));
|
|
253
|
+
if (!files.length) return false;
|
|
254
|
+
for (const file of files) readLayer(file);
|
|
255
|
+
const snapshot = configSnapshot(cwd, dir);
|
|
256
|
+
if (!await confirm(`Delete the IM forwarding configuration file(s)?
|
|
257
|
+
|
|
258
|
+
${files.join("\n")}
|
|
259
|
+
|
|
260
|
+
Forwarding stops immediately and this cannot be undone.`)) return false;
|
|
261
|
+
if (configSnapshot(cwd, dir) !== snapshot) throw new Error("IM configuration changed during clear. Please retry.");
|
|
262
|
+
for (const file of files) {
|
|
263
|
+
try {
|
|
264
|
+
fs.unlinkSync(file);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error.code !== "ENOENT") throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
invalidateConfigCache();
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
function readLiveEnabled(cwd, dir, _cache) {
|
|
273
|
+
return cachedResolveConfig(cwd, dir).config?.enabled === true;
|
|
274
|
+
}
|
|
275
|
+
function readLiveConfig(cwd, dir) {
|
|
276
|
+
try {
|
|
277
|
+
return cachedResolveConfig(cwd, dir);
|
|
278
|
+
} catch {
|
|
279
|
+
return resolveConfig(cwd, dir);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function resolveImForwardConfig(cwd, dir) {
|
|
283
|
+
const result = resolveConfig(cwd, dir);
|
|
284
|
+
const cfg = result.config;
|
|
285
|
+
if (!cfg) return { config: null, problems: result.problems };
|
|
286
|
+
if (cfg.provider !== "feishu") return { config: null, problems: [`Unsupported IM provider: ${cfg.provider}`] };
|
|
287
|
+
const raw = cfg.settings;
|
|
288
|
+
const chatId = typeof raw.chatId === "string" ? raw.chatId.trim() : "";
|
|
289
|
+
if (!chatId) return { config: null, problems: cfg.enabled ? ["im-forward: enabled requires chatId"] : [] };
|
|
290
|
+
const allowUsers = Array.isArray(raw.allowUsers) ? raw.allowUsers.filter((id) => typeof id === "string" && !!id.trim()) : void 0;
|
|
291
|
+
return { config: { enabled: cfg.enabled, chatId, allowUsers: allowUsers?.length ? allowUsers : void 0, larkCli: typeof raw.larkCli === "string" && raw.larkCli.trim() ? raw.larkCli.trim() : void 0, retryBackoffMs: cfg.retryBackoffMs }, problems: chatId.startsWith("oc_") ? [] : ["im-forward: chatId does not look like a chat id (oc_...)"] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// builtin/extensions/im-forward/runtime.ts
|
|
295
|
+
var ROUTE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
296
|
+
var MAX_ROUTES = 500;
|
|
297
|
+
var MAX_SEEN_EVENTS = 1e3;
|
|
298
|
+
var REPLY_CHAIN_MAX_DEPTH = 5;
|
|
299
|
+
var RESTART_BACKOFF_MIN_MS = 5e3;
|
|
300
|
+
var RESTART_BACKOFF_MAX_MS = 6e4;
|
|
301
|
+
var SEND_FAILURE_NOTIFY_THROTTLE_MS = 5 * 60 * 1e3;
|
|
302
|
+
var MAX_INBOUND_RETRIES = 3;
|
|
303
|
+
var DEFAULT_INBOUND_RETRY_BACKOFF_MS = 5e3;
|
|
304
|
+
var MAX_PENDING_IM_INPUTS = 50;
|
|
305
|
+
var PENDING_IM_INPUT_TTL_MS = 60 * 60 * 1e3;
|
|
306
|
+
function summarizeFailureReason(reason) {
|
|
307
|
+
if (!reason) return { summary: "", persistent: false };
|
|
308
|
+
const text = reason.toLowerCase();
|
|
309
|
+
if (/another event bus|already connected/.test(text)) return { summary: "the app's event connection is occupied by another instance (possibly another machine); run /im-forward setup with another app/profile, or stop the other consumer", persistent: true };
|
|
310
|
+
if (/no active profile|not configured/.test(text)) return { summary: "provider not configured", persistent: false };
|
|
311
|
+
if (/auth|unauthorized|token|credential|login/.test(text)) return { summary: "authentication problem", persistent: false };
|
|
312
|
+
if (/permission|forbidden|scope/.test(text)) return { summary: "permission problem", persistent: false };
|
|
313
|
+
if (/network|dns|connect|timeout|unreachable/.test(text)) return { summary: "network problem", persistent: false };
|
|
314
|
+
return { summary: "provider exited", persistent: false };
|
|
315
|
+
}
|
|
316
|
+
var IM_INPUT_FALLBACK_CAPTION = "\u8BF7\u67E5\u770B\u8FD9\u4E9B\u56FE\u7247\u5E76\u5904\u7406\u3002";
|
|
317
|
+
function hashText(input) {
|
|
318
|
+
let hash = 0;
|
|
319
|
+
for (let i = 0; i < input.length; i++) {
|
|
320
|
+
hash = hash * 31 + input.charCodeAt(i) | 0;
|
|
321
|
+
}
|
|
322
|
+
return Math.abs(hash).toString(36);
|
|
323
|
+
}
|
|
324
|
+
function createRuntime(ctx, cfg, connection, liveEnabled, persistEnabled) {
|
|
325
|
+
const IM_INPUT_PREFIX = connection.sourcePrefix;
|
|
326
|
+
const key = (id) => JSON.stringify([cfg.provider, id]);
|
|
327
|
+
const scopes = /* @__PURE__ */ new Set();
|
|
328
|
+
let forwardingEnabled = cfg.enabled === true;
|
|
329
|
+
let configurationFailed = false;
|
|
330
|
+
function isForwardingEnabled() {
|
|
331
|
+
const value = liveEnabled();
|
|
332
|
+
configurationFailed = value === null;
|
|
333
|
+
const live = value === true;
|
|
334
|
+
if (configurationFailed) updateFooterTag();
|
|
335
|
+
if (live !== forwardingEnabled) {
|
|
336
|
+
forwardingEnabled = live;
|
|
337
|
+
updateFooterTag();
|
|
338
|
+
}
|
|
339
|
+
return forwardingEnabled;
|
|
340
|
+
}
|
|
341
|
+
const STATUS_KEY = "im-forward";
|
|
342
|
+
function updateFooterTag() {
|
|
343
|
+
ctx.ui.setStatus?.(STATUS_KEY, configurationFailed ? "[IM!]" : forwardingEnabled ? consumerLastExit ? "[IM!]" : consumerReady ? "[IM]" : "[IM\u2026]" : void 0);
|
|
344
|
+
}
|
|
345
|
+
function writeEnabledToConfig(next) {
|
|
346
|
+
persistEnabled(next);
|
|
347
|
+
}
|
|
348
|
+
let inputEpoch = 0;
|
|
349
|
+
function toggleForwarding() {
|
|
350
|
+
inputEpoch++;
|
|
351
|
+
isForwardingEnabled();
|
|
352
|
+
try {
|
|
353
|
+
writeEnabledToConfig(!forwardingEnabled);
|
|
354
|
+
} catch {
|
|
355
|
+
ctx.ui.notify("IM forwarding: could not save configuration; state unchanged.", "error");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
forwardingEnabled = !forwardingEnabled;
|
|
359
|
+
updateFooterTag();
|
|
360
|
+
if (forwardingEnabled) {
|
|
361
|
+
ctx.log("im-forward: enabled");
|
|
362
|
+
ctx.ui.notify("IM forwarding enabled", "info");
|
|
363
|
+
} else {
|
|
364
|
+
ctx.log("im-forward: disabled");
|
|
365
|
+
ctx.ui.notify("IM forwarding disabled", "info");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
ctx.registerShortcut("ctrl+i", {
|
|
369
|
+
description: "Toggle IM forwarding",
|
|
370
|
+
handler: toggleForwarding
|
|
371
|
+
});
|
|
372
|
+
let currentSessionId = null;
|
|
373
|
+
const pendingInputs = [];
|
|
374
|
+
const replyAnchor = /* @__PURE__ */ new Map();
|
|
375
|
+
const routes = /* @__PURE__ */ new Map();
|
|
376
|
+
const threadRoutes = /* @__PURE__ */ new Map();
|
|
377
|
+
const seenEventIds = /* @__PURE__ */ new Map();
|
|
378
|
+
const retryTimers = /* @__PURE__ */ new Map();
|
|
379
|
+
let forwardCounter = 0;
|
|
380
|
+
let anchorFallbacks = 0;
|
|
381
|
+
let lastSendError;
|
|
382
|
+
let lastSendErrorAt = 0;
|
|
383
|
+
let lastSendFailureNotifyAt = 0;
|
|
384
|
+
function notifySendFailure(error) {
|
|
385
|
+
lastSendError = error;
|
|
386
|
+
lastSendErrorAt = Date.now();
|
|
387
|
+
ctx.log(`im-forward: ${error}`);
|
|
388
|
+
if (Date.now() - lastSendFailureNotifyAt > SEND_FAILURE_NOTIFY_THROTTLE_MS) {
|
|
389
|
+
lastSendFailureNotifyAt = Date.now();
|
|
390
|
+
ctx.ui.notify(`im-forward: send failed \u2014 ${error}`, "warning");
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function removeThreadAliasesOf(route) {
|
|
394
|
+
for (const [threadKey, indexed] of threadRoutes) {
|
|
395
|
+
if (indexed === route) threadRoutes.delete(threadKey);
|
|
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(key(route.threadId));
|
|
403
|
+
removeThreadAliasesOf(route);
|
|
404
|
+
const anchor = replyAnchor.get(route.sessionId);
|
|
405
|
+
if (anchor && key(anchor.messageId) === id) replyAnchor.delete(route.sessionId);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
while (routes.size > MAX_ROUTES) {
|
|
409
|
+
let oldest;
|
|
410
|
+
for (const route of routes.values()) {
|
|
411
|
+
if (!oldest || route.sentAt < oldest.sentAt) oldest = route;
|
|
412
|
+
}
|
|
413
|
+
if (!oldest) break;
|
|
414
|
+
routes.delete(key(oldest.messageId));
|
|
415
|
+
if (oldest.threadId) threadRoutes.delete(key(oldest.threadId));
|
|
416
|
+
removeThreadAliasesOf(oldest);
|
|
417
|
+
}
|
|
418
|
+
const cutoff = now - PENDING_IM_INPUT_TTL_MS;
|
|
419
|
+
while (pendingInputs.length && pendingInputs[0].at <= cutoff) pendingInputs.shift();
|
|
420
|
+
while (pendingInputs.length > MAX_PENDING_IM_INPUTS) pendingInputs.shift();
|
|
421
|
+
for (const [eventId, at] of seenEventIds) {
|
|
422
|
+
if (now - at > ROUTE_TTL_MS) seenEventIds.delete(eventId);
|
|
423
|
+
}
|
|
424
|
+
if (seenEventIds.size > MAX_SEEN_EVENTS) {
|
|
425
|
+
const excess = seenEventIds.size - MAX_SEEN_EVENTS;
|
|
426
|
+
let removed = 0;
|
|
427
|
+
for (const eventId of seenEventIds.keys()) {
|
|
428
|
+
seenEventIds.delete(eventId);
|
|
429
|
+
if (++removed >= excess) break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function resolveOutboundAttribution(sessionId) {
|
|
434
|
+
const messages = ctx.getMessages?.() ?? [];
|
|
435
|
+
let lastUserText;
|
|
436
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
437
|
+
const message = messages[i];
|
|
438
|
+
if (!isRecord(message) || message.role !== "user") continue;
|
|
439
|
+
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();
|
|
440
|
+
if (joined) {
|
|
441
|
+
lastUserText = joined;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (lastUserText === void 0) return void 0;
|
|
446
|
+
for (let j = 0; j < pendingInputs.length; j++) {
|
|
447
|
+
const pending = pendingInputs[j];
|
|
448
|
+
if (pending.sessionId !== sessionId) continue;
|
|
449
|
+
const expected = (pending.messageId ? `${IM_INPUT_PREFIX}
|
|
450
|
+
${pending.caption || IM_INPUT_FALLBACK_CAPTION}` : pending.caption).trim();
|
|
451
|
+
if (lastUserText === expected) {
|
|
452
|
+
pendingInputs.splice(j, 1);
|
|
453
|
+
return { caption: pending.caption || `${IM_INPUT_PREFIX} images`, replyTo: pending.messageId || void 0, threadId: pending.threadId };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return void 0;
|
|
457
|
+
}
|
|
458
|
+
async function providerCall(operation, action) {
|
|
459
|
+
try {
|
|
460
|
+
return await action();
|
|
461
|
+
} catch {
|
|
462
|
+
ctx.log(`im-forward: ${cfg.provider} ${operation} failed`);
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async function acknowledgeMessage(messageId) {
|
|
467
|
+
await providerCall("acknowledge", () => connection.acknowledge(messageId));
|
|
468
|
+
}
|
|
469
|
+
ctx.on("terminal:input", (event) => {
|
|
470
|
+
const text = event.text?.trim() ?? "";
|
|
471
|
+
if (text && !text.startsWith(IM_INPUT_PREFIX) && !text.startsWith("/") && !text.startsWith("!!") && !/@\[[^\]]+\]\(file:/.test(text) && currentSessionId) {
|
|
472
|
+
pendingInputs.push({ sessionId: currentSessionId, caption: text, messageId: "", threadId: null, at: Date.now() });
|
|
473
|
+
pruneState();
|
|
474
|
+
}
|
|
475
|
+
return { action: "continue" };
|
|
476
|
+
});
|
|
477
|
+
ctx.on("message:end", (event) => {
|
|
478
|
+
if (!isForwardingEnabled()) return;
|
|
479
|
+
const message = event.message;
|
|
480
|
+
const stop = message.role === "assistant" ? message.stopReason : void 0;
|
|
481
|
+
if (stop === "aborted" || stop === "error") return;
|
|
482
|
+
const text = assistantForwardText(message);
|
|
483
|
+
if (!text) return;
|
|
484
|
+
const sessionId = currentSessionId;
|
|
485
|
+
if (!sessionId) return;
|
|
486
|
+
const sequence = ++forwardCounter;
|
|
487
|
+
const attribution = resolveOutboundAttribution(sessionId);
|
|
488
|
+
void forwardMessage(text, sessionId, sequence, attribution?.caption, attribution?.replyTo);
|
|
489
|
+
});
|
|
490
|
+
ctx.on("turn:failed", (event) => {
|
|
491
|
+
if (!isForwardingEnabled()) return;
|
|
492
|
+
const sessionId = currentSessionId;
|
|
493
|
+
if (!sessionId) return;
|
|
494
|
+
const reason = failureReason(event.message ?? {});
|
|
495
|
+
const sequence = ++forwardCounter;
|
|
496
|
+
const attribution = resolveOutboundAttribution(sessionId);
|
|
497
|
+
void forwardMessage(`\u26A0\uFE0F \u4EFB\u52A1\u5931\u8D25\uFF1A${reason}`, sessionId, sequence, attribution?.caption, attribution?.replyTo);
|
|
498
|
+
});
|
|
499
|
+
ctx.on("turn:cancelled", (event) => {
|
|
500
|
+
if (!isForwardingEnabled()) return;
|
|
501
|
+
const sessionId = currentSessionId;
|
|
502
|
+
if (!sessionId) return;
|
|
503
|
+
const sequence = ++forwardCounter;
|
|
504
|
+
const attribution = resolveOutboundAttribution(sessionId);
|
|
505
|
+
void forwardMessage(`\u26D4\uFE0F \u4EFB\u52A1\u5DF2\u53D6\u6D88\uFF1A${event.reason}`, sessionId, sequence, attribution?.caption, attribution?.replyTo);
|
|
506
|
+
});
|
|
507
|
+
async function forwardMessage(text, sessionId, sequence, userInput, replyTo) {
|
|
508
|
+
pruneState();
|
|
509
|
+
const sessionName = ctx.getSessionName?.();
|
|
510
|
+
const composed = composeForwardBody(sessionId, sessionName, userInput, text);
|
|
511
|
+
const body = truncateMarkdown(composed);
|
|
512
|
+
if (terminated || !isForwardingEnabled()) return;
|
|
513
|
+
const idempotencyKey = `pie-im-forward-${cfg.provider}-${sessionId}-${sequence}-${hashText(composed)}`;
|
|
514
|
+
let anchor = replyTo ?? replyAnchor.get(sessionId)?.messageId;
|
|
515
|
+
let sent = await providerCall("send", () => connection.send(cfg.targetId, body, idempotencyKey, anchor));
|
|
516
|
+
if (!sent && anchor && !terminated && isForwardingEnabled()) {
|
|
517
|
+
if (replyAnchor.get(sessionId)?.messageId === anchor) replyAnchor.delete(sessionId);
|
|
518
|
+
anchorFallbacks++;
|
|
519
|
+
sent = await providerCall("send", () => connection.send(cfg.targetId, body, idempotencyKey));
|
|
520
|
+
}
|
|
521
|
+
if (!sent) {
|
|
522
|
+
notifySendFailure(`${cfg.provider} send failed`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const { messageId, threadId } = sent;
|
|
526
|
+
const route = { messageId, threadId, sessionId, sentAt: Date.now() };
|
|
527
|
+
routes.set(key(messageId), route);
|
|
528
|
+
if (threadId) threadRoutes.set(key(threadId), route);
|
|
529
|
+
ctx.log(`im-forward: forwarded session=${sessionId} as ${messageId}`);
|
|
530
|
+
if (terminated || !isForwardingEnabled()) return;
|
|
531
|
+
}
|
|
532
|
+
ctx.on("session:changed", (event) => {
|
|
533
|
+
inputEpoch++;
|
|
534
|
+
currentSessionId = event.sessionId;
|
|
535
|
+
if (event.sessionId && !consumer && !consumerRecovering && !restartTimer && !terminated && !suspended) {
|
|
536
|
+
ctx.log("im-forward: session bound, starting event consumer");
|
|
537
|
+
spawnConsumer();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
async function handleInboundEvent(event, viaRetry = false) {
|
|
541
|
+
if (!event) return;
|
|
542
|
+
const eventId = event.event_id ? key(event.event_id) : void 0;
|
|
543
|
+
if (!eventId) return;
|
|
544
|
+
if (!viaRetry && inFlightRetries.has(eventId)) return;
|
|
545
|
+
if (!viaRetry && retryTimers.has(eventId)) return;
|
|
546
|
+
if (!viaRetry && seenEventIds.has(eventId)) return;
|
|
547
|
+
if (!isForwardingEnabled()) {
|
|
548
|
+
if (!viaRetry) return;
|
|
549
|
+
}
|
|
550
|
+
if (!retrySessionIds.has(eventId) && currentSessionId) retrySessionIds.set(eventId, currentSessionId);
|
|
551
|
+
seenEventIds.set(eventId, Date.now());
|
|
552
|
+
pruneState();
|
|
553
|
+
let scheduledNextRetry = false;
|
|
554
|
+
const retry = () => {
|
|
555
|
+
scheduledNextRetry = true;
|
|
556
|
+
scheduleInboundRetry(event, retryDepth(eventId) + 1);
|
|
557
|
+
};
|
|
558
|
+
const terminalCleanup = () => {
|
|
559
|
+
if (!scheduledNextRetry) {
|
|
560
|
+
retryAttempts.delete(eventId);
|
|
561
|
+
retrySessionIds.delete(eventId);
|
|
562
|
+
inFlightRetries.delete(eventId);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
if (!isForwardingEnabled()) {
|
|
566
|
+
terminalCleanup();
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
await handleInboundEventInner(event, retry);
|
|
571
|
+
} catch {
|
|
572
|
+
ctx.log(`im-forward: ${cfg.provider} inbound handling failed`);
|
|
573
|
+
retry();
|
|
574
|
+
} finally {
|
|
575
|
+
terminalCleanup();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async function handleInboundEventInner(event, retry) {
|
|
579
|
+
const matchesTarget = cfg.targetKind === "user" ? event.sender_id === cfg.targetId && (event.chatType === void 0 || event.chatType === "p2p") : event.chat_id === cfg.targetId;
|
|
580
|
+
if (!matchesTarget) return;
|
|
581
|
+
if (cfg.allowUsers && (!event.sender_id || !cfg.allowUsers.includes(event.sender_id))) return;
|
|
582
|
+
const messageId = event.message_id;
|
|
583
|
+
if (!messageId) return;
|
|
584
|
+
const sessionId = currentSessionId;
|
|
585
|
+
const epoch = inputEpoch;
|
|
586
|
+
const scope = ctx.beginUserInput?.(() => !terminated && inputEpoch === epoch && currentSessionId === sessionId && isForwardingEnabled());
|
|
587
|
+
if (!scope) {
|
|
588
|
+
retry();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
scopes.add(scope);
|
|
592
|
+
try {
|
|
593
|
+
const meta = await fetchMessageMeta(messageId);
|
|
594
|
+
if (!meta) {
|
|
595
|
+
retry();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (!scope.isCurrent()) return;
|
|
599
|
+
const resolution = await resolveRouteForInbound(messageId, meta);
|
|
600
|
+
if (!("found" in resolution)) {
|
|
601
|
+
if (isTransient(resolution)) retry();
|
|
602
|
+
else ctx.log(`im-forward: no route for ${messageId}; ignoring`);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const route = resolution.found;
|
|
606
|
+
if (route.sessionId !== sessionId || !scope.isCurrent()) return;
|
|
607
|
+
const imageKeys = event.images;
|
|
608
|
+
const caption = event.text;
|
|
609
|
+
if (!caption && !imageKeys.length) return;
|
|
610
|
+
const submitText = `${IM_INPUT_PREFIX}
|
|
611
|
+
${caption || IM_INPUT_FALLBACK_CAPTION}`;
|
|
612
|
+
const accepted = imageKeys.length ? await injectImageMessage(messageId, imageKeys, submitText, scope) : await scope.submit(submitText);
|
|
613
|
+
if (!accepted || !scope.isCurrent()) {
|
|
614
|
+
if (scope.isCurrent()) {
|
|
615
|
+
ctx.ui.notify("im-forward: message was not accepted; no input submitted.", "warning");
|
|
616
|
+
await forwardMessage(`\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);
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
replyAnchor.set(route.sessionId, { messageId, at: Date.now() });
|
|
621
|
+
pendingInputs.push({ sessionId: route.sessionId, caption, messageId, threadId: route.threadId, at: Date.now() });
|
|
622
|
+
pruneState();
|
|
623
|
+
ctx.log(`im-forward: accepted ${messageId} into session ${route.sessionId} (${imageKeys.length} images)`);
|
|
624
|
+
void acknowledgeMessage(messageId);
|
|
625
|
+
} finally {
|
|
626
|
+
scopes.delete(scope);
|
|
627
|
+
scope.dispose();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const retryAttempts = /* @__PURE__ */ new Map();
|
|
631
|
+
const retrySessionIds = /* @__PURE__ */ new Map();
|
|
632
|
+
function retryDepth(eventId) {
|
|
633
|
+
return eventId ? retryAttempts.get(eventId) ?? 0 : 0;
|
|
634
|
+
}
|
|
635
|
+
const inFlightRetries = /* @__PURE__ */ new Set();
|
|
636
|
+
function scheduleInboundRetry(event, attempt) {
|
|
637
|
+
const eventId = event.event_id ? key(event.event_id) : void 0;
|
|
638
|
+
if (!eventId) return;
|
|
639
|
+
if (terminated || attempt > MAX_INBOUND_RETRIES) {
|
|
640
|
+
if (attempt > MAX_INBOUND_RETRIES) {
|
|
641
|
+
ctx.log(`im-forward: giving up on event ${eventId} after ${MAX_INBOUND_RETRIES} retries`);
|
|
642
|
+
retryAttempts.delete(eventId);
|
|
643
|
+
const boundSession = retrySessionIds.get(eventId);
|
|
644
|
+
retrySessionIds.delete(eventId);
|
|
645
|
+
void forwardMessage(`\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`, boundSession ?? currentSessionId ?? "", ++forwardCounter, void 0, event.message_id);
|
|
646
|
+
}
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const backoff = (cfg.retryBackoffMs ?? DEFAULT_INBOUND_RETRY_BACKOFF_MS) * 2 ** (attempt - 1);
|
|
650
|
+
if (retryTimers.has(eventId)) return;
|
|
651
|
+
retryAttempts.set(eventId, attempt);
|
|
652
|
+
if (!retrySessionIds.has(eventId) && currentSessionId) retrySessionIds.set(eventId, currentSessionId);
|
|
653
|
+
const timer = setTimeout(() => {
|
|
654
|
+
retryTimers.delete(eventId);
|
|
655
|
+
inFlightRetries.add(eventId);
|
|
656
|
+
void handleInboundEvent(event, true).finally(() => {
|
|
657
|
+
inFlightRetries.delete(eventId);
|
|
658
|
+
});
|
|
659
|
+
}, backoff);
|
|
660
|
+
timer.unref?.();
|
|
661
|
+
retryTimers.set(eventId, { attempt, timer, event });
|
|
662
|
+
ctx.log(`im-forward: transient inbound failure; retrying ${eventId} in ${backoff}ms (attempt ${attempt}/${MAX_INBOUND_RETRIES})`);
|
|
663
|
+
}
|
|
664
|
+
ctx.__forceInboundRetriesForTest = () => {
|
|
665
|
+
for (const [eventId, entry] of retryTimers) {
|
|
666
|
+
clearTimeout(entry.timer);
|
|
667
|
+
retryTimers.delete(eventId);
|
|
668
|
+
void handleInboundEvent({ ...entry.event ?? {} }, true);
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
const isTransient = (resolution) => "transient" in resolution && resolution.transient === true;
|
|
672
|
+
async function injectImageMessage(messageId, imageKeys, submitText, scope) {
|
|
673
|
+
if (imageKeys.length > 10) {
|
|
674
|
+
ctx.log("im-forward: refusing more than 10 images in one message");
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
const downloads = [];
|
|
678
|
+
try {
|
|
679
|
+
for (const [index, image] of imageKeys.entries()) {
|
|
680
|
+
if (!scope.isCurrent()) return false;
|
|
681
|
+
downloads.push(await connection.downloadImage(messageId, image, index));
|
|
682
|
+
}
|
|
683
|
+
if (!scope.isCurrent()) return false;
|
|
684
|
+
return await scope.submit(submitText, downloads.map((item) => item.file));
|
|
685
|
+
} catch {
|
|
686
|
+
ctx.log("im-forward: image preparation failed");
|
|
687
|
+
return false;
|
|
688
|
+
} finally {
|
|
689
|
+
for (const item of downloads) {
|
|
690
|
+
try {
|
|
691
|
+
item.cleanup();
|
|
692
|
+
} catch {
|
|
693
|
+
ctx.log("im-forward: temporary image cleanup failed");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async function fetchMessageMeta(messageId) {
|
|
699
|
+
return providerCall("metadata", () => connection.metadata(messageId));
|
|
700
|
+
}
|
|
701
|
+
async function fetchThreadMessages(threadId) {
|
|
702
|
+
return providerCall("thread lookup", () => connection.threadMessages(threadId));
|
|
703
|
+
}
|
|
704
|
+
async function resolveRouteForInbound(messageId, meta) {
|
|
705
|
+
if (meta.threadId) {
|
|
706
|
+
const byThread = threadRoutes.get(key(meta.threadId));
|
|
707
|
+
if (byThread) return { found: byThread };
|
|
708
|
+
}
|
|
709
|
+
const direct = routes.get(key(messageId));
|
|
710
|
+
if (direct) return { found: direct };
|
|
711
|
+
let chain = { notOurs: true };
|
|
712
|
+
if (meta.replyTo) {
|
|
713
|
+
chain = await resolveRouteForReply(meta.replyTo);
|
|
714
|
+
if ("found" in chain) return chain;
|
|
715
|
+
}
|
|
716
|
+
if (!meta.threadId) return chain;
|
|
717
|
+
const byRoot = await resolveRouteByThreadRoot(meta.threadId);
|
|
718
|
+
if ("found" in byRoot) return byRoot;
|
|
719
|
+
return isTransient(chain) || isTransient(byRoot) ? { transient: true } : { notOurs: true };
|
|
720
|
+
}
|
|
721
|
+
async function resolveRouteByThreadRoot(threadId) {
|
|
722
|
+
const messages = await fetchThreadMessages(threadId);
|
|
723
|
+
if (!messages) return { transient: true };
|
|
724
|
+
for (const id of messages) {
|
|
725
|
+
const route = routes.get(key(id));
|
|
726
|
+
if (route) {
|
|
727
|
+
threadRoutes.set(key(threadId), route);
|
|
728
|
+
return { found: route };
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return { notOurs: true };
|
|
732
|
+
}
|
|
733
|
+
async function resolveRouteForReply(startId) {
|
|
734
|
+
let current = startId;
|
|
735
|
+
const visited = /* @__PURE__ */ new Set();
|
|
736
|
+
for (let depth = 0; depth < REPLY_CHAIN_MAX_DEPTH; depth++) {
|
|
737
|
+
const direct = routes.get(key(current));
|
|
738
|
+
if (direct) return { found: direct };
|
|
739
|
+
if (visited.has(current)) return { notOurs: true };
|
|
740
|
+
visited.add(current);
|
|
741
|
+
const parent = await fetchMessageMeta(current);
|
|
742
|
+
if (!parent) return { transient: true };
|
|
743
|
+
if (!parent.replyTo) return { notOurs: true };
|
|
744
|
+
current = parent.replyTo;
|
|
745
|
+
}
|
|
746
|
+
return { notOurs: true };
|
|
747
|
+
}
|
|
748
|
+
let consumer = null;
|
|
749
|
+
let consumerReady = false;
|
|
750
|
+
let consumerRecovering = false;
|
|
751
|
+
let consumerRestarts = 0;
|
|
752
|
+
let consumerLastExit;
|
|
753
|
+
let notifiedPersistentFailure = false;
|
|
754
|
+
let terminated = false;
|
|
755
|
+
let restartTimer;
|
|
756
|
+
let generation;
|
|
757
|
+
function stopGeneration(gen) {
|
|
758
|
+
if (gen.startup) clearTimeout(gen.startup);
|
|
759
|
+
return gen.stopping ??= Promise.resolve().then(() => gen.handle?.stop()).catch(() => {
|
|
760
|
+
ctx.log(`im-forward: ${cfg.provider} consumer stop failed`);
|
|
761
|
+
}).then(() => {
|
|
762
|
+
gen.retired = true;
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
function spawnConsumer() {
|
|
766
|
+
restartTimer = void 0;
|
|
767
|
+
restartDueAt = void 0;
|
|
768
|
+
if (terminated || suspended || consumer || consumerRecovering) return;
|
|
769
|
+
const gen = { handle: null, installed: false, failed: false, retired: false };
|
|
770
|
+
generation = gen;
|
|
771
|
+
consumerReady = false;
|
|
772
|
+
updateFooterTag();
|
|
773
|
+
const recover = () => {
|
|
774
|
+
if (!gen.installed) return;
|
|
775
|
+
void stopGeneration(gen).then(() => {
|
|
776
|
+
if (generation !== gen) return;
|
|
777
|
+
consumer = null;
|
|
778
|
+
consumerRecovering = false;
|
|
779
|
+
if (terminated || suspended) return;
|
|
780
|
+
const backoff = Math.min(RESTART_BACKOFF_MIN_MS * 2 ** Math.min(consumerRestarts, 4), RESTART_BACKOFF_MAX_MS);
|
|
781
|
+
restartTimer = setTimeout(spawnConsumer, backoff);
|
|
782
|
+
restartTimer.unref?.();
|
|
783
|
+
restartDueAt = Date.now() + backoff;
|
|
784
|
+
});
|
|
785
|
+
};
|
|
786
|
+
const fail = (reason) => {
|
|
787
|
+
if (gen.failed || gen.retired || terminated || generation !== gen) return;
|
|
788
|
+
gen.drain = gen.installed;
|
|
789
|
+
gen.failed = true;
|
|
790
|
+
if (gen.startup) clearTimeout(gen.startup);
|
|
791
|
+
consumerRecovering = true;
|
|
792
|
+
consumerReady = false;
|
|
793
|
+
const { summary: safe, persistent } = summarizeFailureReason(reason);
|
|
794
|
+
consumerLastExit = safe ? `${cfg.provider} connection failed (${safe})` : `${cfg.provider} connection failed`;
|
|
795
|
+
consumerRestarts++;
|
|
796
|
+
updateFooterTag();
|
|
797
|
+
ctx.log(`im-forward: consumer exited (${consumerLastExit})`);
|
|
798
|
+
if (!persistent || !notifiedPersistentFailure) {
|
|
799
|
+
if (persistent) notifiedPersistentFailure = true;
|
|
800
|
+
ctx.ui.notify(`IM forwarding: ${cfg.provider} connection failed${safe ? `: ${safe}` : "; check provider configuration/authentication."}${persistent ? " Retrying in the background (see /im-forward status)." : " Retrying."}`, persistent ? "error" : "warning");
|
|
801
|
+
}
|
|
802
|
+
recover();
|
|
803
|
+
};
|
|
804
|
+
gen.startup = setTimeout(fail, 3e4);
|
|
805
|
+
gen.startup.unref?.();
|
|
806
|
+
try {
|
|
807
|
+
gen.handle = connection.consume({
|
|
808
|
+
// Preserve buffered EXIT tail-drain until stop/close settles.
|
|
809
|
+
// A synchronous startup failure has no live stream to drain.
|
|
810
|
+
event: (event) => {
|
|
811
|
+
if (!terminated && !gen.retired && generation === gen && (!gen.failed || gen.drain))
|
|
812
|
+
void handleInboundEvent(event);
|
|
813
|
+
},
|
|
814
|
+
ready: () => {
|
|
815
|
+
if (terminated || gen.failed || gen.retired || generation !== gen) return;
|
|
816
|
+
clearTimeout(gen.startup);
|
|
817
|
+
consumerReady = true;
|
|
818
|
+
notifiedPersistentFailure = false;
|
|
819
|
+
ctx.log("im-forward: event consumer ready");
|
|
820
|
+
consumerRestarts = 0;
|
|
821
|
+
consumerLastExit = void 0;
|
|
822
|
+
updateFooterTag();
|
|
823
|
+
},
|
|
824
|
+
failure: fail
|
|
825
|
+
});
|
|
826
|
+
consumer = gen.handle;
|
|
827
|
+
} catch {
|
|
828
|
+
fail();
|
|
829
|
+
}
|
|
830
|
+
gen.installed = true;
|
|
831
|
+
if (gen.failed) recover();
|
|
832
|
+
}
|
|
833
|
+
let suspended = false;
|
|
834
|
+
let suspendedRestartInMs;
|
|
835
|
+
let restartDueAt;
|
|
836
|
+
const scheduleRestart = (delayMs) => {
|
|
837
|
+
if (restartTimer) clearTimeout(restartTimer);
|
|
838
|
+
restartDueAt = Date.now() + delayMs;
|
|
839
|
+
restartTimer = setTimeout(spawnConsumer, delayMs);
|
|
840
|
+
restartTimer.unref?.();
|
|
841
|
+
};
|
|
842
|
+
ctx.suspendEventConsumer = async () => {
|
|
843
|
+
if (terminated || suspended) return () => {
|
|
844
|
+
};
|
|
845
|
+
inputEpoch++;
|
|
846
|
+
suspended = true;
|
|
847
|
+
const gen = generation;
|
|
848
|
+
if (restartTimer) {
|
|
849
|
+
suspendedRestartInMs = restartDueAt !== void 0 ? Math.max(0, restartDueAt - Date.now()) : 0;
|
|
850
|
+
clearTimeout(restartTimer);
|
|
851
|
+
restartTimer = void 0;
|
|
852
|
+
restartDueAt = void 0;
|
|
853
|
+
}
|
|
854
|
+
consumer = null;
|
|
855
|
+
consumerRecovering = false;
|
|
856
|
+
consumerReady = false;
|
|
857
|
+
if (gen && !gen.retired) await stopGeneration(gen);
|
|
858
|
+
if (terminated) return () => {
|
|
859
|
+
};
|
|
860
|
+
let resumed = false;
|
|
861
|
+
return () => {
|
|
862
|
+
if (resumed || terminated) return;
|
|
863
|
+
resumed = true;
|
|
864
|
+
if (!suspended) return;
|
|
865
|
+
suspended = false;
|
|
866
|
+
if (consumer || consumerRecovering || restartTimer) return;
|
|
867
|
+
const delay = suspendedRestartInMs !== void 0 ? suspendedRestartInMs : Math.min(RESTART_BACKOFF_MIN_MS * 2 ** Math.min(consumerRestarts, 4), RESTART_BACKOFF_MAX_MS);
|
|
868
|
+
suspendedRestartInMs = void 0;
|
|
869
|
+
scheduleRestart(delay);
|
|
870
|
+
};
|
|
871
|
+
};
|
|
872
|
+
updateFooterTag();
|
|
873
|
+
ctx.registerCleanup?.(async () => {
|
|
874
|
+
inputEpoch++;
|
|
875
|
+
terminated = true;
|
|
876
|
+
const resources = [...scopes];
|
|
877
|
+
scopes.clear();
|
|
878
|
+
for (const { timer } of retryTimers.values()) clearTimeout(timer);
|
|
879
|
+
retryTimers.clear();
|
|
880
|
+
retryAttempts.clear();
|
|
881
|
+
retrySessionIds.clear();
|
|
882
|
+
inFlightRetries.clear();
|
|
883
|
+
if (restartTimer) clearTimeout(restartTimer);
|
|
884
|
+
if (generation) generation.retired = true;
|
|
885
|
+
const results = await Promise.allSettled([
|
|
886
|
+
...resources.map((scope) => Promise.resolve().then(() => scope.dispose())),
|
|
887
|
+
Promise.resolve().then(() => connection.dispose?.()),
|
|
888
|
+
generation ? stopGeneration(generation) : Promise.resolve()
|
|
889
|
+
]);
|
|
890
|
+
consumer = null;
|
|
891
|
+
if (results.some((result) => result.status === "rejected")) ctx.log(`im-forward: ${cfg.provider} resource cleanup failed`);
|
|
892
|
+
});
|
|
893
|
+
ctx.registerCommand({
|
|
894
|
+
name: "im-forward",
|
|
895
|
+
description: "IM forwarding status",
|
|
896
|
+
handler: (commandCtx) => {
|
|
897
|
+
isForwardingEnabled();
|
|
898
|
+
const lines = [
|
|
899
|
+
`IM forwarding (${cfg.label ?? cfg.provider})`,
|
|
900
|
+
`- target: ${cfg.targetId}`,
|
|
901
|
+
...cfg.appId ? [`- app: ${cfg.appId}`] : [],
|
|
902
|
+
`- allowed senders: ${cfg.allowUsers?.join(", ") ?? "(anyone in target)"}`,
|
|
903
|
+
`- consumer: ${terminated ? "stopped" : consumer ? consumerReady ? "ready" : "starting" : "idle (waits for first session)"}${consumerLastExit ? ` (last exit: ${consumerLastExit})` : ""}, restarts=${consumerRestarts}`,
|
|
904
|
+
`- routes: ${routes.size} forwarded message(s) routable (${threadRoutes.size} thread(s), ${replyAnchor.size} anchor(s))`,
|
|
905
|
+
`- current session: ${currentSessionId ?? "(none)"}`
|
|
906
|
+
];
|
|
907
|
+
if (!routes.size && !terminated && consumerReady) lines.push("- note: no forwarded message yet \u2014 IM replies are only accepted after the first answer is forwarded. Run any local turn to open the reply entry.");
|
|
908
|
+
if (lastSendError) lines.push(`- last send error: ${lastSendError} (at ${new Date(lastSendErrorAt).toISOString()})`);
|
|
909
|
+
if (anchorFallbacks) lines.push(`- anchor fallbacks to chat root: ${anchorFallbacks}`);
|
|
910
|
+
if (pendingInputs.length) lines.push(`- pending turn attributions: ${pendingInputs.length}`);
|
|
911
|
+
if (retryAttempts.size) lines.push(`- inbound retries pending: ${retryTimers.size} (attempts so far: ${Math.max(...retryAttempts.values())})`);
|
|
912
|
+
commandCtx.ui.notify(lines.join("\n"), "info");
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// builtin/extensions/im-forward/onboarding.ts
|
|
918
|
+
async function runSetup(ctx, dir, providers, signal, isCurrent = () => !signal.aborted, intent = "first-run", suspendEventConsumer) {
|
|
919
|
+
const initial = resolveConfig(ctx.cwd, dir);
|
|
920
|
+
if (initial.problems.length) {
|
|
921
|
+
ctx.ui.notify(initial.problems.join("\n"), "error");
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const snapshot = configSnapshot(ctx.cwd, dir);
|
|
925
|
+
const options = { signal, timeout: 12e4 };
|
|
926
|
+
if (!providers.length || !isCurrent()) return;
|
|
927
|
+
let selected;
|
|
928
|
+
if (intent === "switch-app" && initial.config) {
|
|
929
|
+
const bound = providers.find((item) => item.id === initial.config?.provider);
|
|
930
|
+
if (providers.length === 1 && bound) selected = bound;
|
|
931
|
+
}
|
|
932
|
+
if (!selected) {
|
|
933
|
+
const choice = await ctx.ui.select("IM forwarding setup \u2014 platform", [...providers.map((item) => item.label), "Cancel"], options);
|
|
934
|
+
if (!isCurrent() || !choice || choice === "Cancel") return;
|
|
935
|
+
selected = providers.find((item) => item.label === choice);
|
|
936
|
+
}
|
|
937
|
+
if (!selected || !isCurrent()) return;
|
|
938
|
+
ctx.ui.notify(`IM forwarding setup \u2014 ${selected.label}`, "info");
|
|
939
|
+
const settings = await selected.setup({ ui: ctx.ui, cwd: ctx.cwd, signal, suspendEventConsumer }, initial.config?.provider === selected.id ? initial.config.settings : void 0, void 0, intent);
|
|
940
|
+
if (!isCurrent() || !settings) return;
|
|
941
|
+
const resolved = selected.resolve(settings);
|
|
942
|
+
if (!resolved) {
|
|
943
|
+
ctx.ui.notify("IM forwarding setup: invalid provider settings; nothing was saved.", "error");
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
const profile = typeof settings.larkCliProfile === "string" ? settings.larkCliProfile : void 0;
|
|
947
|
+
const profileLine = profile ? `
|
|
948
|
+
lark-cli profile: ${String(profile)}` : "";
|
|
949
|
+
const appLine = resolved.appId ? `
|
|
950
|
+
App: ${resolved.appId}` : "";
|
|
951
|
+
const confirmed = await ctx.ui.confirm("IM forwarding setup", `Platform: ${selected.label}
|
|
952
|
+
Target: ${resolved.targetId}${appLine}${profileLine}
|
|
953
|
+
Allowed senders: ${resolved.allowUsers?.join(", ") || "anyone in target"}
|
|
954
|
+
Save location: ${saveLocation(ctx.cwd, dir)}
|
|
955
|
+
Enable forwarding and start the connection? No test message will be sent. Saving does not prove connection readiness.`, options);
|
|
956
|
+
if (!isCurrent() || !confirmed) return;
|
|
957
|
+
writeConfig(ctx.cwd, dir, { enabled: true, provider: selected.id, settings }, snapshot);
|
|
958
|
+
return resolveConfig(ctx.cwd, dir).config ?? void 0;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// builtin/extensions/im-forward/providers/feishu/index.ts
|
|
962
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
963
|
+
import * as fs4 from "node:fs";
|
|
964
|
+
import * as os2 from "node:os";
|
|
965
|
+
import * as path6 from "node:path";
|
|
966
|
+
|
|
967
|
+
// builtin/extensions/im-forward/lark-cli.ts
|
|
968
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
969
|
+
import { StringDecoder as StringDecoder2 } from "node:string_decoder";
|
|
970
|
+
|
|
971
|
+
// builtin/extensions/im-forward/launch.ts
|
|
972
|
+
import { spawn } from "node:child_process";
|
|
973
|
+
import * as fs2 from "node:fs";
|
|
974
|
+
import * as path2 from "node:path";
|
|
975
|
+
import { StringDecoder } from "node:string_decoder";
|
|
976
|
+
function resolveLaunchSpec(explicit, cwd = process.cwd(), platform = process.platform, env = process.env) {
|
|
977
|
+
const windows = platform === "win32";
|
|
978
|
+
const candidates = explicit ? [path2.resolve(cwd, explicit)] : (env.PATH ?? "").split(windows ? ";" : ":").filter(Boolean).flatMap((dir) => (windows ? ["lark-cli.exe", "lark-cli.cmd"] : ["lark-cli"]).map((name) => path2.resolve(cwd, dir, name)));
|
|
979
|
+
for (const candidate of candidates) {
|
|
980
|
+
try {
|
|
981
|
+
if (!fs2.statSync(candidate).isFile()) continue;
|
|
982
|
+
const ext = path2.extname(candidate).toLowerCase();
|
|
983
|
+
if (ext === ".js" || ext === ".mjs") return { command: process.execPath, prefix: [candidate] };
|
|
984
|
+
if (windows && ext === ".cmd") {
|
|
985
|
+
const dir = path2.dirname(candidate);
|
|
986
|
+
for (const root of [path2.join(dir, "node_modules", "@larksuite", "cli"), path2.resolve(dir, "..", "@larksuite", "cli")]) {
|
|
987
|
+
try {
|
|
988
|
+
const pkg = JSON.parse(fs2.readFileSync(path2.join(root, "package.json"), "utf8"));
|
|
989
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["lark-cli"];
|
|
990
|
+
if (pkg.name !== "@larksuite/cli" || typeof bin !== "string" || bin.replace(/^\.\//, "") !== "scripts/run.js") continue;
|
|
991
|
+
const entry = path2.join(root, "scripts", "run.js");
|
|
992
|
+
if (fs2.statSync(entry).isFile()) return { command: process.execPath, prefix: [entry] };
|
|
993
|
+
} catch {
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
if (windows && ext !== ".exe") continue;
|
|
999
|
+
fs2.accessSync(candidate, fs2.constants.X_OK);
|
|
1000
|
+
return { command: candidate, prefix: [] };
|
|
1001
|
+
} catch {
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
var treeStops = /* @__PURE__ */ new WeakMap();
|
|
1007
|
+
var hasExited = (child) => child.exitCode != null || child.signalCode != null;
|
|
1008
|
+
function stopOwnedTree(child, signal) {
|
|
1009
|
+
const existing = treeStops.get(child);
|
|
1010
|
+
if (existing) return existing;
|
|
1011
|
+
if (process.platform === "win32" && hasExited(child)) return Promise.resolve(false);
|
|
1012
|
+
const pending = stopOwnedTreeInner(child, signal);
|
|
1013
|
+
treeStops.set(child, pending);
|
|
1014
|
+
void pending.finally(() => treeStops.delete(child));
|
|
1015
|
+
return pending;
|
|
1016
|
+
}
|
|
1017
|
+
function stopOwnedTreeInner(child, signal) {
|
|
1018
|
+
if (process.platform !== "win32" || !child.pid) {
|
|
1019
|
+
try {
|
|
1020
|
+
if (child.pid) process.kill(-child.pid, signal);
|
|
1021
|
+
else child.kill(signal);
|
|
1022
|
+
return Promise.resolve(true);
|
|
1023
|
+
} catch {
|
|
1024
|
+
try {
|
|
1025
|
+
return Promise.resolve(child.kill(signal));
|
|
1026
|
+
} catch {
|
|
1027
|
+
return Promise.resolve(false);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return new Promise((resolve3) => {
|
|
1032
|
+
let done = false;
|
|
1033
|
+
let killer;
|
|
1034
|
+
const finish = (ok) => {
|
|
1035
|
+
if (done) return;
|
|
1036
|
+
done = true;
|
|
1037
|
+
clearTimeout(timer);
|
|
1038
|
+
if (!ok && !hasExited(child)) {
|
|
1039
|
+
try {
|
|
1040
|
+
child.kill(signal);
|
|
1041
|
+
} catch {
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
resolve3(ok);
|
|
1045
|
+
};
|
|
1046
|
+
const timer = setTimeout(() => {
|
|
1047
|
+
try {
|
|
1048
|
+
killer?.kill();
|
|
1049
|
+
} catch {
|
|
1050
|
+
}
|
|
1051
|
+
finish(false);
|
|
1052
|
+
}, 1500);
|
|
1053
|
+
try {
|
|
1054
|
+
const root = process.env.SystemRoot || process.env.windir || "C:\\Windows";
|
|
1055
|
+
killer = spawn(path2.join(root, "System32", "taskkill.exe"), ["/PID", String(child.pid), "/T", "/F"], { shell: false, windowsHide: true, stdio: "ignore" });
|
|
1056
|
+
killer.on("error", () => finish(false));
|
|
1057
|
+
killer.on("exit", (code) => finish(code === 0));
|
|
1058
|
+
} catch {
|
|
1059
|
+
finish(false);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
function lineReader(onLine, max = 1024 * 1024) {
|
|
1064
|
+
const decoder = new StringDecoder("utf8");
|
|
1065
|
+
let pending = "";
|
|
1066
|
+
let dropping = false;
|
|
1067
|
+
const accept = (text) => {
|
|
1068
|
+
for (const [i, part] of text.split("\n").entries()) {
|
|
1069
|
+
if (i) {
|
|
1070
|
+
if (!dropping) onLine(pending);
|
|
1071
|
+
pending = "";
|
|
1072
|
+
dropping = false;
|
|
1073
|
+
}
|
|
1074
|
+
if (!dropping) {
|
|
1075
|
+
pending += part;
|
|
1076
|
+
if (pending.length > max) {
|
|
1077
|
+
pending = "";
|
|
1078
|
+
dropping = true;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
return { write: (chunk) => accept(decoder.write(chunk)), end() {
|
|
1084
|
+
accept(decoder.end());
|
|
1085
|
+
if (pending && !dropping) onLine(pending);
|
|
1086
|
+
pending = "";
|
|
1087
|
+
} };
|
|
1088
|
+
}
|
|
1089
|
+
var shutdowns = /* @__PURE__ */ new WeakMap();
|
|
1090
|
+
function shutdownConsumer(child) {
|
|
1091
|
+
const existing = shutdowns.get(child);
|
|
1092
|
+
if (existing) return existing;
|
|
1093
|
+
const pending = shutdownConsumerInner(child);
|
|
1094
|
+
shutdowns.set(child, pending);
|
|
1095
|
+
return pending;
|
|
1096
|
+
}
|
|
1097
|
+
async function shutdownConsumerInner(child) {
|
|
1098
|
+
let closed = false;
|
|
1099
|
+
const close = new Promise((resolve3) => child.once("close", () => {
|
|
1100
|
+
closed = true;
|
|
1101
|
+
resolve3();
|
|
1102
|
+
}));
|
|
1103
|
+
const wait = async (ms) => {
|
|
1104
|
+
let timer;
|
|
1105
|
+
await Promise.race([close, new Promise((resolve3) => {
|
|
1106
|
+
timer = setTimeout(resolve3, ms);
|
|
1107
|
+
})]);
|
|
1108
|
+
clearTimeout(timer);
|
|
1109
|
+
};
|
|
1110
|
+
try {
|
|
1111
|
+
child.stdin?.end();
|
|
1112
|
+
} catch {
|
|
1113
|
+
}
|
|
1114
|
+
await wait(500);
|
|
1115
|
+
if (!closed) {
|
|
1116
|
+
await stopOwnedTree(child, "SIGTERM");
|
|
1117
|
+
await wait(500);
|
|
1118
|
+
}
|
|
1119
|
+
if (!closed) {
|
|
1120
|
+
await stopOwnedTree(child, "SIGKILL");
|
|
1121
|
+
await wait(500);
|
|
1122
|
+
}
|
|
1123
|
+
child.stdin?.destroy();
|
|
1124
|
+
child.stdout?.destroy();
|
|
1125
|
+
child.stderr?.destroy();
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// builtin/extensions/im-forward/lark-cli.ts
|
|
1129
|
+
async function runLarkCli(binary, args, timeoutMs = 3e4, cwd, options = {}) {
|
|
1130
|
+
return new Promise((resolve3) => {
|
|
1131
|
+
if (options.signal?.aborted) {
|
|
1132
|
+
resolve3({ stdout: "", error: "cancelled" });
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
let child;
|
|
1136
|
+
try {
|
|
1137
|
+
const spec = typeof binary === "string" ? resolveLaunchSpec(binary) ?? (options.spawn ? { command: binary, prefix: [] } : null) : binary;
|
|
1138
|
+
if (!spec) throw new Error("unsupported executable");
|
|
1139
|
+
child = (options.spawn ?? spawn2)(spec.command, [...spec.prefix, ...args], {
|
|
1140
|
+
shell: false,
|
|
1141
|
+
windowsHide: true,
|
|
1142
|
+
stdio: [options.stdin ?? "ignore", "pipe", "pipe"],
|
|
1143
|
+
detached: process.platform !== "win32",
|
|
1144
|
+
...cwd ? { cwd } : {}
|
|
1145
|
+
});
|
|
1146
|
+
} catch {
|
|
1147
|
+
resolve3({ stdout: "", error: "spawn failed" });
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
let stdout = "";
|
|
1151
|
+
let stderr = "";
|
|
1152
|
+
let settled = false;
|
|
1153
|
+
let closed = false;
|
|
1154
|
+
let termination;
|
|
1155
|
+
let treeStopped = false;
|
|
1156
|
+
let killTimer;
|
|
1157
|
+
let closeTimer;
|
|
1158
|
+
const finish = (result) => {
|
|
1159
|
+
if (settled) return;
|
|
1160
|
+
settled = true;
|
|
1161
|
+
clearTimeout(timer);
|
|
1162
|
+
clearTimeout(killTimer);
|
|
1163
|
+
clearTimeout(closeTimer);
|
|
1164
|
+
options.signal?.removeEventListener("abort", abort);
|
|
1165
|
+
resolve3(result);
|
|
1166
|
+
};
|
|
1167
|
+
const terminatedResult = () => ({ stdout: termination?.startsWith("timeout") ? stdout : "", error: termination });
|
|
1168
|
+
const stop = (signal) => {
|
|
1169
|
+
try {
|
|
1170
|
+
return stopOwnedTree(child, signal);
|
|
1171
|
+
} catch {
|
|
1172
|
+
return Promise.resolve();
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
const kill = () => {
|
|
1176
|
+
const stopped = () => {
|
|
1177
|
+
treeStopped = true;
|
|
1178
|
+
if (settled) return;
|
|
1179
|
+
if (closed) {
|
|
1180
|
+
finish(terminatedResult());
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
closeTimer = setTimeout(() => {
|
|
1184
|
+
child.stdout?.destroy();
|
|
1185
|
+
child.stderr?.destroy();
|
|
1186
|
+
finish(terminatedResult());
|
|
1187
|
+
}, options.closeGraceMs ?? 1e3);
|
|
1188
|
+
};
|
|
1189
|
+
void stop("SIGKILL").then(stopped, stopped);
|
|
1190
|
+
};
|
|
1191
|
+
const terminate = (reason, graceful = false) => {
|
|
1192
|
+
if (settled || termination) return;
|
|
1193
|
+
termination = reason;
|
|
1194
|
+
clearTimeout(timer);
|
|
1195
|
+
if (graceful) {
|
|
1196
|
+
const stopped = () => {
|
|
1197
|
+
if (closed) {
|
|
1198
|
+
treeStopped = true;
|
|
1199
|
+
finish(terminatedResult());
|
|
1200
|
+
}
|
|
1201
|
+
};
|
|
1202
|
+
void stop("SIGTERM").then(stopped, stopped);
|
|
1203
|
+
if (!settled) killTimer = setTimeout(kill, options.killGraceMs ?? 1e3);
|
|
1204
|
+
} else kill();
|
|
1205
|
+
};
|
|
1206
|
+
const timer = setTimeout(() => terminate(`timeout after ${timeoutMs}ms`, true), timeoutMs);
|
|
1207
|
+
const abort = () => terminate("cancelled");
|
|
1208
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
1209
|
+
if (options.signal?.aborted) abort();
|
|
1210
|
+
const deliverLine = (line) => {
|
|
1211
|
+
if (settled || termination) return;
|
|
1212
|
+
try {
|
|
1213
|
+
options.onLine?.(line);
|
|
1214
|
+
} catch {
|
|
1215
|
+
terminate("output callback failed");
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
const lines = options.onLine ? lineReader(deliverLine, 16384) : void 0;
|
|
1219
|
+
const errorLines = options.onLine ? lineReader(deliverLine, 16384) : void 0;
|
|
1220
|
+
const outDecoder = new StringDecoder2("utf8");
|
|
1221
|
+
const errDecoder = new StringDecoder2("utf8");
|
|
1222
|
+
child.stdout?.on("data", (chunk) => {
|
|
1223
|
+
if (settled || termination) return;
|
|
1224
|
+
lines?.write(chunk);
|
|
1225
|
+
if (termination) return;
|
|
1226
|
+
stdout += outDecoder.write(chunk);
|
|
1227
|
+
if (stdout.length > 8 * 1024 * 1024) {
|
|
1228
|
+
stdout = "";
|
|
1229
|
+
terminate("stdout exceeds 8 MiB limit");
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
child.stderr?.on("data", (chunk) => {
|
|
1233
|
+
if (settled || termination) return;
|
|
1234
|
+
errorLines?.write(chunk);
|
|
1235
|
+
stderr = (stderr + errDecoder.write(chunk)).slice(-4096);
|
|
1236
|
+
});
|
|
1237
|
+
child.on("error", () => terminate("spawn error"));
|
|
1238
|
+
child.on("close", (code) => {
|
|
1239
|
+
closed = true;
|
|
1240
|
+
if (settled) return;
|
|
1241
|
+
if (termination) {
|
|
1242
|
+
if (treeStopped) finish(terminatedResult());
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
stdout += outDecoder.end();
|
|
1246
|
+
stderr += errDecoder.end();
|
|
1247
|
+
lines?.end();
|
|
1248
|
+
errorLines?.end();
|
|
1249
|
+
if (termination) return;
|
|
1250
|
+
if (code === 0 && (stdout.trim() || options.allowEmpty)) finish({ stdout });
|
|
1251
|
+
else finish({ stdout, error: `exit ${code}${stderr ? `: ${stderr.slice(-300).trim()}` : ""}` });
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// builtin/extensions/im-forward/providers/feishu/helpers.ts
|
|
1257
|
+
function parseLarkCliEnvelope(stdout) {
|
|
1258
|
+
let parsed;
|
|
1259
|
+
try {
|
|
1260
|
+
parsed = JSON.parse(stdout);
|
|
1261
|
+
} catch {
|
|
1262
|
+
return { errorText: `unparseable lark-cli output: ${stdout.slice(0, 200)}` };
|
|
1263
|
+
}
|
|
1264
|
+
if (!isRecord(parsed)) return { errorText: "lark-cli output is not an object" };
|
|
1265
|
+
if (parsed.ok === true && isRecord(parsed.data)) return { ok: true, data: parsed.data };
|
|
1266
|
+
if (parsed.code === 0 && isRecord(parsed.data)) return { ok: true, data: parsed.data };
|
|
1267
|
+
if (parsed.ok === false) {
|
|
1268
|
+
const error = isRecord(parsed.error) ? JSON.stringify(parsed.error) : "unknown";
|
|
1269
|
+
return { ok: false, errorText: `lark-cli error: ${error}` };
|
|
1270
|
+
}
|
|
1271
|
+
if (typeof parsed.code === "number" && parsed.code !== 0) {
|
|
1272
|
+
return { ok: false, errorText: `lark-cli error code ${String(parsed.code)}: ${String(parsed.msg ?? "unknown")}` };
|
|
1273
|
+
}
|
|
1274
|
+
return { errorText: "lark-cli output missing ok field" };
|
|
1275
|
+
}
|
|
1276
|
+
function extractInboundText(content) {
|
|
1277
|
+
if (typeof content !== "string") return "";
|
|
1278
|
+
const trimmed = content.trim();
|
|
1279
|
+
if (!trimmed) return "";
|
|
1280
|
+
if (trimmed.startsWith("{")) {
|
|
1281
|
+
try {
|
|
1282
|
+
const parsed = JSON.parse(trimmed);
|
|
1283
|
+
if (isRecord(parsed) && typeof parsed.text === "string" && Object.keys(parsed).length === 1) {
|
|
1284
|
+
return parsed.text.trim();
|
|
1285
|
+
}
|
|
1286
|
+
} catch {
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return trimmed;
|
|
1290
|
+
}
|
|
1291
|
+
function extractImageKeys(content) {
|
|
1292
|
+
if (typeof content !== "string") return [];
|
|
1293
|
+
return [...new Set(Array.from(content.matchAll(/\[Image:\s*(img_[A-Za-z0-9_-]+)\]/g), (match) => match[1]))];
|
|
1294
|
+
}
|
|
1295
|
+
function extractImageKey(content) {
|
|
1296
|
+
return extractImageKeys(content)[0] ?? null;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// builtin/extensions/im-forward/providers/feishu/setup.ts
|
|
1300
|
+
import * as fs3 from "node:fs";
|
|
1301
|
+
import * as path5 from "node:path";
|
|
1302
|
+
|
|
1303
|
+
// builtin/extensions/im-forward/browser.ts
|
|
1304
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
1305
|
+
import path3 from "node:path";
|
|
1306
|
+
var browserScript = "$ErrorActionPreference = 'Stop'; try { $reader = [System.IO.StreamReader]::new([Console]::OpenStandardInput(), [System.Text.Encoding]::UTF8); $url = $reader.ReadToEnd(); Start-Process -FilePath $url; exit 0 } catch { exit 1 }";
|
|
1307
|
+
function browserLaunch(platform = process.platform, root = process.env.SystemRoot || "C:\\Windows") {
|
|
1308
|
+
if (platform === "win32") {
|
|
1309
|
+
if (!/^[A-Za-z]:\\/.test(root) || /[\x00-\x1f]/.test(root)) return;
|
|
1310
|
+
return { command: path3.win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(browserScript, "utf16le").toString("base64")], stdin: true };
|
|
1311
|
+
}
|
|
1312
|
+
if (platform === "darwin") return { command: "/usr/bin/open", args: ["--"], stdin: false };
|
|
1313
|
+
if (platform === "linux") return { command: "/usr/bin/xdg-open", args: [], stdin: false };
|
|
1314
|
+
}
|
|
1315
|
+
async function openBrowser(url, signal, seam = {}) {
|
|
1316
|
+
if (signal.aborted || !/^https:\/\/[^\s\x00-\x1f\x7f]+$/.test(url)) return "failed";
|
|
1317
|
+
const spec = browserLaunch(seam.platform);
|
|
1318
|
+
if (!spec) return "failed";
|
|
1319
|
+
return new Promise((resolve3) => {
|
|
1320
|
+
let child;
|
|
1321
|
+
let settled = false;
|
|
1322
|
+
let stopping = false;
|
|
1323
|
+
let closed = false;
|
|
1324
|
+
let closeTimer;
|
|
1325
|
+
const finish = (result) => {
|
|
1326
|
+
if (settled) return;
|
|
1327
|
+
settled = true;
|
|
1328
|
+
clearTimeout(timer);
|
|
1329
|
+
clearTimeout(closeTimer);
|
|
1330
|
+
signal.removeEventListener("abort", abort);
|
|
1331
|
+
child?.stdin?.destroy();
|
|
1332
|
+
child?.stdout?.destroy();
|
|
1333
|
+
child?.stderr?.destroy();
|
|
1334
|
+
resolve3(result);
|
|
1335
|
+
};
|
|
1336
|
+
const stop = () => {
|
|
1337
|
+
if (settled || stopping) return;
|
|
1338
|
+
stopping = true;
|
|
1339
|
+
const stopped = () => {
|
|
1340
|
+
if (closed) finish("failed");
|
|
1341
|
+
else closeTimer = setTimeout(() => finish("failed"), 1e3);
|
|
1342
|
+
};
|
|
1343
|
+
try {
|
|
1344
|
+
void (seam.stop ?? stopOwnedTree)(child, "SIGKILL").then(stopped, stopped);
|
|
1345
|
+
} catch {
|
|
1346
|
+
stopped();
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
const abort = () => stop();
|
|
1350
|
+
const timer = setTimeout(stop, seam.timeoutMs ?? 1e4);
|
|
1351
|
+
try {
|
|
1352
|
+
child = (seam.spawn ?? spawn3)(spec.command, spec.stdin ? spec.args : [...spec.args, url], { shell: false, windowsHide: true, detached: process.platform !== "win32", stdio: [spec.stdin ? "pipe" : "ignore", "ignore", "ignore"] });
|
|
1353
|
+
child.on("error", stop);
|
|
1354
|
+
child.on("close", (code) => {
|
|
1355
|
+
closed = true;
|
|
1356
|
+
if (!stopping) finish(code === 0 && !signal.aborted ? "launched" : "failed");
|
|
1357
|
+
});
|
|
1358
|
+
child.stdin?.on("error", stop);
|
|
1359
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1360
|
+
if (signal.aborted) stop();
|
|
1361
|
+
else if (spec.stdin) child.stdin?.end(Buffer.from(url, "utf8"));
|
|
1362
|
+
} catch {
|
|
1363
|
+
finish("failed");
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// builtin/extensions/im-forward/existing-app-terminal.ts
|
|
1369
|
+
import path4 from "node:path";
|
|
1370
|
+
var existingAppInnerScript = "$ErrorActionPreference = 'Stop'; try { $payload = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('__PAYLOAD__')) | ConvertFrom-Json; Set-Location -LiteralPath $payload.cwd; & $payload.command @($payload.args); $code = $LASTEXITCODE; if ($null -eq $code) { exit 1 }; exit $code } catch { Write-Host 'Official setup failed. Close this window and retry from Pie.'; exit 1 }";
|
|
1371
|
+
var existingAppOuterTemplate = "$ErrorActionPreference = 'Stop'; try { $inner = '" + existingAppInnerScript.replaceAll("'", "''") + "'; $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($inner)); $child = Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe') -WindowStyle Normal -Wait -PassThru -ArgumentList @('-NoLogo', '-NoProfile', '-EncodedCommand', $encoded); exit $child.ExitCode } catch { exit 1 }";
|
|
1372
|
+
function existingAppTerminal(binary, cwd, platform = process.platform, root = process.env.SystemRoot || "C:\\Windows") {
|
|
1373
|
+
if (platform !== "win32" || !/^[A-Za-z]:\\/.test(root) || /[\x00-\x1f]/.test(root)) return null;
|
|
1374
|
+
const payload = Buffer.from(JSON.stringify({ command: binary.command, args: [...binary.prefix, "config", "init", "--brand", "feishu"], cwd }), "utf8").toString("base64");
|
|
1375
|
+
const script = existingAppOuterTemplate.replace("__PAYLOAD__", payload);
|
|
1376
|
+
return { command: path4.win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), prefix: ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")] };
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// builtin/extensions/im-forward/providers/feishu/setup.ts
|
|
1380
|
+
function findNpmCli() {
|
|
1381
|
+
const dirs = [path5.dirname(process.execPath), ...(process.env.PATH ?? "").split(path5.delimiter)];
|
|
1382
|
+
for (const dir of dirs) for (const root of [path5.join(dir, "node_modules", "npm"), path5.resolve(dir, "..", "lib", "node_modules", "npm")]) {
|
|
1383
|
+
try {
|
|
1384
|
+
const pkg = JSON.parse(fs3.readFileSync(path5.join(root, "package.json"), "utf8"));
|
|
1385
|
+
const entry = path5.join(root, "bin", "npm-cli.js");
|
|
1386
|
+
if (pkg.name === "npm" && pkg.bin?.npm === "bin/npm-cli.js" && fs3.statSync(entry).isFile()) return { command: process.execPath, prefix: [entry] };
|
|
1387
|
+
} catch {
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return null;
|
|
1391
|
+
}
|
|
1392
|
+
function isOfficialAuthUrl(raw) {
|
|
1393
|
+
try {
|
|
1394
|
+
if (!/^https:\/\/[^\s\x00-\x1f\x7f]+$/.test(raw) || /%0[0-9a-f]|%1[0-9a-f]|%7f/i.test(raw)) return false;
|
|
1395
|
+
const url = new URL(raw);
|
|
1396
|
+
return url.protocol === "https:" && !url.username && !url.password && !raw.includes("#") && (!url.port || url.port === "443") && ["feishu.cn", "larksuite.com"].some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`)) && url.pathname === "/page/cli" && !!url.searchParams.get("user_code") && ![...url.searchParams.keys()].some((key) => /secret|token/i.test(key));
|
|
1397
|
+
} catch {
|
|
1398
|
+
return false;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
function authUrls(line, newAppFlow = false) {
|
|
1402
|
+
const urls = [];
|
|
1403
|
+
const visit = (value) => {
|
|
1404
|
+
if (!isRecord(value)) return;
|
|
1405
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1406
|
+
if (["verification_url", "verification_uri_complete", "console_url"].includes(key) && typeof child === "string" && /^https?:\/\/[^\s\x00-\x1f\x7f]+$/.test(child)) urls.push(child);
|
|
1407
|
+
else if (isRecord(child)) visit(child);
|
|
1408
|
+
}
|
|
1409
|
+
};
|
|
1410
|
+
try {
|
|
1411
|
+
visit(JSON.parse(line));
|
|
1412
|
+
} catch {
|
|
1413
|
+
const match = line.trim().match(/^"(verification_url|verification_uri_complete|console_url)"\s*:\s*("(?:[^"\\]|\\.)*")\s*,?$/);
|
|
1414
|
+
if (match) {
|
|
1415
|
+
try {
|
|
1416
|
+
visit({ [match[1]]: JSON.parse(match[2]) });
|
|
1417
|
+
} catch {
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
const field = line.trim().match(/^(verification_url|verification_uri_complete|console_url)\s*[:=]\s*(https?:\/\/\S+)$/);
|
|
1421
|
+
if (field) visit({ [field[1]]: field[2] });
|
|
1422
|
+
}
|
|
1423
|
+
if (newAppFlow) {
|
|
1424
|
+
const raw = line.trim();
|
|
1425
|
+
try {
|
|
1426
|
+
if (isOfficialAuthUrl(raw)) urls.push(raw);
|
|
1427
|
+
} catch {
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return [...new Set(urls)];
|
|
1431
|
+
}
|
|
1432
|
+
function parseDirectMessage(line) {
|
|
1433
|
+
let raw;
|
|
1434
|
+
try {
|
|
1435
|
+
raw = JSON.parse(line);
|
|
1436
|
+
} catch {
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
if (!isRecord(raw) || raw.type !== "im.message.receive_v1") return null;
|
|
1440
|
+
if ("chat_type" in raw && raw.chat_type !== "p2p") return null;
|
|
1441
|
+
if (typeof raw.chat_id !== "string" || !/^oc_[A-Za-z0-9_-]+$/.test(raw.chat_id)) return null;
|
|
1442
|
+
if (typeof raw.sender_id !== "string" || !/^ou_[A-Za-z0-9_-]+$/.test(raw.sender_id)) return null;
|
|
1443
|
+
return { chatId: raw.chat_id, senderId: raw.sender_id };
|
|
1444
|
+
}
|
|
1445
|
+
function parseChats(stdout) {
|
|
1446
|
+
const parsed = parseLarkCliEnvelope(stdout);
|
|
1447
|
+
if (!parsed.ok || !parsed.data) return null;
|
|
1448
|
+
const list = parsed.data.chats ?? parsed.data.items ?? parsed.data.messages;
|
|
1449
|
+
if (!Array.isArray(list)) return parsed.data.has_more === false ? [] : null;
|
|
1450
|
+
return list.flatMap((item) => isRecord(item) && typeof item.chat_id === "string" && /^oc_[A-Za-z0-9_-]+$/.test(item.chat_id) ? [{ id: item.chat_id, name: typeof item.name === "string" ? item.name.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120) : item.chat_id }] : []);
|
|
1451
|
+
}
|
|
1452
|
+
var failureActions = {
|
|
1453
|
+
config: "Official CLI configuration is missing or invalid. Create a new app, or configure an existing app in a trusted terminal.",
|
|
1454
|
+
auth: "Authorization is missing or expired. Configure the intended app in a trusted terminal, then recheck.",
|
|
1455
|
+
permission: "The bot lacks access or permissions. Check app scopes and chat membership, then recheck.",
|
|
1456
|
+
network: "The service could not be reached. Check connectivity and retry the connection check.",
|
|
1457
|
+
unsupported: "This CLI command or version is unsupported. Check the official CLI version and update it in a trusted terminal.",
|
|
1458
|
+
timeout: "The operation timed out. Check connectivity and retry.",
|
|
1459
|
+
cancel: "The operation was cancelled. Retry when ready; nothing was saved.",
|
|
1460
|
+
unknown: "The CLI returned an unrecognized failure or invalid result. Check the official CLI in a trusted terminal, then retry."
|
|
1461
|
+
};
|
|
1462
|
+
function setupFailure(result) {
|
|
1463
|
+
let type = "";
|
|
1464
|
+
let subtype = "";
|
|
1465
|
+
try {
|
|
1466
|
+
const body = JSON.parse(result.stdout);
|
|
1467
|
+
if (isRecord(body) && isRecord(body.error)) {
|
|
1468
|
+
type = typeof body.error.type === "string" ? body.error.type.toLowerCase() : "";
|
|
1469
|
+
subtype = typeof body.error.subtype === "string" ? body.error.subtype.toLowerCase() : "";
|
|
1470
|
+
}
|
|
1471
|
+
} catch {
|
|
1472
|
+
}
|
|
1473
|
+
const tags = `${type} ${subtype}`;
|
|
1474
|
+
let reason = "unknown";
|
|
1475
|
+
if (/^timeout/.test(result.error ?? "")) reason = "timeout";
|
|
1476
|
+
else if (result.error === "cancelled") reason = "cancel";
|
|
1477
|
+
else if (/config|not_configured/.test(tags)) reason = "config";
|
|
1478
|
+
else if (/permission|forbidden|scope|access_denied/.test(tags)) reason = "permission";
|
|
1479
|
+
else if (/auth|token|credential|unauthorized/.test(tags)) reason = "auth";
|
|
1480
|
+
else if (/network|connection|dns|transport/.test(tags)) reason = "network";
|
|
1481
|
+
else if (/unsupported|unknown_command|invalid_command|version/.test(tags)) reason = "unsupported";
|
|
1482
|
+
return failureActions[reason];
|
|
1483
|
+
}
|
|
1484
|
+
function parseProfiles(stdout) {
|
|
1485
|
+
let parsed;
|
|
1486
|
+
try {
|
|
1487
|
+
parsed = JSON.parse(stdout);
|
|
1488
|
+
} catch {
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
if (!Array.isArray(parsed)) return null;
|
|
1492
|
+
const profiles = parsed.flatMap((item) => {
|
|
1493
|
+
if (!isRecord(item) || typeof item.name !== "string" || !item.name.trim() || typeof item.appId !== "string") return [];
|
|
1494
|
+
return [{ name: item.name.trim(), appId: item.appId, active: item.active === true, user: typeof item.user === "string" ? item.user : void 0 }];
|
|
1495
|
+
});
|
|
1496
|
+
return profiles.length ? profiles : null;
|
|
1497
|
+
}
|
|
1498
|
+
var dependencies = { resolve: resolveLaunchSpec, run: runLarkCli, npm: findNpmCli, openBrowser, terminal: existingAppTerminal };
|
|
1499
|
+
async function setupFeishu(io, previous, deps = dependencies, intent = "first-run") {
|
|
1500
|
+
const { ui, cwd, signal } = io;
|
|
1501
|
+
const options = { signal, timeout: 12e4 };
|
|
1502
|
+
const current = () => !signal.aborted;
|
|
1503
|
+
const run = async (binary2, args, timeout, workdir, callOptions = {}, authorize = false, progressLabel = "", stream) => {
|
|
1504
|
+
const operation = new AbortController();
|
|
1505
|
+
let dialog = new AbortController();
|
|
1506
|
+
let dialogGeneration = 0;
|
|
1507
|
+
let dialogStarted = false;
|
|
1508
|
+
const startedAt = Date.now();
|
|
1509
|
+
const abort = () => operation.abort();
|
|
1510
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1511
|
+
if (signal.aborted) operation.abort();
|
|
1512
|
+
let expired = false;
|
|
1513
|
+
const deadline = setTimeout(() => {
|
|
1514
|
+
expired = true;
|
|
1515
|
+
operation.abort();
|
|
1516
|
+
}, timeout);
|
|
1517
|
+
let done = false;
|
|
1518
|
+
let streamStopped = false;
|
|
1519
|
+
const openings = [];
|
|
1520
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1521
|
+
let received = false;
|
|
1522
|
+
const announceUrl = (url) => {
|
|
1523
|
+
if (!isOfficialAuthUrl(url)) {
|
|
1524
|
+
ui.notify("An authorization link arrived but could not be verified as an official Feishu page; it was NOT displayed or opened. If authorization fails, retry setup in a trusted terminal.", "warning");
|
|
1525
|
+
widget("Unverified link withheld \u2014 not opened automatically", "");
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
ui.notify(url, "info");
|
|
1529
|
+
widget("Opening default browser", url);
|
|
1530
|
+
const opening = Promise.resolve().then(() => active() ? deps.openBrowser?.(url, operation.signal) : "failed").then((result) => {
|
|
1531
|
+
widget(result === "launched" ? "Waiting for browser authorization (launcher completed; authorization not yet confirmed)" : "Browser could not open; manually open link", url);
|
|
1532
|
+
}, () => {
|
|
1533
|
+
widget("Browser could not open; manually open link", url);
|
|
1534
|
+
}).catch(() => {
|
|
1535
|
+
});
|
|
1536
|
+
openings.push(opening);
|
|
1537
|
+
};
|
|
1538
|
+
const active = () => !done && current() && !operation.signal.aborted;
|
|
1539
|
+
let phase = "Requesting official authorization link";
|
|
1540
|
+
let manualUrl = "";
|
|
1541
|
+
const showProgress = () => {
|
|
1542
|
+
if (!active()) return;
|
|
1543
|
+
const previous2 = dialog;
|
|
1544
|
+
dialog = new AbortController();
|
|
1545
|
+
const generation = ++dialogGeneration;
|
|
1546
|
+
previous2.abort();
|
|
1547
|
+
const title = "IM forwarding setup \u2014 working; cancel to stop" + (authorize ? `
|
|
1548
|
+
Browser authorization: ${phase}
|
|
1549
|
+
${manualUrl || "No authorization URL yet. Do not enter secrets in Pie."}` : progressLabel ? `
|
|
1550
|
+
${progressLabel}` : "");
|
|
1551
|
+
const cancel = () => {
|
|
1552
|
+
if (generation === dialogGeneration && active()) operation.abort();
|
|
1553
|
+
};
|
|
1554
|
+
try {
|
|
1555
|
+
void ui.select(title, ["Cancel"], { signal: dialog.signal, timeout: Math.max(1, timeout - (Date.now() - startedAt)) }).then(cancel, cancel);
|
|
1556
|
+
} catch {
|
|
1557
|
+
cancel();
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
const widget = (nextPhase, url = "") => {
|
|
1561
|
+
if (!active()) return;
|
|
1562
|
+
phase = nextPhase;
|
|
1563
|
+
manualUrl = url;
|
|
1564
|
+
try {
|
|
1565
|
+
ui.setWidget?.("im-forward-setup", ["IM forwarding \u2014 browser authorization", phase, manualUrl || "No authorization URL yet. Do not enter secrets in Pie."]);
|
|
1566
|
+
} catch {
|
|
1567
|
+
}
|
|
1568
|
+
if (dialogStarted) showProgress();
|
|
1569
|
+
};
|
|
1570
|
+
if (authorize) widget("Requesting official authorization link");
|
|
1571
|
+
const advisory = authorize ? setTimeout(() => {
|
|
1572
|
+
if (!received) widget("Still waiting for the official authorization link; cancel or wait (two-minute deadline).");
|
|
1573
|
+
}, 15e3) : void 0;
|
|
1574
|
+
try {
|
|
1575
|
+
const pending = deps.run(binary2, args, timeout, workdir, { ...callOptions, signal: operation.signal, ...authorize ? { onLine: (line) => {
|
|
1576
|
+
if (!active()) return;
|
|
1577
|
+
for (const url of authUrls(line, true)) {
|
|
1578
|
+
if (seen.has(url)) continue;
|
|
1579
|
+
seen.add(url);
|
|
1580
|
+
received = true;
|
|
1581
|
+
announceUrl(url);
|
|
1582
|
+
}
|
|
1583
|
+
} } : stream ? { onLine: (line) => {
|
|
1584
|
+
if (!active()) return;
|
|
1585
|
+
if (stream(line)) {
|
|
1586
|
+
streamStopped = true;
|
|
1587
|
+
operation.abort();
|
|
1588
|
+
}
|
|
1589
|
+
} } : {} }).then(async (result2) => {
|
|
1590
|
+
if (openings.length) await Promise.all(openings);
|
|
1591
|
+
done = true;
|
|
1592
|
+
return result2;
|
|
1593
|
+
});
|
|
1594
|
+
dialogStarted = true;
|
|
1595
|
+
showProgress();
|
|
1596
|
+
const result = await pending;
|
|
1597
|
+
if (operation.signal.aborted && !expired && !streamStopped) throw new Error("Operation cancelled; nothing was saved.");
|
|
1598
|
+
return expired ? { stdout: "", error: `timeout after ${timeout}ms` } : result;
|
|
1599
|
+
} finally {
|
|
1600
|
+
done = true;
|
|
1601
|
+
++dialogGeneration;
|
|
1602
|
+
operation.abort();
|
|
1603
|
+
try {
|
|
1604
|
+
await Promise.all(openings);
|
|
1605
|
+
} finally {
|
|
1606
|
+
clearTimeout(advisory);
|
|
1607
|
+
clearTimeout(deadline);
|
|
1608
|
+
dialog.abort();
|
|
1609
|
+
signal.removeEventListener("abort", abort);
|
|
1610
|
+
if (authorize) {
|
|
1611
|
+
try {
|
|
1612
|
+
ui.setWidget?.("im-forward-setup", void 0);
|
|
1613
|
+
} catch {
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
let executable = typeof previous?.larkCli === "string" ? previous.larkCli : void 0;
|
|
1620
|
+
let binary = deps.resolve(executable, cwd);
|
|
1621
|
+
while (!binary && current()) {
|
|
1622
|
+
const choice = await ui.select("IM forwarding setup \u2014 Feishu CLI required", ["Provide existing executable path", "Install official @larksuite/cli", "Cancel"], options);
|
|
1623
|
+
if (!current() || !choice || choice === "Cancel") return;
|
|
1624
|
+
if (choice === "Install official @larksuite/cli") {
|
|
1625
|
+
if (!await ui.confirm("Install Feishu CLI?", "This runs npm install --global @larksuite/cli: network access, global filesystem changes and package postinstall scripts. No messages will be sent. Continue?", options) || !current()) return;
|
|
1626
|
+
const npm = deps.npm();
|
|
1627
|
+
if (!npm) {
|
|
1628
|
+
ui.notify("A reliable npm-cli.js was not found. Install @larksuite/cli yourself using your trusted npm, then provide its executable path. No installer was run.", "warning");
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
const installed = await run(npm, ["install", "--global", "@larksuite/cli"], 12e4, cwd, { signal, allowEmpty: true });
|
|
1632
|
+
if (!current()) return;
|
|
1633
|
+
if (installed.error) {
|
|
1634
|
+
ui.notify("Feishu CLI installation failed or timed out. Configuration was not saved. Install with your trusted npm or provide an existing path.", "error");
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
executable = void 0;
|
|
1638
|
+
binary = deps.resolve(void 0, cwd);
|
|
1639
|
+
if (!binary) ui.notify("Installer completed, but lark-cli is not discoverable. Provide its executable path.", "warning");
|
|
1640
|
+
} else {
|
|
1641
|
+
const input = await ui.input("IM forwarding setup \u2014 existing Feishu CLI executable", "Absolute executable or Node .js/.mjs entry (not a shell command)", options);
|
|
1642
|
+
if (!current() || !input?.trim()) return;
|
|
1643
|
+
executable = input.trim();
|
|
1644
|
+
binary = deps.resolve(executable, cwd);
|
|
1645
|
+
if (!binary) ui.notify("That executable is missing or unsupported. Windows npm cmd shims must belong to the official CLI; arbitrary bat/cmd wrappers are not supported.", "warning");
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
if (!binary || !current()) return;
|
|
1649
|
+
let profileName = typeof previous?.larkCliProfile === "string" && previous.larkCliProfile.trim() ? previous.larkCliProfile.trim() : void 0;
|
|
1650
|
+
let profiles = null;
|
|
1651
|
+
{
|
|
1652
|
+
const listed = await run(binary, ["profile", "list"], 15e3, cwd, { signal, allowEmpty: true });
|
|
1653
|
+
if (!current()) return;
|
|
1654
|
+
profiles = listed.error ? null : parseProfiles(listed.stdout);
|
|
1655
|
+
const describe = (item) => {
|
|
1656
|
+
const parts = [item.appId];
|
|
1657
|
+
if (item.user) parts.push(item.user);
|
|
1658
|
+
if (item.active) parts.push("active");
|
|
1659
|
+
return `${item.name} (${parts.join(" \xB7 ")})`;
|
|
1660
|
+
};
|
|
1661
|
+
const connectDifferent = "Connect a different app\u2026";
|
|
1662
|
+
const suggestProfileName = () => {
|
|
1663
|
+
const taken = new Set((profiles ?? []).map((item) => item.name));
|
|
1664
|
+
if (!taken.has("pie")) return "pie";
|
|
1665
|
+
for (let n = 2; n < 100; n++) if (!taken.has(`pie-${n}`)) return `pie-${n}`;
|
|
1666
|
+
return `pie-${Date.now().toString(36)}`;
|
|
1667
|
+
};
|
|
1668
|
+
const bindNewApp = async () => {
|
|
1669
|
+
if (!await ui.confirm("Connect a different Feishu app?", "The default browser will open for authorization. This creates an external Feishu application as a new lark-cli profile, not just a sign-in. The official CLI will write its own configuration; other profiles stay unchanged. Pie does not collect app secrets. Continue?", options) || !current()) return false;
|
|
1670
|
+
let suggested = suggestProfileName();
|
|
1671
|
+
const entered = await ui.input("IM forwarding setup \u2014 profile name for the new app", `Suggested: ${suggested} (stored in lark-cli, local to this machine)`, options);
|
|
1672
|
+
if (!current()) return false;
|
|
1673
|
+
const raw = (entered ?? "").trim();
|
|
1674
|
+
if (raw) {
|
|
1675
|
+
if (!/^[-\w]{1,64}$/.test(raw)) {
|
|
1676
|
+
ui.notify("Invalid profile name: use 1-64 letters, digits, - or _. Nothing was saved.", "error");
|
|
1677
|
+
return false;
|
|
1678
|
+
}
|
|
1679
|
+
suggested = raw;
|
|
1680
|
+
}
|
|
1681
|
+
ui.notify("Authorize only in the official browser flow. Do not enter secrets in Pie. Open the exact URL displayed below; setup is bounded to two minutes.", "info");
|
|
1682
|
+
const result = await run(binary, ["config", "init", "--new", "--brand", "feishu", "--name", suggested], 12e4, cwd, { signal, allowEmpty: true }, true);
|
|
1683
|
+
if (!current()) return false;
|
|
1684
|
+
if (result.error) {
|
|
1685
|
+
ui.notify(`New-app browser authorization: ${setupFailure(result)} Nothing was saved in Pie. Official CLI configuration may have changed.`, "warning");
|
|
1686
|
+
return false;
|
|
1687
|
+
}
|
|
1688
|
+
profileName = suggested;
|
|
1689
|
+
const relisted = await run(binary, ["profile", "list"], 15e3, cwd, { signal, allowEmpty: true });
|
|
1690
|
+
if (!current()) return false;
|
|
1691
|
+
if (!relisted.error) {
|
|
1692
|
+
const fresh = parseProfiles(relisted.stdout);
|
|
1693
|
+
if (fresh) profiles = fresh;
|
|
1694
|
+
}
|
|
1695
|
+
ui.notify(`IM forwarding will use lark-cli profile "${suggested}" on this machine.`, "info");
|
|
1696
|
+
return true;
|
|
1697
|
+
};
|
|
1698
|
+
const singleProfile = profiles?.length === 1 ? profiles[0] : void 0;
|
|
1699
|
+
if (singleProfile && intent === "switch-app") {
|
|
1700
|
+
const choice = await ui.select("IM forwarding setup \u2014 Feishu app (per machine)", [`Keep current app \u2014 ${describe(singleProfile)}`, connectDifferent, "Cancel"], options);
|
|
1701
|
+
if (!current() || !choice || choice === "Cancel") return;
|
|
1702
|
+
if (choice === connectDifferent) {
|
|
1703
|
+
if (!await bindNewApp()) return;
|
|
1704
|
+
} else profileName = singleProfile.name;
|
|
1705
|
+
} else if (profiles && profiles.length > 1) {
|
|
1706
|
+
const previousProfile = profiles.find((item) => item.name === profileName);
|
|
1707
|
+
if (!previousProfile) profileName = void 0;
|
|
1708
|
+
const items = ["Use the CLI's active app (no per-machine profile)", ...profiles.map(describe), connectDifferent, "Cancel"];
|
|
1709
|
+
const choice = await ui.select("IM forwarding setup \u2014 Feishu app (per machine)", items, options);
|
|
1710
|
+
if (!current() || !choice || choice === "Cancel") return;
|
|
1711
|
+
if (choice === connectDifferent) {
|
|
1712
|
+
if (!await bindNewApp()) return;
|
|
1713
|
+
} else {
|
|
1714
|
+
const index = items.indexOf(choice) - 1;
|
|
1715
|
+
profileName = choice === items[0] ? void 0 : profiles[index]?.name;
|
|
1716
|
+
if (!profileName && choice !== items[0]) return;
|
|
1717
|
+
if (profileName) ui.notify(`IM forwarding will use lark-cli profile "${profileName}" on this machine.`, "info");
|
|
1718
|
+
}
|
|
1719
|
+
} else if (profiles) {
|
|
1720
|
+
profileName = void 0;
|
|
1721
|
+
} else if (intent === "switch-app") {
|
|
1722
|
+
ui.notify("This lark-cli build does not support multiple app profiles; update lark-cli to switch apps.", "warning");
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
const withProfile = (args) => profileName ? [...args, "--profile", profileName] : args;
|
|
1726
|
+
let diagnostic = "";
|
|
1727
|
+
const chatListCommands = [
|
|
1728
|
+
["im", "+chat-list", "--as", "bot", "--format", "json", "--page-size", "100"],
|
|
1729
|
+
["im", "chats", "list", "--as", "bot", "--format", "json", "--params", '{"page_size":100}']
|
|
1730
|
+
].map(withProfile);
|
|
1731
|
+
let chatListForm;
|
|
1732
|
+
const discover = async () => {
|
|
1733
|
+
let firstDiagnostic = "";
|
|
1734
|
+
for (const args of chatListForm ? [chatListForm] : chatListCommands) {
|
|
1735
|
+
const result = await run(binary, args, 3e4, cwd, { signal });
|
|
1736
|
+
if (!firstDiagnostic) firstDiagnostic = setupFailure(result);
|
|
1737
|
+
diagnostic = setupFailure(result);
|
|
1738
|
+
if (!result.error) {
|
|
1739
|
+
const chats2 = parseChats(result.stdout);
|
|
1740
|
+
if (chats2) {
|
|
1741
|
+
chatListForm = args;
|
|
1742
|
+
return chats2;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
if (firstDiagnostic && firstDiagnostic !== failureActions.unknown) diagnostic = firstDiagnostic;
|
|
1747
|
+
return null;
|
|
1748
|
+
};
|
|
1749
|
+
let chats = await discover();
|
|
1750
|
+
if (!current()) return;
|
|
1751
|
+
let stage = "Connection check before authorization";
|
|
1752
|
+
while (!chats && current()) {
|
|
1753
|
+
ui.notify(`${stage}: ${diagnostic}`, "warning");
|
|
1754
|
+
const action = await ui.select("IM forwarding setup \u2014 Feishu authentication", ["Create a new Feishu app (browser authorization)", "Use an existing app (configure in official terminal)", "Recheck connection", "Enter chat ID manually (readiness unverified)", "Cancel"], options);
|
|
1755
|
+
if (!current() || !action || action === "Cancel") return;
|
|
1756
|
+
if (action === "Enter chat ID manually (readiness unverified)") break;
|
|
1757
|
+
if (action === "Create a new Feishu app (browser authorization)") {
|
|
1758
|
+
if (!await ui.confirm("Create a new Feishu app?", "The default browser will open for authorization. This creates an external Feishu application, not just a sign-in to an existing app. The official CLI will write its configuration and existing profiles may change. Pie does not collect app secrets. Continue?", options) || !current()) return;
|
|
1759
|
+
ui.notify("Authorize only in the official browser flow. Do not enter secrets in Pie. Open the exact URL displayed below; setup is bounded to two minutes.", "info");
|
|
1760
|
+
const result = await run(binary, withProfile(["config", "init", "--new", "--brand", "feishu", ...profileName ? ["--name", profileName] : []]), 12e4, cwd, { signal, allowEmpty: true }, true);
|
|
1761
|
+
if (!current()) return;
|
|
1762
|
+
if (result.error) {
|
|
1763
|
+
ui.notify(`New-app browser authorization: ${setupFailure(result)} Nothing was saved in Pie. Official CLI configuration may have changed.`, "warning");
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
stage = "Authorization command completed, but chat listing failed (check app scopes and membership)";
|
|
1767
|
+
} else if (action === "Use an existing app (configure in official terminal)") {
|
|
1768
|
+
const terminal = deps.terminal?.(binary, cwd);
|
|
1769
|
+
let manualReason = "Automatic terminal launch is unavailable on this system. No terminal was opened.";
|
|
1770
|
+
let completed = false;
|
|
1771
|
+
if (terminal) {
|
|
1772
|
+
if (!await ui.confirm("Open official setup in a separate terminal?", "Pie will open a separate Windows terminal running official lark-cli config init --brand feishu. Choose the existing-app option, NOT create a new app. Official CLI configuration and profiles may change. Enter app ID and secret only in that terminal; never in Pie. Pie does not capture credentials. Continue?", options) || !current()) return;
|
|
1773
|
+
const result = await run(terminal, [], 3e5, cwd, { signal, allowEmpty: true }, false, "Complete official setup in separate terminal. Pie does not capture credentials. Cancel closes the owned setup process tree.");
|
|
1774
|
+
if (!current()) return;
|
|
1775
|
+
completed = !result.error;
|
|
1776
|
+
if (!completed) {
|
|
1777
|
+
manualReason = /^timeout/.test(result.error ?? "") ? "Official terminal setup timed out (five-minute limit)." : "Official terminal setup failed or could not open.";
|
|
1778
|
+
ui.notify(`${manualReason} Nothing was saved in Pie. Official CLI configuration may have changed.`, "warning");
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
if (!completed) {
|
|
1782
|
+
const next = await ui.select(`IM forwarding setup \u2014 after official terminal configuration
|
|
1783
|
+
${manualReason}
|
|
1784
|
+
In your trusted terminal run: lark-cli config init --brand feishu
|
|
1785
|
+
Use your configured official CLI if it is not on PATH. Choose existing app, not create new. Profiles may change. Enter credentials only there, never in Pie.
|
|
1786
|
+
After completing that external step, select Recheck connection.`, ["Recheck connection", "Cancel"], options);
|
|
1787
|
+
if (!current() || next !== "Recheck connection") return;
|
|
1788
|
+
}
|
|
1789
|
+
stage = "Official terminal setup completed, but chat listing failed (check app scopes and membership)";
|
|
1790
|
+
} else if (action !== "Recheck connection") return;
|
|
1791
|
+
chats = await discover();
|
|
1792
|
+
if (!current()) return;
|
|
1793
|
+
}
|
|
1794
|
+
const directLabel = "Direct message a user (private chat)";
|
|
1795
|
+
const manual = "Enter a chat_id manually";
|
|
1796
|
+
const labels = (chats ?? []).map((chat) => `${chat.name} \u2014 ${chat.id}`);
|
|
1797
|
+
let target;
|
|
1798
|
+
while (!target && current()) {
|
|
1799
|
+
const choice = await ui.select("IM forwarding setup \u2014 target (bots cannot list P2P chats; DM is supported)", [directLabel, ...labels, manual, "Cancel"], options);
|
|
1800
|
+
if (!current() || !choice || choice === "Cancel") return;
|
|
1801
|
+
if (choice === directLabel) {
|
|
1802
|
+
const dm = await ui.select("IM forwarding setup \u2014 direct message", ["Detect from a message I send to the bot", "Enter user open_id (ou_...)", "Back"], options);
|
|
1803
|
+
if (!current() || !dm || dm === "Back") continue;
|
|
1804
|
+
if (dm.startsWith("Detect")) {
|
|
1805
|
+
let resumeConsumer;
|
|
1806
|
+
const suspend = deps.suspendEventConsumer ?? io.suspendEventConsumer;
|
|
1807
|
+
try {
|
|
1808
|
+
resumeConsumer = await suspend?.();
|
|
1809
|
+
} catch {
|
|
1810
|
+
}
|
|
1811
|
+
try {
|
|
1812
|
+
ui.notify("In Feishu, open the bot's private chat and send any message (for example hello). Pie will detect that P2P chat; it will not send anything.", "info");
|
|
1813
|
+
let found;
|
|
1814
|
+
const detect = await run(binary, withProfile(["event", "consume", "im.message.receive_v1", "--as", "bot"]), 9e4, cwd, { signal, allowEmpty: true, stdin: "pipe" }, false, "In Feishu, open the bot's private chat and send any message. Waiting up to 90 seconds for the DM chat to be detected; nothing is sent by Pie.", (line) => {
|
|
1815
|
+
const event = parseDirectMessage(line);
|
|
1816
|
+
if (!event) return false;
|
|
1817
|
+
found = event;
|
|
1818
|
+
return true;
|
|
1819
|
+
});
|
|
1820
|
+
if (!current()) return;
|
|
1821
|
+
if (!found) {
|
|
1822
|
+
const bus = /another event bus|already connected/i.test(`${detect.stdout}
|
|
1823
|
+
${detect.error ?? ""}`);
|
|
1824
|
+
ui.notify(bus ? "No direct message detected: the app's event connection is still occupied (another consumer on this or another machine). Stop it, or bind a different app/profile, then retry." : "No direct message detected. Check the bot event subscription, try again, or enter the user's open_id.", "warning");
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1827
|
+
target = { chatId: found.chatId, allowUsers: [found.senderId] };
|
|
1828
|
+
break;
|
|
1829
|
+
} finally {
|
|
1830
|
+
try {
|
|
1831
|
+
resumeConsumer?.();
|
|
1832
|
+
} catch {
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
const input = await ui.input("IM forwarding setup \u2014 direct-message user open_id", "ou_\u2026 (not a chat_id)", options);
|
|
1837
|
+
if (!current() || !input?.trim()) continue;
|
|
1838
|
+
const openId = input.trim();
|
|
1839
|
+
if (!/^ou_[A-Za-z0-9_-]+$/.test(openId)) {
|
|
1840
|
+
ui.notify("Invalid user open_id; expected ou_ followed by letters, digits, _ or -. Nothing was saved.", "error");
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1843
|
+
target = { openId, allowUsers: [openId] };
|
|
1844
|
+
break;
|
|
1845
|
+
}
|
|
1846
|
+
if (choice === manual) {
|
|
1847
|
+
const input = await ui.input("IM forwarding setup \u2014 chat_id", "oc_\u2026", options);
|
|
1848
|
+
if (!current() || !input?.trim()) continue;
|
|
1849
|
+
const chatId = input.trim();
|
|
1850
|
+
if (!/^oc_[A-Za-z0-9_-]+$/.test(chatId)) {
|
|
1851
|
+
ui.notify("Invalid chat_id; expected oc_ followed by letters, digits, _ or -. Nothing was saved.", "error");
|
|
1852
|
+
continue;
|
|
1853
|
+
}
|
|
1854
|
+
target = { chatId };
|
|
1855
|
+
break;
|
|
1856
|
+
}
|
|
1857
|
+
const group = chats?.[labels.indexOf(choice)]?.id;
|
|
1858
|
+
if (group) {
|
|
1859
|
+
target = { chatId: group };
|
|
1860
|
+
break;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
if (!current() || !target) return;
|
|
1864
|
+
const boundAppId = profiles?.find((item) => item.name === profileName)?.appId ?? (profileName ? void 0 : profiles?.find((item) => item.active)?.appId);
|
|
1865
|
+
return { ...previous ?? {}, ...target.openId ? { openId: target.openId, chatId: null } : { chatId: target.chatId, openId: null }, larkCli: executable ?? null, larkCliProfile: profileName ?? null, larkCliAppId: boundAppId ?? null, allowUsers: target.allowUsers ?? previous?.allowUsers ?? null };
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// builtin/extensions/im-forward/providers/feishu/index.ts
|
|
1869
|
+
function normalizeEvent(line) {
|
|
1870
|
+
let raw;
|
|
1871
|
+
try {
|
|
1872
|
+
raw = JSON.parse(line);
|
|
1873
|
+
} catch {
|
|
1874
|
+
return null;
|
|
1875
|
+
}
|
|
1876
|
+
if (!isRecord(raw) || raw.type !== "im.message.receive_v1" || typeof raw.event_id !== "string" || typeof raw.chat_id !== "string") return null;
|
|
1877
|
+
const id = raw.message_id ?? raw.id;
|
|
1878
|
+
if (typeof id !== "string") return null;
|
|
1879
|
+
return {
|
|
1880
|
+
event_id: raw.event_id,
|
|
1881
|
+
message_id: id,
|
|
1882
|
+
chat_id: raw.chat_id,
|
|
1883
|
+
sender_id: typeof raw.sender_id === "string" ? raw.sender_id : void 0,
|
|
1884
|
+
chatType: raw.chat_type === void 0 ? void 0 : typeof raw.chat_type === "string" ? raw.chat_type : "invalid",
|
|
1885
|
+
text: extractInboundText(raw.content).replace(/\[Image:\s*img_[A-Za-z0-9_-]+\]/g, "").trim(),
|
|
1886
|
+
images: extractImageKeys(raw.content)
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
function connect(binary, profile, targetKind = "chat") {
|
|
1890
|
+
const lifetime = new AbortController();
|
|
1891
|
+
const active = /* @__PURE__ */ new Set();
|
|
1892
|
+
const native = (...args) => {
|
|
1893
|
+
const pending = runLarkCli(...args);
|
|
1894
|
+
active.add(pending);
|
|
1895
|
+
void pending.finally(() => active.delete(pending));
|
|
1896
|
+
return pending;
|
|
1897
|
+
};
|
|
1898
|
+
const call = async (args) => {
|
|
1899
|
+
const result = await native(binary, profile ? [...args, "--profile", profile] : args, 3e4, void 0, { signal: lifetime.signal });
|
|
1900
|
+
return result.error ? void 0 : parseLarkCliEnvelope(result.stdout).data;
|
|
1901
|
+
};
|
|
1902
|
+
return {
|
|
1903
|
+
sourcePrefix: "[Feishu]",
|
|
1904
|
+
async dispose() {
|
|
1905
|
+
lifetime.abort();
|
|
1906
|
+
await Promise.allSettled([...active]);
|
|
1907
|
+
},
|
|
1908
|
+
async send(target, text, key, replyTo) {
|
|
1909
|
+
const recipient = targetKind === "user" ? ["--user-id", target] : ["--chat-id", target];
|
|
1910
|
+
const data = await call(replyTo ? ["im", "+messages-reply", "--as", "bot", "--message-id", replyTo, "--markdown", text, "--idempotency-key", key] : ["im", "+messages-send", "--as", "bot", ...recipient, "--markdown", text, "--idempotency-key", key]);
|
|
1911
|
+
return typeof data?.message_id === "string" ? { messageId: data.message_id, threadId: typeof data.thread_id === "string" ? data.thread_id : null } : null;
|
|
1912
|
+
},
|
|
1913
|
+
async acknowledge(id) {
|
|
1914
|
+
await call(["im", "reactions", "create", "--as", "bot", "--params", JSON.stringify({ message_id: id }), "--data", JSON.stringify({ reaction_type: { emoji_type: "THUMBSUP" } })]);
|
|
1915
|
+
},
|
|
1916
|
+
async metadata(id) {
|
|
1917
|
+
const data = await call(["im", "+messages-mget", "--as", "bot", "--message-ids", id]);
|
|
1918
|
+
const message = Array.isArray(data?.messages) ? data.messages[0] : void 0;
|
|
1919
|
+
return isRecord(message) ? { threadId: typeof message.thread_id === "string" && message.thread_id || null, replyTo: typeof message.reply_to === "string" && message.reply_to || null } : null;
|
|
1920
|
+
},
|
|
1921
|
+
async threadMessages(id) {
|
|
1922
|
+
const data = await call(["im", "+threads-messages-list", "--as", "bot", "--thread", id]);
|
|
1923
|
+
return Array.isArray(data?.messages) ? data.messages.flatMap((message) => isRecord(message) && typeof message.message_id === "string" ? [message.message_id] : []) : null;
|
|
1924
|
+
},
|
|
1925
|
+
async downloadImage(messageId, image, index) {
|
|
1926
|
+
const directory = fs4.mkdtempSync(path6.join(os2.tmpdir(), "pie-im-forward-"));
|
|
1927
|
+
const cleanup = () => fs4.rmSync(directory, { recursive: true, force: true });
|
|
1928
|
+
try {
|
|
1929
|
+
const name = `image-${index + 1}.png`;
|
|
1930
|
+
const result = await native(binary, profile ? ["im", "+messages-resources-download", "--as", "bot", "--message-id", messageId, "--file-key", image, "--type", "image", "--output", name, "--profile", profile] : ["im", "+messages-resources-download", "--as", "bot", "--message-id", messageId, "--file-key", image, "--type", "image", "--output", name], 3e4, directory, { signal: lifetime.signal });
|
|
1931
|
+
if (result.error || !parseLarkCliEnvelope(result.stdout).ok) throw new Error("Image download failed");
|
|
1932
|
+
let target = path6.join(directory, name);
|
|
1933
|
+
if (fs4.statSync(target).size > 10 * 1024 * 1024) throw new Error("Image exceeds 10 MiB");
|
|
1934
|
+
const detected = readImageFormat(target);
|
|
1935
|
+
if (detected.ext === ".bin") throw new Error("Unsupported image format");
|
|
1936
|
+
if (detected.ext !== ".png") {
|
|
1937
|
+
const corrected = path6.join(directory, `image-${index + 1}${detected.ext}`);
|
|
1938
|
+
fs4.renameSync(target, corrected);
|
|
1939
|
+
target = corrected;
|
|
1940
|
+
}
|
|
1941
|
+
return { file: { path: target, displayName: `feishu-image-${index + 1}${detected.ext}` }, cleanup };
|
|
1942
|
+
} catch (error) {
|
|
1943
|
+
cleanup();
|
|
1944
|
+
throw error;
|
|
1945
|
+
}
|
|
1946
|
+
},
|
|
1947
|
+
consume(callbacks) {
|
|
1948
|
+
let stopped = false;
|
|
1949
|
+
let closed = false;
|
|
1950
|
+
let failed = false;
|
|
1951
|
+
let lastErrorLine = "";
|
|
1952
|
+
const child = spawn4(binary.command, [...binary.prefix, "event", "consume", "im.message.receive_v1", "--as", "bot", ...profile ? ["--profile", profile] : []], { stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, detached: process.platform !== "win32" });
|
|
1953
|
+
const fail = (reason) => {
|
|
1954
|
+
if (!failed && !stopped) {
|
|
1955
|
+
failed = true;
|
|
1956
|
+
callbacks.failure(lastErrorLine ? `${reason} \u2014 ${lastErrorLine}` : reason);
|
|
1957
|
+
}
|
|
1958
|
+
};
|
|
1959
|
+
child.stdin?.on("error", () => void 0);
|
|
1960
|
+
const output = lineReader((line) => {
|
|
1961
|
+
if (closed) return;
|
|
1962
|
+
const event = normalizeEvent(line);
|
|
1963
|
+
if (event) callbacks.event(event);
|
|
1964
|
+
});
|
|
1965
|
+
const safeErrorPatterns = [
|
|
1966
|
+
/another event bus is already connected/i,
|
|
1967
|
+
/no active profile/i,
|
|
1968
|
+
/not configured/i,
|
|
1969
|
+
/authorization|unauthorized|token/i,
|
|
1970
|
+
/permission/i,
|
|
1971
|
+
/connect|network|dns|timeout/i
|
|
1972
|
+
];
|
|
1973
|
+
const errors = lineReader((line) => {
|
|
1974
|
+
const text = line.trim();
|
|
1975
|
+
if (!text) return;
|
|
1976
|
+
if (text.startsWith("[event] ready")) {
|
|
1977
|
+
if (!failed && !stopped) callbacks.ready();
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (safeErrorPatterns.some((pattern) => pattern.test(text)) && text.length <= 200) lastErrorLine = text;
|
|
1981
|
+
}, 4096);
|
|
1982
|
+
child.stdout?.on("data", output.write);
|
|
1983
|
+
child.stdout?.on("end", output.end);
|
|
1984
|
+
child.stderr?.on("data", errors.write);
|
|
1985
|
+
child.stderr?.on("end", errors.end);
|
|
1986
|
+
child.on("error", () => fail("Feishu consumer spawn failed"));
|
|
1987
|
+
child.on("exit", (code, signal) => fail(`Feishu consumer code=${code} signal=${signal}`));
|
|
1988
|
+
child.on("close", (code, signal) => {
|
|
1989
|
+
closed = true;
|
|
1990
|
+
fail(`Feishu consumer code=${code} signal=${signal}`);
|
|
1991
|
+
});
|
|
1992
|
+
return { async stop() {
|
|
1993
|
+
stopped = true;
|
|
1994
|
+
if (!closed) await shutdownConsumer(child);
|
|
1995
|
+
} };
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
var feishuProvider = {
|
|
2000
|
+
id: "feishu",
|
|
2001
|
+
label: "Feishu (Lark)",
|
|
2002
|
+
resolve(raw) {
|
|
2003
|
+
const chatId = typeof raw.chatId === "string" ? raw.chatId.trim() : "";
|
|
2004
|
+
const openId = typeof raw.openId === "string" ? raw.openId.trim() : "";
|
|
2005
|
+
const targetKind = /^oc_[A-Za-z0-9_-]+$/.test(chatId) ? "chat" : /^ou_[A-Za-z0-9_-]+$/.test(openId) ? "user" : void 0;
|
|
2006
|
+
if (!targetKind) return null;
|
|
2007
|
+
const allowed = Array.isArray(raw.allowUsers) ? raw.allowUsers.filter((id) => typeof id === "string" && !!id.trim()) : void 0;
|
|
2008
|
+
const appId = typeof raw.larkCliAppId === "string" && raw.larkCliAppId.trim() ? raw.larkCliAppId.trim() : void 0;
|
|
2009
|
+
return { ...raw, targetId: targetKind === "user" ? openId : chatId, targetKind, allowUsers: allowed?.length ? allowed : void 0, ...appId ? { appId } : {} };
|
|
2010
|
+
},
|
|
2011
|
+
connect(settings, cwd) {
|
|
2012
|
+
const binary = resolveLaunchSpec(typeof settings.larkCli === "string" ? settings.larkCli : void 0, cwd);
|
|
2013
|
+
const profile = typeof settings.larkCliProfile === "string" && settings.larkCliProfile.trim() ? settings.larkCliProfile.trim() : void 0;
|
|
2014
|
+
return binary ? connect(binary, profile, settings.targetKind) : null;
|
|
2015
|
+
},
|
|
2016
|
+
setup: setupFeishu
|
|
2017
|
+
};
|
|
2018
|
+
|
|
2019
|
+
// builtin/extensions/im-forward/index.ts
|
|
2020
|
+
var implementedProviders = [feishuProvider];
|
|
2021
|
+
function imForward(ctx) {
|
|
2022
|
+
initializeImForward(ctx);
|
|
2023
|
+
}
|
|
2024
|
+
function initializeImForward(ctx, providers = implementedProviders) {
|
|
2025
|
+
if (!ctx.hasUI) return;
|
|
2026
|
+
const dir = resolveConfigDir();
|
|
2027
|
+
let disposed = false;
|
|
2028
|
+
let session = null;
|
|
2029
|
+
let setupAbort;
|
|
2030
|
+
let setupFlight;
|
|
2031
|
+
let activeConfig;
|
|
2032
|
+
let runtimeCleanup;
|
|
2033
|
+
let suspendEventConsumer;
|
|
2034
|
+
let toggle;
|
|
2035
|
+
let status;
|
|
2036
|
+
let handlers = /* @__PURE__ */ new Map();
|
|
2037
|
+
const events = ["terminal:input", "message:end", "turn:failed", "turn:cancelled", "session:changed"];
|
|
2038
|
+
for (const name of events) ctx.on(name, (event) => {
|
|
2039
|
+
if (disposed) return;
|
|
2040
|
+
if (name === "session:changed") {
|
|
2041
|
+
session = event.sessionId;
|
|
2042
|
+
setupAbort?.abort();
|
|
2043
|
+
}
|
|
2044
|
+
return handlers.get(name)?.(event);
|
|
2045
|
+
});
|
|
2046
|
+
let warnedMismatch = false;
|
|
2047
|
+
function liveEnabled(config) {
|
|
2048
|
+
if (disposed) return false;
|
|
2049
|
+
const live = readLiveConfig(ctx.cwd, dir);
|
|
2050
|
+
if (live.problems.length || !live.config || configurationIdentity(live.config) !== configurationIdentity(config)) {
|
|
2051
|
+
ctx.ui.setStatus?.("im-forward", "[IM!]");
|
|
2052
|
+
if (!warnedMismatch) {
|
|
2053
|
+
warnedMismatch = true;
|
|
2054
|
+
ctx.ui.notify("IM forwarding configuration changed or cannot be read. Forwarding is paused; run /im-forward setup.", "warning");
|
|
2055
|
+
}
|
|
2056
|
+
return null;
|
|
2057
|
+
}
|
|
2058
|
+
return live.config.enabled;
|
|
2059
|
+
}
|
|
2060
|
+
function activate(config) {
|
|
2061
|
+
if (disposed) return;
|
|
2062
|
+
const provider = providers.find((item) => item.id === config.provider);
|
|
2063
|
+
if (!provider) {
|
|
2064
|
+
ctx.ui.notify(`IM forwarding: unsupported provider "${config.provider}". Run /im-forward setup.`, "error");
|
|
2065
|
+
ctx.ui.setStatus?.("im-forward", "[IM!]");
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
try {
|
|
2069
|
+
const settings = provider.resolve(config.settings);
|
|
2070
|
+
if (!settings) {
|
|
2071
|
+
ctx.ui.notify(`IM forwarding: ${provider.label} target/settings are incomplete. Use Ctrl+I or /im-forward setup.`, "warning");
|
|
2072
|
+
ctx.ui.setStatus?.("im-forward", "[IM!]");
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
const connection = provider.connect(settings, ctx.cwd);
|
|
2076
|
+
if (!connection) {
|
|
2077
|
+
ctx.ui.notify(`IM forwarding: ${provider.label} connection could not be prepared. Use Ctrl+I or /im-forward setup to check provider dependencies and configuration.`, "warning");
|
|
2078
|
+
ctx.ui.setStatus?.("im-forward", "[IM!]");
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
activeConfig = config;
|
|
2082
|
+
warnedMismatch = false;
|
|
2083
|
+
const bridge = Object.create(ctx);
|
|
2084
|
+
bridge.on = (name, handler) => {
|
|
2085
|
+
handlers.set(name, handler);
|
|
2086
|
+
};
|
|
2087
|
+
bridge.registerShortcut = (_key, shortcut) => {
|
|
2088
|
+
toggle = shortcut.handler;
|
|
2089
|
+
};
|
|
2090
|
+
bridge.registerCommand = (command) => {
|
|
2091
|
+
status = command.handler;
|
|
2092
|
+
};
|
|
2093
|
+
bridge.registerCleanup = (cleanup) => {
|
|
2094
|
+
runtimeCleanup = async () => {
|
|
2095
|
+
await cleanup("runtime_switch", AbortSignal.abort());
|
|
2096
|
+
};
|
|
2097
|
+
return () => {
|
|
2098
|
+
};
|
|
2099
|
+
};
|
|
2100
|
+
createRuntime(bridge, { enabled: config.enabled, provider: config.provider, label: provider.label, targetId: settings.targetId, targetKind: settings.targetKind, allowUsers: settings.allowUsers, retryBackoffMs: config.retryBackoffMs, ...settings.appId ? { appId: settings.appId } : {} }, connection, () => liveEnabled(config), (next) => {
|
|
2101
|
+
const latest = readLiveConfig(ctx.cwd, dir);
|
|
2102
|
+
if (latest.problems.length || !latest.config || configurationIdentity(latest.config) !== configurationIdentity(config)) throw new Error("Configuration changed; setup required");
|
|
2103
|
+
writeConfig(ctx.cwd, dir, { enabled: next });
|
|
2104
|
+
});
|
|
2105
|
+
if (session) handlers.get("session:changed")?.({ type: "session:changed", sessionId: session });
|
|
2106
|
+
ctx.__forceInboundRetriesForTest = bridge.__forceInboundRetriesForTest;
|
|
2107
|
+
suspendEventConsumer = bridge.suspendEventConsumer?.bind(bridge);
|
|
2108
|
+
} catch {
|
|
2109
|
+
ctx.ui.notify(`IM forwarding: ${provider.id} initialization failed; check provider configuration/authentication.`, "error");
|
|
2110
|
+
ctx.ui.setStatus?.("im-forward", "[IM!]");
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
async function retire() {
|
|
2114
|
+
handlers = /* @__PURE__ */ new Map();
|
|
2115
|
+
toggle = void 0;
|
|
2116
|
+
status = void 0;
|
|
2117
|
+
activeConfig = void 0;
|
|
2118
|
+
suspendEventConsumer = void 0;
|
|
2119
|
+
const cleanup = runtimeCleanup;
|
|
2120
|
+
runtimeCleanup = void 0;
|
|
2121
|
+
ctx.ui.setStatus?.("im-forward", void 0);
|
|
2122
|
+
try {
|
|
2123
|
+
await cleanup?.();
|
|
2124
|
+
} catch {
|
|
2125
|
+
ctx.log("im-forward: runtime cleanup failed");
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
function setup(intent = "first-run") {
|
|
2129
|
+
if (setupFlight) return setupFlight;
|
|
2130
|
+
if (disposed) return Promise.resolve();
|
|
2131
|
+
const controller = new AbortController();
|
|
2132
|
+
setupAbort = controller;
|
|
2133
|
+
const setupScope = ctx.beginUserInput?.(() => !disposed && !controller.signal.aborted);
|
|
2134
|
+
controller.signal.addEventListener("abort", () => setupScope?.dispose(), { once: true });
|
|
2135
|
+
const current = () => {
|
|
2136
|
+
if (setupScope && !setupScope.isCurrent()) controller.abort();
|
|
2137
|
+
return !disposed && !controller.signal.aborted;
|
|
2138
|
+
};
|
|
2139
|
+
setupFlight = (async () => {
|
|
2140
|
+
try {
|
|
2141
|
+
const config = await runSetup(ctx, dir, providers, controller.signal, current, intent, suspendEventConsumer);
|
|
2142
|
+
if (!config || disposed) return;
|
|
2143
|
+
await retire();
|
|
2144
|
+
if (!disposed) {
|
|
2145
|
+
const latest = resolveConfig(ctx.cwd, dir);
|
|
2146
|
+
if (latest.problems.length || !latest.config) {
|
|
2147
|
+
ctx.ui.setStatus?.("im-forward", "[IM!]");
|
|
2148
|
+
ctx.ui.notify("IM forwarding configuration cannot be read; run /im-forward setup.", "error");
|
|
2149
|
+
} else activate(latest.config);
|
|
2150
|
+
}
|
|
2151
|
+
} catch {
|
|
2152
|
+
if (!disposed && !controller.signal.aborted) ctx.ui.notify("IM forwarding setup could not complete; check configuration path permissions and provider authentication.", "error");
|
|
2153
|
+
} finally {
|
|
2154
|
+
setupScope?.dispose();
|
|
2155
|
+
setupFlight = void 0;
|
|
2156
|
+
if (setupAbort === controller) setupAbort = void 0;
|
|
2157
|
+
}
|
|
2158
|
+
})();
|
|
2159
|
+
return setupFlight;
|
|
2160
|
+
}
|
|
2161
|
+
async function toggleNow(commandCtx) {
|
|
2162
|
+
if (!toggle || !activeConfig) {
|
|
2163
|
+
await setup();
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
const latest = resolveConfig(ctx.cwd, dir);
|
|
2167
|
+
if (!latest.config || latest.problems.length || configurationIdentity(latest.config) !== configurationIdentity(activeConfig)) {
|
|
2168
|
+
commandCtx.ui.notify("IM forwarding configuration changed. Run /im-forward setup before forwarding.", "warning");
|
|
2169
|
+
ctx.ui.setStatus?.("im-forward", "[IM!]");
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
await toggle(commandCtx);
|
|
2173
|
+
}
|
|
2174
|
+
function showStatus(commandCtx) {
|
|
2175
|
+
if (status) status(commandCtx);
|
|
2176
|
+
else commandCtx.ui.notify("IM forwarding is not configured or unavailable. Use Ctrl+I or /im-forward setup.", "info");
|
|
2177
|
+
}
|
|
2178
|
+
async function clearConfiguration(commandCtx) {
|
|
2179
|
+
setupAbort?.abort();
|
|
2180
|
+
try {
|
|
2181
|
+
const removed = await clearImConfig(ctx.cwd, dir, (message) => commandCtx.ui.confirm("Clear IM forwarding configuration", `${message}
|
|
2182
|
+
|
|
2183
|
+
This does not remove the app credentials lark-cli itself stores; re-running setup may reuse the previous app.`));
|
|
2184
|
+
if (disposed) return;
|
|
2185
|
+
if (!removed) {
|
|
2186
|
+
commandCtx.ui.notify(hasConfigLayers(ctx.cwd, dir) ? "IM forwarding configuration was not cleared." : "No IM forwarding configuration files exist.", "info");
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
await retire();
|
|
2190
|
+
commandCtx.ui.notify("IM forwarding configuration cleared. Use /im-forward setup to configure again.", "info");
|
|
2191
|
+
} catch (error) {
|
|
2192
|
+
ctx.ui.setStatus?.("im-forward", "[IM!]");
|
|
2193
|
+
commandCtx.ui.notify(error.message || "IM forwarding configuration could not be cleared.", "error");
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
ctx.registerShortcut("ctrl+i", { description: "Set up or toggle IM forwarding", handler: () => toggleNow(ctx) });
|
|
2197
|
+
ctx.registerCommand({ path: ["im-forward", "status"], description: "Show IM forwarding status", handler: async (commandCtx) => {
|
|
2198
|
+
if (!disposed) showStatus(commandCtx);
|
|
2199
|
+
} });
|
|
2200
|
+
ctx.registerCommand({ path: ["im-forward", "switch-app"], description: "Rebind the configured platform to a different app/account", handler: async () => {
|
|
2201
|
+
if (!disposed) await setup("switch-app");
|
|
2202
|
+
} });
|
|
2203
|
+
ctx.registerCommand({ path: ["im-forward", "setup"], description: "Run the IM forwarding setup wizard", handler: async () => {
|
|
2204
|
+
if (!disposed) await setup();
|
|
2205
|
+
} });
|
|
2206
|
+
ctx.registerCommand({ path: ["im-forward", "toggle"], description: "Toggle IM forwarding on/off (same as Ctrl+I)", handler: async (commandCtx) => {
|
|
2207
|
+
if (!disposed) await toggleNow(commandCtx);
|
|
2208
|
+
} });
|
|
2209
|
+
ctx.registerCommand({ path: ["im-forward", "clear"], description: "Remove IM forwarding configuration (with confirmation)", handler: async (commandCtx) => {
|
|
2210
|
+
if (!disposed) await clearConfiguration(commandCtx);
|
|
2211
|
+
} });
|
|
2212
|
+
ctx.registerCommand({ name: "im-forward", path: ["im-forward"], description: "IM forwarding menu (status/setup/toggle/clear)", handler: async (commandCtx, args) => {
|
|
2213
|
+
if (disposed) return;
|
|
2214
|
+
const arg = args?.trim();
|
|
2215
|
+
if (arg === "status") {
|
|
2216
|
+
showStatus(commandCtx);
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
if (arg === "setup") {
|
|
2220
|
+
await setup();
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
if (arg === "switch-app") {
|
|
2224
|
+
await setup("switch-app");
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
if (arg === "toggle") {
|
|
2228
|
+
await toggleNow(commandCtx);
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
if (arg === "clear") {
|
|
2232
|
+
await clearConfiguration(commandCtx);
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
if (arg) {
|
|
2236
|
+
commandCtx.ui.notify("Usage: /im-forward [status|setup|switch-app|toggle|clear]; bare /im-forward opens the menu", "info");
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
const options = toggle && activeConfig ? ["Status", "Setup\u2026", "Switch app\u2026", "Toggle forwarding on/off", "Clear configuration\u2026"] : hasConfigLayers(ctx.cwd, dir) ? ["Status", "Setup\u2026", "Switch app\u2026", "Clear configuration\u2026"] : ["Setup\u2026"];
|
|
2240
|
+
const choice = await commandCtx.ui.select("IM forwarding", options);
|
|
2241
|
+
if (disposed || !choice) return;
|
|
2242
|
+
if (choice === "Status") showStatus(commandCtx);
|
|
2243
|
+
else if (choice === "Setup\u2026") await setup();
|
|
2244
|
+
else if (choice === "Switch app\u2026") await setup("switch-app");
|
|
2245
|
+
else if (choice === "Toggle forwarding on/off") await toggleNow(commandCtx);
|
|
2246
|
+
else if (choice === "Clear configuration\u2026") await clearConfiguration(commandCtx);
|
|
2247
|
+
} });
|
|
2248
|
+
ctx.registerCleanup?.(async () => {
|
|
2249
|
+
disposed = true;
|
|
2250
|
+
setupAbort?.abort();
|
|
2251
|
+
await retire();
|
|
2252
|
+
ctx.ui.setStatus?.("im-forward", void 0);
|
|
2253
|
+
});
|
|
2254
|
+
const initial = resolveConfig(ctx.cwd, dir);
|
|
2255
|
+
if (initial.problems.length) {
|
|
2256
|
+
ctx.ui.notify(initial.problems.join("\n"), "error");
|
|
2257
|
+
ctx.ui.setStatus?.("im-forward", "[IM!]");
|
|
2258
|
+
} else if (initial.config) activate(initial.config);
|
|
2259
|
+
}
|
|
2260
|
+
export {
|
|
2261
|
+
assistantForwardText,
|
|
2262
|
+
composeForwardBody,
|
|
2263
|
+
imForward as default,
|
|
2264
|
+
detectImageMime,
|
|
2265
|
+
estimatePostRequestBytes,
|
|
2266
|
+
extractImageKey,
|
|
2267
|
+
extractImageKeys,
|
|
2268
|
+
extractInboundText,
|
|
2269
|
+
failureReason,
|
|
2270
|
+
initializeImForward,
|
|
2271
|
+
inputHeaderLine,
|
|
2272
|
+
parseLarkCliEnvelope,
|
|
2273
|
+
readLiveConfig,
|
|
2274
|
+
readLiveEnabled,
|
|
2275
|
+
resolveImForwardConfig,
|
|
2276
|
+
sessionLabel,
|
|
2277
|
+
truncateMarkdown
|
|
2278
|
+
};
|