tellrun 1.2.0 → 1.2.5

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.
@@ -1,83 +1,22 @@
1
- import { randomUUID } from "node:crypto";
2
- import { chmodSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
1
+ import { chmodSync, mkdirSync, readFileSync } from "node:fs";
3
2
  import { dirname, join } from "node:path";
4
3
  import { appDirectory } from "../config.js";
5
4
  import { writeJson } from "./json.js";
5
+ import { acquireLock } from "./lock.js";
6
6
  import { activeTaskStatuses, stateSchema } from "./schema.js";
7
7
  /** 打开文件状态并取得单实例锁;所有业务写入统一替换 state.json。 */
8
8
  export function openStore(path = join(appDirectory, "state.json")) {
9
9
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
10
10
  if (dirname(path) === appDirectory)
11
11
  chmodSync(appDirectory, 0o700);
12
- const lock = `${path}.lock`;
13
- const owner = `${process.pid}-${randomUUID()}`;
14
- let owned = false;
12
+ const release = acquireLock(path);
15
13
  let usable = true;
16
14
  let updating = false;
17
15
  /** 流式预览只保留内存中的最新片段,不为每个文本增量写磁盘。 */
18
16
  let draft;
19
- /** 只删除本实例的所有权文件;非空目录不能被另一个退出中的实例移除。 */
20
- function release() {
21
- if (!owned)
22
- return;
23
- rmSync(join(lock, owner), { force: true });
24
- owned = false;
25
- try {
26
- rmdirSync(lock);
27
- }
28
- catch (error) {
29
- if (!(error instanceof Error &&
30
- "code" in error &&
31
- ["ENOENT", "ENOTEMPTY"].includes(String(error.code))))
32
- throw error;
33
- }
34
- }
17
+ /** 单个发送循环正在处理的回复;交互关闭时仍需保留它的消息清理关联。 */
18
+ let delivering;
35
19
  try {
36
- /** mkdir 竞争唯一写入者;回收旧锁必须先确认 PID 已退出,再删除该所有权文件。 */
37
- for (let attempt = 0; attempt < 3 && !owned; attempt++) {
38
- try {
39
- mkdirSync(lock, { mode: 0o700 });
40
- writeFileSync(join(lock, owner), "", { flag: "wx", mode: 0o600 });
41
- owned = true;
42
- if (readdirSync(lock).length !== 1)
43
- throw new Error("State lock busy. Try again.");
44
- }
45
- catch (error) {
46
- if (owned || !(error instanceof Error && "code" in error && error.code === "EEXIST"))
47
- throw error;
48
- try {
49
- const owners = readdirSync(lock);
50
- if (owners.length === 0 && Date.now() - statSync(lock).mtimeMs > 30_000) {
51
- rmdirSync(lock);
52
- continue;
53
- }
54
- const previous = owners.length === 1 ? owners[0] : undefined;
55
- const pid = previous?.match(/^([1-9]\d*)-[0-9a-f-]+$/)?.[1];
56
- if (!previous || !pid)
57
- throw new Error("State lock initializing or invalid. Check for another instance.");
58
- try {
59
- process.kill(Number(pid), 0);
60
- }
61
- catch (error) {
62
- if (!(error instanceof Error && "code" in error && error.code === "ESRCH"))
63
- throw error;
64
- /** 只有成功移除旧所有权文件的实例才能回收目录,避免竞争时删除新锁。 */
65
- unlinkSync(join(lock, previous));
66
- rmdirSync(lock);
67
- continue;
68
- }
69
- throw new Error("Another Tellrun instance is running. Stop it first.");
70
- }
71
- catch (error) {
72
- if (!(error instanceof Error &&
73
- "code" in error &&
74
- ["ENOENT", "ENOTEMPTY"].includes(String(error.code))))
75
- throw error;
76
- }
77
- }
78
- }
79
- if (!owned)
80
- throw new Error("Cannot acquire state lock. Try again.");
81
20
  let state;
82
21
  try {
83
22
  const contents = readFileSync(path, "utf8");
@@ -92,9 +31,11 @@ export function openStore(path = join(appDirectory, "state.json")) {
92
31
  catch (error) {
93
32
  if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
94
33
  throw error;
95
- state = { channels: [], inboundUpdates: [], tasks: [], outbox: [] };
34
+ state = { channels: [], inboundUpdates: [], tasks: [], interactions: [], outbox: [] };
96
35
  writeJson(path, state);
97
36
  }
37
+ /** 交互撤回可能清空发送队列;实例内保持递增,避免迟到发送回执删除新消息。 */
38
+ let replyId = state.outbox.reduce((max, item) => Math.max(max, item.id), 0);
98
39
  /** 一次业务操作只保存一个快照;嵌套操作合并写入,失败时回滚内存并停止后续写入。 */
99
40
  function update(action) {
100
41
  if (!usable)
@@ -106,6 +47,14 @@ export function openStore(path = join(appDirectory, "state.json")) {
106
47
  updating = true;
107
48
  try {
108
49
  const result = action();
50
+ /** state 只保留运行所需记录;发送中的交互及待发送编辑不能提前回收。 */
51
+ state.interactions = state.interactions.filter((item) => item.status !== "closed" ||
52
+ item.id === delivering?.interactionId ||
53
+ state.outbox.some((reply) => reply.interactionId === item.id));
54
+ const latest = new Set(state.channels.map((channel) => state.tasks.findLast((task) => task.channelId === channel.id && !activeTaskStatuses.has(task.status))?.id));
55
+ state.tasks = state.tasks.filter((task) => activeTaskStatuses.has(task.status) ||
56
+ latest.has(task.id) ||
57
+ state.interactions.some((item) => item.taskId === task.id));
109
58
  try {
110
59
  writeJson(path, state);
111
60
  }
@@ -124,20 +73,65 @@ export function openStore(path = join(appDirectory, "state.json")) {
124
73
  }
125
74
  }
126
75
  /** 无回复时跳过入队;其余按 Unicode 字符拆分,和触发它的状态变更一起保存。 */
127
- function enqueueReply(channelId, text) {
76
+ function enqueueReply(channelId, text, delivery = {}) {
128
77
  if (text === undefined)
129
78
  return;
130
- const parts = text.match(/[\s\S]{1,2000}/gu) ?? ["(No text reply)"];
79
+ const parts = delivery.messageId
80
+ ? [text]
81
+ : (text.match(/[\s\S]{1,2000}/gu) ?? ["(No text reply)"]);
131
82
  for (const [index, part] of parts.entries()) {
132
83
  state.outbox.push({
133
- id: state.outbox.reduce((max, item) => Math.max(max, item.id), 0) + 1,
84
+ id: ++replyId,
134
85
  channelId,
135
86
  text: parts.length > 1 ? `(${index + 1}/${parts.length})\n${part}` : part,
136
87
  attempts: 0,
137
88
  retryAt: Date.now(),
89
+ ...delivery,
90
+ ...((delivery.interactionId || delivery.permissionMenuId) && !delivery.messageId
91
+ ? { promptEnd: index === parts.length - 1 }
92
+ : {}),
138
93
  });
139
94
  }
140
95
  }
96
+ /** 新建、使用或失效菜单时清理按钮,发送途中的旧消息由回执继续清理。 */
97
+ function closePermissionMenu(channelId) {
98
+ const channel = state.channels.find((item) => item.id === channelId);
99
+ if (!channel?.permissionMenu)
100
+ return;
101
+ state.outbox = state.outbox.filter((reply) => reply.permissionMenuId !== channel.permissionMenu?.id);
102
+ for (const message of channel.permissionMenu.messages)
103
+ enqueueReply(channel.id, message.text, { messageId: message.messageId });
104
+ channel.permissionMenu = null;
105
+ }
106
+ /** 关闭请求或单个已答问题;撤回未发送提示,已发送消息保留正文并移除按钮。 */
107
+ function closeInteraction(id, reason, questionIndex) {
108
+ const interaction = state.interactions.find((item) => item.id === id);
109
+ if (!interaction || interaction.status === "closed")
110
+ return;
111
+ update(() => {
112
+ const interaction = state.interactions.find((item) => item.id === id);
113
+ const task = state.tasks.find((task) => task.id === interaction?.taskId);
114
+ if (!interaction || !task)
115
+ return;
116
+ if (questionIndex === undefined) {
117
+ interaction.status = "closed";
118
+ interaction.resolution = reason;
119
+ }
120
+ state.outbox = state.outbox.filter((reply) => reply.interactionId !== id ||
121
+ (questionIndex !== undefined && reply.questionIndex !== questionIndex));
122
+ for (const message of interaction.messages) {
123
+ if (questionIndex !== undefined && message.questionIndex !== questionIndex)
124
+ continue;
125
+ enqueueReply(task.channelId, `${message.text}\n\n${reason}`, {
126
+ interactionId: id,
127
+ messageId: message.messageId,
128
+ ...(message.questionIndex !== undefined
129
+ ? { questionIndex: message.questionIndex }
130
+ : {}),
131
+ });
132
+ }
133
+ });
134
+ }
141
135
  /** 终态与最终回复一起保存,成功时只发送正文;迟到事件不能覆盖已完成结果。 */
142
136
  function finishTask(id, result) {
143
137
  if (!state.tasks.some((task) => task.id === id && activeTaskStatuses.has(task.status)))
@@ -147,6 +141,8 @@ export function openStore(path = join(appDirectory, "state.json")) {
147
141
  if (!task)
148
142
  throw new Error("Task not found.");
149
143
  Object.assign(task, { status: result.status, result: result.text });
144
+ for (const interaction of state.interactions.filter((item) => item.taskId === id))
145
+ closeInteraction(interaction.id, "Task ended. Request closed.");
150
146
  if (result.status === "interrupted") {
151
147
  const channel = state.channels.find((channel) => channel.id === task.channelId);
152
148
  if (channel)
@@ -169,6 +165,20 @@ export function openStore(path = join(appDirectory, "state.json")) {
169
165
  if (draft?.taskId === id)
170
166
  draft = undefined;
171
167
  }
168
+ /** 命令与交互按钮共用停止逻辑;先使交互失效,再由核心层请求中断。 */
169
+ function cancelTask(id) {
170
+ if (state.tasks.find((task) => task.id === id)?.status === "queued") {
171
+ finishTask(id, { status: "cancelled", text: "Cancelled before starting." });
172
+ }
173
+ else
174
+ update(() => {
175
+ const task = state.tasks.find((task) => task.id === id && activeTaskStatuses.has(task.status));
176
+ if (task)
177
+ task.status = "cancelling";
178
+ for (const interaction of state.interactions.filter((item) => item.taskId === id))
179
+ closeInteraction(interaction.id, "Stopping. Request closed.");
180
+ });
181
+ }
172
182
  return {
173
183
  /** 去重标识、任务和必要回复合并保存,成功返回后才能确认入站事件。 */
174
184
  accept(message, handle) {
@@ -177,8 +187,10 @@ export function openStore(path = join(appDirectory, "state.json")) {
177
187
  channel.chatId === message.channel.chatId);
178
188
  if (existing &&
179
189
  state.inboundUpdates.some((item) => item.channelId === existing.id && item.eventId === message.eventId))
180
- return;
181
- update(() => {
190
+ return ["interaction", "permissions"].includes(message.content.type)
191
+ ? "Already handled."
192
+ : undefined;
193
+ return update(() => {
182
194
  let channel = state.channels.find((channel) => channel.id === existing?.id);
183
195
  if (!channel) {
184
196
  channel = {
@@ -187,6 +199,8 @@ export function openStore(path = join(appDirectory, "state.json")) {
187
199
  cwd: null,
188
200
  sessionId: null,
189
201
  paused: false,
202
+ permissionMode: null,
203
+ permissionMenu: null,
190
204
  };
191
205
  state.channels.push(channel);
192
206
  }
@@ -195,17 +209,75 @@ export function openStore(path = join(appDirectory, "state.json")) {
195
209
  channelId: channel.id,
196
210
  eventId: message.eventId,
197
211
  });
198
- enqueueReply(channel.id, handle(channel));
212
+ const reply = handle(channel);
213
+ if (!["interaction", "permissions"].includes(message.content.type))
214
+ enqueueReply(channel.id, reply);
215
+ return reply;
199
216
  });
200
217
  },
218
+ /** 成功拉取后才能回收已确认更新;空响应也说明服务端已无可重投记录。 */
219
+ confirmUpdates(accountId, offset, empty) {
220
+ const pending = state.inboundUpdates.filter((item) => {
221
+ const channel = state.channels.find((channel) => channel.id === item.channelId);
222
+ return (channel?.platform !== "telegram" ||
223
+ channel.accountId !== accountId ||
224
+ (!empty && !(Number(item.eventId) < offset)));
225
+ });
226
+ if (pending.length !== state.inboundUpdates.length)
227
+ update(() => {
228
+ state.inboundUpdates = pending;
229
+ });
230
+ },
201
231
  /** 新会话或目录变化只影响后续任务,已保存任务仍使用自己的快照。 */
202
232
  setSession(channelId, cwd) {
203
233
  update(() => {
204
234
  const channel = state.channels.find((channel) => channel.id === channelId);
205
- if (channel)
235
+ if (channel) {
236
+ closePermissionMenu(channelId);
237
+ if (cwd !== channel.cwd)
238
+ channel.permissionMode = null;
206
239
  Object.assign(channel, { cwd, sessionId: null });
240
+ }
241
+ });
242
+ },
243
+ /** 菜单只依赖通用模式信息;不启动 Agent 或调用模型。 */
244
+ openPermissions(channelId, modes) {
245
+ update(() => {
246
+ const channel = state.channels.find((item) => item.id === channelId);
247
+ if (!channel)
248
+ return;
249
+ closePermissionMenu(channelId);
250
+ channel.permissionMenu = {
251
+ id: crypto.randomUUID(),
252
+ expiresAt: Date.now() + 600_000,
253
+ options: modes.map(({ id, label }) => ({ id, label })),
254
+ messages: [],
255
+ };
256
+ enqueueReply(channelId, `Permissions\nCurrent selection: ${modes.find((mode) => mode.id === channel.permissionMode)?.label ?? channel.permissionMode ?? modes[0]?.label ?? "Not set"}\n\n${modes.map((mode, index) => `${index + 1}. ${mode.label}`).join("\n")}\n\nApplies to future tasks. The current task keeps its permissions. Modes are subject to local configuration.`, { permissionMenuId: channel.permissionMenu.id });
207
257
  });
208
258
  },
259
+ /** 校验聊天、原消息、菜单和有效期;只保存模式,不改变已提交任务的快照。 */
260
+ choosePermissions(channelId, content) {
261
+ const channel = state.channels.find((item) => item.id === channelId);
262
+ const menu = channel?.permissionMenu;
263
+ if (!menu ||
264
+ menu.id !== content.id ||
265
+ menu.expiresAt <= Date.now() ||
266
+ !menu.messages.some((message) => message.messageId === content.messageId))
267
+ return "Menu expired. Open /permissions again.";
268
+ const option = menu.options[content.optionIndex];
269
+ if (!option)
270
+ return "Invalid permission option.";
271
+ update(() => {
272
+ const current = state.channels.find((item) => item.id === channelId);
273
+ if (!current)
274
+ return;
275
+ current.permissionMode = option.id;
276
+ closePermissionMenu(channelId);
277
+ enqueueReply(channelId, `Selected: ${option.label}. Applies to the next task.`);
278
+ });
279
+ return "Permission mode saved.";
280
+ },
209
281
  /** 暂停状态跨重启保留,恢复须由用户明确触发。 */
210
282
  setPaused(channelId, paused) {
211
283
  update(() => {
@@ -216,7 +288,13 @@ export function openStore(path = join(appDirectory, "state.json")) {
216
288
  },
217
289
  /** 返回快照,避免调用方直接修改内存中的持久状态。 */
218
290
  activeTask() {
219
- return structuredClone(state.tasks.find((task) => activeTaskStatuses.has(task.status)));
291
+ const task = state.tasks.find((task) => activeTaskStatuses.has(task.status));
292
+ return task
293
+ ? structuredClone({
294
+ ...task,
295
+ interactions: state.interactions.filter((item) => item.taskId === task.id && item.status !== "closed"),
296
+ })
297
+ : undefined;
220
298
  },
221
299
  /** 限制预览为末尾 2000 个 Unicode 字符,完整结果仍由最终回复保存。 */
222
300
  setDraft(id, text) {
@@ -227,6 +305,8 @@ export function openStore(path = join(appDirectory, "state.json")) {
227
305
  pendingDraft(accountId, userId) {
228
306
  if (!draft?.text.trim())
229
307
  return;
308
+ if (state.interactions.some((item) => item.taskId === draft?.taskId && item.blocking && item.status !== "closed"))
309
+ return;
230
310
  const task = state.tasks.find((task) => task.id === draft?.taskId && task.status === "running");
231
311
  const channel = state.channels.find((channel) => channel.id === task?.channelId);
232
312
  if (channel?.platform === "telegram" &&
@@ -236,7 +316,7 @@ export function openStore(path = join(appDirectory, "state.json")) {
236
316
  },
237
317
  /** 最近结果按聊天隔离,供 /last 再次查看。 */
238
318
  lastTask(channelId) {
239
- return structuredClone(state.tasks.findLast((task) => task.channelId === channelId));
319
+ return structuredClone(state.tasks.findLast((task) => task.channelId === channelId && !activeTaskStatuses.has(task.status)) ?? state.tasks.findLast((task) => task.channelId === channelId));
240
320
  },
241
321
  /** 接收时固定目录、会话与输入,限制全局只有一个未结束任务。 */
242
322
  createTask(channel, prompt) {
@@ -251,6 +331,7 @@ export function openStore(path = join(appDirectory, "state.json")) {
251
331
  cwd: channel.cwd,
252
332
  sessionId: channel.sessionId,
253
333
  prompt,
334
+ permissionMode: channel.permissionMode,
254
335
  inputs: [],
255
336
  status: "queued",
256
337
  result: null,
@@ -335,50 +416,230 @@ export function openStore(path = join(appDirectory, "state.json")) {
335
416
  task.status = "running";
336
417
  });
337
418
  },
338
- /** 未派发任务直接取消;其余等待 Agent 确认终态。 */
339
- cancelTask(id) {
340
- if (state.tasks.find((task) => task.id === id)?.status === "queued") {
341
- finishTask(id, { status: "cancelled", text: "Cancelled before starting." });
419
+ cancelTask,
420
+ finishTask,
421
+ /** 交互展示与任务关联一起保存;多题分别发送,任意一段都能明确回复。 */
422
+ addInteraction(taskId, request) {
423
+ update(() => {
424
+ const task = state.tasks.find((task) => task.id === taskId && task.status === "running");
425
+ if (!task || state.interactions.some((item) => item.id === request.id))
426
+ return;
427
+ state.interactions.push({
428
+ ...request,
429
+ taskId,
430
+ questions: request.questions.map((question) => ({ ...question, answer: null })),
431
+ decision: null,
432
+ status: "pending",
433
+ resolution: null,
434
+ messages: [],
435
+ });
436
+ if (request.kind === "approval") {
437
+ enqueueReply(task.channelId, `Task #${taskId} · Approval\n${request.text}\n\nChoose below.`, { interactionId: request.id });
438
+ }
439
+ else {
440
+ for (const [index, question] of request.questions.entries())
441
+ enqueueReply(task.channelId, `Task #${taskId} · Question ${index + 1}/${request.questions.length}\n${question.header}\n${question.question}${question.options.map((option, i) => `\n\n${i + 1}. ${option.label}\n${option.description}`).join("")}\n\n${question.allowText ? "Pick an option or reply to this message." : "Pick an option or reply with its number or label."}\nAnswer all questions to submit.`, { interactionId: request.id, questionIndex: index });
442
+ }
443
+ });
444
+ },
445
+ /** 与入站去重同一事务领取选择;不把普通消息或旧问题回复误作当前任务输入。 */
446
+ answerInteraction(channelId, content) {
447
+ const interaction = state.interactions.find((item) => content.type === "interaction"
448
+ ? item.id === content.id
449
+ : item.messages.some((message) => message.messageId === content.replyToMessageId) &&
450
+ state.tasks.some((task) => task.id === item.taskId && task.channelId === channelId));
451
+ if (!interaction)
452
+ return content.type === "interaction"
453
+ ? "Request expired."
454
+ : content.replyToMessageId !== undefined
455
+ ? "Send a new message to continue."
456
+ : undefined;
457
+ const task = state.tasks.find((task) => task.id === interaction.taskId && task.channelId === channelId);
458
+ const message = interaction.messages.find((item) => item.messageId ===
459
+ (content.type === "interaction" ? content.messageId : content.replyToMessageId));
460
+ if (task?.status !== "running" ||
461
+ !message ||
462
+ interaction.status !== "pending" ||
463
+ interaction.expiresAt <= Date.now())
464
+ return "Request closed. Check /status.";
465
+ if (content.type === "interaction" && content.action === "stop") {
466
+ update(() => {
467
+ const channel = state.channels.find((channel) => channel.id === channelId);
468
+ if (channel)
469
+ channel.paused = true;
470
+ cancelTask(task.id);
471
+ });
472
+ return "Stopping. Wait for the result.";
342
473
  }
343
- else
474
+ if (interaction.kind === "approval") {
475
+ if (content.type !== "interaction" ||
476
+ (content.action !== "accept" && content.action !== "decline") ||
477
+ !interaction.choices.includes(content.action))
478
+ return "Use the approval buttons.";
344
479
  update(() => {
345
- const task = state.tasks.find((task) => task.id === id && activeTaskStatuses.has(task.status));
346
- if (task)
347
- task.status = "cancelling";
480
+ const current = state.interactions.find((item) => item.id === interaction.id);
481
+ if (!current)
482
+ return;
483
+ current.decision = content.action;
484
+ current.status = "ready";
348
485
  });
486
+ return "Saved. Submitting...";
487
+ }
488
+ const question = message.questionIndex === undefined
489
+ ? undefined
490
+ : interaction.questions[message.questionIndex];
491
+ if (!question || question.answer !== null)
492
+ return "Question closed.";
493
+ let answer;
494
+ if (content.type === "interaction") {
495
+ if (content.action !== "option" ||
496
+ content.questionIndex !== message.questionIndex ||
497
+ content.optionIndex === undefined)
498
+ return "Invalid option.";
499
+ answer = question.options[content.optionIndex]?.label;
500
+ }
501
+ else {
502
+ answer = content.text.trim();
503
+ answer =
504
+ question.options.find((option, index) => String(index + 1) === answer || option.label === answer)?.label ?? (question.allowText ? answer : undefined);
505
+ }
506
+ if (!answer)
507
+ return "Invalid answer. Try again.";
508
+ update(() => {
509
+ const current = state.interactions.find((item) => item.id === interaction.id);
510
+ const question = current?.questions[message.questionIndex ?? -1];
511
+ if (!current || !question)
512
+ return;
513
+ question.answer = answer;
514
+ closeInteraction(interaction.id, "Answer saved.", message.questionIndex);
515
+ if (current.questions.every((question) => question.answer !== null))
516
+ current.status = "ready";
517
+ });
518
+ return "Answer saved.";
349
519
  },
350
- finishTask,
520
+ /** 提交前持久化 sending;重启不会重放已经领取的批准或答案。 */
521
+ claimInteraction(taskId) {
522
+ const interaction = state.interactions.find((item) => item.taskId === taskId && item.status === "ready" && item.expiresAt > Date.now());
523
+ if (!interaction ||
524
+ !state.tasks.some((task) => task.id === taskId && task.status === "running"))
525
+ return;
526
+ return update(() => {
527
+ const current = state.interactions.find((item) => item.id === interaction.id);
528
+ if (!current)
529
+ return;
530
+ current.status = "sending";
531
+ const answer = current.kind === "approval" && current.decision
532
+ ? { decision: current.decision }
533
+ : {
534
+ answers: Object.fromEntries(current.questions.map((question) => [
535
+ question.id,
536
+ { answers: [question.answer ?? ""] },
537
+ ])),
538
+ };
539
+ return { id: current.id, answer };
540
+ });
541
+ },
542
+ closeInteraction,
351
543
  /** 结果不确定的任务暂停并通知用户,不自动重新派发。 */
352
544
  recoverTasks() {
545
+ if (state.channels.some((channel) => channel.permissionMenu))
546
+ update(() => {
547
+ for (const channel of state.channels)
548
+ closePermissionMenu(channel.id);
549
+ });
353
550
  for (const task of state.tasks.filter((task) => activeTaskStatuses.has(task.status) && task.status !== "queued")) {
354
551
  finishTask(task.id, {
355
552
  status: "interrupted",
356
553
  text: "Service restarted; task result unknown. Check the project, then /resume and send a new task.",
357
554
  });
358
555
  }
556
+ for (const interaction of state.interactions)
557
+ closeInteraction(interaction.id, "Service restarted. Request closed.");
359
558
  },
360
- /** 按机器人与授权用户取最早回复,重试保持发送顺序。 */
361
- pendingReply(accountId, userId) {
559
+ /** 按机器人与授权用户领取最早回复,内存关联保留到发送成功或失败。 */
560
+ claimReply(accountId, userId) {
561
+ if (delivering)
562
+ return;
563
+ for (const channel of state.channels)
564
+ if (channel.permissionMenu && channel.permissionMenu.expiresAt <= Date.now())
565
+ update(() => closePermissionMenu(channel.id));
362
566
  for (const reply of state.outbox) {
567
+ const interaction = state.interactions.find((item) => item.id === reply.interactionId);
568
+ if (reply.interactionId &&
569
+ !reply.messageId &&
570
+ (interaction?.status !== "pending" ||
571
+ interaction.expiresAt <= Date.now() ||
572
+ (reply.questionIndex !== undefined &&
573
+ interaction.questions[reply.questionIndex]?.answer !== null)))
574
+ continue;
363
575
  const channel = state.channels.find((channel) => channel.id === reply.channelId);
364
576
  if (channel?.platform === "telegram" &&
365
577
  channel.accountId === accountId &&
366
578
  channel.userId === userId) {
367
- return reply.retryAt <= Date.now()
368
- ? structuredClone({ ...reply, chatId: channel.chatId })
369
- : undefined;
579
+ if (reply.retryAt > Date.now())
580
+ return;
581
+ delivering = structuredClone(reply);
582
+ return structuredClone({
583
+ ...reply,
584
+ chatId: channel.chatId,
585
+ interaction,
586
+ permissionMenu: channel.permissionMenu?.id === reply.permissionMenuId
587
+ ? channel.permissionMenu
588
+ : undefined,
589
+ });
370
590
  }
371
591
  }
372
592
  },
373
- /** 发送成功或永久拒绝后移出发送队列,任务结果仍保留。 */
374
- removeReply(id) {
593
+ /** 发送成功或永久拒绝后移出队列;/last 只保留每个聊天的最近结果。 */
594
+ removeReply(id, delivery) {
375
595
  update(() => {
596
+ const channelId = delivering?.id === id
597
+ ? delivering.channelId
598
+ : state.outbox.find((reply) => reply.id === id)?.channelId;
599
+ if (delivering?.id === id)
600
+ delivering = undefined;
376
601
  state.outbox = state.outbox.filter((reply) => reply.id !== id);
602
+ if (delivery?.permissionMenuId && channelId !== undefined) {
603
+ const channel = state.channels.find((item) => item.id === channelId);
604
+ if (channel?.permissionMenu?.id === delivery.permissionMenuId &&
605
+ channel.permissionMenu.expiresAt > Date.now())
606
+ channel.permissionMenu.messages.push({
607
+ messageId: delivery.messageId,
608
+ text: delivery.text,
609
+ });
610
+ else
611
+ enqueueReply(channelId, delivery.text, { messageId: delivery.messageId });
612
+ }
613
+ const interaction = state.interactions.find((item) => item.id === delivery?.interactionId);
614
+ const task = state.tasks.find((task) => task.id === interaction?.taskId);
615
+ if (interaction && task && delivery) {
616
+ interaction.messages.push({
617
+ messageId: delivery.messageId,
618
+ text: delivery.text,
619
+ ...(delivery.questionIndex !== undefined
620
+ ? { questionIndex: delivery.questionIndex }
621
+ : {}),
622
+ });
623
+ /** 发送途中请求可能已关闭或该问题已回答;迟到的消息也必须移除按钮。 */
624
+ if (interaction.status !== "pending" ||
625
+ interaction.expiresAt <= Date.now() ||
626
+ (delivery.questionIndex !== undefined &&
627
+ interaction.questions[delivery.questionIndex]?.answer !== null))
628
+ enqueueReply(task.channelId, `${delivery.text}\n\n${interaction.resolution ?? "Request closed."}`, {
629
+ interactionId: interaction.id,
630
+ messageId: delivery.messageId,
631
+ ...(delivery.questionIndex !== undefined
632
+ ? { questionIndex: delivery.questionIndex }
633
+ : {}),
634
+ });
635
+ }
377
636
  });
378
637
  },
379
638
  /** 发送失败只更新回复的重试信息,不影响任务执行状态。 */
380
639
  retryReply(id, seconds) {
381
640
  update(() => {
641
+ if (delivering?.id === id)
642
+ delivering = undefined;
382
643
  const reply = state.outbox.find((reply) => reply.id === id);
383
644
  if (reply)
384
645
  Object.assign(reply, {