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.
- package/README.md +42 -90
- package/README.zh-CN.md +42 -90
- package/dist/agents/codex-interaction.js +126 -0
- package/dist/agents/codex-interaction.js.map +1 -0
- package/dist/agents/codex.js +173 -11
- package/dist/agents/codex.js.map +1 -1
- package/dist/cli.js +11 -6
- package/dist/cli.js.map +1 -1
- package/dist/core/relay.js +39 -9
- package/dist/core/relay.js.map +1 -1
- package/dist/im/telegram.js +150 -14
- package/dist/im/telegram.js.map +1 -1
- package/dist/launchd.js +212 -0
- package/dist/launchd.js.map +1 -0
- package/dist/logger.js +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/main.js +27 -2
- package/dist/main.js.map +1 -1
- package/dist/service.js +15 -0
- package/dist/service.js.map +1 -0
- package/dist/storage/lock.js +78 -0
- package/dist/storage/lock.js.map +1 -0
- package/dist/storage/schema.js +50 -0
- package/dist/storage/schema.js.map +1 -1
- package/dist/storage/store.js +353 -92
- package/dist/storage/store.js.map +1 -1
- package/dist/systemd.js +1 -7
- package/dist/systemd.js.map +1 -1
- package/docs/architecture.md +118 -91
- package/docs/prd.md +13 -8
- package/package.json +1 -1
package/dist/storage/store.js
CHANGED
|
@@ -1,83 +1,22 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
|
346
|
-
if (
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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, {
|