@jc_stack/ez-agents 0.1.0-beta.27 → 0.1.0-beta.29
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 +9 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +28 -0
- package/Dockerfile +1 -0
- package/README.md +79 -8
- 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/docker-runtime.md +20 -0
- 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 +40 -4
- package/docs/upgrades.md +11 -1
- package/package.json +7 -2
- 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 +5 -3
- package/src/config.ts +20 -2
- package/src/control-state.ts +256 -15
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +10 -4
- package/src/host-executor.ts +7 -1
- package/src/identity.ts +11 -3
- package/src/index.ts +149 -54
- package/src/menu.ts +26 -9
- package/src/message-history.ts +52 -0
- package/src/message.ts +48 -7
- 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 +63 -18
- 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/runs.ts +67 -9
- package/src/schedule-cli.ts +11 -6
- package/src/scheduler.ts +17 -7
- package/src/updates/control.mjs +4 -0
- package/src/web-launcher.ts +19 -0
- package/templates/agent-guidance.md +58 -2
- package/templates/deployments.md +24 -0
- 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/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/codex-session.test.ts +8 -5
- package/test/config.test.ts +15 -0
- 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/executor.test.ts +56 -0
- package/test/host-executor.test.ts +28 -0
- package/test/intake-relay.test.ts +126 -5
- package/test/message-history.test.ts +127 -0
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +34 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/updates.test.mjs +39 -0
package/src/identity.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Context } from 'grammy'
|
|
2
2
|
import type { Owner } from './control-state.js'
|
|
3
|
+
import { ownerId, ownerEpoch } from './control-state.js'
|
|
3
4
|
|
|
4
5
|
export const isOwner = (ctx: Pick<Context, 'from' | 'chat'>, owner: Owner | null): boolean =>
|
|
5
6
|
Boolean(
|
|
@@ -12,9 +13,16 @@ export const isOwner = (ctx: Pick<Context, 'from' | 'chat'>, owner: Owner | null
|
|
|
12
13
|
ctx.chat?.id === owner.telegramChatId,
|
|
13
14
|
)
|
|
14
15
|
|
|
15
|
-
export const ownsRun = (owner: Owner | null, run: {telegramUserId
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
export const ownsRun = (owner: Owner | null, run: {application?: unknown; ownerId?: string; ownerEpoch?: string; telegramEpoch?: string; telegramUserId?: number; chatId?: number}): boolean =>
|
|
17
|
+
// Legacy app runs used the original Telegram owner IDs as bookkeeping. Their
|
|
18
|
+
// validated application binding remains the authority, not the current TG link.
|
|
19
|
+
Boolean(owner && (run.application && run.ownerId === undefined
|
|
20
|
+
? ownerId(owner) === `telegram:${run.telegramUserId}:${run.chatId}`
|
|
21
|
+
: (run.chatId === undefined ||
|
|
22
|
+
(run.chatId === owner.telegramChatId && (run.telegramEpoch ?? owner.pairedAt) === (owner.telegramLinkedAt ?? owner.pairedAt))) && (run.ownerId !== undefined
|
|
23
|
+
? run.ownerId === ownerId(owner) && run.ownerEpoch === ownerEpoch(owner)
|
|
24
|
+
: Number.isSafeInteger(run.telegramUserId) && run.telegramUserId! > 0 &&
|
|
25
|
+
run.chatId === owner.telegramChatId && (owner.kind === 'group' || run.telegramUserId === owner.telegramUserId))))
|
|
18
26
|
|
|
19
27
|
export const assertId = (id: string): string => {
|
|
20
28
|
if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error('Invalid record identifier')
|
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'
|
|
@@ -20,7 +22,7 @@ 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,6 +69,7 @@ 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
74
|
const durableWorkerChoice = () => control.captureChoice(aiMenu.initial)
|
|
69
75
|
const binDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin')
|
|
@@ -81,6 +87,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
81
87
|
let activeBackend = false
|
|
82
88
|
const ownerStopped = new WeakSet<ChildProcess>()
|
|
83
89
|
let activeChild: ChildProcess | null = null
|
|
90
|
+
let runtimeStarted = false
|
|
84
91
|
let shuttingDown = false
|
|
85
92
|
let wakePollRetry: (() => void) | undefined
|
|
86
93
|
const pollingAbort = new AbortController()
|
|
@@ -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,12 +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
|
|
164
|
+
if (!telegramEnabled && !run.application && !run.delivery && !run.scheduled) return
|
|
138
165
|
if (!run.scheduled && await runs.running(false)) return
|
|
139
166
|
const owner = (await control.status()).owner
|
|
140
167
|
if (!owner || !ownsRun(owner, run)) {
|
|
141
168
|
await runs.patch(run.id, { status: 'failed', endedAt: new Date().toISOString() })
|
|
142
169
|
return
|
|
143
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
|
+
}
|
|
144
175
|
if (config.channelBackendUrl) {
|
|
145
176
|
if (run.external || run.taskId || run.scheduled || run.id.startsWith('r_update_')) { await runs.patch(run.id, { status: 'failed' }); return }
|
|
146
177
|
activeBackend = true
|
|
@@ -205,6 +236,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
205
236
|
model: selected.model,
|
|
206
237
|
effort: selected.effort,
|
|
207
238
|
codexAutoCompactTokens: config.codexAutoCompactTokens,
|
|
239
|
+
codexSandbox: !run.taskId && selected.cli === 'codex' ? config.codexSandbox : undefined,
|
|
208
240
|
sessionId: session.nativeSessionId || session.sessionId,
|
|
209
241
|
isResume: session.hasStarted,
|
|
210
242
|
eventSource: run.external?.sourceId,
|
|
@@ -242,8 +274,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
242
274
|
})
|
|
243
275
|
|
|
244
276
|
if (!run.scheduled && activeTypingTimer) clearInterval(activeTypingTimer)
|
|
245
|
-
if (!run.external && !run.taskId && !run.scheduled) activeTypingTimer = setInterval(() => {
|
|
246
|
-
if (performance.now() - executionStarted < 30000) void bot.api.sendChatAction(run.chatId
|
|
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(() => {})
|
|
247
279
|
}, 4000)
|
|
248
280
|
|
|
249
281
|
const completion = finished.then((code) => {
|
|
@@ -282,7 +314,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
282
314
|
}
|
|
283
315
|
await runs.patch(run.id, { status: 'failed', failureReason: 'executor-start', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
284
316
|
console.error('run start failed', run.id, safeError(error))
|
|
285
|
-
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.`)
|
|
286
318
|
setImmediate(() => {
|
|
287
319
|
void runs
|
|
288
320
|
.nextQueued(false)
|
|
@@ -299,16 +331,32 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
299
331
|
if (sourceWork) return sourceWork
|
|
300
332
|
sourceWork = (async () => {
|
|
301
333
|
if (shuttingDown) return
|
|
334
|
+
const foreground = await runs.running(false)
|
|
302
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
|
+
}
|
|
303
351
|
if (!owner) return
|
|
304
352
|
if (!config.channelBackendUrl) {
|
|
305
353
|
await drainTaskRequests()
|
|
306
354
|
for (const task of await tasks.list()) if (task.state === 'pending' || task.state === 'active' || task.unwatchPending) {
|
|
307
355
|
try { await tasks.decide(task.id) } catch { /* Failed or stale grants cannot launch. */ }
|
|
308
356
|
}
|
|
309
|
-
await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
|
|
357
|
+
if (telegramOwner(owner)) await queueUpdateAttention(config.controlDir,owner,runs,await durableWorkerChoice())
|
|
310
358
|
}
|
|
311
|
-
for (const source of config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
359
|
+
for (const source of !telegramOwner(owner) || config.channelBackendUrl ? [] : await sources.available(owner)) {
|
|
312
360
|
try {
|
|
313
361
|
const batch = await sources.batch(source)
|
|
314
362
|
unavailableSources.delete(source.id)
|
|
@@ -334,9 +382,6 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
334
382
|
}
|
|
335
383
|
await runs.running(true)
|
|
336
384
|
if (!config.channelBackendUrl) await scheduler.tick(owner,runs)
|
|
337
|
-
for (const [id,child] of background) {
|
|
338
|
-
if (await scheduler.cancelled(id)) terminateJob(child)
|
|
339
|
-
}
|
|
340
385
|
for (const run of (await runs.list()).filter(r => r.status === 'queued')) {
|
|
341
386
|
if (shuttingDown) break
|
|
342
387
|
await startJob(run)
|
|
@@ -373,6 +418,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
373
418
|
}, 250)
|
|
374
419
|
}
|
|
375
420
|
const drainInbox = (force = false): Promise<void> => {
|
|
421
|
+
if (!telegramEnabled) return Promise.resolve()
|
|
376
422
|
if (intakeWork) return intakeWork
|
|
377
423
|
intakeWork = (async () => {
|
|
378
424
|
if (shuttingDown) return
|
|
@@ -387,7 +433,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
387
433
|
normalizing = entry.update
|
|
388
434
|
replay.add(entry.update)
|
|
389
435
|
try {
|
|
390
|
-
await bot
|
|
436
|
+
await bot!.handleUpdate(entry.update)
|
|
391
437
|
} finally {
|
|
392
438
|
replay.delete(entry.update)
|
|
393
439
|
normalizing = undefined
|
|
@@ -396,9 +442,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
396
442
|
await withStartLock(async () => {
|
|
397
443
|
if (shuttingDown || !(await inbox.pending(batch.id))) return
|
|
398
444
|
const first = collected[0]
|
|
399
|
-
|
|
445
|
+
const owner = telegramOwner((await control.status()).owner)
|
|
446
|
+
if (first && owner)
|
|
400
447
|
run = await runs.create({
|
|
401
448
|
id: batch.id,
|
|
449
|
+
ownerId: ownerId(owner), ownerEpoch: ownerEpoch(owner),
|
|
450
|
+
telegramEpoch: owner.telegramLinkedAt ?? owner.pairedAt,
|
|
402
451
|
chatId: first.chatId,
|
|
403
452
|
telegramUserId: first.fromId,
|
|
404
453
|
messageId: first.messageId,
|
|
@@ -442,8 +491,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
442
491
|
? { reply_parameters: { message_id: item.replyToMessageId } }
|
|
443
492
|
: {}
|
|
444
493
|
const owner = (await control.status()).owner
|
|
445
|
-
const
|
|
446
|
-
|
|
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 (
|
|
447
500
|
!origin ||
|
|
448
501
|
!owner ||
|
|
449
502
|
!ownsRun(owner, origin) ||
|
|
@@ -451,35 +504,42 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
451
504
|
item.chatId !== origin.chatId
|
|
452
505
|
)
|
|
453
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')
|
|
454
512
|
const receiptIds: number[] = []
|
|
455
513
|
|
|
456
514
|
if (item.type === 'reaction' && item.emoji && item.messageId) {
|
|
457
515
|
const emoji = normalizeReactionEmoji(item.emoji)
|
|
458
516
|
if (!emoji) throw new Error('Unsupported reaction')
|
|
459
517
|
attemptedDelivery = true
|
|
460
|
-
await bot
|
|
518
|
+
await bot!.api.setMessageReaction(item.chatId, item.messageId, [
|
|
461
519
|
{ type: 'emoji', emoji: emoji as any },
|
|
462
520
|
])
|
|
463
521
|
console.info('run reaction sent', { run_id: item.runId, emoji })
|
|
464
522
|
} else if (item.type === 'document' && item.documentPath) {
|
|
465
|
-
const docPath = await workspaceFile(origin
|
|
523
|
+
const docPath = await workspaceFile(origin?.scheduled ? await taskWorkspace(config.workspace,origin.id) : config.workspace, item.documentPath)
|
|
466
524
|
await paceSend()
|
|
525
|
+
await authorizeChannelDelivery()
|
|
467
526
|
attemptedDelivery = true
|
|
468
|
-
const sent = await bot
|
|
527
|
+
const sent = await bot!.api.sendDocument(item.chatId, new InputFile(docPath), {
|
|
469
528
|
caption: item.text,
|
|
470
529
|
...replyParams,
|
|
471
530
|
})
|
|
472
531
|
console.info('run document sent', { run_id: item.runId, path: docPath })
|
|
473
532
|
receiptIds.push(sent.message_id)
|
|
474
533
|
} else if (item.type === 'voice' && item.voiceText) {
|
|
475
|
-
await bot
|
|
534
|
+
await bot!.api.sendChatAction(item.chatId, 'record_voice')
|
|
476
535
|
const { buffer } = await synthesizeSpeech(item.voiceText, {
|
|
477
536
|
geminiApiKey: config.geminiApiKey,
|
|
478
537
|
openaiApiKey: config.openaiApiKey,
|
|
479
538
|
})
|
|
480
539
|
await paceSend()
|
|
540
|
+
await authorizeChannelDelivery()
|
|
481
541
|
attemptedDelivery = true
|
|
482
|
-
const sent = await bot
|
|
542
|
+
const sent = await bot!.api.sendVoice(item.chatId, new InputFile(buffer, 'voice.ogg'), replyParams)
|
|
483
543
|
receiptIds.push(sent.message_id)
|
|
484
544
|
console.info('run voice sent', { run_id: item.runId })
|
|
485
545
|
} else if (item.type === 'approval' && item.approvalPrompt && item.approvalActionId) {
|
|
@@ -489,7 +549,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
489
549
|
const html = `⚠️ <b>Approval Required</b>\n\n${markdownToTelegramHtml(item.approvalPrompt)}`
|
|
490
550
|
await paceSend()
|
|
491
551
|
attemptedDelivery = true
|
|
492
|
-
const sent = await bot
|
|
552
|
+
const sent = await bot!.api.sendMessage(item.chatId, html, {
|
|
493
553
|
parse_mode: 'HTML',
|
|
494
554
|
reply_markup: keyboard,
|
|
495
555
|
...replyParams,
|
|
@@ -498,14 +558,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
498
558
|
receiptIds.push(sent.message_id)
|
|
499
559
|
} else if (item.text) {
|
|
500
560
|
attemptedDelivery = true
|
|
501
|
-
const ids = await sendChat(item.chatId, item.text, item.replyToMessageId)
|
|
561
|
+
const ids = await sendChat(item.chatId, item.text, item.replyToMessageId,authorizeChannelDelivery)
|
|
502
562
|
receiptIds.push(...ids)
|
|
503
563
|
console.info('run message sent', { run_id: item.runId, outbox_id: item.id, message_ids: ids })
|
|
504
564
|
} else throw new Error('Outbox item has no supported payload')
|
|
505
565
|
await runs.markOutboxSent(item.id, receiptIds)
|
|
506
566
|
console.info('run timing', { run_id: item.runId, outbox_id: item.id, phase: 'delivery',
|
|
507
567
|
delivery_processing_ms: Math.round(performance.now() - deliveryStarted),
|
|
508
|
-
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)) })
|
|
509
569
|
} catch (error) {
|
|
510
570
|
console.error('outbox item processing failed', item.id, safeError(error))
|
|
511
571
|
await runs.failOutbox(
|
|
@@ -521,7 +581,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
521
581
|
const checkOwner = async (ctx: Context): Promise<boolean> => {
|
|
522
582
|
if (!ctx.from || ctx.from.is_bot || ctx.message?.sender_chat) return false
|
|
523
583
|
const state = await control.status()
|
|
524
|
-
if (!state.owner) {
|
|
584
|
+
if (!telegramOwner(state.owner)) {
|
|
525
585
|
if (ctx.chat?.type !== 'private') return false
|
|
526
586
|
const result = await control.requestPairing(ctx.from.id, ctx.chat.id)
|
|
527
587
|
if (result === 'requested')
|
|
@@ -545,8 +605,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
545
605
|
]
|
|
546
606
|
// Keep the retired command from becoming an agent prompt while old clients catch up.
|
|
547
607
|
const retiredCommands = ['/settings']
|
|
548
|
-
const
|
|
549
|
-
const
|
|
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_]+$/, '')
|
|
550
612
|
const statusKeyboard = () => new InlineKeyboard()
|
|
551
613
|
.text('Stop active work', 'menu:stop').text('Cancel queue', 'menu:cancel').row()
|
|
552
614
|
.text('Retry failed incoming message', 'menu:retry').row()
|
|
@@ -619,11 +681,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
619
681
|
|
|
620
682
|
// Returning from the polling handler acknowledges intake, not execution. Only
|
|
621
683
|
// return after the authorized update has reached the atomic local journal.
|
|
684
|
+
if (bot) {
|
|
622
685
|
bot.use(async (ctx, next) => {
|
|
623
686
|
if (ctx.callbackQuery && (await control.status()).owner?.kind === 'group') {
|
|
624
687
|
if (!isOwner(ctx, (await control.status()).owner)) return
|
|
625
688
|
try {
|
|
626
|
-
const member = await bot
|
|
689
|
+
const member = await bot!.api.getChatMember(ctx.chat!.id, ctx.from!.id)
|
|
627
690
|
if (!['creator', 'administrator', 'member'].includes(member.status) &&
|
|
628
691
|
!(member.status === 'restricted' && member.is_member)) return
|
|
629
692
|
} catch { throw new Error('Group membership verification unavailable; retry the update') }
|
|
@@ -631,15 +694,15 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
631
694
|
if ((ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup') &&
|
|
632
695
|
!isOwner(ctx, (await control.status()).owner)) {
|
|
633
696
|
if (config.channelBackendUrl) return
|
|
634
|
-
const owner = (await control.status()).owner
|
|
697
|
+
const owner = telegramOwner((await control.status()).owner)
|
|
635
698
|
const message = ctx.message
|
|
636
699
|
if (!owner && message && !message.sender_chat && ctx.from && !ctx.from.is_bot) {
|
|
637
700
|
await control.requestPairing(ctx.from.id, ctx.chat.id, ctx.chat.title)
|
|
638
701
|
return
|
|
639
702
|
}
|
|
640
703
|
if (!owner || !message?.text || message.sender_chat || !ctx.from || ctx.from.is_bot) return
|
|
641
|
-
await telegramSource
|
|
642
|
-
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
|
|
643
706
|
if (owner.kind === 'group') return
|
|
644
707
|
if (ctx.from.id !== owner.telegramUserId) return
|
|
645
708
|
if (replay.has(ctx.update)) {
|
|
@@ -647,7 +710,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
647
710
|
updateId: ctx.update.update_id, chatId: owner.telegramChatId, fromId: owner.telegramUserId,
|
|
648
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}),
|
|
649
712
|
})
|
|
650
|
-
} 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()
|
|
651
714
|
return
|
|
652
715
|
}
|
|
653
716
|
if (replay.has(ctx.update)) {
|
|
@@ -661,19 +724,36 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
661
724
|
const approval = ctx.callbackQuery?.data?.startsWith('approval:')
|
|
662
725
|
if (!ordinary && !approval) return next()
|
|
663
726
|
if (!(await checkOwner(ctx))) return
|
|
664
|
-
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()
|
|
665
728
|
})
|
|
666
729
|
|
|
667
|
-
bot
|
|
730
|
+
bot!.on('message:text', async (ctx) => {
|
|
668
731
|
if (!(await checkOwner(ctx))) return
|
|
669
732
|
|
|
670
733
|
const text = controlCommand(ctx.message.text)
|
|
671
734
|
|
|
672
|
-
if (config.channelBackendUrl && ['/new', '/ai', '/settings'].includes(text ?? '')) {
|
|
735
|
+
if (config.channelBackendUrl && ['/new', '/chats', '/rename', '/ai', '/settings'].includes(text ?? '')) {
|
|
673
736
|
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
674
737
|
return
|
|
675
738
|
}
|
|
676
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
|
+
}
|
|
677
757
|
if (text === '/ai') {
|
|
678
758
|
await aiMenu.list(ctx)
|
|
679
759
|
return
|
|
@@ -741,6 +821,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
741
821
|
|
|
742
822
|
if (text === '/menu') {
|
|
743
823
|
const menuKeyboard = mainKeyboard()
|
|
824
|
+
if (launcher && ctx.chat.type === 'private' && (await control.status()).owner?.kind !== 'group') menuKeyboard.row().webApp(launcher.label, launcher.url)
|
|
744
825
|
await ctx.reply('⚡ <b>Ezenciel Agent Menu</b>\nSelect an action below:', {
|
|
745
826
|
parse_mode: 'HTML',
|
|
746
827
|
reply_markup: menuKeyboard,
|
|
@@ -768,7 +849,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
768
849
|
})
|
|
769
850
|
})
|
|
770
851
|
|
|
771
|
-
bot
|
|
852
|
+
bot!.on('message:photo', async (ctx) => {
|
|
772
853
|
if (!(await checkOwner(ctx))) return
|
|
773
854
|
try {
|
|
774
855
|
const photos = ctx.message.photo
|
|
@@ -798,7 +879,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
798
879
|
}
|
|
799
880
|
})
|
|
800
881
|
|
|
801
|
-
bot
|
|
882
|
+
bot!.on('message:document', async (ctx) => {
|
|
802
883
|
if (!(await checkOwner(ctx))) return
|
|
803
884
|
try {
|
|
804
885
|
const doc = ctx.message.document
|
|
@@ -829,10 +910,10 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
829
910
|
}
|
|
830
911
|
})
|
|
831
912
|
|
|
832
|
-
bot
|
|
913
|
+
bot!.on('message:voice', async (ctx) => {
|
|
833
914
|
if (!(await checkOwner(ctx))) return
|
|
834
915
|
try {
|
|
835
|
-
await bot
|
|
916
|
+
await bot!.api.sendChatAction(ctx.chat.id, 'typing').catch(() => {})
|
|
836
917
|
const voice = ctx.message.voice
|
|
837
918
|
const fileInfo = await ctx.api.getFile(voice.file_id)
|
|
838
919
|
if (!fileInfo.file_path) throw new Error('Telegram attachment path unavailable')
|
|
@@ -861,7 +942,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
861
942
|
}
|
|
862
943
|
})
|
|
863
944
|
|
|
864
|
-
bot
|
|
945
|
+
bot!.on('callback_query:data', async (ctx) => {
|
|
865
946
|
if (!isOwner(ctx, (await control.status()).owner)) {
|
|
866
947
|
await ctx.answerCallbackQuery()
|
|
867
948
|
return
|
|
@@ -898,19 +979,24 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
898
979
|
text: JSON.stringify({ event: 'approval_decision', actionId, decision, prompt: request?.prompt }),
|
|
899
980
|
messageId: ctx.callbackQuery.message?.message_id,
|
|
900
981
|
updateId: ctx.update.update_id,
|
|
901
|
-
chatId: run.chatId
|
|
982
|
+
chatId: run.chatId!,
|
|
902
983
|
fromId: ctx.from.id,
|
|
903
984
|
})
|
|
904
985
|
console.info('Approval decision recorded', { actionId, decision: isApproved ? 'approved' : 'denied' })
|
|
905
986
|
}
|
|
906
|
-
} else if (config.channelBackendUrl && (['menu:new', 'menu:ai', 'menu:settings'].includes(data) || data.startsWith('ai:'))) {
|
|
987
|
+
} else if (config.channelBackendUrl && (['menu:new', 'menu:chats', 'menu:ai', 'menu:settings'].includes(data) || data.startsWith('ai:') || data.startsWith('chat:'))) {
|
|
907
988
|
await ctx.answerCallbackQuery()
|
|
908
989
|
await ctx.reply('Conversation and model settings are managed in the connected application.')
|
|
990
|
+
} else if (await conversationMenu.handle(ctx)) {
|
|
991
|
+
return
|
|
909
992
|
} else if (await aiMenu.handle(ctx)) {
|
|
910
993
|
return
|
|
911
994
|
} else if (data.startsWith('menu:')) {
|
|
912
995
|
const action = data.slice(5)
|
|
913
|
-
if (action === '
|
|
996
|
+
if (action === 'chats') {
|
|
997
|
+
await ctx.answerCallbackQuery()
|
|
998
|
+
await conversationMenu.list(ctx)
|
|
999
|
+
} else if (action === 'ai') {
|
|
914
1000
|
await ctx.answerCallbackQuery()
|
|
915
1001
|
await aiMenu.list(ctx)
|
|
916
1002
|
} else if (action === 'settings') {
|
|
@@ -958,16 +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
|
|
970
1058
|
pollingAbort.abort()
|
|
1059
|
+
await applicationChannel.stop()
|
|
971
1060
|
wakePollRetry?.()
|
|
972
1061
|
if (intakeTimer) clearTimeout(intakeTimer)
|
|
973
1062
|
if (sourceTimer) clearInterval(sourceTimer)
|
|
@@ -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,18 +1115,22 @@ 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
|
|
1030
1125
|
// intervention without restarting setup or terminating authorized work.
|
|
1031
1126
|
while (!shuttingDown) {
|
|
1032
1127
|
try {
|
|
1033
|
-
await bot
|
|
1034
|
-
await bot
|
|
1128
|
+
await bot!.api.setMyCommands(commands)
|
|
1129
|
+
await bot!.api.setMyCommands(commands, { scope: { type: 'all_private_chats' } })
|
|
1035
1130
|
// grammY types its Node signal with the older abort-controller shim.
|
|
1036
|
-
await bot
|
|
1131
|
+
await bot!.init(pollingAbort.signal as unknown as Parameters<typeof Bot.prototype.init>[0])
|
|
1037
1132
|
if (shuttingDown) break
|
|
1038
|
-
await bot
|
|
1133
|
+
await bot!.start({
|
|
1039
1134
|
drop_pending_updates: false,
|
|
1040
1135
|
onStart: (botInfo) => console.log(`✓ Bot @${botInfo.username} polling for messages...`),
|
|
1041
1136
|
})
|
|
@@ -1067,7 +1162,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
1067
1162
|
}
|
|
1068
1163
|
}
|
|
1069
1164
|
}
|
|
1070
|
-
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 }
|
|
1071
1166
|
}
|
|
1072
1167
|
|
|
1073
1168
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|