@jc_stack/ez-agents 0.1.0-beta.12
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/.dockerignore +24 -0
- package/.env.example +26 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +73 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/SECURITY.md +26 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/bin/ezenciel-agents +2 -0
- package/bin/ezenciel-agents-ai +2 -0
- package/bin/ezenciel-agents-ai.mjs +5 -0
- package/bin/ezenciel-agents-approval +2 -0
- package/bin/ezenciel-agents-approval.mjs +16 -0
- package/bin/ezenciel-agents-create +12 -0
- package/bin/ezenciel-agents-docker +6 -0
- package/bin/ezenciel-agents-host +5 -0
- package/bin/ezenciel-agents-install +2 -0
- package/bin/ezenciel-agents-message +2 -0
- package/bin/ezenciel-agents-message.mjs +16 -0
- package/bin/ezenciel-agents-owner +2 -0
- package/bin/ezenciel-agents-owner.mjs +18 -0
- package/bin/ezenciel-agents-react +2 -0
- package/bin/ezenciel-agents-react.mjs +16 -0
- package/bin/ezenciel-agents-setup.mjs +18 -0
- package/bin/ezenciel-agents-source +2 -0
- package/bin/ezenciel-agents-source.mjs +16 -0
- package/bin/ezenciel-agents-tools.mjs +3 -0
- package/bin/ezenciel-agents.mjs +37 -0
- package/compose.whatsapp.yaml +12 -0
- package/compose.yaml +40 -0
- package/default-plugins.json +1 -0
- package/docker/entrypoint.sh +15 -0
- package/docker/healthcheck.mjs +8 -0
- package/docker/plugin-smoke.mjs +48 -0
- package/docker/pnpm-lock.yaml +415 -0
- package/docker/recovery.ts +11 -0
- package/docker/run.ts +52 -0
- package/docker/smoke.mjs +47 -0
- package/docker/status-smoke.mjs +30 -0
- package/docker/upgrade-smoke.mjs +58 -0
- package/docs/architecture/ai-selection.md +37 -0
- package/docs/architecture/authority-boundaries.md +14 -0
- package/docs/architecture/event-sources.md +34 -0
- package/docs/architecture/telegram-intake.md +29 -0
- package/docs/development-and-testing.md +18 -0
- package/docs/docker-runtime.md +118 -0
- package/docs/host-service.md +80 -0
- package/docs/plugin-contributions.md +34 -0
- package/docs/plugins.md +181 -0
- package/docs/releasing.md +71 -0
- package/docs/setup.md +234 -0
- package/docs/upgrades.md +193 -0
- package/package.json +106 -0
- package/scripts/assert-local-registry.mjs +22 -0
- package/scripts/release-check.mjs +14 -0
- package/scripts/smoke.ts +102 -0
- package/src/agent-install.ts +96 -0
- package/src/ai-cli.ts +22 -0
- package/src/ai.ts +88 -0
- package/src/approval-cli.ts +59 -0
- package/src/approval.ts +119 -0
- package/src/audio.ts +184 -0
- package/src/client-defaults.ts +101 -0
- package/src/config.ts +48 -0
- package/src/control-state.ts +350 -0
- package/src/desktop-bridge.ts +284 -0
- package/src/event-sources.ts +112 -0
- package/src/executor.ts +335 -0
- package/src/files.ts +75 -0
- package/src/format.ts +57 -0
- package/src/host-executor-client.ts +46 -0
- package/src/host-executor-protocol.ts +2 -0
- package/src/host-executor.ts +129 -0
- package/src/identity.ts +17 -0
- package/src/inbox.ts +171 -0
- package/src/index.ts +812 -0
- package/src/install-config.ts +56 -0
- package/src/install-tools.mjs +98 -0
- package/src/menu.ts +123 -0
- package/src/message-send.ts +57 -0
- package/src/message.ts +68 -0
- package/src/owner-args.ts +4 -0
- package/src/owner.ts +30 -0
- package/src/plugins/manager.mjs +272 -0
- package/src/react.ts +33 -0
- package/src/reaction.ts +32 -0
- package/src/read-request.ts +72 -0
- package/src/reply.ts +13 -0
- package/src/runs.ts +445 -0
- package/src/service.ts +28 -0
- package/src/setup.ts +180 -0
- package/src/software-status.ts +23 -0
- package/src/source-cli.ts +18 -0
- package/src/update-attention.ts +18 -0
- package/src/updates/artifact.mjs +83 -0
- package/src/updates/binding.mjs +27 -0
- package/src/updates/control.mjs +131 -0
- package/src/updates/launch.mjs +13 -0
- package/src/updates/runtime.mjs +140 -0
- package/src/updates/status.mjs +49 -0
- package/src/updates/supervisor.mjs +102 -0
- package/src/version.ts +4 -0
- package/src/workspace.ts +32 -0
- package/templates/agent/AGENTS.md +49 -0
- package/templates/agent/SOUL.md +11 -0
- package/templates/agent/TOOLS.md +46 -0
- package/templates/agent/USER.md +5 -0
- package/templates/updates.md +45 -0
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +28 -0
- package/test/ai.test.ts +105 -0
- package/test/approval.test.ts +40 -0
- package/test/audio.test.ts +77 -0
- package/test/client-defaults.test.ts +64 -0
- package/test/codex-context.test.ts +33 -0
- package/test/config.test.ts +32 -0
- package/test/control-state.test.ts +58 -0
- package/test/desktop-bridge.test.ts +159 -0
- package/test/docker-runtime.test.ts +23 -0
- package/test/event-sources.test.ts +113 -0
- package/test/executor.test.ts +135 -0
- package/test/files.test.ts +50 -0
- package/test/format.test.ts +41 -0
- package/test/host-executor.test.ts +149 -0
- package/test/inbox-burst.test.ts +66 -0
- package/test/inbox.test.ts +102 -0
- package/test/install-config.test.ts +75 -0
- package/test/install-tools.test.mjs +59 -0
- package/test/intake-relay.test.ts +337 -0
- package/test/owner-help.test.mjs +10 -0
- package/test/plugin-manager.test.mjs +157 -0
- package/test/publish-guard.test.ts +21 -0
- package/test/reaction.test.ts +122 -0
- package/test/read-request.test.ts +134 -0
- package/test/relay.test.ts +254 -0
- package/test/release-entrypoints.test.mjs +26 -0
- package/test/runs.test.ts +116 -0
- package/test/security.test.ts +85 -0
- package/test/setup.test.ts +68 -0
- package/test/software-status.test.ts +31 -0
- package/test/update-attention.test.ts +21 -0
- package/test/updates.test.mjs +282 -0
- package/test/upgrade-pause.test.ts +70 -0
- package/test/workspace.test.ts +80 -0
- package/tsconfig.json +19 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
3
|
+
import { queueUpdateAttention } from './update-attention.js'
|
|
4
|
+
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
5
|
+
import { dirname, join, basename } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { Bot, InlineKeyboard, InputFile, GrammyError, type Context } from 'grammy'
|
|
9
|
+
import type { ChildProcess } from 'node:child_process'
|
|
10
|
+
import { isOwner } from './identity.js'
|
|
11
|
+
import type { Update } from 'grammy/types'
|
|
12
|
+
import { InboxStore, type IncomingItem } from './inbox.js'
|
|
13
|
+
import { loadConfig, type Config } from './config.js'
|
|
14
|
+
import { ControlStore } from './control-state.js'
|
|
15
|
+
import { ApprovalStore } from './approval.js'
|
|
16
|
+
import { startExecutorJob, terminateJob } from './executor.js'
|
|
17
|
+
import { RunStore, type RunRecord } from './runs.js'
|
|
18
|
+
import { splitTelegramText } from './reply.js'
|
|
19
|
+
import { markdownToTelegramHtml, escapeHtml } from './format.js'
|
|
20
|
+
import { sanitizeFileName, stageIncomingFile, workspaceFile } from './files.js'
|
|
21
|
+
import { transcribeAudio, synthesizeSpeech } from './audio.js'
|
|
22
|
+
import { normalizeReactionEmoji } from './reaction.js'
|
|
23
|
+
import { downloadTelegramFile } from './read-request.js'
|
|
24
|
+
import { createAiMenu, mainCommands, mainKeyboard } from './menu.js'
|
|
25
|
+
import { presetLabel } from './ai.js'
|
|
26
|
+
import { initializeWorkspace } from './workspace.js'
|
|
27
|
+
import { softwareStatus } from './software-status.js'
|
|
28
|
+
|
|
29
|
+
export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
30
|
+
const safeError = (error: unknown): string => {
|
|
31
|
+
let message = error instanceof Error ? error.message : 'Unknown error'
|
|
32
|
+
for (const secret of [config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
|
|
33
|
+
if (secret) message = message.replaceAll(secret, '[redacted]')
|
|
34
|
+
}
|
|
35
|
+
return message
|
|
36
|
+
}
|
|
37
|
+
const bot = new Bot(config.telegramBotToken)
|
|
38
|
+
const control = new ControlStore(config.controlDir, config.pairingTtlMs)
|
|
39
|
+
const approvals = new ApprovalStore(config.controlDir)
|
|
40
|
+
const runs = new RunStore(config.controlDir)
|
|
41
|
+
const inbox = new InboxStore(config.controlDir)
|
|
42
|
+
const sources = new EventSources(config.controlDir)
|
|
43
|
+
const aiMenu = createAiMenu(control, config.executorCli, undefined, config.workspace)
|
|
44
|
+
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
45
|
+
|
|
46
|
+
let activeTypingTimer: ReturnType<typeof setInterval> | null = null
|
|
47
|
+
let activeChild: ChildProcess | null = null
|
|
48
|
+
let shuttingDown = false
|
|
49
|
+
let nextSendAt = 0
|
|
50
|
+
const paceSend = async () => {
|
|
51
|
+
const delay = nextSendAt - Date.now()
|
|
52
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
|
|
53
|
+
nextSendAt = Date.now() + 1000
|
|
54
|
+
}
|
|
55
|
+
let startLock: Promise<void> = Promise.resolve()
|
|
56
|
+
const withStartLock = (work: () => Promise<void>): Promise<void> => {
|
|
57
|
+
const next = startLock.then(work, work)
|
|
58
|
+
startLock = next.then(
|
|
59
|
+
() => undefined,
|
|
60
|
+
() => undefined,
|
|
61
|
+
)
|
|
62
|
+
return next
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sendChat = async (chatId: number, text: string, replyToMessageId?: number): Promise<number[]> => {
|
|
66
|
+
const ids: number[] = []
|
|
67
|
+
const parts = splitTelegramText(text)
|
|
68
|
+
for (let i = 0; i < parts.length; i++) {
|
|
69
|
+
await paceSend()
|
|
70
|
+
const part = parts[i]
|
|
71
|
+
const replyParams =
|
|
72
|
+
i === 0 && replyToMessageId ? { reply_parameters: { message_id: replyToMessageId } } : {}
|
|
73
|
+
try {
|
|
74
|
+
const html = markdownToTelegramHtml(part)
|
|
75
|
+
const sent = await bot.api.sendMessage(chatId, html, { parse_mode: 'HTML', ...replyParams })
|
|
76
|
+
ids.push(sent.message_id)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (
|
|
79
|
+
!(error instanceof GrammyError) ||
|
|
80
|
+
error.error_code !== 400 ||
|
|
81
|
+
!error.description.includes('parse entities')
|
|
82
|
+
)
|
|
83
|
+
throw error
|
|
84
|
+
// Fallback to plain text if HTML parsing fails
|
|
85
|
+
const sent = await bot.api.sendMessage(chatId, part, { ...replyParams })
|
|
86
|
+
ids.push(sent.message_id)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return ids
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const startJob = async (run: RunRecord): Promise<void> => {
|
|
93
|
+
await withStartLock(async () => {
|
|
94
|
+
if (shuttingDown || activeChild) return
|
|
95
|
+
// stat uses the effective UID; access uses the relay's isolated real UID.
|
|
96
|
+
if (await stat(join(config.controlDir,'upgrade-pause.json')).then(()=>true,e=>{if(e.code==='ENOENT')return false;throw e})) return
|
|
97
|
+
if ((await runs.get(run.id))?.status !== 'queued') return
|
|
98
|
+
if (await runs.running()) return
|
|
99
|
+
const owner = (await control.status()).owner
|
|
100
|
+
if (!owner || owner.telegramUserId !== run.telegramUserId || owner.telegramChatId !== run.chatId) {
|
|
101
|
+
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
let texts = run.texts
|
|
105
|
+
if (run.external) {
|
|
106
|
+
// Availability failures leave durable queued work for a later check.
|
|
107
|
+
let events: SourceEvent[]
|
|
108
|
+
try { events = await sources.check(run.external, owner); unavailableSources.delete(run.external.sourceId) } catch { unavailableSources.add(run.external.sourceId); return }
|
|
109
|
+
if (!events.length) {
|
|
110
|
+
await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
texts = events.map(event => JSON.stringify(event))
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
117
|
+
if (!started.execution) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
118
|
+
const session = run.external
|
|
119
|
+
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
120
|
+
: await control.executionSession(started.execution)
|
|
121
|
+
const selected = started.execution.preset
|
|
122
|
+
const { child, cleanup } = await launch(texts, {
|
|
123
|
+
workspace: config.workspace,
|
|
124
|
+
timeoutMs: config.executorTimeoutMs,
|
|
125
|
+
runId: started.id,
|
|
126
|
+
controlDir: config.controlDir,
|
|
127
|
+
binDir,
|
|
128
|
+
cli: selected.cli,
|
|
129
|
+
model: selected.model,
|
|
130
|
+
effort: selected.effort,
|
|
131
|
+
sessionId: session.nativeSessionId || session.sessionId,
|
|
132
|
+
isResume: session.hasStarted,
|
|
133
|
+
eventSource: run.external?.sourceId,
|
|
134
|
+
onSession: run.external ? undefined : (id) => control.saveNativeSession(session.sessionId, id),
|
|
135
|
+
})
|
|
136
|
+
activeChild = child
|
|
137
|
+
const finished = new Promise<number | null>((resolve) => {
|
|
138
|
+
if (child.exitCode !== null || child.signalCode !== null) resolve(child.exitCode)
|
|
139
|
+
else child.once('close', resolve)
|
|
140
|
+
})
|
|
141
|
+
await runs.patch(started.id, { pid: child.pid })
|
|
142
|
+
console.info('run started', {
|
|
143
|
+
run_id: started.id,
|
|
144
|
+
pid: child.pid,
|
|
145
|
+
cli: selected.cli,
|
|
146
|
+
session: session.sessionId,
|
|
147
|
+
isResume: session.hasStarted,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
151
|
+
if (!run.external) activeTypingTimer = setInterval(() => {
|
|
152
|
+
void bot.api.sendChatAction(run.chatId, 'typing').catch(() => {})
|
|
153
|
+
}, 4000)
|
|
154
|
+
|
|
155
|
+
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
156
|
+
if (chunk.trim()) console.error('executor stderr', started.id, chunk.trim())
|
|
157
|
+
})
|
|
158
|
+
void finished.then((code) => {
|
|
159
|
+
void (async () => {
|
|
160
|
+
await withStartLock(async () => {
|
|
161
|
+
try {
|
|
162
|
+
await cleanup()
|
|
163
|
+
if (code === 0 && !run.external) await control.markSessionStarted(session.sessionId)
|
|
164
|
+
await runs.patch(started.id, { status: code === 0 ? 'completed' : 'failed', endedAt: new Date().toISOString() })
|
|
165
|
+
} catch (error) {
|
|
166
|
+
await runs.patch(started.id, { status: 'failed', endedAt: new Date().toISOString() })
|
|
167
|
+
console.error('Session completion failed', safeError(error))
|
|
168
|
+
} finally {
|
|
169
|
+
activeChild = null
|
|
170
|
+
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
171
|
+
activeTypingTimer = null
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
console.info('run ended', { run_id: started.id, code })
|
|
175
|
+
const next = await runs.nextQueued()
|
|
176
|
+
if (next && !shuttingDown) await startJob(next)
|
|
177
|
+
})().catch((error) => console.error('Run completion failed', error.message))
|
|
178
|
+
})
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (activeTypingTimer) {
|
|
181
|
+
clearInterval(activeTypingTimer)
|
|
182
|
+
activeTypingTimer = null
|
|
183
|
+
}
|
|
184
|
+
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
|
|
185
|
+
console.error('run start failed', run.id, safeError(error))
|
|
186
|
+
await sendChat(run.chatId, `Run ${run.id} failed to start. Check the local relay log.`)
|
|
187
|
+
setImmediate(() => {
|
|
188
|
+
void runs
|
|
189
|
+
.nextQueued()
|
|
190
|
+
.then((next) => next && startJob(next))
|
|
191
|
+
.catch(console.error)
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const unavailableSources = new Set<string>()
|
|
198
|
+
let sourceWork: Promise<void> | undefined
|
|
199
|
+
const drainSources = (): Promise<void> => {
|
|
200
|
+
if (sourceWork) return sourceWork
|
|
201
|
+
sourceWork = (async () => {
|
|
202
|
+
if (shuttingDown) return
|
|
203
|
+
const owner = (await control.status()).owner
|
|
204
|
+
if (!owner) return
|
|
205
|
+
await queueUpdateAttention(config.controlDir,owner,runs,await control.captureChoice(aiMenu.initial))
|
|
206
|
+
for (const source of await sources.available(owner)) {
|
|
207
|
+
try {
|
|
208
|
+
const batch = await sources.batch(source)
|
|
209
|
+
unavailableSources.delete(source.id)
|
|
210
|
+
if (!batchReady(batch.events)) continue
|
|
211
|
+
await withStartLock(async () => {
|
|
212
|
+
if (shuttingDown) return
|
|
213
|
+
await sources.remember(source, batch)
|
|
214
|
+
const groups = new Map<string, SourceEvent[]>()
|
|
215
|
+
for (const event of batch.events) groups.set(event.conversationId, [...(groups.get(event.conversationId) || []), event])
|
|
216
|
+
for (const events of groups.values()) await runs.create({
|
|
217
|
+
id: eventRunId(source, events), chatId: owner.telegramChatId, telegramUserId: owner.telegramUserId,
|
|
218
|
+
texts: [], execution: await control.captureChoice(aiMenu.initial),
|
|
219
|
+
external: { sourceId: source.id, bindingId: source.bindingId, eventIds: events.map(e => e.id) },
|
|
220
|
+
})
|
|
221
|
+
// Acknowledgement follows durable run creation; replay uses the saved batch.
|
|
222
|
+
await sources.advance(source, batch.cursor)
|
|
223
|
+
})
|
|
224
|
+
} catch { unavailableSources.add(source.id) }
|
|
225
|
+
}
|
|
226
|
+
for (const run of (await runs.list()).filter(r => r.status === 'queued')) {
|
|
227
|
+
if (activeChild || shuttingDown) break
|
|
228
|
+
await startJob(run)
|
|
229
|
+
}
|
|
230
|
+
})().finally(() => { sourceWork = undefined })
|
|
231
|
+
return sourceWork
|
|
232
|
+
}
|
|
233
|
+
let sourceTimer: ReturnType<typeof setInterval> | undefined
|
|
234
|
+
|
|
235
|
+
const replay = new WeakSet<Update>()
|
|
236
|
+
let collected: IncomingItem[] = []
|
|
237
|
+
let normalizing: Update | undefined
|
|
238
|
+
let intakeTimer: ReturnType<typeof setTimeout> | undefined
|
|
239
|
+
let intakeWork: Promise<void> | undefined
|
|
240
|
+
const collectItem = (item: IncomingItem) => {
|
|
241
|
+
const message = normalizing?.message
|
|
242
|
+
if (message?.media_group_id) item.text = `[Telegram album: ${message.media_group_id}]\n${item.text}`
|
|
243
|
+
if (message && !message.text && message.reply_to_message) {
|
|
244
|
+
const quoted = message.reply_to_message
|
|
245
|
+
item.text = `[Quoted message ${quoted.message_id}]: ${quoted.text || quoted.caption || '[media]'}\n\n${item.text}`
|
|
246
|
+
}
|
|
247
|
+
collected.push(item)
|
|
248
|
+
}
|
|
249
|
+
const scheduleIntake = () => {
|
|
250
|
+
if (shuttingDown || intakeTimer) return
|
|
251
|
+
intakeTimer = setTimeout(() => {
|
|
252
|
+
intakeTimer = undefined
|
|
253
|
+
void drainInbox().catch((error) => console.error('Inbox drain failed', safeError(error)))
|
|
254
|
+
}, 250)
|
|
255
|
+
}
|
|
256
|
+
const drainInbox = (force = false): Promise<void> => {
|
|
257
|
+
if (intakeWork) return intakeWork
|
|
258
|
+
intakeWork = (async () => {
|
|
259
|
+
if (shuttingDown) return
|
|
260
|
+
const batch = await inbox.next(force)
|
|
261
|
+
if (!batch) return
|
|
262
|
+
try {
|
|
263
|
+
let run = await runs.get(batch.id)
|
|
264
|
+
if (!run) {
|
|
265
|
+
collected = []
|
|
266
|
+
for (const entry of batch.entries) {
|
|
267
|
+
if (shuttingDown || !(await inbox.pending(batch.id))) return
|
|
268
|
+
normalizing = entry.update
|
|
269
|
+
replay.add(entry.update)
|
|
270
|
+
try {
|
|
271
|
+
await bot.handleUpdate(entry.update)
|
|
272
|
+
} finally {
|
|
273
|
+
replay.delete(entry.update)
|
|
274
|
+
normalizing = undefined
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
await withStartLock(async () => {
|
|
278
|
+
if (shuttingDown || !(await inbox.pending(batch.id))) return
|
|
279
|
+
const first = collected[0]
|
|
280
|
+
if (first)
|
|
281
|
+
run = await runs.create({
|
|
282
|
+
id: batch.id,
|
|
283
|
+
chatId: first.chatId,
|
|
284
|
+
telegramUserId: first.fromId,
|
|
285
|
+
messageId: first.messageId,
|
|
286
|
+
texts: collected.map((item) => item.text),
|
|
287
|
+
execution: batch.entries[0].execution,
|
|
288
|
+
})
|
|
289
|
+
await inbox.finish(batch.id)
|
|
290
|
+
})
|
|
291
|
+
} else await inbox.finish(batch.id)
|
|
292
|
+
if (run) await startJob(run)
|
|
293
|
+
} catch (error) {
|
|
294
|
+
await inbox.finish(batch.id, true)
|
|
295
|
+
console.error('Inbox batch failed', batch.id, safeError(error))
|
|
296
|
+
}
|
|
297
|
+
})().finally(() => {
|
|
298
|
+
intakeWork = undefined
|
|
299
|
+
if (!shuttingDown)
|
|
300
|
+
void inbox
|
|
301
|
+
.status()
|
|
302
|
+
.then((state) => {
|
|
303
|
+
if (state.pending) scheduleIntake()
|
|
304
|
+
})
|
|
305
|
+
.catch((error) => console.error('Inbox status failed', safeError(error)))
|
|
306
|
+
})
|
|
307
|
+
return intakeWork
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let isDraining = false
|
|
311
|
+
const drainOutbox = async (onlyRunId?: string): Promise<void> => {
|
|
312
|
+
if (isDraining) return
|
|
313
|
+
isDraining = true
|
|
314
|
+
try {
|
|
315
|
+
for (const item of await runs.pendingOutbox()) {
|
|
316
|
+
if (onlyRunId && item.runId !== onlyRunId) continue
|
|
317
|
+
const claimed = await runs.claimOutbox(item.id)
|
|
318
|
+
if (!claimed) continue
|
|
319
|
+
let attemptedDelivery = false
|
|
320
|
+
try {
|
|
321
|
+
const replyParams = item.replyToMessageId
|
|
322
|
+
? { reply_parameters: { message_id: item.replyToMessageId } }
|
|
323
|
+
: {}
|
|
324
|
+
const owner = (await control.status()).owner
|
|
325
|
+
const origin = await runs.get(item.runId)
|
|
326
|
+
if (
|
|
327
|
+
!origin ||
|
|
328
|
+
!owner ||
|
|
329
|
+
origin.telegramUserId !== owner.telegramUserId ||
|
|
330
|
+
origin.chatId !== owner.telegramChatId ||
|
|
331
|
+
item.chatId !== origin.chatId
|
|
332
|
+
)
|
|
333
|
+
throw new Error('Outbox ownership mismatch')
|
|
334
|
+
const receiptIds: number[] = []
|
|
335
|
+
|
|
336
|
+
if (item.type === 'reaction' && item.emoji && item.messageId) {
|
|
337
|
+
const emoji = normalizeReactionEmoji(item.emoji)
|
|
338
|
+
if (!emoji) throw new Error('Unsupported reaction')
|
|
339
|
+
attemptedDelivery = true
|
|
340
|
+
await bot.api.setMessageReaction(item.chatId, item.messageId, [
|
|
341
|
+
{ type: 'emoji', emoji: emoji as any },
|
|
342
|
+
])
|
|
343
|
+
console.info('run reaction sent', { run_id: item.runId, emoji })
|
|
344
|
+
} else if (item.type === 'document' && item.documentPath) {
|
|
345
|
+
const docPath = await workspaceFile(config.workspace, item.documentPath)
|
|
346
|
+
await paceSend()
|
|
347
|
+
attemptedDelivery = true
|
|
348
|
+
const sent = await bot.api.sendDocument(item.chatId, new InputFile(docPath), {
|
|
349
|
+
caption: item.text,
|
|
350
|
+
...replyParams,
|
|
351
|
+
})
|
|
352
|
+
console.info('run document sent', { run_id: item.runId, path: docPath })
|
|
353
|
+
receiptIds.push(sent.message_id)
|
|
354
|
+
} else if (item.type === 'voice' && item.voiceText) {
|
|
355
|
+
await bot.api.sendChatAction(item.chatId, 'record_voice')
|
|
356
|
+
const { buffer } = await synthesizeSpeech(item.voiceText, {
|
|
357
|
+
geminiApiKey: config.geminiApiKey,
|
|
358
|
+
openaiApiKey: config.openaiApiKey,
|
|
359
|
+
})
|
|
360
|
+
await paceSend()
|
|
361
|
+
attemptedDelivery = true
|
|
362
|
+
const sent = await bot.api.sendVoice(item.chatId, new InputFile(buffer, 'voice.ogg'), replyParams)
|
|
363
|
+
receiptIds.push(sent.message_id)
|
|
364
|
+
console.info('run voice sent', { run_id: item.runId })
|
|
365
|
+
} else if (item.type === 'approval' && item.approvalPrompt && item.approvalActionId) {
|
|
366
|
+
const keyboard = new InlineKeyboard()
|
|
367
|
+
.text('Approve ✅', `approval:${item.approvalActionId}:approve`)
|
|
368
|
+
.text('Deny ❌', `approval:${item.approvalActionId}:deny`)
|
|
369
|
+
const html = `⚠️ <b>Approval Required</b>\n\n${markdownToTelegramHtml(item.approvalPrompt)}`
|
|
370
|
+
await paceSend()
|
|
371
|
+
attemptedDelivery = true
|
|
372
|
+
const sent = await bot.api.sendMessage(item.chatId, html, {
|
|
373
|
+
parse_mode: 'HTML',
|
|
374
|
+
reply_markup: keyboard,
|
|
375
|
+
...replyParams,
|
|
376
|
+
})
|
|
377
|
+
console.info('run approval requested', { run_id: item.runId, action_id: item.approvalActionId })
|
|
378
|
+
receiptIds.push(sent.message_id)
|
|
379
|
+
} else if (item.text) {
|
|
380
|
+
attemptedDelivery = true
|
|
381
|
+
const ids = await sendChat(item.chatId, item.text, item.replyToMessageId)
|
|
382
|
+
receiptIds.push(...ids)
|
|
383
|
+
console.info('run message sent', { run_id: item.runId, outbox_id: item.id, message_ids: ids })
|
|
384
|
+
} else throw new Error('Outbox item has no supported payload')
|
|
385
|
+
await runs.markOutboxSent(item.id, receiptIds)
|
|
386
|
+
} catch (error) {
|
|
387
|
+
console.error('outbox item processing failed', item.id, safeError(error))
|
|
388
|
+
await runs.failOutbox(
|
|
389
|
+
item.id,
|
|
390
|
+
safeError(error),
|
|
391
|
+
attemptedDelivery && !(error instanceof GrammyError),
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} finally {
|
|
396
|
+
isDraining = false
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const checkOwner = async (ctx: Context): Promise<boolean> => {
|
|
401
|
+
if (!ctx.from || ctx.from.is_bot || ctx.chat?.type !== 'private') return false
|
|
402
|
+
const state = await control.status()
|
|
403
|
+
if (!state.owner) {
|
|
404
|
+
const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
|
|
405
|
+
if (result === 'requested')
|
|
406
|
+
await ctx.reply('Owner approval is pending. Confirm this request through the local setup assistant.')
|
|
407
|
+
if (result === 'pending') await ctx.reply('Owner approval is still pending.')
|
|
408
|
+
if (result === 'capacity')
|
|
409
|
+
await ctx.reply('Owner setup is unavailable. Ask the local administrator to review pending requests.')
|
|
410
|
+
if (result === 'owner-exists') await ctx.reply('This agent already has an owner.')
|
|
411
|
+
return false
|
|
412
|
+
}
|
|
413
|
+
return isOwner(ctx, state.owner)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const aliases = [
|
|
417
|
+
{ command: 'help', description: 'Show available controls' },
|
|
418
|
+
{ command: 'status', description: 'Work, queue and delivery health' },
|
|
419
|
+
{ command: 'stop', description: 'Stop active work; keep queued messages' },
|
|
420
|
+
{ command: 'cancel', description: 'Cancel pending messages; keep active work' },
|
|
421
|
+
{ command: 'retry', description: 'Retry the latest failed incoming batch' },
|
|
422
|
+
{ command: 'new', description: 'New conversation; keep workspace files' },
|
|
423
|
+
]
|
|
424
|
+
const commands = mainCommands
|
|
425
|
+
const controlCommand = (text?: string) => text?.trim().replace(/@[a-zA-Z0-9_]+$/, '')
|
|
426
|
+
const statusText = async () => {
|
|
427
|
+
const running = await runs.running()
|
|
428
|
+
const all = await runs.list()
|
|
429
|
+
const incoming = await inbox.status()
|
|
430
|
+
const delivery = await runs.deliveryStatus()
|
|
431
|
+
const session = await control.getActiveSession()
|
|
432
|
+
const ai = await control.aiState(aiMenu.initial)
|
|
433
|
+
const selected = ai.presets.find((p) => p.id === ai.selectedId)!
|
|
434
|
+
return [
|
|
435
|
+
...await softwareStatus(config.controlDir),
|
|
436
|
+
`AI: ${selected.name} (${presetLabel(selected)})`,
|
|
437
|
+
`Default: ${ai.presets.find((p) => p.id === ai.defaultId)!.name}`,
|
|
438
|
+
`Session: ${session?.sessionId.slice(0, 8) || 'none'}`,
|
|
439
|
+
`Work: ${running ? `running ${running.id}` : 'idle'}`,
|
|
440
|
+
`Queue: ${all.filter((r) => r.status === 'queued').length} runs; ${incoming.pending} incoming messages`,
|
|
441
|
+
`Failed: ${all.filter((r) => r.status === 'failed').length} runs; ${incoming.failed} incoming batches`,
|
|
442
|
+
`Delivery: ${delivery.failed} failed; ${delivery.unknown} unknown/in-flight (inspect before retrying)`,
|
|
443
|
+
...(unavailableSources.size ? [`Unavailable event sources: ${[...unavailableSources].join(', ')}`] : []),
|
|
444
|
+
'/stop stops active work only. /cancel clears pending work only.',
|
|
445
|
+
...(config.executorCli === 'grok'
|
|
446
|
+
? [
|
|
447
|
+
'Known limitation: interrupted Grok sessions may stall on resume. /new explicitly resets the conversation.',
|
|
448
|
+
]
|
|
449
|
+
: []),
|
|
450
|
+
].join('\n')
|
|
451
|
+
}
|
|
452
|
+
const cancelPending = async () => {
|
|
453
|
+
let count = 0
|
|
454
|
+
await withStartLock(async () => {
|
|
455
|
+
count += await inbox.cancel()
|
|
456
|
+
for (const run of await runs.list()) {
|
|
457
|
+
if (run.status !== 'queued') continue
|
|
458
|
+
await runs.patch(run.id, { status: 'cancelled', endedAt: new Date().toISOString() })
|
|
459
|
+
count++
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
return `Cancelled ${count} pending messages/runs. Active work was not stopped.`
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Returning from the polling handler acknowledges intake, not execution. Only
|
|
466
|
+
// return after the authorized update has reached the atomic local journal.
|
|
467
|
+
bot.use(async (ctx, next) => {
|
|
468
|
+
if (replay.has(ctx.update)) {
|
|
469
|
+
if (isOwner(ctx, (await control.status()).owner)) return next()
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
const message = ctx.message
|
|
473
|
+
const command = controlCommand(message?.text)
|
|
474
|
+
if (command && [...commands, ...aliases].map((c) => `/${c.command}`).concat('/menu').includes(command)) return next()
|
|
475
|
+
const ordinary = message && (message.text || message.photo || message.document || message.voice)
|
|
476
|
+
const approval = ctx.callbackQuery?.data?.startsWith('approval:')
|
|
477
|
+
if (!ordinary && !approval) return next()
|
|
478
|
+
if (!(await checkOwner(ctx))) return
|
|
479
|
+
if (await inbox.accept(ctx.update, await control.captureChoice(aiMenu.initial))) scheduleIntake()
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
bot.on('message:text', async (ctx) => {
|
|
483
|
+
if (!(await checkOwner(ctx))) return
|
|
484
|
+
|
|
485
|
+
const text = controlCommand(ctx.message.text)
|
|
486
|
+
|
|
487
|
+
if (text === '/ai' || text === '/settings') {
|
|
488
|
+
await aiMenu.list(ctx, text === '/settings')
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (text === '/help') {
|
|
493
|
+
await ctx.reply([...commands, ...aliases.filter((a) => !commands.some((c) => c.command === a.command))]
|
|
494
|
+
.map((c) => `/${c.command} — ${c.description}`).join('\n'))
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
if (text === '/retry') {
|
|
498
|
+
const id = await inbox.retryLatest(ctx.from.id, ctx.chat.id)
|
|
499
|
+
if (id) scheduleIntake()
|
|
500
|
+
await ctx.reply(id ? `Incoming batch ${id} queued for retry.` : 'No failed incoming batch to retry.')
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
if (text === '/cancel') {
|
|
504
|
+
await ctx.reply(await cancelPending())
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Steering & session commands
|
|
509
|
+
if (text === '/stop') {
|
|
510
|
+
const running = await runs.running()
|
|
511
|
+
if (running && running.pid) {
|
|
512
|
+
try {
|
|
513
|
+
if (activeChild) terminateJob(activeChild)
|
|
514
|
+
} catch {}
|
|
515
|
+
if (activeTypingTimer) {
|
|
516
|
+
clearInterval(activeTypingTimer)
|
|
517
|
+
activeTypingTimer = null
|
|
518
|
+
}
|
|
519
|
+
await ctx.reply(
|
|
520
|
+
'🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
|
|
521
|
+
)
|
|
522
|
+
} else {
|
|
523
|
+
await ctx.reply('No active run in progress.')
|
|
524
|
+
}
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (text === '/new') {
|
|
529
|
+
await control.aiState(aiMenu.initial)
|
|
530
|
+
const next = await control.resetSession()
|
|
531
|
+
await ctx.reply(
|
|
532
|
+
`🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>). Agent files and memory preserved.`,
|
|
533
|
+
{ parse_mode: 'HTML' },
|
|
534
|
+
)
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (text === '/status') {
|
|
539
|
+
await ctx.reply(await statusText(), { reply_markup: new InlineKeyboard()
|
|
540
|
+
.text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
|
|
541
|
+
.text('Retry failed incoming message', 'menu:retry') })
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (text === '/menu') {
|
|
546
|
+
const menuKeyboard = mainKeyboard()
|
|
547
|
+
await ctx.reply('⚡ <b>Ezenciel Agent Menu</b>\nSelect an action below:', {
|
|
548
|
+
parse_mode: 'HTML',
|
|
549
|
+
reply_markup: menuKeyboard,
|
|
550
|
+
})
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Quoted reply context forwarding
|
|
555
|
+
let promptText = ctx.message.text
|
|
556
|
+
if (ctx.message.reply_to_message) {
|
|
557
|
+
const quoted = ctx.message.reply_to_message
|
|
558
|
+
const quotedText = ('text' in quoted && quoted.text) || ('caption' in quoted && quoted.caption) || ''
|
|
559
|
+
const quotedSender = quoted.from?.is_bot ? 'Agent' : 'Owner'
|
|
560
|
+
if (quotedText) {
|
|
561
|
+
promptText = `[Quoted message from ${quotedSender} (ID: ${quoted.message_id})]: "${quotedText}"\n\n${promptText}`
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
collectItem({
|
|
566
|
+
text: promptText,
|
|
567
|
+
messageId: ctx.message.message_id,
|
|
568
|
+
updateId: ctx.update.update_id,
|
|
569
|
+
chatId: ctx.chat.id,
|
|
570
|
+
fromId: ctx.from.id,
|
|
571
|
+
})
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
bot.on('message:photo', async (ctx) => {
|
|
575
|
+
if (!(await checkOwner(ctx))) return
|
|
576
|
+
try {
|
|
577
|
+
const photos = ctx.message.photo
|
|
578
|
+
const photo = photos[photos.length - 1] // highest resolution
|
|
579
|
+
const fileInfo = await ctx.api.getFile(photo.file_id)
|
|
580
|
+
if (!fileInfo.file_path) throw new Error('Telegram attachment path unavailable')
|
|
581
|
+
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
582
|
+
const buffer = await downloadTelegramFile(fileUrl)
|
|
583
|
+
const fileName = sanitizeFileName(basename(fileInfo.file_path) || 'photo.jpg')
|
|
584
|
+
const staged = await stageIncomingFile(config.workspace, fileName, buffer)
|
|
585
|
+
const caption = ctx.message.caption?.trim() || ''
|
|
586
|
+
const prompt = `[Attached image staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
|
|
587
|
+
collectItem({
|
|
588
|
+
text: prompt,
|
|
589
|
+
messageId: ctx.message.message_id,
|
|
590
|
+
updateId: ctx.update.update_id,
|
|
591
|
+
chatId: ctx.chat.id,
|
|
592
|
+
fromId: ctx.from.id,
|
|
593
|
+
})
|
|
594
|
+
} catch (err) {
|
|
595
|
+
console.error('Failed to process incoming photo:', safeError(err))
|
|
596
|
+
await ctx
|
|
597
|
+
.reply('⚠️ Photo could not be processed. Check the local relay log; /retry retries the saved batch.')
|
|
598
|
+
.catch(() => {})
|
|
599
|
+
throw err
|
|
600
|
+
}
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
bot.on('message:document', async (ctx) => {
|
|
604
|
+
if (!(await checkOwner(ctx))) return
|
|
605
|
+
try {
|
|
606
|
+
const doc = ctx.message.document
|
|
607
|
+
const fileInfo = await ctx.api.getFile(doc.file_id)
|
|
608
|
+
if (!fileInfo.file_path) throw new Error('Telegram attachment path unavailable')
|
|
609
|
+
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
610
|
+
const buffer = await downloadTelegramFile(fileUrl)
|
|
611
|
+
const fileName = sanitizeFileName(doc.file_name || basename(fileInfo.file_path) || 'document.bin')
|
|
612
|
+
const staged = await stageIncomingFile(config.workspace, fileName, buffer)
|
|
613
|
+
const caption = ctx.message.caption?.trim() || ''
|
|
614
|
+
const prompt = `[Attached document staged at ${staged.relativePath} (type: ${staged.fileType}, size: ${buffer.length} bytes)]${caption ? `\n\nCaption: ${caption}` : ''}`
|
|
615
|
+
collectItem({
|
|
616
|
+
text: prompt,
|
|
617
|
+
messageId: ctx.message.message_id,
|
|
618
|
+
updateId: ctx.update.update_id,
|
|
619
|
+
chatId: ctx.chat.id,
|
|
620
|
+
fromId: ctx.from.id,
|
|
621
|
+
})
|
|
622
|
+
} catch (err) {
|
|
623
|
+
console.error('Failed to process incoming document:', safeError(err))
|
|
624
|
+
await ctx
|
|
625
|
+
.reply(
|
|
626
|
+
'⚠️ Document could not be processed. Check the local relay log; /retry retries the saved batch.',
|
|
627
|
+
)
|
|
628
|
+
.catch(() => {})
|
|
629
|
+
throw err
|
|
630
|
+
}
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
bot.on('message:voice', async (ctx) => {
|
|
634
|
+
if (!(await checkOwner(ctx))) return
|
|
635
|
+
try {
|
|
636
|
+
await bot.api.sendChatAction(ctx.chat.id, 'typing').catch(() => {})
|
|
637
|
+
const voice = ctx.message.voice
|
|
638
|
+
const fileInfo = await ctx.api.getFile(voice.file_id)
|
|
639
|
+
if (!fileInfo.file_path) throw new Error('Telegram attachment path unavailable')
|
|
640
|
+
const fileUrl = `https://api.telegram.org/file/bot${config.telegramBotToken}/${fileInfo.file_path}`
|
|
641
|
+
const buffer = await downloadTelegramFile(fileUrl)
|
|
642
|
+
const transcript = await transcribeAudio(buffer, voice.mime_type || 'audio/ogg', {
|
|
643
|
+
geminiApiKey: config.geminiApiKey,
|
|
644
|
+
openaiApiKey: config.openaiApiKey,
|
|
645
|
+
})
|
|
646
|
+
const prompt = `[Inbound Voice Note (duration: ${voice.duration}s)]:\n"${transcript}"`
|
|
647
|
+
collectItem({
|
|
648
|
+
text: prompt,
|
|
649
|
+
messageId: ctx.message.message_id,
|
|
650
|
+
updateId: ctx.update.update_id,
|
|
651
|
+
chatId: ctx.chat.id,
|
|
652
|
+
fromId: ctx.from.id,
|
|
653
|
+
})
|
|
654
|
+
} catch (err) {
|
|
655
|
+
console.error('Failed to transcribe incoming voice note:', safeError(err))
|
|
656
|
+
await ctx
|
|
657
|
+
.reply(
|
|
658
|
+
'⚠️ Voice note could not be transcribed. The log identifies the failing request. Use /retry to retry the saved batch.',
|
|
659
|
+
)
|
|
660
|
+
.catch(() => {})
|
|
661
|
+
throw err
|
|
662
|
+
}
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
bot.on('callback_query:data', async (ctx) => {
|
|
666
|
+
if (!isOwner(ctx, (await control.status()).owner)) {
|
|
667
|
+
await ctx.answerCallbackQuery()
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
const data = ctx.callbackQuery.data
|
|
671
|
+
if (data.startsWith('approval:')) {
|
|
672
|
+
const [, actionId, decision] = data.split(':')
|
|
673
|
+
if (actionId && (decision === 'approve' || decision === 'deny')) {
|
|
674
|
+
const request = await approvals.getDecision(actionId)
|
|
675
|
+
const run = request?.runId ? await runs.get(request.runId) : null
|
|
676
|
+
if (!run || run.chatId !== ctx.chat?.id || run.telegramUserId !== ctx.from.id) {
|
|
677
|
+
await ctx.answerCallbackQuery({ text: 'Approval unavailable' })
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
const isApproved = decision === 'approve'
|
|
681
|
+
try {
|
|
682
|
+
await approvals.recordDecision(
|
|
683
|
+
actionId,
|
|
684
|
+
isApproved ? 'approved' : 'denied',
|
|
685
|
+
ctx.from.id,
|
|
686
|
+
ctx.update.update_id,
|
|
687
|
+
)
|
|
688
|
+
} catch {
|
|
689
|
+
await ctx.answerCallbackQuery({ text: 'Approval expired or already decided' })
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
await ctx.answerCallbackQuery({ text: isApproved ? 'Approved ✅' : 'Denied ❌' }).catch(() => {})
|
|
693
|
+
const original = ctx.callbackQuery.message?.text || ''
|
|
694
|
+
const updated = `${escapeHtml(original)}\n\n<b>Decision:</b> ${isApproved ? 'Approved ✅' : 'Denied ❌'}`
|
|
695
|
+
await ctx.editMessageText(updated, { parse_mode: 'HTML' }).catch(() => {})
|
|
696
|
+
collectItem({
|
|
697
|
+
text: JSON.stringify({ event: 'approval_decision', actionId, decision, prompt: request?.prompt }),
|
|
698
|
+
messageId: ctx.callbackQuery.message?.message_id,
|
|
699
|
+
updateId: ctx.update.update_id,
|
|
700
|
+
chatId: run.chatId,
|
|
701
|
+
fromId: ctx.from.id,
|
|
702
|
+
})
|
|
703
|
+
console.info('Approval decision recorded', { actionId, decision: isApproved ? 'approved' : 'denied' })
|
|
704
|
+
}
|
|
705
|
+
} else if (await aiMenu.handle(ctx)) {
|
|
706
|
+
return
|
|
707
|
+
} else if (data.startsWith('menu:')) {
|
|
708
|
+
const action = data.slice(5)
|
|
709
|
+
if (action === 'ai' || action === 'settings') {
|
|
710
|
+
await ctx.answerCallbackQuery()
|
|
711
|
+
await aiMenu.list(ctx, action === 'settings')
|
|
712
|
+
} else if (action === 'retry') {
|
|
713
|
+
await ctx.answerCallbackQuery()
|
|
714
|
+
const id = await inbox.retryLatest(ctx.from.id, ctx.chat!.id)
|
|
715
|
+
if (id) scheduleIntake()
|
|
716
|
+
await ctx.reply(id ? `Incoming batch ${id} queued for retry.` : 'No failed incoming batch to retry.')
|
|
717
|
+
} else if (action === 'new') {
|
|
718
|
+
await control.aiState(aiMenu.initial)
|
|
719
|
+
const next = await control.resetSession()
|
|
720
|
+
await ctx.answerCallbackQuery({ text: 'New session started' })
|
|
721
|
+
await ctx.reply(
|
|
722
|
+
`🔄 Started fresh conversation session (<code>${next.sessionId.slice(0, 8)}</code>).`,
|
|
723
|
+
{ parse_mode: 'HTML' },
|
|
724
|
+
)
|
|
725
|
+
} else if (action === 'status') {
|
|
726
|
+
await ctx.answerCallbackQuery()
|
|
727
|
+
await ctx.reply(await statusText())
|
|
728
|
+
} else if (action === 'cancel') {
|
|
729
|
+
await ctx.answerCallbackQuery()
|
|
730
|
+
await ctx.reply(await cancelPending())
|
|
731
|
+
} else if (action === 'stop') {
|
|
732
|
+
const running = await runs.running()
|
|
733
|
+
if (running && running.pid) {
|
|
734
|
+
if (activeChild) terminateJob(activeChild)
|
|
735
|
+
await ctx.answerCallbackQuery({ text: 'Run stopped' })
|
|
736
|
+
await ctx.reply(
|
|
737
|
+
'🛑 Stop requested for active work. Queued work remains and will run next. /cancel clears it.',
|
|
738
|
+
)
|
|
739
|
+
} else {
|
|
740
|
+
await ctx.answerCallbackQuery({ text: 'No run active' })
|
|
741
|
+
await ctx.reply('No active run in progress.')
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
bot.catch((error) => {
|
|
748
|
+
console.error('Telegram update failure', safeError(error.error))
|
|
749
|
+
// Do not let polling acknowledge an update whose journal write failed.
|
|
750
|
+
throw error
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
const stop = async () => {
|
|
754
|
+
shuttingDown = true
|
|
755
|
+
if (intakeTimer) clearTimeout(intakeTimer)
|
|
756
|
+
if (sourceTimer) clearInterval(sourceTimer)
|
|
757
|
+
if (activeChild) terminateJob(activeChild)
|
|
758
|
+
if (activeTypingTimer) clearInterval(activeTypingTimer)
|
|
759
|
+
if (sourceWork) await sourceWork.catch(() => {})
|
|
760
|
+
if (bot.isRunning()) await bot.stop()
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const start = async () => {
|
|
764
|
+
await initializeWorkspace(config.workspace)
|
|
765
|
+
sourceTimer = setInterval(() => {
|
|
766
|
+
void drainSources().catch(error => console.error('Event-source drain failed', safeError(error)))
|
|
767
|
+
}, 1000)
|
|
768
|
+
const drainTimer = setInterval(() => {
|
|
769
|
+
void drainOutbox().catch((error) => console.error('Outbox drain failed', error.message))
|
|
770
|
+
}, 250)
|
|
771
|
+
drainTimer.unref()
|
|
772
|
+
try {
|
|
773
|
+
console.log(`ezenciel-agents listening with workspace ${config.workspace}`)
|
|
774
|
+
console.log(`Authority control state: ${config.controlDir}`)
|
|
775
|
+
console.log(`CLI executor: ${config.executorCli}`)
|
|
776
|
+
await aiMenu.refresh()
|
|
777
|
+
|
|
778
|
+
// Reconcile stale runs and start any queued run on boot
|
|
779
|
+
const currentRunning = await runs.running()
|
|
780
|
+
if (!currentRunning) {
|
|
781
|
+
const pendingRun = await runs.nextQueued()
|
|
782
|
+
if (pendingRun) {
|
|
783
|
+
console.info('Processing queued run on startup:', pendingRun.id)
|
|
784
|
+
void startJob(pendingRun)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
await bot.api.deleteWebhook({ drop_pending_updates: false })
|
|
789
|
+
await bot.api.setMyCommands(commands)
|
|
790
|
+
await bot.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
|
|
791
|
+
await bot.init()
|
|
792
|
+
scheduleIntake()
|
|
793
|
+
await bot.start({
|
|
794
|
+
drop_pending_updates: false,
|
|
795
|
+
onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
|
|
796
|
+
})
|
|
797
|
+
} finally {
|
|
798
|
+
clearInterval(drainTimer)
|
|
799
|
+
if (sourceTimer) clearInterval(sourceTimer)
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return { bot, start, stop, drainOutbox, drainInbox, drainSources }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
806
|
+
const relay = createRelay(loadConfig())
|
|
807
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const)
|
|
808
|
+
process.once(signal, () => {
|
|
809
|
+
void relay.stop()
|
|
810
|
+
})
|
|
811
|
+
await relay.start()
|
|
812
|
+
}
|