@rynx-ai/plugin-channel-lark 0.1.9
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/dist/cli-mirror/mirror-index.d.ts +46 -0
- package/dist/cli-mirror/mirror-index.js +48 -0
- package/dist/cli-mirror/rollout-mirror.d.ts +85 -0
- package/dist/cli-mirror/rollout-mirror.js +333 -0
- package/dist/cli-mirror/rollout-watcher.d.ts +109 -0
- package/dist/cli-mirror/rollout-watcher.js +347 -0
- package/dist/host-adapters.d.ts +82 -0
- package/dist/host-adapters.js +404 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/lark-card-stream.d.ts +363 -0
- package/dist/lark-card-stream.js +1335 -0
- package/dist/lark-content.d.ts +30 -0
- package/dist/lark-content.js +138 -0
- package/dist/lark-conversation-directory.d.ts +65 -0
- package/dist/lark-conversation-directory.js +106 -0
- package/dist/lark-diff-card.d.ts +25 -0
- package/dist/lark-diff-card.js +58 -0
- package/dist/lark-fork-transcript.d.ts +16 -0
- package/dist/lark-fork-transcript.js +141 -0
- package/dist/lark-resume-card.d.ts +31 -0
- package/dist/lark-resume-card.js +105 -0
- package/dist/lark-sdk/client.d.ts +8 -0
- package/dist/lark-sdk/client.js +32 -0
- package/dist/lark-sdk/index.d.ts +1 -0
- package/dist/lark-sdk/index.js +1 -0
- package/dist/lark-sender.d.ts +114 -0
- package/dist/lark-sender.js +323 -0
- package/dist/lark-session-store.d.ts +13 -0
- package/dist/lark-session-store.js +14 -0
- package/dist/lark-settings-card.d.ts +55 -0
- package/dist/lark-settings-card.js +158 -0
- package/dist/lark-setup.d.ts +17 -0
- package/dist/lark-setup.js +86 -0
- package/dist/lark.d.ts +500 -0
- package/dist/lark.js +2010 -0
- package/dist/runtime.d.ts +16 -0
- package/dist/runtime.js +157 -0
- package/dist/runtime.mjs +130863 -0
- package/package.json +35 -0
- package/rynx-plugin.json +42 -0
package/dist/lark.js
ADDED
|
@@ -0,0 +1,2010 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { CodexRuntimeError, collectLiveRun, } from "@rynx-ai/core";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
import { LarkSessionCardController, } from "./lark-card-stream.js";
|
|
9
|
+
import { buildLarkCardReplyPrompt, chunkLarkReplyText, extractLarkTextPrompt, isLarkNewSessionCommand, isLarkStopCommand, parseLarkCommand, } from "./lark-content.js";
|
|
10
|
+
import { deriveConversationShortId, MemoryLarkConversationDirectory, } from "./lark-conversation-directory.js";
|
|
11
|
+
import { buildDiffCardJson } from "./lark-diff-card.js";
|
|
12
|
+
import { buildForkTranscript } from "./lark-fork-transcript.js";
|
|
13
|
+
import { buildChatApplink, buildOpenChatCardJson, buildResumeCardJson, } from "./lark-resume-card.js";
|
|
14
|
+
import { LarkClientProvider, LarkSdkMessageSender } from "./lark-sender.js";
|
|
15
|
+
import { buildSettingsCard, buildSettingsCardJson, buildSettingsResultCard, SETTINGS_DEFAULT_VALUE, SETTINGS_EFFORT_VALUES, } from "./lark-settings-card.js";
|
|
16
|
+
import { getRuntimeProfile, isAgentRuntimeId, RUNTIME_PROFILES, } from "@rynx-ai/core";
|
|
17
|
+
import { SteerController } from "@rynx-ai/core";
|
|
18
|
+
import { toErrorMessage } from "@rynx-ai/core";
|
|
19
|
+
import { findInnermostAllowedRoot } from "@rynx-ai/core";
|
|
20
|
+
export { chunkLarkReplyText, extractLarkTextPrompt, isLarkNewSessionCommand, isLarkStopCommand, };
|
|
21
|
+
export { LarkClientProvider, LarkSdkMessageSender };
|
|
22
|
+
const DEFAULT_UNSUPPORTED_MESSAGE = "暂时只支持文本消息,请发送文本内容。";
|
|
23
|
+
const DEFAULT_EMPTY_MESSAGE = "请发送文本内容。";
|
|
24
|
+
const DEFAULT_EMPTY_RESPONSE = "抱歉,我暂时没有生成回复。";
|
|
25
|
+
const DEFAULT_NEW_SESSION_RESPONSE = "已开启新会话。";
|
|
26
|
+
const DEFAULT_STOP_NO_INFLIGHT_RESPONSE = "当前没有正在进行的任务。";
|
|
27
|
+
const DEFAULT_STOP_ACK_RESPONSE = "已停止当前任务。";
|
|
28
|
+
const LARK_WORK_STARTED_REACTION = "Get";
|
|
29
|
+
const LARK_WORK_COMPLETED_REACTION = "DONE";
|
|
30
|
+
/** Reaction added to a message whose text was steered into the running turn. */
|
|
31
|
+
const LARK_STEER_ACK_REACTION = "Get";
|
|
32
|
+
const LARK_COMMANDS = [
|
|
33
|
+
{ name: "new", help: "/new — 开启新会话", category: "takeover" },
|
|
34
|
+
{ name: "stop", help: "/stop 或 /cancel — 停止当前正在进行的任务", category: "other" },
|
|
35
|
+
{ name: "runtime", help: "/runtime — 卡片切换运行时(codex/traex/claude)/ 模型 / 思考强度", category: "other" },
|
|
36
|
+
{ name: "model", help: "/model — 卡片切换当前运行时的模型 / 思考强度", category: "other" },
|
|
37
|
+
{ name: "agent", help: "/agent [名称] — 列出/切换声明式 agent(运行时+模型+提示+权限的预设)", category: "other" },
|
|
38
|
+
{ name: "models", help: "/models — 列出当前运行时可用模型", category: "info" },
|
|
39
|
+
{ name: "resume", help: "/resume [短id] — 列出最近会话/跳转或转发到指定会话", category: "takeover" },
|
|
40
|
+
{ name: "fork", help: "/fork — 把当前会话 fork 到新群/新话题(带上下文+历史记录)", category: "takeover" },
|
|
41
|
+
{ name: "cwd", help: "/cwd [目录] — 查看/绑定当前会话的工作目录(不存在则新建)", category: "takeover" },
|
|
42
|
+
{ name: "goal", help: "/goal [目标|clear] — 查看/设置/清除当前会话目标", category: "action" },
|
|
43
|
+
{ name: "diff", help: "/diff — 显示当前工作区 git 改动(含未跟踪文件)", category: "action" },
|
|
44
|
+
{ name: "help", help: "/help — 显示本帮助", category: "info" },
|
|
45
|
+
];
|
|
46
|
+
function larkCommandNames(category) {
|
|
47
|
+
return new Set(LARK_COMMANDS.filter((c) => c.category === category).map((c) => c.name));
|
|
48
|
+
}
|
|
49
|
+
const LARK_TAKEOVER_COMMANDS = larkCommandNames("takeover");
|
|
50
|
+
const LARK_INFO_COMMANDS = larkCommandNames("info");
|
|
51
|
+
const LARK_ACTION_COMMANDS = larkCommandNames("action");
|
|
52
|
+
const LARK_HELP_TEXT = ["可用命令:", ...LARK_COMMANDS.map((c) => c.help)].join("\n");
|
|
53
|
+
const LARK_BACKEND_UNSUPPORTED = "当前后端不支持该命令(仅 app_server 后端可用)。";
|
|
54
|
+
const LARK_NO_ACTIVE_SESSION = "请先发一条消息开始会话,再使用该命令。";
|
|
55
|
+
const DIFF_REPLY_MAX_BYTES = 60 * 1024;
|
|
56
|
+
/** Max diff body embedded in the `/diff` card (kept under Feishu's card size limit). */
|
|
57
|
+
const DIFF_CARD_MAX_BYTES = 16 * 1024;
|
|
58
|
+
const defaultLogger = {
|
|
59
|
+
log(entry) {
|
|
60
|
+
console.log(JSON.stringify({
|
|
61
|
+
level: "info",
|
|
62
|
+
type: "lark",
|
|
63
|
+
...entry,
|
|
64
|
+
}));
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
/** Route SDK diagnostics through the runner logger instead of stdout, which is
|
|
68
|
+
* reserved for the NDJSON RPC transport in an out-of-process plugin. */
|
|
69
|
+
function larkSdkLogger(logger) {
|
|
70
|
+
const write = (level, messages) => {
|
|
71
|
+
const text = messages
|
|
72
|
+
.map((message) => (message instanceof Error ? message.message : String(message)))
|
|
73
|
+
.join(" ")
|
|
74
|
+
.replace(/[A-Za-z0-9_-]{30,}/g, "***");
|
|
75
|
+
logger.log({ event: `sdk.${level}`, error: text });
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
error: (...messages) => write("error", messages),
|
|
79
|
+
warn: (...messages) => write("warn", messages),
|
|
80
|
+
info: () => undefined,
|
|
81
|
+
debug: () => undefined,
|
|
82
|
+
trace: () => undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export class LarkConversationKeyResolver {
|
|
86
|
+
resolve(event) {
|
|
87
|
+
const tenantKey = event.tenant_key ?? event.sender.tenant_key ?? "unknown-tenant";
|
|
88
|
+
const threadId = event.message.thread_id?.trim();
|
|
89
|
+
if (threadId) {
|
|
90
|
+
return `lark:${tenantKey}:thread:${threadId}`;
|
|
91
|
+
}
|
|
92
|
+
return `lark:${tenantKey}:chat:${event.message.chat_id}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export class PerThreadQueue {
|
|
96
|
+
tails = new Map();
|
|
97
|
+
enqueue(key, task) {
|
|
98
|
+
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
99
|
+
const next = previous
|
|
100
|
+
.catch(() => undefined)
|
|
101
|
+
.then(task);
|
|
102
|
+
const tracked = next.finally(() => {
|
|
103
|
+
if (this.tails.get(key) === tracked) {
|
|
104
|
+
this.tails.delete(key);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
this.tails.set(key, tracked);
|
|
108
|
+
return tracked;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export class LarkMessageDeduper {
|
|
112
|
+
ttlMs;
|
|
113
|
+
entries = new Map();
|
|
114
|
+
constructor(ttlMs = 60 * 60 * 1000) {
|
|
115
|
+
this.ttlMs = ttlMs;
|
|
116
|
+
}
|
|
117
|
+
markIfNew(messageId, now = Date.now()) {
|
|
118
|
+
this.prune(now);
|
|
119
|
+
const expiresAt = this.entries.get(messageId);
|
|
120
|
+
if (expiresAt && expiresAt > now) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
this.entries.set(messageId, now + this.ttlMs);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
prune(now) {
|
|
127
|
+
for (const [messageId, expiresAt] of this.entries) {
|
|
128
|
+
if (expiresAt <= now) {
|
|
129
|
+
this.entries.delete(messageId);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** One-line title for a session's registry row: collapse whitespace, trim, cap
|
|
135
|
+
* at 60 chars + ellipsis. Empty prompt ⇒ undefined (no title). */
|
|
136
|
+
function larkSessionTitle(prompt) {
|
|
137
|
+
const text = prompt.replace(/\s+/g, " ").trim();
|
|
138
|
+
if (!text)
|
|
139
|
+
return undefined;
|
|
140
|
+
return text.length <= 60 ? text : `${text.slice(0, 59)}…`;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Pull a model's supported reasoning efforts from a `model/list` entry,
|
|
144
|
+
* tolerating both the App Server camelCase (`supportedReasoningEfforts`) and the
|
|
145
|
+
* cache/snake (`supported_reasoning_levels`) spellings. Empty when unknown.
|
|
146
|
+
*/
|
|
147
|
+
function extractSupportedEfforts(model) {
|
|
148
|
+
const raw = model.supportedReasoningEfforts ??
|
|
149
|
+
model.supported_reasoning_levels ??
|
|
150
|
+
model.supportedReasoningLevels;
|
|
151
|
+
if (!Array.isArray(raw)) {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
const efforts = [];
|
|
155
|
+
const seen = new Set();
|
|
156
|
+
for (const item of raw) {
|
|
157
|
+
const record = item && typeof item === "object" && !Array.isArray(item)
|
|
158
|
+
? item
|
|
159
|
+
: undefined;
|
|
160
|
+
const effort = typeof item === "string"
|
|
161
|
+
? item.trim()
|
|
162
|
+
: typeof record?.reasoningEffort === "string"
|
|
163
|
+
? record.reasoningEffort.trim()
|
|
164
|
+
: typeof record?.effort === "string"
|
|
165
|
+
? record.effort.trim()
|
|
166
|
+
: "";
|
|
167
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(effort) || seen.has(effort))
|
|
168
|
+
continue;
|
|
169
|
+
seen.add(effort);
|
|
170
|
+
efforts.push(effort);
|
|
171
|
+
}
|
|
172
|
+
return efforts;
|
|
173
|
+
}
|
|
174
|
+
export class LarkMessageIngestor {
|
|
175
|
+
conversationRuntime;
|
|
176
|
+
messageSender;
|
|
177
|
+
/** Default runtime for this bot; per-conversation choices override it. */
|
|
178
|
+
provider;
|
|
179
|
+
/** Default declarative agent for this bot; per-conversation `/agent` overrides it. */
|
|
180
|
+
defaultAgent;
|
|
181
|
+
/** Host-backed agent catalog for `/agent` and execution freezing. */
|
|
182
|
+
agentCatalog;
|
|
183
|
+
/** Transient per-conversation settings-card selections, keyed by sessionKey. */
|
|
184
|
+
pendingSettings = new Map();
|
|
185
|
+
cwd;
|
|
186
|
+
activeSessionStore;
|
|
187
|
+
conversationKeyResolver;
|
|
188
|
+
deduper;
|
|
189
|
+
queue;
|
|
190
|
+
logger;
|
|
191
|
+
cardThrottleMs;
|
|
192
|
+
capabilities;
|
|
193
|
+
conversationDirectory;
|
|
194
|
+
allowedRoots;
|
|
195
|
+
botOpenId;
|
|
196
|
+
disableP2p;
|
|
197
|
+
groupAllowlistEnabled;
|
|
198
|
+
groupAllowlist;
|
|
199
|
+
mirror;
|
|
200
|
+
sessionTitleWriter;
|
|
201
|
+
sessionProvisioner;
|
|
202
|
+
inFlight = new Map();
|
|
203
|
+
constructor({ conversationRuntime, messageSender, cwd, defaultRuntime = "codex", defaultAgent, agentCatalog, activeSessionStore, conversationKeyResolver = new LarkConversationKeyResolver(), deduper = new LarkMessageDeduper(), queue = new PerThreadQueue(), logger = defaultLogger, cardThrottleMs = 250, capabilities, conversationDirectory = new MemoryLarkConversationDirectory(), allowedRoots = [], botOpenId = null, disableP2p = false, groupAllowlistEnabled = false, groupAllowlist = [], mirror, sessionTitleWriter, sessionProvisioner, }) {
|
|
204
|
+
this.conversationRuntime = conversationRuntime;
|
|
205
|
+
this.messageSender = messageSender;
|
|
206
|
+
this.provider = defaultRuntime;
|
|
207
|
+
this.defaultAgent = defaultAgent;
|
|
208
|
+
this.agentCatalog = agentCatalog;
|
|
209
|
+
this.cwd = cwd;
|
|
210
|
+
this.activeSessionStore = activeSessionStore;
|
|
211
|
+
this.conversationKeyResolver = conversationKeyResolver;
|
|
212
|
+
this.deduper = deduper;
|
|
213
|
+
this.queue = queue;
|
|
214
|
+
this.logger = logger;
|
|
215
|
+
this.cardThrottleMs = cardThrottleMs;
|
|
216
|
+
this.capabilities = capabilities;
|
|
217
|
+
this.conversationDirectory = conversationDirectory;
|
|
218
|
+
this.allowedRoots = allowedRoots;
|
|
219
|
+
this.botOpenId = botOpenId ?? null;
|
|
220
|
+
this.disableP2p = disableP2p;
|
|
221
|
+
this.groupAllowlistEnabled = groupAllowlistEnabled;
|
|
222
|
+
this.groupAllowlist = new Set(groupAllowlist);
|
|
223
|
+
this.mirror = mirror;
|
|
224
|
+
this.sessionTitleWriter = sessionTitleWriter;
|
|
225
|
+
this.sessionProvisioner = sessionProvisioner;
|
|
226
|
+
}
|
|
227
|
+
/** Best-effort title update for the daemon-owned machine-session record. */
|
|
228
|
+
registerSession(sessionId, prompt) {
|
|
229
|
+
const title = larkSessionTitle(prompt);
|
|
230
|
+
if (!this.sessionTitleWriter || !title)
|
|
231
|
+
return;
|
|
232
|
+
void this.sessionTitleWriter(sessionId, title).catch((error) => {
|
|
233
|
+
this.logger.log({
|
|
234
|
+
event: "session_meta.title_failed",
|
|
235
|
+
session_id: sessionId,
|
|
236
|
+
error: toErrorMessage(error),
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Set this bot's own `open_id` once it has been resolved asynchronously
|
|
242
|
+
* (the runtime fetches it from the bot info API at startup).
|
|
243
|
+
*/
|
|
244
|
+
setBotOpenId(openId) {
|
|
245
|
+
this.botOpenId = openId ?? null;
|
|
246
|
+
}
|
|
247
|
+
/** Whether a bridge-driven turn is currently running for `sessionKey`. The
|
|
248
|
+
* CLI mirror reads this to skip turns already shown live in Feishu. */
|
|
249
|
+
isInFlight(sessionKey) {
|
|
250
|
+
return this.inFlight.has(sessionKey);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Whether a group message is addressed to *this* bot. With a known
|
|
254
|
+
* `botOpenId`, that means one of the `@`-mentions carries our `open_id` —
|
|
255
|
+
* so in a group hosting several bots, only the one actually mentioned runs.
|
|
256
|
+
* When `botOpenId` is unknown, fall back to "any mention" (legacy single-bot
|
|
257
|
+
* behavior) rather than going silent.
|
|
258
|
+
*/
|
|
259
|
+
isAddressedToBot(event) {
|
|
260
|
+
const mentions = event.message.mentions ?? [];
|
|
261
|
+
if (!this.botOpenId) {
|
|
262
|
+
return mentions.length > 0;
|
|
263
|
+
}
|
|
264
|
+
return mentions.some((mention) => mention.id?.open_id === this.botOpenId);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Handle a Feishu CardKit button click. Supported actions:
|
|
268
|
+
* - `{ kind: "stop_turn", sessionKey }` — the in-card "停止" button.
|
|
269
|
+
* - `{ kind: "resume_forward", threadId, chatId }` — forward a topic thread
|
|
270
|
+
* into `chatId` (from the `/resume` card).
|
|
271
|
+
* Anything else is logged and ignored.
|
|
272
|
+
*/
|
|
273
|
+
async ingestCardAction(event) {
|
|
274
|
+
const value = event.value;
|
|
275
|
+
const kind = typeof value?.kind === "string" ? value.kind : null;
|
|
276
|
+
if (kind === "resume_forward") {
|
|
277
|
+
return this.handleResumeForwardAction(event);
|
|
278
|
+
}
|
|
279
|
+
if (kind === "settings_select") {
|
|
280
|
+
return this.handleSettingsSelect(event);
|
|
281
|
+
}
|
|
282
|
+
if (kind === "settings_apply") {
|
|
283
|
+
return this.handleSettingsApply(event);
|
|
284
|
+
}
|
|
285
|
+
if (kind === "settings_cancel") {
|
|
286
|
+
return this.handleSettingsCancel(event);
|
|
287
|
+
}
|
|
288
|
+
if (kind !== "stop_turn") {
|
|
289
|
+
this.logger.log({
|
|
290
|
+
event: "card.action.unknown",
|
|
291
|
+
kind,
|
|
292
|
+
message_id: event.messageId,
|
|
293
|
+
});
|
|
294
|
+
return {};
|
|
295
|
+
}
|
|
296
|
+
const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
|
|
297
|
+
if (!sessionKey) {
|
|
298
|
+
this.logger.log({
|
|
299
|
+
event: "card.action.missing_session_key",
|
|
300
|
+
message_id: event.messageId,
|
|
301
|
+
});
|
|
302
|
+
return {};
|
|
303
|
+
}
|
|
304
|
+
const aborted = this.interruptSession(sessionKey);
|
|
305
|
+
this.logger.log({
|
|
306
|
+
event: aborted ? "session.stopped" : "session.stop_noop",
|
|
307
|
+
message_id: event.messageId,
|
|
308
|
+
session_key: sessionKey,
|
|
309
|
+
provider: this.provider,
|
|
310
|
+
source: "card_button",
|
|
311
|
+
});
|
|
312
|
+
// No toast — the cancellation appears as a grey blockquote at the bottom
|
|
313
|
+
// of the card via the executor's CodexRuntimeError → finalize path.
|
|
314
|
+
return {};
|
|
315
|
+
}
|
|
316
|
+
async handleResumeForwardAction(event) {
|
|
317
|
+
const value = event.value;
|
|
318
|
+
const sourceThreadId = typeof value?.sourceThreadId === "string" ? value.sourceThreadId : null;
|
|
319
|
+
const targetChatId = typeof value?.targetChatId === "string" ? value.targetChatId : null;
|
|
320
|
+
const targetThreadId = typeof value?.targetThreadId === "string" ? value.targetThreadId : null;
|
|
321
|
+
if (!sourceThreadId || !this.messageSender.forwardThread) {
|
|
322
|
+
return { toast: { type: "error", content: "无法转发该话题。" } };
|
|
323
|
+
}
|
|
324
|
+
// Forward into the current thread when /resume was invoked inside one, so
|
|
325
|
+
// it lands in-place instead of spawning a new thread in the topic group.
|
|
326
|
+
const receiveIdType = targetThreadId ? "thread_id" : "chat_id";
|
|
327
|
+
const receiveId = targetThreadId ?? targetChatId;
|
|
328
|
+
if (!receiveId) {
|
|
329
|
+
return { toast: { type: "error", content: "无法转发该话题。" } };
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
await this.messageSender.forwardThread({
|
|
333
|
+
threadId: sourceThreadId,
|
|
334
|
+
receiveId,
|
|
335
|
+
receiveIdType,
|
|
336
|
+
uuid: randomUUID(),
|
|
337
|
+
});
|
|
338
|
+
this.logger.log({
|
|
339
|
+
event: "resume.forwarded",
|
|
340
|
+
message_id: event.messageId,
|
|
341
|
+
thread_id: sourceThreadId,
|
|
342
|
+
chat_id: receiveId,
|
|
343
|
+
});
|
|
344
|
+
return { toast: { type: "success", content: "已转发到当前会话" } };
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
return { toast: { type: "error", content: `转发失败:${toErrorMessage(error)}` } };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** Where a forward should land for the conversation `event` arrived in:
|
|
351
|
+
* inside the current topic thread when present, else the chat. */
|
|
352
|
+
forwardTargetOf(event) {
|
|
353
|
+
if (event.message.thread_id) {
|
|
354
|
+
return { receiveId: event.message.thread_id, receiveIdType: "thread_id" };
|
|
355
|
+
}
|
|
356
|
+
return { receiveId: event.message.chat_id, receiveIdType: "chat_id" };
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Cancel the in-flight turn for `sessionKey`, if any. Returns true if a
|
|
360
|
+
* turn was actually aborted. Safe to call from outside the per-session
|
|
361
|
+
* queue (that's the whole point — the queue is serialized but interrupt
|
|
362
|
+
* must bypass it).
|
|
363
|
+
*/
|
|
364
|
+
interruptSession(sessionKey) {
|
|
365
|
+
const turn = this.inFlight.get(sessionKey);
|
|
366
|
+
if (!turn) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
turn.abort.abort();
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
registerInFlight(sessionKey) {
|
|
373
|
+
const existing = this.inFlight.get(sessionKey);
|
|
374
|
+
if (existing) {
|
|
375
|
+
// Per-session queue should prevent overlap, but be defensive: abort
|
|
376
|
+
// any prior controller so it doesn't leak.
|
|
377
|
+
existing.abort.abort();
|
|
378
|
+
}
|
|
379
|
+
const turn = {
|
|
380
|
+
abort: new AbortController(),
|
|
381
|
+
steer: new SteerController(),
|
|
382
|
+
};
|
|
383
|
+
this.inFlight.set(sessionKey, turn);
|
|
384
|
+
return turn;
|
|
385
|
+
}
|
|
386
|
+
clearInFlight(sessionKey, turn) {
|
|
387
|
+
if (this.inFlight.get(sessionKey) === turn) {
|
|
388
|
+
this.inFlight.delete(sessionKey);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async ingest(event) {
|
|
392
|
+
const sessionKey = this.conversationKeyResolver.resolve(event);
|
|
393
|
+
const prompt = ["text", "post"].includes(event.message.message_type)
|
|
394
|
+
? extractLarkTextPrompt(event)
|
|
395
|
+
: "";
|
|
396
|
+
if (!this.deduper.markIfNew(event.message.message_id)) {
|
|
397
|
+
this.logger.log({
|
|
398
|
+
event: "message.duplicate",
|
|
399
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
400
|
+
chat_id: event.message.chat_id,
|
|
401
|
+
thread_id: event.message.thread_id,
|
|
402
|
+
message_id: event.message.message_id,
|
|
403
|
+
session_key: sessionKey,
|
|
404
|
+
});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// Access control: optionally forbid private (p2p) chats so the bot is
|
|
408
|
+
// group-only. p2p chats are always "addressed" to the bot, so a notice is
|
|
409
|
+
// sent back rather than going silently dark.
|
|
410
|
+
if (event.message.chat_type !== "group" && this.disableP2p) {
|
|
411
|
+
this.logger.log({
|
|
412
|
+
event: "message.access_denied",
|
|
413
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
414
|
+
chat_id: event.message.chat_id,
|
|
415
|
+
thread_id: event.message.thread_id,
|
|
416
|
+
message_id: event.message.message_id,
|
|
417
|
+
session_key: sessionKey,
|
|
418
|
+
reason: "p2p_disabled",
|
|
419
|
+
});
|
|
420
|
+
await this.sendReplyText(event, "私聊已被管理员禁用,请在授权的群聊中使用本机器人。").catch(() => { });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
// In a group, only run when *this* bot is the one `@`-mentioned. A group can
|
|
424
|
+
// host several bots, and every bot receives the message event — without this
|
|
425
|
+
// check, an `@` aimed at one bot would make all of them reply.
|
|
426
|
+
if (event.message.chat_type === "group" && !this.isAddressedToBot(event)) {
|
|
427
|
+
this.logger.log({
|
|
428
|
+
event: "message.ignored",
|
|
429
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
430
|
+
chat_id: event.message.chat_id,
|
|
431
|
+
thread_id: event.message.thread_id,
|
|
432
|
+
message_id: event.message.message_id,
|
|
433
|
+
session_key: sessionKey,
|
|
434
|
+
reason: (event.message.mentions?.length ?? 0) === 0
|
|
435
|
+
? "group_message_without_mention"
|
|
436
|
+
: "group_message_not_addressed_to_bot",
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
// Access control: when the group allowlist is enabled, only run in groups
|
|
441
|
+
// whose chat_id is listed. Checked after the mention gate above, so we only
|
|
442
|
+
// reply to messages actually addressed to this bot (not every group chatter).
|
|
443
|
+
if (event.message.chat_type === "group" &&
|
|
444
|
+
this.groupAllowlistEnabled &&
|
|
445
|
+
!this.groupAllowlist.has(event.message.chat_id)) {
|
|
446
|
+
this.logger.log({
|
|
447
|
+
event: "message.access_denied",
|
|
448
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
449
|
+
chat_id: event.message.chat_id,
|
|
450
|
+
thread_id: event.message.thread_id,
|
|
451
|
+
message_id: event.message.message_id,
|
|
452
|
+
session_key: sessionKey,
|
|
453
|
+
reason: "group_not_allowlisted",
|
|
454
|
+
});
|
|
455
|
+
await this.sendReplyText(event, "本群未在机器人白名单内,暂不可使用。").catch(() => { });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// /stop bypasses the per-session queue on purpose — the in-flight turn
|
|
459
|
+
// is what we want to abort, and that turn is currently holding the queue.
|
|
460
|
+
if (isLarkStopCommand(prompt)) {
|
|
461
|
+
await this.handleStopCommand(event, sessionKey);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const command = parseLarkCommand(prompt);
|
|
465
|
+
// While a turn is already running for this session, a new message acts on
|
|
466
|
+
// that turn instead of queueing behind it:
|
|
467
|
+
// - a takeover command (/new /cwd /fork /resume) stops the running turn,
|
|
468
|
+
// then executes via its handler below (/stop is handled above);
|
|
469
|
+
// - plain text is injected into the running turn via `turn/steer`.
|
|
470
|
+
const inflight = this.inFlight.get(sessionKey);
|
|
471
|
+
if (inflight) {
|
|
472
|
+
if (command && LARK_TAKEOVER_COMMANDS.has(command.name)) {
|
|
473
|
+
if (this.interruptSession(sessionKey)) {
|
|
474
|
+
this.logger.log({
|
|
475
|
+
event: "session.stopped",
|
|
476
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
477
|
+
chat_id: event.message.chat_id,
|
|
478
|
+
thread_id: event.message.thread_id,
|
|
479
|
+
message_id: event.message.message_id,
|
|
480
|
+
session_key: sessionKey,
|
|
481
|
+
provider: this.provider,
|
|
482
|
+
source: "command_takeover",
|
|
483
|
+
command: command.name,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
else if (!command && prompt) {
|
|
488
|
+
if (inflight.steer.send(prompt)) {
|
|
489
|
+
await this.tryCreateReaction(event, sessionKey, LARK_STEER_ACK_REACTION);
|
|
490
|
+
this.logger.log({
|
|
491
|
+
event: "session.steered",
|
|
492
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
493
|
+
chat_id: event.message.chat_id,
|
|
494
|
+
thread_id: event.message.thread_id,
|
|
495
|
+
message_id: event.message.message_id,
|
|
496
|
+
session_key: sessionKey,
|
|
497
|
+
provider: this.provider,
|
|
498
|
+
});
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
// Backend can't steer (e.g. the `codex exec` fallback never subscribes)
|
|
502
|
+
// → fall through to the queue so the message runs as the next turn.
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Informational commands (/help, /models, /search) answer instantly and
|
|
506
|
+
// also bypass the queue so they respond even while a turn is running.
|
|
507
|
+
if (command && LARK_INFO_COMMANDS.has(command.name)) {
|
|
508
|
+
await this.handleInfoCommand(event, sessionKey, command);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
// Action commands (/goal, /diff) operate on the current session / working
|
|
512
|
+
// tree without running a Codex turn; also bypass the queue.
|
|
513
|
+
if (command && LARK_ACTION_COMMANDS.has(command.name)) {
|
|
514
|
+
await this.handleActionCommand(event, sessionKey, command);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// /resume lists / navigates the user's recent conversations; bypasses queue.
|
|
518
|
+
if (command && command.name === "resume") {
|
|
519
|
+
await this.handleResumeCommand(event, sessionKey, command);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
// /fork spins the conversation off into a new group/thread; bypasses queue.
|
|
523
|
+
if (command && command.name === "fork") {
|
|
524
|
+
await this.handleForkCommand(event, sessionKey);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// /cwd binds the conversation's working directory; bypasses queue.
|
|
528
|
+
if (command && command.name === "cwd") {
|
|
529
|
+
await this.handleCwdCommand(event, sessionKey, command);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// /runtime and /model open the interactive settings card; bypass queue.
|
|
533
|
+
if (command && (command.name === "runtime" || command.name === "model")) {
|
|
534
|
+
await this.handleSettingsCommand(event, sessionKey, command.name === "runtime");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// /agent lists declarative agents or selects one for this conversation.
|
|
538
|
+
if (command && command.name === "agent") {
|
|
539
|
+
await this.handleAgentCommand(event, sessionKey, command);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
await this.queue.enqueue(sessionKey, async () => {
|
|
543
|
+
if (isLarkNewSessionCommand(prompt)) {
|
|
544
|
+
await this.handleNewSessionCommand(event, sessionKey);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
// Best-effort, off the turn's critical path: the directory only feeds
|
|
548
|
+
// /resume, so a lagging or failed write must never delay the reply.
|
|
549
|
+
void this.recordConversation(event, sessionKey, prompt);
|
|
550
|
+
const session = await this.activeSessionStore.ensure(sessionKey);
|
|
551
|
+
if (session.source !== "existing") {
|
|
552
|
+
this.logger.log({
|
|
553
|
+
event: "session.started",
|
|
554
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
555
|
+
chat_id: event.message.chat_id,
|
|
556
|
+
thread_id: event.message.thread_id,
|
|
557
|
+
message_id: event.message.message_id,
|
|
558
|
+
session_key: sessionKey,
|
|
559
|
+
session_id: session.sessionId,
|
|
560
|
+
provider: this.provider,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
// Register the session's identity row (idempotent): the first turn fixes
|
|
564
|
+
// its source + title; rotated (`/new`) and forked sessions land here on
|
|
565
|
+
// their first turn too, so the control plane lists every Lark session.
|
|
566
|
+
this.registerSession(session.sessionId, prompt);
|
|
567
|
+
this.logMessageReceived(event, sessionKey, session.sessionId);
|
|
568
|
+
const startedReactionId = await this.tryCreateReaction(event, sessionKey, LARK_WORK_STARTED_REACTION, session.sessionId);
|
|
569
|
+
const reply = await this.buildReply(event, sessionKey, session.sessionId, prompt, session.cwd ?? this.cwd, await this.resolveTurnExecution(sessionKey, session));
|
|
570
|
+
if (reply.text) {
|
|
571
|
+
const sent = await this.sendReplyText(event, reply.text);
|
|
572
|
+
if (sent) {
|
|
573
|
+
this.logger.log({
|
|
574
|
+
event: "message.completed",
|
|
575
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
576
|
+
chat_id: event.message.chat_id,
|
|
577
|
+
thread_id: event.message.thread_id,
|
|
578
|
+
message_id: event.message.message_id,
|
|
579
|
+
session_key: sessionKey,
|
|
580
|
+
session_id: session.sessionId,
|
|
581
|
+
provider: reply.provider,
|
|
582
|
+
model: reply.model,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
await this.tryCreateReaction(event, sessionKey, LARK_WORK_COMPLETED_REACTION, session.sessionId);
|
|
587
|
+
await this.tryDeleteReaction(event, sessionKey, startedReactionId, LARK_WORK_STARTED_REACTION, session.sessionId);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
async handleStopCommand(event, sessionKey) {
|
|
591
|
+
const aborted = this.interruptSession(sessionKey);
|
|
592
|
+
this.logger.log({
|
|
593
|
+
event: aborted ? "session.stopped" : "session.stop_noop",
|
|
594
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
595
|
+
chat_id: event.message.chat_id,
|
|
596
|
+
thread_id: event.message.thread_id,
|
|
597
|
+
message_id: event.message.message_id,
|
|
598
|
+
session_key: sessionKey,
|
|
599
|
+
provider: this.provider,
|
|
600
|
+
});
|
|
601
|
+
await this.sendReplyText(event, aborted ? DEFAULT_STOP_ACK_RESPONSE : DEFAULT_STOP_NO_INFLIGHT_RESPONSE);
|
|
602
|
+
}
|
|
603
|
+
async handleInfoCommand(event, sessionKey, command) {
|
|
604
|
+
const session = await this.activeSessionStore.get(sessionKey);
|
|
605
|
+
const runtime = session?.runtime ?? this.provider;
|
|
606
|
+
let text;
|
|
607
|
+
switch (command.name) {
|
|
608
|
+
case "models":
|
|
609
|
+
text = await this.renderModelsReply(runtime);
|
|
610
|
+
break;
|
|
611
|
+
case "help":
|
|
612
|
+
default:
|
|
613
|
+
text = LARK_HELP_TEXT;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
await this.sendReplyText(event, text);
|
|
617
|
+
this.logger.log({
|
|
618
|
+
event: "command.handled",
|
|
619
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
620
|
+
chat_id: event.message.chat_id,
|
|
621
|
+
thread_id: event.message.thread_id,
|
|
622
|
+
message_id: event.message.message_id,
|
|
623
|
+
session_key: sessionKey,
|
|
624
|
+
command: command.name,
|
|
625
|
+
provider: this.provider,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
async handleActionCommand(event, sessionKey, command) {
|
|
629
|
+
if (command.name === "diff") {
|
|
630
|
+
await this.handleDiffCommand(event, sessionKey);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
const text = command.name === "goal"
|
|
634
|
+
? await this.renderGoalReply(sessionKey, command.args)
|
|
635
|
+
: LARK_HELP_TEXT;
|
|
636
|
+
await this.sendReplyText(event, text);
|
|
637
|
+
}
|
|
638
|
+
this.logger.log({
|
|
639
|
+
event: "command.handled",
|
|
640
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
641
|
+
chat_id: event.message.chat_id,
|
|
642
|
+
thread_id: event.message.thread_id,
|
|
643
|
+
message_id: event.message.message_id,
|
|
644
|
+
session_key: sessionKey,
|
|
645
|
+
command: command.name,
|
|
646
|
+
provider: this.provider,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Render the working-tree diff as a CardKit card (summary + per-file stats +
|
|
651
|
+
* a collapsed full diff). Falls back to a plain-text reply when the sender
|
|
652
|
+
* has no card capability or the card send fails.
|
|
653
|
+
*/
|
|
654
|
+
async handleDiffCommand(event, sessionKey) {
|
|
655
|
+
const result = await this.collectGitDiff();
|
|
656
|
+
if (result.kind === "error") {
|
|
657
|
+
await this.sendReplyText(event, `git diff 失败:${result.message}`);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (result.kind === "clean") {
|
|
661
|
+
await this.sendReplyText(event, "工作区干净,没有改动。");
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (this.messageSender.createCard) {
|
|
665
|
+
try {
|
|
666
|
+
await this.sendStandaloneCard(event, buildDiffCardJson(result.input));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
this.logger.log({
|
|
671
|
+
event: "command.diff_card_failed",
|
|
672
|
+
chat_id: event.message.chat_id,
|
|
673
|
+
thread_id: event.message.thread_id,
|
|
674
|
+
message_id: event.message.message_id,
|
|
675
|
+
session_key: sessionKey,
|
|
676
|
+
error: toErrorMessage(error),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
await this.sendReplyText(event, result.fallbackText);
|
|
681
|
+
}
|
|
682
|
+
async collectGitDiff() {
|
|
683
|
+
const cwd = this.cwd ?? process.cwd();
|
|
684
|
+
try {
|
|
685
|
+
const [{ stdout: diff }, { stdout: numstat }, { stdout: untracked }] = await Promise.all([
|
|
686
|
+
execFileAsync("git", ["-C", cwd, "diff"], { maxBuffer: 8 * 1024 * 1024 }),
|
|
687
|
+
execFileAsync("git", ["-C", cwd, "diff", "--numstat"], { maxBuffer: 8 * 1024 * 1024 }),
|
|
688
|
+
execFileAsync("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard"], {
|
|
689
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
690
|
+
}),
|
|
691
|
+
]);
|
|
692
|
+
const files = [];
|
|
693
|
+
let totalAdditions = 0;
|
|
694
|
+
let totalDeletions = 0;
|
|
695
|
+
for (const line of numstat.split("\n")) {
|
|
696
|
+
if (!line.trim()) {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
const [addRaw, delRaw, ...rest] = line.split("\t");
|
|
700
|
+
const filePath = rest.join("\t").trim();
|
|
701
|
+
if (!filePath) {
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
const additions = addRaw === "-" ? null : Number(addRaw);
|
|
705
|
+
const deletions = delRaw === "-" ? null : Number(delRaw);
|
|
706
|
+
if (additions != null && Number.isFinite(additions)) {
|
|
707
|
+
totalAdditions += additions;
|
|
708
|
+
}
|
|
709
|
+
if (deletions != null && Number.isFinite(deletions)) {
|
|
710
|
+
totalDeletions += deletions;
|
|
711
|
+
}
|
|
712
|
+
files.push({ path: filePath, additions, deletions });
|
|
713
|
+
}
|
|
714
|
+
const untrackedFiles = untracked
|
|
715
|
+
.split("\n")
|
|
716
|
+
.map((file) => file.trim())
|
|
717
|
+
.filter(Boolean);
|
|
718
|
+
for (const filePath of untrackedFiles) {
|
|
719
|
+
files.push({ path: filePath, additions: null, deletions: null, untracked: true });
|
|
720
|
+
}
|
|
721
|
+
if (files.length === 0 && !diff.trim()) {
|
|
722
|
+
return { kind: "clean" };
|
|
723
|
+
}
|
|
724
|
+
const overCardLimit = Buffer.byteLength(diff, "utf8") > DIFF_CARD_MAX_BYTES;
|
|
725
|
+
const diffBody = overCardLimit ? diff.slice(0, DIFF_CARD_MAX_BYTES) : diff.trimEnd();
|
|
726
|
+
// Plain-text fallback mirrors the previous /diff output.
|
|
727
|
+
let body = diff.trimEnd();
|
|
728
|
+
if (untrackedFiles.length) {
|
|
729
|
+
const list = untrackedFiles.map((file) => `?? ${file}`).join("\n");
|
|
730
|
+
body = body ? `${body}\n\n未跟踪文件:\n${list}` : `未跟踪文件:\n${list}`;
|
|
731
|
+
}
|
|
732
|
+
const fallbackText = Buffer.byteLength(body, "utf8") > DIFF_REPLY_MAX_BYTES
|
|
733
|
+
? `${body.slice(0, DIFF_REPLY_MAX_BYTES)}\n… (diff 过长已截断)`
|
|
734
|
+
: body;
|
|
735
|
+
return {
|
|
736
|
+
kind: "diff",
|
|
737
|
+
input: { files, totalAdditions, totalDeletions, diffBody, truncated: overCardLimit },
|
|
738
|
+
fallbackText,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
return { kind: "error", message: toErrorMessage(error) };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async renderGoalReply(sessionKey, args) {
|
|
746
|
+
if (!this.capabilities) {
|
|
747
|
+
return LARK_BACKEND_UNSUPPORTED;
|
|
748
|
+
}
|
|
749
|
+
const session = await this.activeSessionStore.get(sessionKey);
|
|
750
|
+
if (!session) {
|
|
751
|
+
return LARK_NO_ACTIVE_SESSION;
|
|
752
|
+
}
|
|
753
|
+
const sub = args.trim();
|
|
754
|
+
if (!sub) {
|
|
755
|
+
const result = await this.capabilities.getGoal(session.sessionId);
|
|
756
|
+
if (!result.ok) {
|
|
757
|
+
return this.capabilityErrorText(result);
|
|
758
|
+
}
|
|
759
|
+
const goal = result.data;
|
|
760
|
+
if (!goal?.objective) {
|
|
761
|
+
return "当前没有设置目标。用 `/goal <目标>` 设置。";
|
|
762
|
+
}
|
|
763
|
+
const status = goal.status ? `(${goal.status})` : "";
|
|
764
|
+
return `当前目标:${goal.objective}${status}`;
|
|
765
|
+
}
|
|
766
|
+
if (sub === "clear") {
|
|
767
|
+
const result = await this.capabilities.clearGoal(session.sessionId);
|
|
768
|
+
return result.ok ? "已清除当前会话目标。" : this.capabilityErrorText(result);
|
|
769
|
+
}
|
|
770
|
+
const result = await this.capabilities.setGoal(session.sessionId, sub);
|
|
771
|
+
return result.ok ? `已设置目标:${sub}` : this.capabilityErrorText(result);
|
|
772
|
+
}
|
|
773
|
+
capabilityErrorText(result) {
|
|
774
|
+
switch (result.reason) {
|
|
775
|
+
case "no_active_thread":
|
|
776
|
+
return LARK_NO_ACTIVE_SESSION;
|
|
777
|
+
case "unsupported":
|
|
778
|
+
return LARK_BACKEND_UNSUPPORTED;
|
|
779
|
+
default:
|
|
780
|
+
return `操作失败:${result.message ?? "未知错误"}`;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
conversationKindOf(event) {
|
|
784
|
+
if (event.message.thread_id) {
|
|
785
|
+
return "thread";
|
|
786
|
+
}
|
|
787
|
+
return event.message.chat_type === "group" ? "group" : "p2p";
|
|
788
|
+
}
|
|
789
|
+
async recordConversation(event, sessionKey, prompt) {
|
|
790
|
+
const title = prompt.trim();
|
|
791
|
+
if (!title) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
await this.conversationDirectory.record({
|
|
796
|
+
conversationKey: sessionKey,
|
|
797
|
+
chatId: event.message.chat_id,
|
|
798
|
+
threadId: event.message.thread_id,
|
|
799
|
+
kind: this.conversationKindOf(event),
|
|
800
|
+
title,
|
|
801
|
+
participantOpenId: event.sender.sender_id?.open_id,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
catch (error) {
|
|
805
|
+
this.logger.log({
|
|
806
|
+
event: "conversation.record_failed",
|
|
807
|
+
session_key: sessionKey,
|
|
808
|
+
message_id: event.message.message_id,
|
|
809
|
+
error: toErrorMessage(error),
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async handleCwdCommand(event, sessionKey, command) {
|
|
814
|
+
const session = await this.activeSessionStore.get(sessionKey);
|
|
815
|
+
const currentCwd = session?.cwd ?? this.cwd;
|
|
816
|
+
const arg = command.args.trim();
|
|
817
|
+
if (!arg) {
|
|
818
|
+
await this.sendReplyText(event, currentCwd ? `当前工作目录:${currentCwd}` : "当前使用默认工作目录。");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const base = currentCwd ?? this.allowedRoots[0] ?? process.cwd();
|
|
822
|
+
const resolved = path.isAbsolute(arg) ? path.resolve(arg) : path.resolve(base, arg);
|
|
823
|
+
if (!findInnermostAllowedRoot(resolved, this.allowedRoots)) {
|
|
824
|
+
await this.sendReplyText(event, `目录不在允许范围内(需位于 AGENT_ALLOWED_DIRS 内):${resolved}`);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
await mkdir(resolved, { recursive: true });
|
|
829
|
+
}
|
|
830
|
+
catch (error) {
|
|
831
|
+
await this.sendReplyText(event, `创建目录失败:${toErrorMessage(error)}`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
await this.activeSessionStore.bindCwd(sessionKey, resolved);
|
|
835
|
+
await this.sendReplyText(event, `已绑定工作目录:${resolved},并开启新会话。`);
|
|
836
|
+
this.logger.log({
|
|
837
|
+
event: "command.handled",
|
|
838
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
839
|
+
chat_id: event.message.chat_id,
|
|
840
|
+
thread_id: event.message.thread_id,
|
|
841
|
+
message_id: event.message.message_id,
|
|
842
|
+
session_key: sessionKey,
|
|
843
|
+
command: "cwd",
|
|
844
|
+
provider: this.provider,
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
async handleResumeCommand(event, sessionKey, command) {
|
|
848
|
+
const arg = command.args.trim();
|
|
849
|
+
if (arg) {
|
|
850
|
+
await this.resumeByShortId(event, arg);
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
await this.resumeList(event);
|
|
854
|
+
}
|
|
855
|
+
this.logger.log({
|
|
856
|
+
event: "command.handled",
|
|
857
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
858
|
+
chat_id: event.message.chat_id,
|
|
859
|
+
thread_id: event.message.thread_id,
|
|
860
|
+
message_id: event.message.message_id,
|
|
861
|
+
session_key: sessionKey,
|
|
862
|
+
command: "resume",
|
|
863
|
+
provider: this.provider,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
async resumeList(event) {
|
|
867
|
+
const openId = event.sender.sender_id?.open_id;
|
|
868
|
+
if (!openId) {
|
|
869
|
+
await this.sendReplyText(event, "无法识别你的身份,暂时无法列出历史会话。");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const records = await this.conversationDirectory.listForUser(openId, 5);
|
|
873
|
+
if (records.length === 0) {
|
|
874
|
+
await this.sendReplyText(event, "你还没有可恢复的历史会话。");
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (this.canUseCardSender()) {
|
|
878
|
+
try {
|
|
879
|
+
await this.sendStandaloneCard(event, buildResumeCardJson(records, {
|
|
880
|
+
chatId: event.message.chat_id,
|
|
881
|
+
threadId: event.message.thread_id,
|
|
882
|
+
}));
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
this.logger.log({
|
|
887
|
+
event: "resume.card_failed",
|
|
888
|
+
message_id: event.message.message_id,
|
|
889
|
+
error: toErrorMessage(error),
|
|
890
|
+
});
|
|
891
|
+
// fall through to text
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const lines = records.map((record, index) => {
|
|
895
|
+
if (record.kind === "thread") {
|
|
896
|
+
return `${index + 1}. ${record.title} \`${record.shortId}\`(话题,发送 /resume ${record.shortId} 转发到此)`;
|
|
897
|
+
}
|
|
898
|
+
return `${index + 1}. ${record.title} \`${record.shortId}\` ${buildChatApplink(record.chatId)}`;
|
|
899
|
+
});
|
|
900
|
+
await this.sendReplyText(event, ["最近会话:", ...lines].join("\n"));
|
|
901
|
+
}
|
|
902
|
+
async resumeByShortId(event, shortId) {
|
|
903
|
+
const record = await this.conversationDirectory.getByShortId(shortId);
|
|
904
|
+
if (!record) {
|
|
905
|
+
await this.sendReplyText(event, `没有找到 id 为 \`${shortId}\` 的会话。`);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (record.kind === "thread" && record.threadId) {
|
|
909
|
+
if (!this.messageSender.forwardThread) {
|
|
910
|
+
await this.sendReplyText(event, "当前不支持转发话题。");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const target = this.forwardTargetOf(event);
|
|
914
|
+
try {
|
|
915
|
+
await this.messageSender.forwardThread({
|
|
916
|
+
threadId: record.threadId,
|
|
917
|
+
receiveId: target.receiveId,
|
|
918
|
+
receiveIdType: target.receiveIdType,
|
|
919
|
+
uuid: randomUUID(),
|
|
920
|
+
});
|
|
921
|
+
await this.sendReplyText(event, `已把话题「${record.title}」转发到当前会话。`);
|
|
922
|
+
}
|
|
923
|
+
catch (error) {
|
|
924
|
+
await this.sendReplyText(event, `转发失败:${toErrorMessage(error)}`);
|
|
925
|
+
}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
await this.sendReplyText(event, `会话「${record.title}」:${buildChatApplink(record.chatId)}`);
|
|
929
|
+
}
|
|
930
|
+
async sendStandaloneCard(event, cardJson) {
|
|
931
|
+
const sender = this.messageSender;
|
|
932
|
+
if (!sender.createCard) {
|
|
933
|
+
throw new Error("card sender unavailable");
|
|
934
|
+
}
|
|
935
|
+
const { cardId } = await sender.createCard({ cardJson });
|
|
936
|
+
const uuid = randomUUID();
|
|
937
|
+
if (event.message.thread_id && sender.replyInteractive) {
|
|
938
|
+
const { messageId } = await sender.replyInteractive({
|
|
939
|
+
messageId: event.message.message_id,
|
|
940
|
+
cardId,
|
|
941
|
+
uuid,
|
|
942
|
+
replyInThread: true,
|
|
943
|
+
});
|
|
944
|
+
return { cardId, messageId };
|
|
945
|
+
}
|
|
946
|
+
if (sender.sendInteractive) {
|
|
947
|
+
const { messageId } = await sender.sendInteractive({
|
|
948
|
+
chatId: event.message.chat_id,
|
|
949
|
+
cardId,
|
|
950
|
+
uuid,
|
|
951
|
+
});
|
|
952
|
+
return { cardId, messageId };
|
|
953
|
+
}
|
|
954
|
+
return { cardId };
|
|
955
|
+
}
|
|
956
|
+
async handleForkCommand(event, sessionKey) {
|
|
957
|
+
const sender = this.messageSender;
|
|
958
|
+
if (!this.capabilities?.forkSession ||
|
|
959
|
+
!sender.sendText ||
|
|
960
|
+
!sender.listMessages ||
|
|
961
|
+
!sender.listChatMembers) {
|
|
962
|
+
await this.sendReplyText(event, "当前不支持 /fork(仅 app_server 后端 + 飞书机器人能力)。");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const session = await this.activeSessionStore.get(sessionKey);
|
|
966
|
+
if (!session) {
|
|
967
|
+
await this.sendReplyText(event, LARK_NO_ACTIVE_SESSION);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const tenantKey = event.tenant_key ?? event.sender.tenant_key ?? "unknown-tenant";
|
|
971
|
+
const isThread = Boolean(event.message.thread_id);
|
|
972
|
+
const directoryRecord = await this.conversationDirectory.get(sessionKey);
|
|
973
|
+
// Prefer the real Feishu chat name; fall back to the last user message, then
|
|
974
|
+
// a sensible default. (p2p chats have no name, so the fallback applies.)
|
|
975
|
+
let chatName = "";
|
|
976
|
+
if (sender.getChat) {
|
|
977
|
+
try {
|
|
978
|
+
chatName = (await sender.getChat({ chatId: event.message.chat_id })).name;
|
|
979
|
+
}
|
|
980
|
+
catch (error) {
|
|
981
|
+
this.logger.log({ event: "fork.get_chat_failed", error: toErrorMessage(error), message_id: event.message.message_id });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const baseName = chatName.trim() || directoryRecord?.title?.trim() || "Codex 会话";
|
|
985
|
+
// Members serve both the transcript (id → name) and, for the group case,
|
|
986
|
+
// the new group's member list. Fetch once, best-effort.
|
|
987
|
+
let members = [];
|
|
988
|
+
try {
|
|
989
|
+
members = await sender.listChatMembers({ chatId: event.message.chat_id });
|
|
990
|
+
}
|
|
991
|
+
catch (error) {
|
|
992
|
+
this.logger.log({ event: "fork.members_failed", error: toErrorMessage(error), message_id: event.message.message_id });
|
|
993
|
+
}
|
|
994
|
+
const nameById = new Map(members.map((m) => [m.memberId, m.name]));
|
|
995
|
+
let transcript = "";
|
|
996
|
+
try {
|
|
997
|
+
const messages = await sender.listMessages({
|
|
998
|
+
containerIdType: isThread ? "thread" : "chat",
|
|
999
|
+
containerId: isThread ? event.message.thread_id : event.message.chat_id,
|
|
1000
|
+
pageSize: 50,
|
|
1001
|
+
});
|
|
1002
|
+
transcript = buildForkTranscript(messages, nameById);
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
this.logger.log({ event: "fork.transcript_failed", error: toErrorMessage(error), message_id: event.message.message_id });
|
|
1006
|
+
}
|
|
1007
|
+
const header = `📋 原会话「${baseName}」记录(fork):`;
|
|
1008
|
+
const body = transcript ? `${header}\n${transcript}` : header;
|
|
1009
|
+
let newSessionKey;
|
|
1010
|
+
let newChatId = null;
|
|
1011
|
+
try {
|
|
1012
|
+
if (isThread) {
|
|
1013
|
+
const sent = await sender.sendText({
|
|
1014
|
+
chatId: event.message.chat_id,
|
|
1015
|
+
text: body,
|
|
1016
|
+
uuid: randomUUID(),
|
|
1017
|
+
});
|
|
1018
|
+
if (!sent.threadId) {
|
|
1019
|
+
await this.sendReplyText(event, "fork 失败:未能创建新话题。");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
newSessionKey = `lark:${tenantKey}:thread:${sent.threadId}`;
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
if (!sender.createChat) {
|
|
1026
|
+
await this.sendReplyText(event, "当前不支持建群。");
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const userIdList = members.map((m) => m.memberId).filter(Boolean);
|
|
1030
|
+
const created = await sender.createChat({
|
|
1031
|
+
name: `${baseName} - fork`,
|
|
1032
|
+
userIdList,
|
|
1033
|
+
uuid: randomUUID(),
|
|
1034
|
+
});
|
|
1035
|
+
await sender.sendText({ chatId: created.chatId, text: body, uuid: randomUUID() });
|
|
1036
|
+
newSessionKey = `lark:${tenantKey}:chat:${created.chatId}`;
|
|
1037
|
+
newChatId = created.chatId;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
catch (error) {
|
|
1041
|
+
await this.sendReplyText(event, `fork 失败:${toErrorMessage(error)}`);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
// Bind the new conversation to a forked Codex thread carrying context.
|
|
1045
|
+
// Carry the source cwd so the forked thread's working dir stays consistent.
|
|
1046
|
+
// The session id is opaque (no routing baked in); newSessionKey is the
|
|
1047
|
+
// routing key the store maps to it.
|
|
1048
|
+
if (!this.sessionProvisioner) {
|
|
1049
|
+
await this.sendReplyText(event, LARK_BACKEND_UNSUPPORTED);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
let newLocalThreadId;
|
|
1053
|
+
let forkResult;
|
|
1054
|
+
try {
|
|
1055
|
+
newLocalThreadId = await this.sessionProvisioner({
|
|
1056
|
+
sessionKey: newSessionKey,
|
|
1057
|
+
fromSessionId: session.sessionId,
|
|
1058
|
+
});
|
|
1059
|
+
await this.activeSessionStore.set(newSessionKey, newLocalThreadId, session.cwd);
|
|
1060
|
+
forkResult = await this.capabilities.forkSession(session.sessionId, newLocalThreadId);
|
|
1061
|
+
}
|
|
1062
|
+
catch (error) {
|
|
1063
|
+
await this.sendReplyText(event, `fork 会话创建失败:${toErrorMessage(error)}`);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const contextNote = forkResult.ok
|
|
1067
|
+
? "已带上当前会话上下文。"
|
|
1068
|
+
: `但未能带上上下文:${this.capabilityErrorText(forkResult)}`;
|
|
1069
|
+
// Surface a one-tap entry into the new conversation in the current chat.
|
|
1070
|
+
if (newChatId) {
|
|
1071
|
+
const entryCardJson = buildOpenChatCardJson(newChatId, `${baseName} - fork`);
|
|
1072
|
+
let sentCard = false;
|
|
1073
|
+
if (this.canUseCardSender()) {
|
|
1074
|
+
try {
|
|
1075
|
+
await this.sendStandaloneCard(event, entryCardJson);
|
|
1076
|
+
sentCard = true;
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
this.logger.log({ event: "fork.entry_card_failed", error: toErrorMessage(error), message_id: event.message.message_id });
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
if (!sentCard) {
|
|
1083
|
+
await this.sendReplyText(event, `已 fork 到新群「${baseName} - fork」,点此进入:${buildChatApplink(newChatId)}(${contextNote})`);
|
|
1084
|
+
}
|
|
1085
|
+
else if (!forkResult.ok) {
|
|
1086
|
+
await this.sendReplyText(event, `提示:${contextNote}`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
// Topic thread: the root message is already visible in this group.
|
|
1091
|
+
await this.sendReplyText(event, `已在本群新建话题(见上方新消息),可在其中继续对话。${contextNote}`);
|
|
1092
|
+
}
|
|
1093
|
+
this.logger.log({
|
|
1094
|
+
event: "command.handled",
|
|
1095
|
+
tenant_key: tenantKey,
|
|
1096
|
+
chat_id: event.message.chat_id,
|
|
1097
|
+
thread_id: event.message.thread_id,
|
|
1098
|
+
message_id: event.message.message_id,
|
|
1099
|
+
session_key: sessionKey,
|
|
1100
|
+
command: "fork",
|
|
1101
|
+
provider: this.provider,
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Send the `/runtime` (or `/model`) settings card and seed the pending
|
|
1106
|
+
* selection state with the conversation's current settings.
|
|
1107
|
+
*/
|
|
1108
|
+
async handleSettingsCommand(event, sessionKey, includeRuntime) {
|
|
1109
|
+
const session = await this.activeSessionStore.get(sessionKey);
|
|
1110
|
+
const currentRuntime = session?.runtime ?? this.provider;
|
|
1111
|
+
const currentModel = session?.model;
|
|
1112
|
+
const currentEffort = session?.reasoningEffort;
|
|
1113
|
+
if (!this.canUseCardSender()) {
|
|
1114
|
+
await this.sendReplyText(event, this.renderSettingsSummary(currentRuntime, currentModel, currentEffort));
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
const models = await this.fetchModelOptions(currentRuntime);
|
|
1118
|
+
const pending = {
|
|
1119
|
+
includeRuntime,
|
|
1120
|
+
currentRuntime,
|
|
1121
|
+
currentModel,
|
|
1122
|
+
runtime: currentRuntime,
|
|
1123
|
+
model: currentModel ?? SETTINGS_DEFAULT_VALUE,
|
|
1124
|
+
effort: currentEffort ?? SETTINGS_DEFAULT_VALUE,
|
|
1125
|
+
models,
|
|
1126
|
+
};
|
|
1127
|
+
this.pendingSettings.set(sessionKey, pending);
|
|
1128
|
+
const cardJson = buildSettingsCardJson(this.settingsCardInput(sessionKey, pending));
|
|
1129
|
+
try {
|
|
1130
|
+
await this.sendStandaloneCard(event, cardJson);
|
|
1131
|
+
}
|
|
1132
|
+
catch (error) {
|
|
1133
|
+
this.pendingSettings.delete(sessionKey);
|
|
1134
|
+
await this.sendReplyText(event, `发送设置卡片失败:${toErrorMessage(error)}`);
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
this.logger.log({
|
|
1138
|
+
event: "command.handled",
|
|
1139
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1140
|
+
chat_id: event.message.chat_id,
|
|
1141
|
+
thread_id: event.message.thread_id,
|
|
1142
|
+
message_id: event.message.message_id,
|
|
1143
|
+
session_key: sessionKey,
|
|
1144
|
+
command: includeRuntime ? "runtime" : "model",
|
|
1145
|
+
provider: currentRuntime,
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* `/agent` — list declarative agents, or select one for this conversation.
|
|
1150
|
+
* Selecting rotates to a fresh session (so sandbox/persona/tools don't change
|
|
1151
|
+
* mid-thread) and drops explicit runtime/model overrides so the agent drives them.
|
|
1152
|
+
*/
|
|
1153
|
+
async handleAgentCommand(event, sessionKey, command) {
|
|
1154
|
+
const name = command.args.trim();
|
|
1155
|
+
const session = await this.activeSessionStore.get(sessionKey);
|
|
1156
|
+
const current = session?.agent;
|
|
1157
|
+
if (!name) {
|
|
1158
|
+
if (!this.agentCatalog) {
|
|
1159
|
+
await this.sendReplyText(event, "当前运行环境不支持 agent 目录。");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
let specs;
|
|
1163
|
+
try {
|
|
1164
|
+
specs = await this.agentCatalog.list();
|
|
1165
|
+
}
|
|
1166
|
+
catch (error) {
|
|
1167
|
+
await this.sendReplyText(event, `读取 agent 失败:${toErrorMessage(error)}`);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
const lines = ["可用 agent:"];
|
|
1171
|
+
if (specs.length === 0) {
|
|
1172
|
+
lines.push(" (暂无;用 `rynx agent add <名称>` 创建)");
|
|
1173
|
+
}
|
|
1174
|
+
else {
|
|
1175
|
+
for (const spec of specs) {
|
|
1176
|
+
const marker = spec.id === current ? "✓" : "·";
|
|
1177
|
+
const runtime = spec.runtime ?? "默认";
|
|
1178
|
+
const model = spec.model ?? "默认";
|
|
1179
|
+
const display = spec.name && spec.name !== spec.id ? `${spec.name} · ` : "";
|
|
1180
|
+
lines.push(` ${marker} ${spec.id}(${display}${runtime} / ${model})`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
lines.push(current ? `\n当前:${current}` : "\n当前:未选择(用全局默认)");
|
|
1184
|
+
lines.push("用 `/agent <名称>` 切换。");
|
|
1185
|
+
await this.sendReplyText(event, lines.join("\n"));
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (!this.agentCatalog) {
|
|
1189
|
+
await this.sendReplyText(event, "当前运行环境不支持 agent 目录。");
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
let spec;
|
|
1193
|
+
try {
|
|
1194
|
+
spec = await this.agentCatalog.get(name);
|
|
1195
|
+
}
|
|
1196
|
+
catch (error) {
|
|
1197
|
+
await this.sendReplyText(event, `agent「${name}」配置无效:${toErrorMessage(error)}`);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
if (!spec) {
|
|
1201
|
+
await this.sendReplyText(event, `未找到 agent「${name}」。用 /agent 查看可用列表。`);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
await this.activeSessionStore.setAgent(sessionKey, name);
|
|
1205
|
+
const runtime = spec.runtime ?? "默认运行时";
|
|
1206
|
+
const model = spec.model ?? "默认模型";
|
|
1207
|
+
await this.sendReplyText(event, `已切换到 agent「${name}」(${runtime} / ${model}),并开启新会话。`);
|
|
1208
|
+
this.logger.log({
|
|
1209
|
+
event: "command.handled",
|
|
1210
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1211
|
+
chat_id: event.message.chat_id,
|
|
1212
|
+
thread_id: event.message.thread_id,
|
|
1213
|
+
message_id: event.message.message_id,
|
|
1214
|
+
session_key: sessionKey,
|
|
1215
|
+
command: "agent",
|
|
1216
|
+
provider: spec.runtime ?? this.provider,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Label shown for the model in the turn card. An empty model means the
|
|
1221
|
+
* runtime follows its own configured default (e.g. traex with no
|
|
1222
|
+
* `TRAEX_MODEL`); show a placeholder so the status row isn't blank (the
|
|
1223
|
+
* runtime itself is shown separately).
|
|
1224
|
+
*/
|
|
1225
|
+
displayModel(model) {
|
|
1226
|
+
return model?.trim() || "默认模型";
|
|
1227
|
+
}
|
|
1228
|
+
renderSettingsSummary(runtime, model, effort) {
|
|
1229
|
+
return [
|
|
1230
|
+
"当前会话设置:",
|
|
1231
|
+
`• 运行时:${getRuntimeProfile(runtime).displayName}`,
|
|
1232
|
+
`• 模型:${model ?? "(运行时默认)"}`,
|
|
1233
|
+
`• 思考强度:${effort ?? "(默认)"}`,
|
|
1234
|
+
"(当前环境不支持交互卡片,无法在此切换。)",
|
|
1235
|
+
].join("\n");
|
|
1236
|
+
}
|
|
1237
|
+
/** Assemble the settings-card input from the current pending selection. */
|
|
1238
|
+
settingsCardInput(sessionKey, pending) {
|
|
1239
|
+
return {
|
|
1240
|
+
sessionKey,
|
|
1241
|
+
includeRuntime: pending.includeRuntime,
|
|
1242
|
+
runtimeOptions: Object.keys(RUNTIME_PROFILES).map((id) => ({
|
|
1243
|
+
value: id,
|
|
1244
|
+
label: RUNTIME_PROFILES[id].displayName,
|
|
1245
|
+
})),
|
|
1246
|
+
models: pending.models,
|
|
1247
|
+
selectedRuntime: isAgentRuntimeId(pending.runtime) ? pending.runtime : pending.currentRuntime,
|
|
1248
|
+
selectedModel: pending.model,
|
|
1249
|
+
selectedEffort: pending.effort,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
async fetchModelOptions(runtime) {
|
|
1253
|
+
if (!this.capabilities) {
|
|
1254
|
+
return [];
|
|
1255
|
+
}
|
|
1256
|
+
try {
|
|
1257
|
+
const response = await this.capabilities.listModels(runtime);
|
|
1258
|
+
const models = (response?.data ?? []).filter((model) => !model.hidden);
|
|
1259
|
+
return models.map((model) => ({
|
|
1260
|
+
value: String(model.model || model.id),
|
|
1261
|
+
label: String(model.displayName || model.model || model.id),
|
|
1262
|
+
isDefault: Boolean(model.isDefault),
|
|
1263
|
+
supportedEfforts: extractSupportedEfforts(model),
|
|
1264
|
+
}));
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
this.logger.log({ event: "settings.models_failed", error: toErrorMessage(error) });
|
|
1268
|
+
return [];
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Handle a settings-card dropdown change. A runtime change re-scopes the
|
|
1273
|
+
* model list (and resets model/effort); a model change re-scopes the effort
|
|
1274
|
+
* list — both re-render the card in place via the callback response so the
|
|
1275
|
+
* dependent dropdowns update. Effort changes just update pending state.
|
|
1276
|
+
*/
|
|
1277
|
+
async handleSettingsSelect(event) {
|
|
1278
|
+
const value = event.value;
|
|
1279
|
+
const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
|
|
1280
|
+
const field = typeof value?.field === "string" ? value.field : null;
|
|
1281
|
+
const option = typeof event.option === "string" ? event.option : null;
|
|
1282
|
+
if (!sessionKey || !field || option === null) {
|
|
1283
|
+
return {};
|
|
1284
|
+
}
|
|
1285
|
+
const pending = this.pendingSettings.get(sessionKey);
|
|
1286
|
+
if (!pending) {
|
|
1287
|
+
return {};
|
|
1288
|
+
}
|
|
1289
|
+
if (field === "runtime") {
|
|
1290
|
+
if (!isAgentRuntimeId(option) || option === pending.runtime) {
|
|
1291
|
+
return {};
|
|
1292
|
+
}
|
|
1293
|
+
pending.runtime = option;
|
|
1294
|
+
pending.models = await this.fetchModelOptions(option);
|
|
1295
|
+
// A model from the old runtime can't carry over → reset model + effort.
|
|
1296
|
+
pending.model = SETTINGS_DEFAULT_VALUE;
|
|
1297
|
+
pending.effort = SETTINGS_DEFAULT_VALUE;
|
|
1298
|
+
return this.settingsCardResponse(sessionKey, pending);
|
|
1299
|
+
}
|
|
1300
|
+
if (field === "model") {
|
|
1301
|
+
if (option === pending.model) {
|
|
1302
|
+
return {};
|
|
1303
|
+
}
|
|
1304
|
+
pending.model = option;
|
|
1305
|
+
// Available efforts depend on the model → reset the effort selection.
|
|
1306
|
+
pending.effort = SETTINGS_DEFAULT_VALUE;
|
|
1307
|
+
return this.settingsCardResponse(sessionKey, pending);
|
|
1308
|
+
}
|
|
1309
|
+
if (field === "effort") {
|
|
1310
|
+
pending.effort = option;
|
|
1311
|
+
}
|
|
1312
|
+
return {};
|
|
1313
|
+
}
|
|
1314
|
+
/** Card-action response that re-renders the open settings card in place. */
|
|
1315
|
+
settingsCardResponse(sessionKey, pending) {
|
|
1316
|
+
return {
|
|
1317
|
+
card: { type: "raw", data: buildSettingsCard(this.settingsCardInput(sessionKey, pending)) },
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
/** Apply the pending settings-card selections to the conversation. */
|
|
1321
|
+
async handleSettingsApply(event) {
|
|
1322
|
+
const value = event.value;
|
|
1323
|
+
const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
|
|
1324
|
+
if (!sessionKey) {
|
|
1325
|
+
return { toast: { type: "error", content: "无法识别会话。" } };
|
|
1326
|
+
}
|
|
1327
|
+
const pending = this.pendingSettings.get(sessionKey);
|
|
1328
|
+
if (!pending) {
|
|
1329
|
+
return { toast: { type: "warning", content: "设置已过期,请重新发送 /runtime。" } };
|
|
1330
|
+
}
|
|
1331
|
+
// Pending state is the source of truth — the dropdowns re-render it on each
|
|
1332
|
+
// change, so the selected model is always valid for the selected runtime.
|
|
1333
|
+
const runtime = pending.includeRuntime && isAgentRuntimeId(pending.runtime)
|
|
1334
|
+
? pending.runtime
|
|
1335
|
+
: pending.currentRuntime;
|
|
1336
|
+
const runtimeChanged = runtime !== pending.currentRuntime;
|
|
1337
|
+
const model = pending.model === SETTINGS_DEFAULT_VALUE ? undefined : pending.model;
|
|
1338
|
+
const selectedModel = pending.models.find((candidate) => candidate.value === pending.model);
|
|
1339
|
+
const allowedEfforts = selectedModel?.supportedEfforts?.length
|
|
1340
|
+
? selectedModel.supportedEfforts
|
|
1341
|
+
: SETTINGS_EFFORT_VALUES;
|
|
1342
|
+
const effort = pending.effort !== SETTINGS_DEFAULT_VALUE && allowedEfforts.includes(pending.effort)
|
|
1343
|
+
? pending.effort
|
|
1344
|
+
: undefined;
|
|
1345
|
+
// Only a runtime change rotates to a fresh session (its rollout/transcript
|
|
1346
|
+
// store is runtime-specific). model / effort switch on the SAME thread —
|
|
1347
|
+
// both lineages take them per-turn — so the conversation keeps its context.
|
|
1348
|
+
const rotate = runtimeChanged;
|
|
1349
|
+
await this.activeSessionStore.applySettings(sessionKey, {
|
|
1350
|
+
runtime,
|
|
1351
|
+
model,
|
|
1352
|
+
reasoningEffort: effort,
|
|
1353
|
+
rotate,
|
|
1354
|
+
});
|
|
1355
|
+
this.pendingSettings.delete(sessionKey);
|
|
1356
|
+
const summary = [
|
|
1357
|
+
`运行时:${getRuntimeProfile(runtime).displayName}`,
|
|
1358
|
+
`模型:${model ?? "运行时默认"}`,
|
|
1359
|
+
`思考强度:${effort ?? "默认"}`,
|
|
1360
|
+
].join(" · ");
|
|
1361
|
+
this.logger.log({
|
|
1362
|
+
event: "settings.applied",
|
|
1363
|
+
session_key: sessionKey,
|
|
1364
|
+
message_id: event.messageId,
|
|
1365
|
+
provider: runtime,
|
|
1366
|
+
model: model ?? undefined,
|
|
1367
|
+
reasoning_effort: effort ?? null,
|
|
1368
|
+
rotated: rotate,
|
|
1369
|
+
});
|
|
1370
|
+
const headline = rotate ? `已应用并开启新会话 — ${summary}` : `已更新 — ${summary}`;
|
|
1371
|
+
// Replace the card via the callback response (atomic — no flash/revert).
|
|
1372
|
+
return this.settingsResultCard(pending.includeRuntime, "green", `✅ ${headline}`);
|
|
1373
|
+
}
|
|
1374
|
+
/** Cancel an open settings card without changing anything. */
|
|
1375
|
+
handleSettingsCancel(event) {
|
|
1376
|
+
const value = event.value;
|
|
1377
|
+
const sessionKey = typeof value?.sessionKey === "string" ? value.sessionKey : null;
|
|
1378
|
+
if (!sessionKey) {
|
|
1379
|
+
return {};
|
|
1380
|
+
}
|
|
1381
|
+
const pending = this.pendingSettings.get(sessionKey);
|
|
1382
|
+
if (!pending) {
|
|
1383
|
+
return {};
|
|
1384
|
+
}
|
|
1385
|
+
const includeRuntime = pending.includeRuntime;
|
|
1386
|
+
this.pendingSettings.delete(sessionKey);
|
|
1387
|
+
return this.settingsResultCard(includeRuntime, "grey", "已取消,设置未改动。");
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Build a card-action response that swaps the settings card for a static
|
|
1391
|
+
* result card. Returning the card in the callback response replaces it
|
|
1392
|
+
* atomically; an out-of-band `card.update` would briefly show the result and
|
|
1393
|
+
* then revert to the form.
|
|
1394
|
+
*/
|
|
1395
|
+
settingsResultCard(includeRuntime, template, content) {
|
|
1396
|
+
return {
|
|
1397
|
+
card: {
|
|
1398
|
+
type: "raw",
|
|
1399
|
+
data: buildSettingsResultCard({ includeRuntime, template, content }),
|
|
1400
|
+
},
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
async renderModelsReply(runtime) {
|
|
1404
|
+
if (!this.capabilities) {
|
|
1405
|
+
return LARK_BACKEND_UNSUPPORTED;
|
|
1406
|
+
}
|
|
1407
|
+
try {
|
|
1408
|
+
const response = await this.capabilities.listModels(runtime);
|
|
1409
|
+
if (!response) {
|
|
1410
|
+
return LARK_BACKEND_UNSUPPORTED;
|
|
1411
|
+
}
|
|
1412
|
+
const models = (response.data ?? []).filter((model) => !model.hidden);
|
|
1413
|
+
if (models.length === 0) {
|
|
1414
|
+
return "未获取到可用模型。";
|
|
1415
|
+
}
|
|
1416
|
+
const lines = models.map((model) => {
|
|
1417
|
+
const label = model.displayName || model.model || model.id;
|
|
1418
|
+
return `• ${label}${model.isDefault ? " ⭐️" : ""}`;
|
|
1419
|
+
});
|
|
1420
|
+
return ["可用模型:", ...lines].join("\n");
|
|
1421
|
+
}
|
|
1422
|
+
catch (error) {
|
|
1423
|
+
return `获取模型列表失败:${toErrorMessage(error)}`;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
async handleNewSessionCommand(event, sessionKey) {
|
|
1427
|
+
const currentSession = await this.activeSessionStore.get(sessionKey);
|
|
1428
|
+
this.logMessageReceived(event, sessionKey, currentSession?.sessionId);
|
|
1429
|
+
const rotated = await this.activeSessionStore.rotate(sessionKey);
|
|
1430
|
+
this.logger.log({
|
|
1431
|
+
event: "session.rotated",
|
|
1432
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1433
|
+
chat_id: event.message.chat_id,
|
|
1434
|
+
thread_id: event.message.thread_id,
|
|
1435
|
+
message_id: event.message.message_id,
|
|
1436
|
+
session_key: sessionKey,
|
|
1437
|
+
session_id: rotated.sessionId,
|
|
1438
|
+
previous_session_id: rotated.previousSessionId,
|
|
1439
|
+
provider: this.provider,
|
|
1440
|
+
});
|
|
1441
|
+
const sent = await this.sendReplyText(event, DEFAULT_NEW_SESSION_RESPONSE);
|
|
1442
|
+
if (sent) {
|
|
1443
|
+
this.logger.log({
|
|
1444
|
+
event: "message.completed",
|
|
1445
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1446
|
+
chat_id: event.message.chat_id,
|
|
1447
|
+
thread_id: event.message.thread_id,
|
|
1448
|
+
message_id: event.message.message_id,
|
|
1449
|
+
session_key: sessionKey,
|
|
1450
|
+
session_id: rotated.sessionId,
|
|
1451
|
+
provider: this.provider,
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
logMessageReceived(event, sessionKey, sessionId) {
|
|
1456
|
+
this.logger.log({
|
|
1457
|
+
event: "message.received",
|
|
1458
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1459
|
+
chat_id: event.message.chat_id,
|
|
1460
|
+
thread_id: event.message.thread_id,
|
|
1461
|
+
message_id: event.message.message_id,
|
|
1462
|
+
session_key: sessionKey,
|
|
1463
|
+
session_id: sessionId,
|
|
1464
|
+
provider: this.provider,
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
async tryCreateReaction(event, sessionKey, emojiType, sessionId) {
|
|
1468
|
+
try {
|
|
1469
|
+
return await this.messageSender.createReaction({
|
|
1470
|
+
messageId: event.message.message_id,
|
|
1471
|
+
emojiType,
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
catch (error) {
|
|
1475
|
+
this.logger.log({
|
|
1476
|
+
event: "message.reaction_failed",
|
|
1477
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1478
|
+
chat_id: event.message.chat_id,
|
|
1479
|
+
thread_id: event.message.thread_id,
|
|
1480
|
+
message_id: event.message.message_id,
|
|
1481
|
+
session_key: sessionKey,
|
|
1482
|
+
session_id: sessionId,
|
|
1483
|
+
emoji_type: emojiType,
|
|
1484
|
+
error: toErrorMessage(error),
|
|
1485
|
+
});
|
|
1486
|
+
return undefined;
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
async tryDeleteReaction(event, sessionKey, reactionId, emojiType, sessionId) {
|
|
1490
|
+
if (!reactionId) {
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
try {
|
|
1494
|
+
await this.messageSender.deleteReaction({
|
|
1495
|
+
messageId: event.message.message_id,
|
|
1496
|
+
reactionId,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
catch (error) {
|
|
1500
|
+
this.logger.log({
|
|
1501
|
+
event: "message.reaction_delete_failed",
|
|
1502
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1503
|
+
chat_id: event.message.chat_id,
|
|
1504
|
+
thread_id: event.message.thread_id,
|
|
1505
|
+
message_id: event.message.message_id,
|
|
1506
|
+
session_key: sessionKey,
|
|
1507
|
+
session_id: sessionId,
|
|
1508
|
+
emoji_type: emojiType,
|
|
1509
|
+
error: toErrorMessage(error),
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* If this turn replies to a Lark message card, fetch the parent card and wrap
|
|
1515
|
+
* the prompt so the agent receives the card content as context. Generic: it
|
|
1516
|
+
* forwards any interactive (card) parent and names no specific skill — the
|
|
1517
|
+
* agent decides how to act on the card. Returns null when the parent is not a
|
|
1518
|
+
* card (or cannot be fetched), so the caller falls back to the raw prompt.
|
|
1519
|
+
*/
|
|
1520
|
+
async tryBuildCardReplyPrompt(event, sessionKey, sessionId, prompt) {
|
|
1521
|
+
const parentMessageId = event.message.parent_id?.trim();
|
|
1522
|
+
if (!parentMessageId || !this.messageSender.getMessage) {
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
try {
|
|
1526
|
+
const parentMessage = await this.messageSender.getMessage({ messageId: parentMessageId });
|
|
1527
|
+
const content = parentMessage?.body?.content ?? parentMessage?.content;
|
|
1528
|
+
const parentContent = typeof content === "string" && content.trim() ? content : null;
|
|
1529
|
+
if (parentMessage?.msgType !== "interactive" || !parentContent) {
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
this.logger.log({
|
|
1533
|
+
event: "message.card_reply_detected",
|
|
1534
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1535
|
+
chat_id: event.message.chat_id,
|
|
1536
|
+
thread_id: event.message.thread_id,
|
|
1537
|
+
message_id: event.message.message_id,
|
|
1538
|
+
session_key: sessionKey,
|
|
1539
|
+
session_id: sessionId,
|
|
1540
|
+
provider: this.provider,
|
|
1541
|
+
});
|
|
1542
|
+
return buildLarkCardReplyPrompt({
|
|
1543
|
+
userPrompt: prompt,
|
|
1544
|
+
cardContent: parentContent,
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
this.logger.log({
|
|
1549
|
+
event: "message.parent_lookup_failed",
|
|
1550
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1551
|
+
chat_id: event.message.chat_id,
|
|
1552
|
+
thread_id: event.message.thread_id,
|
|
1553
|
+
message_id: event.message.message_id,
|
|
1554
|
+
session_key: sessionKey,
|
|
1555
|
+
session_id: sessionId,
|
|
1556
|
+
provider: this.provider,
|
|
1557
|
+
error: toErrorMessage(error),
|
|
1558
|
+
});
|
|
1559
|
+
return null;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Map a session to the {@link TurnSettings} a turn runs with. Already-frozen
|
|
1564
|
+
* sessions are resolved locally; first-turn defaults and named-agent lookup
|
|
1565
|
+
* are delegated to the capability-gated host and then persisted exactly once.
|
|
1566
|
+
*/
|
|
1567
|
+
async resolveTurnExecution(sessionKey, session) {
|
|
1568
|
+
if (session.runtime) {
|
|
1569
|
+
return {
|
|
1570
|
+
provider: session.runtime,
|
|
1571
|
+
providerExplicit: true,
|
|
1572
|
+
model: session.model,
|
|
1573
|
+
reasoningEffort: session.reasoningEffort,
|
|
1574
|
+
agentName: session.agent,
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
// Lightweight unit-test/custom embeddings may omit the host resolver.
|
|
1578
|
+
if (!this.agentCatalog) {
|
|
1579
|
+
return {
|
|
1580
|
+
provider: session.runtime ?? this.provider,
|
|
1581
|
+
providerExplicit: session.runtime != null,
|
|
1582
|
+
model: session.model,
|
|
1583
|
+
reasoningEffort: session.reasoningEffort,
|
|
1584
|
+
agentName: session.agent ?? this.defaultAgent,
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
const plan = await this.agentCatalog.resolveExecution({
|
|
1588
|
+
...(this.defaultAgent ? { defaultAgent: this.defaultAgent } : {}),
|
|
1589
|
+
session: {
|
|
1590
|
+
...(session.runtime ? { runtime: session.runtime } : {}),
|
|
1591
|
+
...(session.model ? { model: session.model } : {}),
|
|
1592
|
+
...(session.reasoningEffort ? { reasoningEffort: session.reasoningEffort } : {}),
|
|
1593
|
+
...(session.agent ? { agent: session.agent } : {}),
|
|
1594
|
+
},
|
|
1595
|
+
});
|
|
1596
|
+
// Freshly resolved (not yet frozen) → persist it so it can't drift later.
|
|
1597
|
+
if (!plan.alreadyFrozen) {
|
|
1598
|
+
await this.activeSessionStore.freeze(sessionKey, {
|
|
1599
|
+
runtime: plan.runtime,
|
|
1600
|
+
model: plan.model,
|
|
1601
|
+
reasoningEffort: plan.reasoningEffort,
|
|
1602
|
+
agent: plan.agent,
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
provider: plan.runtime,
|
|
1607
|
+
providerExplicit: true,
|
|
1608
|
+
model: plan.model,
|
|
1609
|
+
reasoningEffort: plan.reasoningEffort,
|
|
1610
|
+
agentName: plan.agent,
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
async buildReply(event, sessionKey, sessionId, prompt, cwd, settings) {
|
|
1614
|
+
if (!["text", "post"].includes(event.message.message_type)) {
|
|
1615
|
+
return {
|
|
1616
|
+
text: DEFAULT_UNSUPPORTED_MESSAGE,
|
|
1617
|
+
provider: settings.provider,
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
const runtimePrompt = (await this.tryBuildCardReplyPrompt(event, sessionKey, sessionId, prompt)) ?? prompt;
|
|
1621
|
+
if (!runtimePrompt) {
|
|
1622
|
+
return {
|
|
1623
|
+
text: DEFAULT_EMPTY_MESSAGE,
|
|
1624
|
+
provider: settings.provider,
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
const turn = this.registerInFlight(sessionKey);
|
|
1628
|
+
try {
|
|
1629
|
+
if (this.canUseCardSender()) {
|
|
1630
|
+
const streamed = await this.tryBuildReplyWithCard(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings);
|
|
1631
|
+
if (streamed) {
|
|
1632
|
+
return streamed;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return await this.buildReplyDrain(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings);
|
|
1636
|
+
}
|
|
1637
|
+
finally {
|
|
1638
|
+
// Claim the bridge's own rollout lines before releasing the in-flight
|
|
1639
|
+
// gate, so the CLI mirror's cursor sits past them and they're not
|
|
1640
|
+
// mirrored back into Feishu (they were already shown live).
|
|
1641
|
+
if (this.mirror && turn.codexSessionId) {
|
|
1642
|
+
try {
|
|
1643
|
+
await this.mirror.claimToEof(turn.codexSessionId);
|
|
1644
|
+
}
|
|
1645
|
+
catch (error) {
|
|
1646
|
+
this.logger.log({
|
|
1647
|
+
event: "mirror.claim_failed",
|
|
1648
|
+
session_key: sessionKey,
|
|
1649
|
+
session_id: sessionId,
|
|
1650
|
+
error: toErrorMessage(error),
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
this.clearInFlight(sessionKey, turn);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
canUseCardSender() {
|
|
1658
|
+
return Boolean(this.messageSender.createCard &&
|
|
1659
|
+
this.messageSender.batchUpdateCard &&
|
|
1660
|
+
(this.messageSender.sendInteractive || this.messageSender.replyInteractive));
|
|
1661
|
+
}
|
|
1662
|
+
async tryBuildReplyWithCard(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings) {
|
|
1663
|
+
let started;
|
|
1664
|
+
try {
|
|
1665
|
+
started = await this.conversationRuntime.runLive({
|
|
1666
|
+
message: runtimePrompt,
|
|
1667
|
+
threadId: sessionId,
|
|
1668
|
+
// When an agent is selected but the user hasn't pinned a runtime, pass
|
|
1669
|
+
// undefined so the agent spec's `executor.runtime` decides the backend.
|
|
1670
|
+
provider: settings.providerExplicit || !settings.agentName ? settings.provider : undefined,
|
|
1671
|
+
model: settings.model,
|
|
1672
|
+
reasoningEffort: settings.reasoningEffort,
|
|
1673
|
+
agentName: settings.agentName,
|
|
1674
|
+
cwd,
|
|
1675
|
+
signal: turn.abort.signal,
|
|
1676
|
+
steer: turn.steer,
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
catch (error) {
|
|
1680
|
+
const errorMessage = toErrorMessage(error);
|
|
1681
|
+
this.logger.log({
|
|
1682
|
+
event: "message.failed",
|
|
1683
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1684
|
+
chat_id: event.message.chat_id,
|
|
1685
|
+
thread_id: event.message.thread_id,
|
|
1686
|
+
message_id: event.message.message_id,
|
|
1687
|
+
session_key: sessionKey,
|
|
1688
|
+
session_id: sessionId,
|
|
1689
|
+
provider: settings.provider,
|
|
1690
|
+
error: errorMessage,
|
|
1691
|
+
});
|
|
1692
|
+
return {
|
|
1693
|
+
text: `处理失败:${errorMessage}`,
|
|
1694
|
+
provider: settings.provider,
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
const orchestrator = new LarkSessionCardController({
|
|
1698
|
+
sender: this.messageSender,
|
|
1699
|
+
session: {
|
|
1700
|
+
chatId: event.message.chat_id,
|
|
1701
|
+
messageId: event.message.message_id,
|
|
1702
|
+
threadId: event.message.thread_id,
|
|
1703
|
+
},
|
|
1704
|
+
meta: {
|
|
1705
|
+
runtime: getRuntimeProfile(started.provider).shortLabel,
|
|
1706
|
+
model: this.displayModel(started.model),
|
|
1707
|
+
effort: settings.reasoningEffort ?? null,
|
|
1708
|
+
// Same stable id the conversation directory keys on, so the footer id is
|
|
1709
|
+
// exactly what `/resume <id>` accepts (conversationKey === sessionKey).
|
|
1710
|
+
resumeShortId: deriveConversationShortId(sessionKey),
|
|
1711
|
+
},
|
|
1712
|
+
throttleMs: this.cardThrottleMs,
|
|
1713
|
+
logger: { log: (entry) => this.logger.log({ event: "card.update", ...entry }) },
|
|
1714
|
+
stopButton: { sessionKey },
|
|
1715
|
+
onRuntimeSession: (runtimeSessionId) => this.noteRuntimeSession(turn, runtimeSessionId),
|
|
1716
|
+
});
|
|
1717
|
+
try {
|
|
1718
|
+
// Live co-drive single-writer: `runLive` yields THIS turn's canonical events
|
|
1719
|
+
// off the session bus (the forwarder mirrors them). The server's `onMirror`
|
|
1720
|
+
// already persisted them — do NOT persist again here (that was the double
|
|
1721
|
+
// write). Only render the card. Lark shows the user message inline from the
|
|
1722
|
+
// chat, so keep `session.input.consumed` out of the card render.
|
|
1723
|
+
for await (const sessionEvent of started.generator) {
|
|
1724
|
+
if (sessionEvent.type !== "session.input.consumed") {
|
|
1725
|
+
await orchestrator.ingest(sessionEvent);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
await orchestrator.finalizeAll("completed");
|
|
1729
|
+
}
|
|
1730
|
+
catch (error) {
|
|
1731
|
+
const errorMessage = toErrorMessage(error);
|
|
1732
|
+
const cancelled = error instanceof CodexRuntimeError && error.code === "codex_cancelled";
|
|
1733
|
+
this.logger.log({
|
|
1734
|
+
event: cancelled ? "message.cancelled" : "message.failed",
|
|
1735
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1736
|
+
chat_id: event.message.chat_id,
|
|
1737
|
+
thread_id: event.message.thread_id,
|
|
1738
|
+
message_id: event.message.message_id,
|
|
1739
|
+
session_key: sessionKey,
|
|
1740
|
+
session_id: sessionId,
|
|
1741
|
+
provider: this.provider,
|
|
1742
|
+
error: errorMessage,
|
|
1743
|
+
});
|
|
1744
|
+
// The normalizer already emitted `response.failed` (so the card shows the
|
|
1745
|
+
// error); this finalizeAll is a safety net for any still-open card.
|
|
1746
|
+
await orchestrator.finalizeAll(cancelled ? "cancelled" : "failed");
|
|
1747
|
+
return {
|
|
1748
|
+
// A user-triggered cancel needs no redundant chat reply (the card carries it).
|
|
1749
|
+
text: cancelled ? "" : `处理失败:${errorMessage}`,
|
|
1750
|
+
provider: started.provider,
|
|
1751
|
+
model: started.model,
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
// The card carries the answer; only emit a plain-text reply when no card
|
|
1755
|
+
// could be posted (e.g. the card API failed), so the chat isn't left empty.
|
|
1756
|
+
return {
|
|
1757
|
+
text: orchestrator.cardStarted ? "" : orchestrator.fallbackText.trim() || DEFAULT_EMPTY_RESPONSE,
|
|
1758
|
+
provider: started.provider,
|
|
1759
|
+
model: started.model,
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* On the turn's first `session.status` carrying a runtime session id, bind it
|
|
1764
|
+
* to the in-flight turn and register it with the CLI mirror (so the mirror
|
|
1765
|
+
* watches that rollout file, and the bridge's own lines are later claimed).
|
|
1766
|
+
*/
|
|
1767
|
+
noteRuntimeSession(turn, runtimeSessionId) {
|
|
1768
|
+
if (turn.codexSessionId || !runtimeSessionId) {
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
turn.codexSessionId = runtimeSessionId;
|
|
1772
|
+
if (this.mirror) {
|
|
1773
|
+
void this.mirror.noteBoundSession(runtimeSessionId).catch((error) => {
|
|
1774
|
+
this.logger.log({
|
|
1775
|
+
event: "mirror.note_failed",
|
|
1776
|
+
session_key: runtimeSessionId,
|
|
1777
|
+
error: toErrorMessage(error),
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
async buildReplyDrain(event, sessionKey, sessionId, runtimePrompt, cwd, turn, settings) {
|
|
1783
|
+
try {
|
|
1784
|
+
// Live co-drive: inject the turn and collect the reply text from the turn's
|
|
1785
|
+
// mirrored bus events (the server's onMirror persists them — we only read).
|
|
1786
|
+
const started = await this.conversationRuntime.runLive({
|
|
1787
|
+
message: runtimePrompt,
|
|
1788
|
+
threadId: sessionId,
|
|
1789
|
+
provider: settings.providerExplicit || !settings.agentName ? settings.provider : undefined,
|
|
1790
|
+
model: settings.model,
|
|
1791
|
+
reasoningEffort: settings.reasoningEffort,
|
|
1792
|
+
agentName: settings.agentName,
|
|
1793
|
+
cwd,
|
|
1794
|
+
signal: turn.abort.signal,
|
|
1795
|
+
steer: turn.steer,
|
|
1796
|
+
});
|
|
1797
|
+
const answer = await collectLiveRun(started.generator);
|
|
1798
|
+
return {
|
|
1799
|
+
text: answer.trim() || DEFAULT_EMPTY_RESPONSE,
|
|
1800
|
+
provider: started.provider,
|
|
1801
|
+
model: started.model,
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
catch (error) {
|
|
1805
|
+
const errorMessage = toErrorMessage(error);
|
|
1806
|
+
this.logger.log({
|
|
1807
|
+
event: "message.failed",
|
|
1808
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1809
|
+
chat_id: event.message.chat_id,
|
|
1810
|
+
thread_id: event.message.thread_id,
|
|
1811
|
+
message_id: event.message.message_id,
|
|
1812
|
+
session_key: sessionKey,
|
|
1813
|
+
session_id: sessionId,
|
|
1814
|
+
provider: settings.provider,
|
|
1815
|
+
error: errorMessage,
|
|
1816
|
+
});
|
|
1817
|
+
return {
|
|
1818
|
+
text: `处理失败:${errorMessage}`,
|
|
1819
|
+
provider: settings.provider,
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
async sendReplyText(event, text) {
|
|
1824
|
+
const chunks = chunkLarkReplyText(text);
|
|
1825
|
+
if (chunks.length === 0) {
|
|
1826
|
+
return false;
|
|
1827
|
+
}
|
|
1828
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
1829
|
+
const uuid = `lark-reply:${event.message.message_id}:${index}`;
|
|
1830
|
+
if (event.message.thread_id) {
|
|
1831
|
+
await this.messageSender.replyText({
|
|
1832
|
+
messageId: event.message.message_id,
|
|
1833
|
+
text: chunk,
|
|
1834
|
+
uuid,
|
|
1835
|
+
replyInThread: true,
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
else {
|
|
1839
|
+
await this.messageSender.createText({
|
|
1840
|
+
chatId: event.message.chat_id,
|
|
1841
|
+
text: chunk,
|
|
1842
|
+
uuid,
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return true;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
/** Per-instance option readers: parse raw `channels.<id>.options` values
|
|
1850
|
+
* (strings from JSON) into the typed shapes the ingestor expects; `undefined`
|
|
1851
|
+
* ⇒ "not set for this instance" so the caller falls back to global config. */
|
|
1852
|
+
function larkStringOption(options, key) {
|
|
1853
|
+
const value = options?.[key];
|
|
1854
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1855
|
+
}
|
|
1856
|
+
function larkBoolOption(options, key) {
|
|
1857
|
+
const value = options?.[key];
|
|
1858
|
+
if (typeof value === "boolean")
|
|
1859
|
+
return value;
|
|
1860
|
+
if (typeof value === "string" && value.trim())
|
|
1861
|
+
return value.trim().toLowerCase() === "true";
|
|
1862
|
+
return undefined;
|
|
1863
|
+
}
|
|
1864
|
+
function larkListOption(options, key) {
|
|
1865
|
+
const value = options?.[key];
|
|
1866
|
+
if (Array.isArray(value))
|
|
1867
|
+
return value.map(String).map((entry) => entry.trim()).filter(Boolean);
|
|
1868
|
+
if (typeof value === "string" && value.trim()) {
|
|
1869
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
1870
|
+
}
|
|
1871
|
+
return undefined;
|
|
1872
|
+
}
|
|
1873
|
+
export class LocalLarkBotRuntime {
|
|
1874
|
+
clientProvider;
|
|
1875
|
+
messageIngestor;
|
|
1876
|
+
logger;
|
|
1877
|
+
connected = false;
|
|
1878
|
+
lastEventAt = null;
|
|
1879
|
+
lastError = null;
|
|
1880
|
+
constructor({ executionDefaults, agentCatalog, conversationRuntime, options, defaultAgent, logger = defaultLogger, clientProvider = new LarkClientProvider(options, larkSdkLogger(logger)), messageIngestor, capabilities, activeSessionStore: injectedActiveSessionStore, conversationDirectory: injectedConversationDirectory, sessionTitleWriter, sessionProvisioner, }) {
|
|
1881
|
+
this.clientProvider = clientProvider;
|
|
1882
|
+
this.logger = logger;
|
|
1883
|
+
if (messageIngestor) {
|
|
1884
|
+
this.messageIngestor = messageIngestor;
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
if (!clientProvider) {
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
if (!injectedConversationDirectory || !injectedActiveSessionStore) {
|
|
1891
|
+
throw new Error("LocalLarkBotRuntime requires explicit plugin-scoped state stores");
|
|
1892
|
+
}
|
|
1893
|
+
const conversationDirectory = injectedConversationDirectory;
|
|
1894
|
+
// The conversation → current-session routing map is plugin-owned state.
|
|
1895
|
+
const activeSessionStore = injectedActiveSessionStore;
|
|
1896
|
+
const messageSender = clientProvider.getMessageSender();
|
|
1897
|
+
const larkCwd = larkStringOption(options, "LARK_CWD");
|
|
1898
|
+
if (!executionDefaults || !agentCatalog) {
|
|
1899
|
+
throw new Error("LocalLarkBotRuntime requires host execution defaults and an agent catalog adapter");
|
|
1900
|
+
}
|
|
1901
|
+
const allowedRoots = [...new Set([
|
|
1902
|
+
...executionDefaults.allowedRoots.map((root) => path.resolve(root)),
|
|
1903
|
+
...(larkCwd ? [path.resolve(larkCwd)] : []),
|
|
1904
|
+
])];
|
|
1905
|
+
const ingestor = new LarkMessageIngestor({
|
|
1906
|
+
conversationRuntime,
|
|
1907
|
+
messageSender,
|
|
1908
|
+
cwd: larkCwd,
|
|
1909
|
+
defaultRuntime: executionDefaults.runtime,
|
|
1910
|
+
defaultAgent: defaultAgent ?? executionDefaults.defaultAgent,
|
|
1911
|
+
agentCatalog,
|
|
1912
|
+
activeSessionStore,
|
|
1913
|
+
conversationDirectory,
|
|
1914
|
+
logger,
|
|
1915
|
+
capabilities,
|
|
1916
|
+
allowedRoots,
|
|
1917
|
+
disableP2p: larkBoolOption(options, "LARK_DISABLE_P2P") ?? false,
|
|
1918
|
+
groupAllowlistEnabled: larkBoolOption(options, "LARK_GROUP_ALLOWLIST_ENABLED") ?? false,
|
|
1919
|
+
groupAllowlist: larkListOption(options, "LARK_GROUP_ALLOWLIST") ?? [],
|
|
1920
|
+
sessionTitleWriter,
|
|
1921
|
+
sessionProvisioner,
|
|
1922
|
+
});
|
|
1923
|
+
this.messageIngestor = ingestor;
|
|
1924
|
+
}
|
|
1925
|
+
getStatus() {
|
|
1926
|
+
return {
|
|
1927
|
+
connected: this.connected,
|
|
1928
|
+
mode: "ws",
|
|
1929
|
+
last_event_at: this.lastEventAt,
|
|
1930
|
+
last_error: this.lastError,
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
async start() {
|
|
1934
|
+
if (!this.clientProvider || !this.messageIngestor) {
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
// Resolve our own open_id before connecting so group `@`-mention routing is
|
|
1938
|
+
// in effect for the very first message. Best-effort: on failure the
|
|
1939
|
+
// ingestor keeps its legacy "any mention triggers" behavior.
|
|
1940
|
+
if (this.clientProvider.getBotOpenId) {
|
|
1941
|
+
const botOpenId = await this.clientProvider.getBotOpenId().catch(() => null);
|
|
1942
|
+
this.messageIngestor.setBotOpenId(botOpenId);
|
|
1943
|
+
this.logger.log({
|
|
1944
|
+
event: botOpenId ? "runtime.bot_identity_resolved" : "runtime.bot_identity_unresolved",
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
try {
|
|
1948
|
+
await this.clientProvider.start({
|
|
1949
|
+
onMessage: (event) => {
|
|
1950
|
+
this.lastEventAt = new Date().toISOString();
|
|
1951
|
+
void this.messageIngestor?.ingest(event).catch((error) => {
|
|
1952
|
+
this.lastError = toErrorMessage(error);
|
|
1953
|
+
this.logger.log({
|
|
1954
|
+
event: "runtime.ingest_failed",
|
|
1955
|
+
error: this.lastError,
|
|
1956
|
+
tenant_key: event.tenant_key ?? event.sender.tenant_key,
|
|
1957
|
+
chat_id: event.message.chat_id,
|
|
1958
|
+
thread_id: event.message.thread_id,
|
|
1959
|
+
message_id: event.message.message_id,
|
|
1960
|
+
});
|
|
1961
|
+
});
|
|
1962
|
+
},
|
|
1963
|
+
onCardAction: async (cardEvent) => {
|
|
1964
|
+
this.lastEventAt = new Date().toISOString();
|
|
1965
|
+
try {
|
|
1966
|
+
return await this.messageIngestor.ingestCardAction(cardEvent);
|
|
1967
|
+
}
|
|
1968
|
+
catch (error) {
|
|
1969
|
+
this.lastError = toErrorMessage(error);
|
|
1970
|
+
this.logger.log({
|
|
1971
|
+
event: "runtime.card_action_failed",
|
|
1972
|
+
error: this.lastError,
|
|
1973
|
+
message_id: cardEvent.messageId,
|
|
1974
|
+
});
|
|
1975
|
+
return {
|
|
1976
|
+
toast: {
|
|
1977
|
+
type: "error",
|
|
1978
|
+
content: `卡片操作失败:${this.lastError}`,
|
|
1979
|
+
},
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
},
|
|
1983
|
+
});
|
|
1984
|
+
this.connected = true;
|
|
1985
|
+
this.lastError = null;
|
|
1986
|
+
this.logger.log({
|
|
1987
|
+
event: "runtime.connected",
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
catch (error) {
|
|
1991
|
+
this.connected = false;
|
|
1992
|
+
this.lastError = toErrorMessage(error);
|
|
1993
|
+
this.logger.log({
|
|
1994
|
+
event: "runtime.connection_failed",
|
|
1995
|
+
error: this.lastError,
|
|
1996
|
+
});
|
|
1997
|
+
throw error;
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
async stop() {
|
|
2001
|
+
if (!this.clientProvider) {
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
await this.clientProvider.stop();
|
|
2005
|
+
this.connected = false;
|
|
2006
|
+
this.logger.log({
|
|
2007
|
+
event: "runtime.disconnected",
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
}
|