@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.28
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/.env.example +10 -1
- package/AGENTS.md +40 -9
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +31 -1
- package/Dockerfile +1 -0
- package/README.md +84 -12
- package/bin/ezenciel-agents-application +2 -0
- package/bin/ezenciel-agents-application.mjs +16 -0
- package/compose.yaml +8 -0
- package/docker/entrypoint.sh +20 -2
- package/docker/healthcheck.mjs +1 -1
- package/docker/run.ts +3 -3
- package/docker/smoke.mjs +41 -2
- package/docs/application-channel.md +366 -0
- package/docs/architecture/ai-selection.md +12 -15
- package/docs/docker-runtime.md +29 -0
- package/docs/host-service.md +5 -8
- package/docs/local-qa.md +1 -1
- package/docs/managed-applications.md +68 -0
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugin-connection.md +76 -0
- package/docs/plugins.md +54 -5
- package/docs/repair.md +26 -25
- package/docs/responsive-channels.md +13 -55
- package/docs/scheduling.md +40 -36
- package/docs/setup.md +11 -21
- package/docs/standalone-cli.md +2 -2
- package/docs/upgrades.md +43 -18
- package/package.json +8 -4
- package/src/agent-guidance.ts +32 -3
- package/src/ai-cli.ts +5 -1
- package/src/ai.ts +6 -28
- package/src/application-channel.ts +308 -0
- package/src/application-cli.ts +41 -0
- package/src/application-client.mjs +87 -0
- package/src/application-origin.ts +15 -0
- package/src/codex-session.ts +7 -10
- package/src/config.ts +23 -5
- package/src/control-state.ts +274 -21
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/desktop-bridge.ts +11 -43
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +29 -58
- package/src/host-executor.ts +11 -9
- package/src/identity.ts +11 -3
- package/src/index.ts +191 -93
- package/src/menu.ts +76 -55
- package/src/message-history.ts +52 -0
- package/src/message-send.ts +1 -1
- package/src/message.ts +49 -7
- package/src/model-policy.ts +5 -15
- package/src/owner.ts +7 -1
- package/src/plugins/connection-artifacts.mjs +31 -0
- package/src/plugins/connection.mjs +124 -0
- package/src/plugins/manager.mjs +93 -23
- package/src/plugins/native-tasks.d.mts +4 -0
- package/src/plugins/native-tasks.mjs +66 -0
- package/src/plugins/workspace-lease.d.mts +3 -0
- package/src/plugins/workspace-lease.mjs +44 -0
- package/src/repair-policy.ts +0 -8
- package/src/reply-context.ts +3 -29
- package/src/runs.ts +67 -9
- package/src/schedule-cli.ts +33 -15
- package/src/scheduled-tasks.ts +20 -21
- package/src/scheduler.ts +55 -22
- package/src/task-executor.ts +4 -5
- package/src/task-workspace.ts +2 -11
- package/src/update-attention.ts +1 -1
- package/src/updates/binding.mjs +2 -6
- package/src/updates/control.mjs +4 -0
- package/src/updates/supervisor.mjs +10 -4
- package/src/web-launcher.ts +19 -0
- package/src/workspace.ts +3 -1
- package/templates/agent/AGENTS.md +13 -55
- package/templates/agent-guidance.md +90 -37
- package/templates/deployments.md +24 -0
- package/templates/failure-review.md +6 -0
- package/templates/maintainer-purpose.md +12 -6
- package/test/agent-guidance.test.ts +29 -39
- package/test/ai-cli.test.ts +9 -0
- package/test/ai.test.ts +66 -22
- package/test/application-channel.test.ts +283 -0
- package/test/application-client.test.mjs +84 -0
- package/test/application-controls.test.ts +224 -0
- package/test/application-only.test.ts +100 -0
- package/test/busy-reply-relay.test.ts +11 -7
- package/test/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/client-defaults.test.ts +1 -1
- package/test/codex-session.test.ts +18 -10
- package/test/config.test.ts +16 -1
- package/test/connection-artifacts.test.mjs +32 -0
- package/test/conversation-menu.test.ts +67 -0
- package/test/conversations.test.ts +84 -0
- package/test/desktop-bridge.test.ts +17 -11
- package/test/engine-handoff.test.ts +73 -0
- package/test/event-sources.test.ts +5 -8
- package/test/executor.test.ts +68 -16
- package/test/failure.test.ts +64 -0
- package/test/host-executor.test.ts +58 -17
- package/test/install-config.test.ts +1 -1
- package/test/intake-relay.test.ts +169 -25
- package/test/message-history.test.ts +127 -0
- package/test/model-policy.test.ts +23 -48
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +70 -10
- package/test/repair-policy.test.ts +8 -12
- package/test/runs.test.ts +13 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/schedule-cli.test.ts +34 -5
- package/test/scheduled-tasks.test.ts +79 -8
- package/test/scheduler.test.ts +30 -1
- package/test/task-native.test.ts +5 -2
- package/test/update-attention.test.ts +1 -2
- package/test/updates.test.mjs +44 -5
- package/test/workspace.test.ts +2 -3
- package/scripts/smoke-busy-reply.ts +0 -58
- package/src/reply-executor.ts +0 -55
- package/src/reply-mcp.ts +0 -23
- package/templates/agent/TOOLS.md +0 -105
- package/templates/chat-guidance.md +0 -23
- package/templates/standalone-tools.md +0 -20
- package/templates/updates.md +0 -45
- package/test/reply.test.ts +0 -159
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { ApplicationChannel } from './application-channel.js'
|
|
1
2
|
import { failureEvidence } from './failure.js'
|
|
2
3
|
import { TelegramSource } from './telegram-source.js'
|
|
3
4
|
import { Tasks } from './tasks.js'
|
|
4
5
|
import { taskRequests } from './task-rpc.js'
|
|
5
6
|
import { executionBlockReason } from './execution-authority.js'
|
|
7
|
+
import {authorizeDeliveryContext} from './delivery-context.mjs'
|
|
6
8
|
import { dispatchChannel } from './channel-backend.js'
|
|
7
9
|
import { randomUUID } from 'node:crypto'
|
|
8
10
|
import { stat } from 'node:fs/promises'
|
|
@@ -14,13 +16,13 @@ import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-
|
|
|
14
16
|
import { dirname, join, basename } from 'node:path'
|
|
15
17
|
import { fileURLToPath } from 'node:url'
|
|
16
18
|
import path from 'node:path'
|
|
17
|
-
import { Bot, InlineKeyboard, InputFile, GrammyError, type Context } from 'grammy'
|
|
19
|
+
import { Bot, InlineKeyboard, InputFile, GrammyError, HttpError, type Context } from 'grammy'
|
|
18
20
|
import type { ChildProcess } from 'node:child_process'
|
|
19
21
|
import { isOwner, ownsRun } from './identity.js'
|
|
20
22
|
import type { Update } from 'grammy/types'
|
|
21
23
|
import { InboxStore, type IncomingItem } from './inbox.js'
|
|
22
24
|
import { loadConfig, type Config } from './config.js'
|
|
23
|
-
import { ControlStore } from './control-state.js'
|
|
25
|
+
import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
24
26
|
import { ApprovalStore } from './approval.js'
|
|
25
27
|
import { startExecutorJob, terminateJob } from './executor.js'
|
|
26
28
|
import { RunStore, type RunRecord } from './runs.js'
|
|
@@ -30,6 +32,7 @@ import { sanitizeFileName, stageIncomingFile, workspaceFile } from './files.js'
|
|
|
30
32
|
import { transcribeAudio, synthesizeSpeech } from './audio.js'
|
|
31
33
|
import { normalizeReactionEmoji } from './reaction.js'
|
|
32
34
|
import { downloadTelegramFile } from './read-request.js'
|
|
35
|
+
import { createConversationMenu } from './conversation-menu.js'
|
|
33
36
|
import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
|
|
34
37
|
import { chatPreset, initialPreset, persistedPreset, presetLabel, statusPreset } from './ai.js'
|
|
35
38
|
import { discoverDefaults } from './client-defaults.js'
|
|
@@ -45,7 +48,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
45
48
|
}
|
|
46
49
|
return message
|
|
47
50
|
}
|
|
48
|
-
const
|
|
51
|
+
const telegramEnabled = config.telegramEnabled !== false
|
|
52
|
+
if (!telegramEnabled && (!config.applicationPort || config.channelBackendUrl)) throw new Error('Application-only execution requires the native application listener')
|
|
53
|
+
const bot = telegramEnabled ? new Bot(config.telegramBotToken) : null
|
|
49
54
|
const control = new ControlStore(config.controlDir, config.pairingTtlMs)
|
|
50
55
|
const approvals = new ApprovalStore(config.controlDir)
|
|
51
56
|
const runs = new RunStore(config.controlDir)
|
|
@@ -64,8 +69,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
64
69
|
let taskTimer: ReturnType<typeof setInterval> | undefined
|
|
65
70
|
let drainTimer: ReturnType<typeof setInterval> | undefined
|
|
66
71
|
const codexHome = join(config.controlDir, 'cli', 'codex')
|
|
72
|
+
const conversationMenu = createConversationMenu(control, runs)
|
|
67
73
|
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace, codexHome)
|
|
68
|
-
const durableWorkerChoice = () => (
|
|
74
|
+
const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
|
|
69
75
|
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
70
76
|
const pagerDuty = config.pagerDutyRoutingKey && config.pagerDutyStocksHealthUrl
|
|
71
77
|
? new PagerDutyStocksMonitor({
|
|
@@ -79,11 +85,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
79
85
|
|
|
80
86
|
let activeTypingTimer: ReturnType<typeof setInterval> | null = null
|
|
81
87
|
let activeBackend = false
|
|
82
|
-
let activeReply: ChildProcess | null = null
|
|
83
88
|
const ownerStopped = new WeakSet<ChildProcess>()
|
|
84
89
|
let activeChild: ChildProcess | null = null
|
|
90
|
+
let runtimeStarted = false
|
|
85
91
|
let shuttingDown = false
|
|
86
92
|
let wakePollRetry: (() => void) | undefined
|
|
93
|
+
const pollingAbort = new AbortController()
|
|
87
94
|
let nextSendAt = 0
|
|
88
95
|
const paceSend = async () => {
|
|
89
96
|
const delay = nextSendAt - Date.now()
|
|
@@ -100,17 +107,36 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
100
107
|
return next
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
const
|
|
110
|
+
const applicationChannel = new ApplicationChannel({
|
|
111
|
+
controlDir: config.controlDir, initial: aiMenu.initial,
|
|
112
|
+
aiControls: aiMenu,
|
|
113
|
+
wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
|
|
114
|
+
cancel: id => withStartLock(async () => {
|
|
115
|
+
const run = await runs.get(id)
|
|
116
|
+
if (run?.status === 'queued') await runs.patch(id, {status:'cancelled',endedAt:new Date().toISOString()})
|
|
117
|
+
else if (run?.status === 'running') {
|
|
118
|
+
const child = background.get(id)
|
|
119
|
+
if (child) { await scheduler.cancel(id); terminateJob(child); return }
|
|
120
|
+
if (!activeChild || (await runs.running(false))?.id !== id) throw new Error('Application cancellation unavailable')
|
|
121
|
+
ownerStopped.add(activeChild)
|
|
122
|
+
terminateJob(activeChild)
|
|
123
|
+
}
|
|
124
|
+
}),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const sendChat = async (chatId: number, text: string, replyToMessageId?: number, authorize?:()=>Promise<void>): Promise<number[]> => {
|
|
128
|
+
if (!bot) throw new Error('Telegram delivery is disabled')
|
|
104
129
|
const ids: number[] = []
|
|
105
130
|
const parts = splitTelegramText(text)
|
|
106
131
|
for (let i = 0; i < parts.length; i++) {
|
|
107
132
|
await paceSend()
|
|
133
|
+
await authorize?.()
|
|
108
134
|
const part = parts[i]
|
|
109
135
|
const replyParams =
|
|
110
136
|
i === 0 && replyToMessageId ? { reply_parameters: { message_id: replyToMessageId } } : {}
|
|
111
137
|
try {
|
|
112
138
|
const html = markdownToTelegramHtml(part)
|
|
113
|
-
const sent = await bot
|
|
139
|
+
const sent = await bot!.api.sendMessage(chatId, html, { parse_mode: 'HTML', ...replyParams })
|
|
114
140
|
ids.push(sent.message_id)
|
|
115
141
|
} catch (error) {
|
|
116
142
|
if (
|
|
@@ -120,14 +146,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
120
146
|
)
|
|
121
147
|
throw error
|
|
122
148
|
// Fallback to plain text if HTML parsing fails
|
|
123
|
-
const sent = await bot
|
|
149
|
+
const sent = await bot!.api.sendMessage(chatId, part, { ...replyParams })
|
|
124
150
|
ids.push(sent.message_id)
|
|
125
151
|
}
|
|
126
152
|
}
|
|
127
153
|
return ids
|
|
128
154
|
}
|
|
129
155
|
|
|
130
|
-
const telegramSource = new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat)
|
|
156
|
+
const telegramSource = telegramEnabled ? new TelegramSource(config.controlDir, config.telegramBotToken.split(':')[0], sendChat) : null
|
|
131
157
|
|
|
132
158
|
const startJob = async (run: RunRecord): Promise<void> => {
|
|
133
159
|
await withStartLock(async () => {
|
|
@@ -135,17 +161,17 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
135
161
|
// stat uses the effective UID; access uses the relay's isolated real UID.
|
|
136
162
|
if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
|
|
137
163
|
if ((await runs.get(run.id))?.status !== 'queued') return
|
|
138
|
-
|
|
139
|
-
if (!
|
|
140
|
-
if (activeReply) return
|
|
141
|
-
run = await runs.patch(run.id, { replyOnly: true })
|
|
142
|
-
}
|
|
143
|
-
if (!run.scheduled && !run.replyOnly && await runs.running(false)) return
|
|
164
|
+
if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled) return
|
|
165
|
+
if (!run.scheduled && await runs.running(false)) return
|
|
144
166
|
const owner = (await control.status()).owner
|
|
145
167
|
if (!owner || !ownsRun(owner, run)) {
|
|
146
168
|
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
|
|
147
169
|
return
|
|
148
170
|
}
|
|
171
|
+
if (run.application || run.delivery) {
|
|
172
|
+
try { await applicationChannel.bindings.authorize(run) }
|
|
173
|
+
catch { await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() }); return }
|
|
174
|
+
}
|
|
149
175
|
if (config.channelBackendUrl) {
|
|
150
176
|
if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); return }
|
|
151
177
|
activeBackend = true
|
|
@@ -167,7 +193,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
167
193
|
return
|
|
168
194
|
}
|
|
169
195
|
if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
|
|
170
|
-
if (run.
|
|
196
|
+
if (run.scheduled ? background.size >= 4 : activeChild) return
|
|
171
197
|
let texts = run.texts
|
|
172
198
|
if (run.external) {
|
|
173
199
|
// Availability failures leave durable queued work for a later check.
|
|
@@ -194,11 +220,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
194
220
|
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
195
221
|
if (!started.execution && !run.taskId) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
196
222
|
const maintenanceWakeup = run.id.startsWith('r_update_')
|
|
197
|
-
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled ||
|
|
223
|
+
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled || maintenanceWakeup)
|
|
198
224
|
const session = startsOwnSession
|
|
199
225
|
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
200
226
|
: await control.executionSession(started.execution!)
|
|
201
|
-
const selected = run.taskId ?
|
|
227
|
+
const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
202
228
|
const { child, cleanup } = await launch(texts, {
|
|
203
229
|
workspace: run.scheduled ? await taskWorkspace(config.workspace,run.id) : config.workspace,
|
|
204
230
|
timeoutMs: config.executorTimeoutMs,
|
|
@@ -210,10 +236,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
210
236
|
model: selected.model,
|
|
211
237
|
effort: selected.effort,
|
|
212
238
|
codexAutoCompactTokens: config.codexAutoCompactTokens,
|
|
239
|
+
codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
|
|
213
240
|
sessionId: session.nativeSessionId || session.sessionId,
|
|
214
241
|
isResume: session.hasStarted,
|
|
215
242
|
eventSource: run.external?.sourceId,
|
|
216
|
-
onSession: run.external || run.taskId ||
|
|
243
|
+
onSession: run.external || run.taskId || maintenanceWakeup ? undefined : async (id) => { await runs.patch(run.id,{nativeSessionId:id}); if (!run.scheduled) await control.saveNativeSession(session.sessionId,id) },
|
|
217
244
|
})
|
|
218
245
|
const executionStarted = performance.now()
|
|
219
246
|
// Attach before disk writes: a fast child can close while PID persistence
|
|
@@ -231,8 +258,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
231
258
|
console.info('run timing', { run_id: run.id, phase: 'launch',
|
|
232
259
|
queue_ms: Math.max(0, Date.parse(started.startedAt!) - Date.parse(run.createdAt)),
|
|
233
260
|
startup_ms: Math.round(executionStarted - launchStarted), resumed: session.hasStarted })
|
|
234
|
-
if (run.
|
|
235
|
-
else if (run.scheduled) background.set(run.id,child)
|
|
261
|
+
if (run.scheduled) background.set(run.id,child)
|
|
236
262
|
else activeChild = child
|
|
237
263
|
const finished = new Promise<number | null>((resolve) => {
|
|
238
264
|
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
@@ -247,9 +273,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
247
273
|
isResume: session.hasStarted,
|
|
248
274
|
})
|
|
249
275
|
|
|
250
|
-
if (!run.scheduled &&
|
|
251
|
-
if (!run.
|
|
252
|
-
if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId
|
|
276
|
+
if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
|
|
277
|
+
if (bot && run.chatId !== undefined && !run.application && !run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
|
|
278
|
+
if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId!, 'typing').catch(() => {})
|
|
253
279
|
}, 4000)
|
|
254
280
|
|
|
255
281
|
const completion = finished.then((code) => {
|
|
@@ -259,15 +285,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
259
285
|
await withStartLock(async () => {
|
|
260
286
|
try {
|
|
261
287
|
await cleanup()
|
|
262
|
-
if (code === 0 && !run.external && !run.taskId && !run.scheduled
|
|
288
|
+
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
263
289
|
const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
|
|
264
290
|
await runs.patch(started.id, { status: cancelled ? 'cancelled' : code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString(), exitCode: code, ...(code !== 0 && !cancelled ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
265
291
|
} catch (error) {
|
|
266
292
|
await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
267
293
|
console.error('Session completion failed', safeError(error))
|
|
268
294
|
} finally {
|
|
269
|
-
if (run.
|
|
270
|
-
else if (run.scheduled) background.delete(run.id)
|
|
295
|
+
if (run.scheduled) background.delete(run.id)
|
|
271
296
|
else {
|
|
272
297
|
activeChild = null
|
|
273
298
|
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
@@ -283,13 +308,13 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
283
308
|
completions.add(completion)
|
|
284
309
|
void completion.finally(() => completions.delete(completion))
|
|
285
310
|
} catch (error) {
|
|
286
|
-
if (!run.scheduled &&
|
|
311
|
+
if (!run.scheduled && activeTypingTimer) {
|
|
287
312
|
clearInterval(activeTypingTimer)
|
|
288
313
|
activeTypingTimer = null
|
|
289
314
|
}
|
|
290
315
|
await runs.patch(run.id, { status: 'failed', failureReason: 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
291
316
|
console.error('run start failed', run.id, safeError(error))
|
|
292
|
-
await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
|
|
317
|
+
if (bot && !run.application && !run.delivery && run.chatId !== undefined) await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
|
|
293
318
|
setImmediate(() => {
|
|
294
319
|
void runs
|
|
295
320
|
.nextQueued(false)
|
|
@@ -306,16 +331,32 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
306
331
|
if (sourceWork) return sourceWork
|
|
307
332
|
sourceWork = (async () => {
|
|
308
333
|
if (shuttingDown) return
|
|
334
|
+
const foreground = await runs.running(false)
|
|
309
335
|
const owner = (await control.status()).owner
|
|
336
|
+
if (foreground && activeChild) {
|
|
337
|
+
try {
|
|
338
|
+
if (!ownsRun(owner, foreground)) throw new Error('Owner channel revoked')
|
|
339
|
+
if (foreground.application) await applicationChannel.bindings.authorize(foreground)
|
|
340
|
+
}
|
|
341
|
+
catch { ownerStopped.add(activeChild); terminateJob(activeChild) }
|
|
342
|
+
}
|
|
343
|
+
for (const [id, child] of background) {
|
|
344
|
+
const run = await runs.get(id)
|
|
345
|
+
try {
|
|
346
|
+
if (!run || !ownsRun(owner, run)) throw new Error('Owner revoked')
|
|
347
|
+
if (run.delivery) await applicationChannel.bindings.authorize(run)
|
|
348
|
+
} catch { ownerStopped.add(child); terminateJob(child); continue }
|
|
349
|
+
if (await scheduler.cancelled(id)) terminateJob(child)
|
|
350
|
+
}
|
|
310
351
|
if (!owner) return
|
|
311
352
|
if (!config.channelBackendUrl) {
|
|
312
353
|
await drainTaskRequests()
|
|
313
354
|
for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
|
|
314
355
|
try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
|
|
315
356
|
}
|
|
316
|
-
await queueUpdateAttention(config.controlDir,owner,runs,durableWorkerChoice())
|
|
357
|
+
if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
|
|
317
358
|
}
|
|
318
|
-
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
359
|
+
for (const source of !telegramOwner(owner) || config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
319
360
|
try {
|
|
320
361
|
const batch = await sources.batch(source)
|
|
321
362
|
unavailableSources.delete(source.id)
|
|
@@ -330,7 +371,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
330
371
|
await runs.create({
|
|
331
372
|
taskId: task?.id,
|
|
332
373
|
id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
|
|
333
|
-
texts: [], execution: durableWorkerChoice(),
|
|
374
|
+
texts: [], execution: await durableWorkerChoice(),
|
|
334
375
|
external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
|
|
335
376
|
})
|
|
336
377
|
}
|
|
@@ -341,9 +382,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
341
382
|
}
|
|
342
383
|
await runs.running(true)
|
|
343
384
|
if (!config.channelBackendUrl) await scheduler.tick(owner,runs)
|
|
344
|
-
for (const [id,child] of background) {
|
|
345
|
-
if (await scheduler.cancelled(id)) terminateJob(child)
|
|
346
|
-
}
|
|
347
385
|
for (const run of (await runs.list()).filter(r => r.status === 'queued')) {
|
|
348
386
|
if (shuttingDown) break
|
|
349
387
|
await startJob(run)
|
|
@@ -380,6 +418,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
380
418
|
}, 250)
|
|
381
419
|
}
|
|
382
420
|
const drainInbox = (force = false): Promise<void> => {
|
|
421
|
+
if (!telegramEnabled) return Promise.resolve()
|
|
383
422
|
if (intakeWork) return intakeWork
|
|
384
423
|
intakeWork = (async () => {
|
|
385
424
|
if (shuttingDown) return
|
|
@@ -394,7 +433,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
394
433
|
normalizing = entry.update
|
|
395
434
|
replay.add(entry.update)
|
|
396
435
|
try {
|
|
397
|
-
await bot
|
|
436
|
+
await bot!.handleUpdate(entry.update)
|
|
398
437
|
} finally {
|
|
399
438
|
replay.delete(entry.update)
|
|
400
439
|
normalizing = undefined
|
|
@@ -403,9 +442,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
403
442
|
await withStartLock(async () => {
|
|
404
443
|
if (shuttingDown || !(await inbox.pending(batch.id))) return
|
|
405
444
|
const first = collected[0]
|
|
406
|
-
|
|
445
|
+
const owner = telegramOwner((await control.status()).owner)
|
|
446
|
+
if (first && owner)
|
|
407
447
|
run = await runs.create({
|
|
408
448
|
id: batch.id,
|
|
449
|
+
ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner),
|
|
450
|
+
telegramEpoch: owner.telegramLinkedAt ?? owner.pairedAt,
|
|
409
451
|
chatId: first.chatId,
|
|
410
452
|
telegramUserId: first.fromId,
|
|
411
453
|
messageId: first.messageId,
|
|
@@ -449,8 +491,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
449
491
|
? { reply_parameters: { message_id: item.replyToMessageId } }
|
|
450
492
|
: {}
|
|
451
493
|
const owner = (await control.status()).owner
|
|
452
|
-
const
|
|
453
|
-
|
|
494
|
+
const authorizeChannelDelivery=async()=>{if(item.deliveryContext)authorizeDeliveryContext(item.deliveryContext,(await control.status()).owner)}
|
|
495
|
+
const origin = item.runId ? await runs.get(item.runId) : null
|
|
496
|
+
if (item.deliveryContext) {
|
|
497
|
+
const context=authorizeDeliveryContext(item.deliveryContext,owner)
|
|
498
|
+
if(item.runId||item.chatId!==context.owner.telegramChatId||!['message','document','voice'].includes(item.type??''))throw new Error('Outbox delivery context mismatch')
|
|
499
|
+
} else if (
|
|
454
500
|
!origin ||
|
|
455
501
|
!owner ||
|
|
456
502
|
!ownsRun(owner, origin) ||
|
|
@@ -458,35 +504,42 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
458
504
|
item.chatId !== origin.chatId
|
|
459
505
|
)
|
|
460
506
|
throw new Error('Outbox ownership mismatch')
|
|
507
|
+
if (origin?.application || origin?.delivery) {
|
|
508
|
+
await applicationChannel.deliver(origin, item)
|
|
509
|
+
continue
|
|
510
|
+
}
|
|
511
|
+
if (!telegramEnabled || item.chatId === undefined) throw new Error('Telegram delivery is disabled')
|
|
461
512
|
const receiptIds: number[] = []
|
|
462
513
|
|
|
463
514
|
if (item.type === 'reaction' && item.emoji && item.messageId) {
|
|
464
515
|
const emoji = normalizeReactionEmoji(item.emoji)
|
|
465
516
|
if (!emoji) throw new Error('Unsupported reaction')
|
|
466
517
|
attemptedDelivery = true
|
|
467
|
-
await bot
|
|
518
|
+
await bot!.api.setMessageReaction(item.chatId, item.messageId, [
|
|
468
519
|
{ type: 'emoji', emoji: emoji as any },
|
|
469
520
|
])
|
|
470
521
|
console.info('run reaction sent', { run_id: item.runId, emoji })
|
|
471
522
|
} else if (item.type === 'document' && item.documentPath) {
|
|
472
|
-
const docPath = await workspaceFile(origin
|
|
523
|
+
const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.workspace,origin.id) : config.workspace, item.documentPath)
|
|
473
524
|
await paceSend()
|
|
525
|
+
await authorizeChannelDelivery()
|
|
474
526
|
attemptedDelivery = true
|
|
475
|
-
const sent = await bot
|
|
527
|
+
const sent = await bot!.api.sendDocument(item.chatId, new InputFile(docPath), {
|
|
476
528
|
caption: item.text,
|
|
477
529
|
...replyParams,
|
|
478
530
|
})
|
|
479
531
|
console.info('run document sent', { run_id: item.runId, path: docPath })
|
|
480
532
|
receiptIds.push(sent.message_id)
|
|
481
533
|
} else if (item.type === 'voice' && item.voiceText) {
|
|
482
|
-
await bot
|
|
534
|
+
await bot!.api.sendChatAction(item.chatId, 'record_voice')
|
|
483
535
|
const { buffer } = await synthesizeSpeech(item.voiceText, {
|
|
484
536
|
geminiApiKey: config.geminiApiKey,
|
|
485
537
|
openaiApiKey: config.openaiApiKey,
|
|
486
538
|
})
|
|
487
539
|
await paceSend()
|
|
540
|
+
await authorizeChannelDelivery()
|
|
488
541
|
attemptedDelivery = true
|
|
489
|
-
const sent = await bot
|
|
542
|
+
const sent = await bot!.api.sendVoice(item.chatId, new InputFile(buffer, 'voice.ogg'), replyParams)
|
|
490
543
|
receiptIds.push(sent.message_id)
|
|
491
544
|
console.info('run voice sent', { run_id: item.runId })
|
|
492
545
|
} else if (item.type === 'approval' && item.approvalPrompt && item.approvalActionId) {
|
|
@@ -496,7 +549,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
496
549
|
const html = `⚠️ <b>Approval Required</b>\n\n${markdownToTelegramHtml(item.approvalPrompt)}`
|
|
497
550
|
await paceSend()
|
|
498
551
|
attemptedDelivery = true
|
|
499
|
-
const sent = await bot
|
|
552
|
+
const sent = await bot!.api.sendMessage(item.chatId, html, {
|
|
500
553
|
parse_mode: 'HTML',
|
|
501
554
|
reply_markup: keyboard,
|
|
502
555
|
...replyParams,
|
|
@@ -505,14 +558,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
505
558
|
receiptIds.push(sent.message_id)
|
|
506
559
|
} else if (item.text) {
|
|
507
560
|
attemptedDelivery = true
|
|
508
|
-
const ids = await sendChat(item.chatId, item.text, item.replyToMessageId)
|
|
561
|
+
const ids = await sendChat(item.chatId, item.text, item.replyToMessageId,authorizeChannelDelivery)
|
|
509
562
|
receiptIds.push(...ids)
|
|
510
563
|
console.info('run message sent', { run_id: item.runId, outbox_id: item.id, message_ids: ids })
|
|
511
564
|
} else throw new Error('Outbox item has no supported payload')
|
|
512
565
|
await runs.markOutboxSent(item.id, receiptIds)
|
|
513
566
|
console.info('run timing', { run_id: item.runId, outbox_id: item.id, phase: 'delivery',
|
|
514
567
|
delivery_processing_ms: Math.round(performance.now() - deliveryStarted),
|
|
515
|
-
run_to_delivery_ms: Math.max(0, Date.now() - Date.parse(origin.createdAt)) })
|
|
568
|
+
run_to_delivery_ms: Math.max(0, Date.now() - Date.parse(origin?.createdAt??item.createdAt)) })
|
|
516
569
|
} catch (error) {
|
|
517
570
|
console.error('outbox item processing failed', item.id, safeError(error))
|
|
518
571
|
await runs.failOutbox(
|
|
@@ -528,7 +581,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
528
581
|
const checkOwner = async (ctx: Context): Promise<boolean> => {
|
|
529
582
|
if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
|
|
530
583
|
const state = await control.status()
|
|
531
|
-
if (!state.owner) {
|
|
584
|
+
if (!telegramOwner(state.owner)) {
|
|
532
585
|
if (ctx.chat?.type !== 'private') return false
|
|
533
586
|
const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
|
|
534
587
|
if (result === 'requested')
|
|
@@ -550,8 +603,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
550
603
|
{ command: 'retry', description: 'Retry the latest failed incoming batch' },
|
|
551
604
|
{ command: 'new', description: 'New conversation; keep workspace files' },
|
|
552
605
|
]
|
|
553
|
-
|
|
554
|
-
const
|
|
606
|
+
// Keep the retired command from becoming an agent prompt while old clients catch up.
|
|
607
|
+
const retiredCommands = ['/settings']
|
|
608
|
+
const launcher = config.webLauncher
|
|
609
|
+
const commands = [...mainCommands, ...(launcher ? [{ command: launcher.command, description: launcher.label }] : [])]
|
|
610
|
+
const controlCommand = (text?: string) => /^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s|$)/.test(text?.trim() ?? '')
|
|
611
|
+
? '/rename' : text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
|
|
555
612
|
const statusKeyboard = () => new InlineKeyboard()
|
|
556
613
|
.text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
|
|
557
614
|
.text('Retry failed incoming message', 'menu:retry').row()
|
|
@@ -560,7 +617,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
560
617
|
const owner = (await control.status()).owner
|
|
561
618
|
if (!owner) return 'Scheduled tasks\n\nNo paired owner.'
|
|
562
619
|
// This intentionally uses the reader that does not create a schedules directory.
|
|
563
|
-
return scheduledTasksText(await scheduler.
|
|
620
|
+
return scheduledTasksText(await scheduler.listActiveReadOnly(await runs.list()), owner)
|
|
564
621
|
}
|
|
565
622
|
const replyScheduledTasks = async (ctx: Context) => {
|
|
566
623
|
for (const part of splitTelegramText(await scheduledTasks())) await ctx.reply(part)
|
|
@@ -624,11 +681,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
624
681
|
|
|
625
682
|
// Returning from the polling handler acknowledges intake, not execution. Only
|
|
626
683
|
// return after the authorized update has reached the atomic local journal.
|
|
684
|
+
if (bot) {
|
|
627
685
|
bot.use(async (ctx, next) => {
|
|
628
686
|
if (ctx.callbackQuery && (await control.status()).owner?.kind === 'group') {
|
|
629
687
|
if (!isOwner(ctx, (await control.status()).owner)) return
|
|
630
688
|
try {
|
|
631
|
-
const member = await bot
|
|
689
|
+
const member = await bot!.api.getChatMember(ctx.chat!.id, ctx.from!.id)
|
|
632
690
|
if (!['creator', 'administrator', 'member'].includes(member.status) &&
|
|
633
691
|
!(member.status === 'restricted' && member.is_member)) return
|
|
634
692
|
} catch { throw new Error('Group membership verification unavailable; retry the update') }
|
|
@@ -636,23 +694,23 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
636
694
|
if ((ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') &&
|
|
637
695
|
!isOwner(ctx, (await control.status()).owner)) {
|
|
638
696
|
if (config.channelBackendUrl) return
|
|
639
|
-
const owner = (await control.status()).owner
|
|
697
|
+
const owner = telegramOwner((await control.status()).owner)
|
|
640
698
|
const message = ctx.message
|
|
641
699
|
if (!owner && message && !message.sender_chat && ctx.from && !ctx.from.is_bot) {
|
|
642
700
|
await control.requestPairing(ctx.from.id, ctx.chat.id, ctx.chat.title)
|
|
643
701
|
return
|
|
644
702
|
}
|
|
645
703
|
if (!owner || !message?.text || message.sender_chat || !ctx.from || ctx.from.is_bot) return
|
|
646
|
-
await telegramSource
|
|
647
|
-
if (await telegramSource
|
|
704
|
+
await telegramSource!.start(owner)
|
|
705
|
+
if (await telegramSource!.capture(ctx.update.update_id, message as import('grammy/types').Message.TextMessage, ctx.from)) return
|
|
648
706
|
if (owner.kind === 'group') return
|
|
649
707
|
if (ctx.from.id !== owner.telegramUserId) return
|
|
650
708
|
if (replay.has(ctx.update)) {
|
|
651
709
|
collected.push({
|
|
652
710
|
updateId: ctx.update.update_id, chatId: owner.telegramChatId, fromId: owner.telegramUserId,
|
|
653
|
-
text:
|
|
711
|
+
text: JSON.stringify({event:'owner_message_in_unbound_group',chatId:ctx.chat.id,title:ctx.chat.title,messageId:message.message_id,text:message.text}),
|
|
654
712
|
})
|
|
655
|
-
} else if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial))) scheduleIntake()
|
|
713
|
+
} else if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial, message?.text || message?.caption))) scheduleIntake()
|
|
656
714
|
return
|
|
657
715
|
}
|
|
658
716
|
if (replay.has(ctx.update)) {
|
|
@@ -661,21 +719,47 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
661
719
|
}
|
|
662
720
|
const message = ctx.message
|
|
663
721
|
const command = controlCommand(message?.text)
|
|
664
|
-
if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu').includes(command)) return next()
|
|
722
|
+
if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu', ...retiredCommands).includes(command)) return next()
|
|
665
723
|
const ordinary = message && (message.text || message.photo || message.document || message.voice)
|
|
666
724
|
const approval = ctx.callbackQuery?.data?.startsWith('approval:')
|
|
667
725
|
if (!ordinary && !approval) return next()
|
|
668
726
|
if (!(await checkOwner(ctx))) return
|
|
669
|
-
if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial))) scheduleIntake()
|
|
727
|
+
if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial, message?.text || message?.caption))) scheduleIntake()
|
|
670
728
|
})
|
|
671
729
|
|
|
672
|
-
bot
|
|
730
|
+
bot!.on('message:text', async (ctx) => {
|
|
673
731
|
if (!(await checkOwner(ctx))) return
|
|
674
732
|
|
|
675
733
|
const text = controlCommand(ctx.message.text)
|
|
676
734
|
|
|
677
|
-
if (
|
|
678
|
-
await
|
|
735
|
+
if (config.channelBackendUrl && ['/new', '/chats', '/rename', '/ai', '/settings'].includes(text ?? '')) {
|
|
736
|
+
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (launcher && text === `/${launcher.command}`) {
|
|
741
|
+
if (ctx.chat.type !== 'private' || (await control.status()).owner?.kind === 'group') return
|
|
742
|
+
await ctx.reply(launcher.label, { reply_markup: new InlineKeyboard().webApp(launcher.label, launcher.url) })
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (text === '/chats') {
|
|
747
|
+
await conversationMenu.list(ctx)
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
if (text === '/rename') {
|
|
751
|
+
try {
|
|
752
|
+
await control.renameSession(ctx.message.text.trim().replace(/^\/rename(?:@[a-zA-Z0-9_]+)?(?:\s+|$)/, ''))
|
|
753
|
+
await ctx.reply('Conversation renamed.')
|
|
754
|
+
} catch (error) { await ctx.reply(error instanceof Error ? error.message : 'Rename failed.') }
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
if (text === '/ai') {
|
|
758
|
+
await aiMenu.list(ctx)
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
if (text === '/settings') {
|
|
762
|
+
await ctx.reply('Settings was removed. Use /ai to choose the client, model, and reasoning level.')
|
|
679
763
|
return
|
|
680
764
|
}
|
|
681
765
|
|
|
@@ -695,11 +779,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
695
779
|
return
|
|
696
780
|
}
|
|
697
781
|
|
|
698
|
-
if (config.channelBackendUrl && ['/new', '/ai', '/settings'].includes(text ?? '')) {
|
|
699
|
-
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
700
|
-
return
|
|
701
|
-
}
|
|
702
|
-
|
|
703
782
|
// Steering & session commands
|
|
704
783
|
if (text === '/stop' && config.channelBackendUrl) {
|
|
705
784
|
await ctx.reply('This channel uses an application backend. Stopping its active job is not supported here; check the application. /cancel removes only pending relay work.')
|
|
@@ -709,7 +788,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
709
788
|
const running = await runs.running(false)
|
|
710
789
|
if ((running && running.pid) || background.size) {
|
|
711
790
|
try {
|
|
712
|
-
if (activeReply) { ownerStopped.add(activeReply); terminateJob(activeReply) }
|
|
713
791
|
if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
|
|
714
792
|
for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
|
|
715
793
|
} catch {}
|
|
@@ -743,6 +821,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
743
821
|
|
|
744
822
|
if (text === '/menu') {
|
|
745
823
|
const menuKeyboard = mainKeyboard()
|
|
824
|
+
if (launcher && ctx.chat.type === 'private' && (await control.status()).owner?.kind !== 'group') menuKeyboard.row().webApp(launcher.label, launcher.url)
|
|
746
825
|
await ctx.reply('⚡ <b>Ezenciel Agent Menu</b>\nSelect an action below:', {
|
|
747
826
|
parse_mode: 'HTML',
|
|
748
827
|
reply_markup: menuKeyboard,
|
|
@@ -770,7 +849,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
770
849
|
})
|
|
771
850
|
})
|
|
772
851
|
|
|
773
|
-
bot
|
|
852
|
+
bot!.on('message:photo', async (ctx) => {
|
|
774
853
|
if (!(await checkOwner(ctx))) return
|
|
775
854
|
try {
|
|
776
855
|
const photos = ctx.message.photo
|
|
@@ -800,7 +879,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
800
879
|
}
|
|
801
880
|
})
|
|
802
881
|
|
|
803
|
-
bot
|
|
882
|
+
bot!.on('message:document', async (ctx) => {
|
|
804
883
|
if (!(await checkOwner(ctx))) return
|
|
805
884
|
try {
|
|
806
885
|
const doc = ctx.message.document
|
|
@@ -831,10 +910,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
831
910
|
}
|
|
832
911
|
})
|
|
833
912
|
|
|
834
|
-
bot
|
|
913
|
+
bot!.on('message:voice', async (ctx) => {
|
|
835
914
|
if (!(await checkOwner(ctx))) return
|
|
836
915
|
try {
|
|
837
|
-
await bot
|
|
916
|
+
await bot!.api.sendChatAction(ctx.chat.id, 'typing').catch(() => {})
|
|
838
917
|
const voice = ctx.message.voice
|
|
839
918
|
const fileInfo = await ctx.api.getFile(voice.file_id)
|
|
840
919
|
if (!fileInfo.file_path) throw new Error('Telegram attachment path unavailable')
|
|
@@ -863,7 +942,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
863
942
|
}
|
|
864
943
|
})
|
|
865
944
|
|
|
866
|
-
bot
|
|
945
|
+
bot!.on('callback_query:data', async (ctx) => {
|
|
867
946
|
if (!isOwner(ctx, (await control.status()).owner)) {
|
|
868
947
|
await ctx.answerCallbackQuery()
|
|
869
948
|
return
|
|
@@ -900,21 +979,29 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
900
979
|
text: JSON.stringify({ event: 'approval_decision', actionId, decision, prompt: request?.prompt }),
|
|
901
980
|
messageId: ctx.callbackQuery.message?.message_id,
|
|
902
981
|
updateId: ctx.update.update_id,
|
|
903
|
-
chatId: run.chatId
|
|
982
|
+
chatId: run.chatId!,
|
|
904
983
|
fromId: ctx.from.id,
|
|
905
984
|
})
|
|
906
985
|
console.info('Approval decision recorded', { actionId, decision: isApproved ? 'approved' : 'denied' })
|
|
907
986
|
}
|
|
908
|
-
} else if (config.channelBackendUrl && ['menu:new', 'menu:ai', 'menu:settings'].includes(data)) {
|
|
987
|
+
} else if (config.channelBackendUrl && (['menu:new', 'menu:chats', 'menu:ai', 'menu:settings'].includes(data) || data.startsWith('ai:') || data.startsWith('chat:'))) {
|
|
909
988
|
await ctx.answerCallbackQuery()
|
|
910
989
|
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
990
|
+
} else if (await conversationMenu.handle(ctx)) {
|
|
991
|
+
return
|
|
911
992
|
} else if (await aiMenu.handle(ctx)) {
|
|
912
993
|
return
|
|
913
994
|
} else if (data.startsWith('menu:')) {
|
|
914
995
|
const action = data.slice(5)
|
|
915
|
-
if (action === '
|
|
996
|
+
if (action === 'chats') {
|
|
997
|
+
await ctx.answerCallbackQuery()
|
|
998
|
+
await conversationMenu.list(ctx)
|
|
999
|
+
} else if (action === 'ai') {
|
|
916
1000
|
await ctx.answerCallbackQuery()
|
|
917
|
-
await aiMenu.list(ctx
|
|
1001
|
+
await aiMenu.list(ctx)
|
|
1002
|
+
} else if (action === 'settings') {
|
|
1003
|
+
await ctx.answerCallbackQuery({ text: 'Settings was removed; use Choose AI.' })
|
|
1004
|
+
await aiMenu.list(ctx)
|
|
918
1005
|
} else if (action === 'retry') {
|
|
919
1006
|
await ctx.answerCallbackQuery()
|
|
920
1007
|
const id = await inbox.retryLatest(ctx.from.id, ctx.chat!.id, (await control.status()).owner)
|
|
@@ -943,7 +1030,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
943
1030
|
} else if (action === 'stop') {
|
|
944
1031
|
const running = await runs.running(false)
|
|
945
1032
|
if ((running && running.pid) || background.size) {
|
|
946
|
-
if (activeReply) { ownerStopped.add(activeReply); terminateJob(activeReply) }
|
|
947
1033
|
if (activeChild) { ownerStopped.add(activeChild); terminateJob(activeChild) }
|
|
948
1034
|
for (const [id,child] of background) { await scheduler.cancel(id); ownerStopped.add(child); terminateJob(child) }
|
|
949
1035
|
await ctx.answerCallbackQuery({ text: 'Run stopped' })
|
|
@@ -958,15 +1044,19 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
958
1044
|
}
|
|
959
1045
|
})
|
|
960
1046
|
|
|
961
|
-
bot
|
|
1047
|
+
bot!.catch((error) => {
|
|
962
1048
|
console.error('Telegram update failure', safeError(error.error))
|
|
963
1049
|
// Do not let polling acknowledge an update whose journal write failed.
|
|
964
1050
|
throw error
|
|
965
1051
|
})
|
|
1052
|
+
}
|
|
966
1053
|
|
|
967
1054
|
let stopWork: Promise<void> | undefined
|
|
968
1055
|
const stop = (): Promise<void> => stopWork ?? (stopWork = (async () => {
|
|
969
1056
|
shuttingDown = true
|
|
1057
|
+
runtimeStarted = false
|
|
1058
|
+
pollingAbort.abort()
|
|
1059
|
+
await applicationChannel.stop()
|
|
970
1060
|
wakePollRetry?.()
|
|
971
1061
|
if (intakeTimer) clearTimeout(intakeTimer)
|
|
972
1062
|
if (sourceTimer) clearInterval(sourceTimer)
|
|
@@ -975,7 +1065,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
975
1065
|
await Promise.all([sourceWork, intakeWork].map(work => work?.catch(() => {})))
|
|
976
1066
|
// Finish registering in-flight launches before taking the child snapshot.
|
|
977
1067
|
await withStartLock(async () => {
|
|
978
|
-
if (activeReply) terminateJob(activeReply)
|
|
979
1068
|
if (activeChild) terminateJob(activeChild)
|
|
980
1069
|
for (const child of background.values()) terminateJob(child)
|
|
981
1070
|
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
@@ -984,9 +1073,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
984
1073
|
await Promise.all([taskWork, outboxWork].map(work => work?.catch(() => {})))
|
|
985
1074
|
pagerDuty?.stop()
|
|
986
1075
|
try {
|
|
987
|
-
if (bot
|
|
1076
|
+
if (bot?.isRunning()) await bot.stop()
|
|
988
1077
|
} finally {
|
|
989
|
-
await telegramSource
|
|
1078
|
+
await telegramSource?.stop()
|
|
990
1079
|
}
|
|
991
1080
|
})())
|
|
992
1081
|
|
|
@@ -994,9 +1083,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
994
1083
|
let failed = false
|
|
995
1084
|
try {
|
|
996
1085
|
await initializeWorkspace(config.workspace)
|
|
1086
|
+
if (config.applicationPort) await applicationChannel.listen(config.applicationPort, config.applicationHost)
|
|
1087
|
+
runtimeStarted = true
|
|
997
1088
|
pagerDuty?.start()
|
|
998
1089
|
const owner = (await control.status()).owner
|
|
999
|
-
if (owner && !config.channelBackendUrl) await telegramSource
|
|
1090
|
+
if (telegramEnabled && telegramOwner(owner) && !config.channelBackendUrl) await telegramSource!.start(owner!)
|
|
1000
1091
|
await scheduler.recover(runs)
|
|
1001
1092
|
sourceTimer = setInterval(() => {
|
|
1002
1093
|
void drainSources().catch(error => console.error('Event-source drain failed', safeError(error)))
|
|
@@ -1024,31 +1115,38 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1024
1115
|
}
|
|
1025
1116
|
}
|
|
1026
1117
|
|
|
1118
|
+
if (!telegramEnabled) {
|
|
1119
|
+
await new Promise<void>(resolve => { wakePollRetry = resolve; if (shuttingDown) resolve() })
|
|
1120
|
+
return
|
|
1121
|
+
}
|
|
1027
1122
|
scheduleIntake()
|
|
1028
1123
|
// Telegram polling is a delivery surface, not the scheduler or executor.
|
|
1029
|
-
//
|
|
1124
|
+
// grammY owns in-poll reconnects. Escaped permanent faults wait for
|
|
1125
|
+
// intervention without restarting setup or terminating authorized work.
|
|
1030
1126
|
while (!shuttingDown) {
|
|
1031
1127
|
try {
|
|
1032
|
-
await bot
|
|
1033
|
-
await bot
|
|
1034
|
-
|
|
1035
|
-
await bot.init
|
|
1036
|
-
|
|
1128
|
+
await bot!.api.setMyCommands(commands)
|
|
1129
|
+
await bot!.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
|
|
1130
|
+
// grammY types its Node signal with the older abort-controller shim.
|
|
1131
|
+
await bot!.init(pollingAbort.signal as unknown as Parameters<typeof Bot.prototype.init>[0])
|
|
1132
|
+
if (shuttingDown) break
|
|
1133
|
+
await bot!.start({
|
|
1037
1134
|
drop_pending_updates: false,
|
|
1038
1135
|
onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
|
|
1039
1136
|
})
|
|
1040
1137
|
if (!shuttingDown) throw new Error('Telegram polling stopped unexpectedly')
|
|
1041
1138
|
} catch (error) {
|
|
1042
1139
|
if (shuttingDown) break
|
|
1043
|
-
|
|
1140
|
+
const transient = error instanceof HttpError || (error instanceof GrammyError && (error.error_code === 429 || error.error_code >= 500))
|
|
1141
|
+
console.error(transient ? 'Telegram transport interrupted; retrying' : 'Telegram polling stopped; repair configuration and restart the relay. Existing work remains active', safeError(error))
|
|
1044
1142
|
await new Promise<void>((resolve) => {
|
|
1045
|
-
let timer: ReturnType<typeof setTimeout>
|
|
1143
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1046
1144
|
const wake = () => {
|
|
1047
1145
|
clearTimeout(timer)
|
|
1048
1146
|
if (wakePollRetry === wake) wakePollRetry = undefined
|
|
1049
1147
|
resolve()
|
|
1050
1148
|
}
|
|
1051
|
-
timer = setTimeout(wake, 5000)
|
|
1149
|
+
if (transient) timer = setTimeout(wake, 5000)
|
|
1052
1150
|
wakePollRetry = wake
|
|
1053
1151
|
})
|
|
1054
1152
|
}
|
|
@@ -1064,7 +1162,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1064
1162
|
}
|
|
1065
1163
|
}
|
|
1066
1164
|
}
|
|
1067
|
-
return { bot, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests }
|
|
1165
|
+
return { bot: bot!, isRunning: () => telegramEnabled ? Boolean(bot?.isRunning()) : runtimeStarted, start, stop, drainOutbox, drainInbox, drainSources, drainTaskRequests, applicationChannel }
|
|
1068
1166
|
}
|
|
1069
1167
|
|
|
1070
1168
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|