@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, readdir, rename, rm, appendFile, realpath } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { isHostRunId } from './host-executor-protocol.js'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { startExecutorJob, terminateJob, resolveExecutor, type ExecutorOptions } from './executor.js'
|
|
6
|
+
import { readModels, validateSelection } from './ai.js'
|
|
7
|
+
import type { ChildProcess } from 'node:child_process'
|
|
8
|
+
import { packageVersion } from './version.js'
|
|
9
|
+
import { installedPluginVersions } from './software-status.js'
|
|
10
|
+
|
|
11
|
+
export type HostBinding = { name: string; workspace: string; controlDir: string; binDir: string; toolsHome?: string }
|
|
12
|
+
export type HostInstallation = { cli: string; agents: HostBinding[] }
|
|
13
|
+
|
|
14
|
+
export const serveHostExecutor = async (installation: HostInstallation, signal: AbortSignal) => {
|
|
15
|
+
resolveExecutor(installation.cli)
|
|
16
|
+
if (new Set(installation.agents.map(a=>a.workspace)).size !== installation.agents.length ||
|
|
17
|
+
new Set(installation.agents.map(a=>a.controlDir)).size !== installation.agents.length)
|
|
18
|
+
throw new Error('Each agent requires a separate workspace and control directory')
|
|
19
|
+
const active = new Map<string, ChildProcess>()
|
|
20
|
+
const busy = new Set<string>()
|
|
21
|
+
const tasks = new Set<Promise<void>>()
|
|
22
|
+
const locks: string[] = []
|
|
23
|
+
try {
|
|
24
|
+
for (const agent of installation.agents) {
|
|
25
|
+
if (![agent.workspace,agent.controlDir,agent.binDir].every(path.isAbsolute)) throw new Error('Host bindings require absolute paths')
|
|
26
|
+
if (agent.toolsHome) {
|
|
27
|
+
if (!path.isAbsolute(agent.toolsHome)) throw new Error('Plugin registry binding requires an absolute path')
|
|
28
|
+
const config=JSON.parse(await readFile(path.join(agent.toolsHome,'config.json'),'utf8'))
|
|
29
|
+
if (config.schemaVersion!==1 || config.workspace!==await realpath(agent.workspace)) throw new Error('Plugin registry belongs to another workspace')
|
|
30
|
+
}
|
|
31
|
+
const directory = path.join(agent.controlDir,'host-executor')
|
|
32
|
+
await mkdir(directory,{recursive:true,mode:0o700})
|
|
33
|
+
const lock=path.join(directory,'worker.lock')
|
|
34
|
+
try { const prior=JSON.parse(await readFile(lock,'utf8')); try { process.kill(prior.pid,0); throw new Error('Host executor already running') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }; await rm(lock) }
|
|
35
|
+
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
36
|
+
await writeFile(lock,JSON.stringify({pid:process.pid}),{mode:0o600,flag:'wx'})
|
|
37
|
+
locks.push(lock)
|
|
38
|
+
await writeFile(path.join(directory,'models.json'),JSON.stringify(await readModels()),{mode:0o600})
|
|
39
|
+
// A host crash is terminal for a claimed job. Never replay an action.
|
|
40
|
+
for (const file of await readdir(directory)) if (file.endsWith('.running.json')) {
|
|
41
|
+
const base=path.join(directory,file.slice(0,-13))
|
|
42
|
+
try {
|
|
43
|
+
const prior=JSON.parse(await readFile(base+'.process.json','utf8'))
|
|
44
|
+
try { process.kill(prior.pid,0); throw new Error('Previous host CLI is still running; stop it before recovery') }
|
|
45
|
+
catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error }
|
|
46
|
+
} catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
47
|
+
await rm(base+'.process.json',{force:true})
|
|
48
|
+
await appendFile(base+'.events',JSON.stringify({stream:'exit',code:1})+'\n',{mode:0o600})
|
|
49
|
+
await rm(path.join(directory,file))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
let catalogAt=Date.now()
|
|
53
|
+
while (!signal.aborted) {
|
|
54
|
+
const parent=Number(process.env.EZ_HOST_SUPERVISOR_PID)
|
|
55
|
+
if(parent) { try { process.kill(parent,0) } catch { break } }
|
|
56
|
+
if(Date.now()-catalogAt>30000){
|
|
57
|
+
const models=JSON.stringify(await readModels())
|
|
58
|
+
for(const agent of installation.agents){
|
|
59
|
+
const file=path.join(agent.controlDir,'host-executor/models.json')
|
|
60
|
+
await writeFile(file+'.tmp',models,{mode:0o600});await rename(file+'.tmp',file)
|
|
61
|
+
}
|
|
62
|
+
catalogAt=Date.now()
|
|
63
|
+
}
|
|
64
|
+
for (const agent of installation.agents) {
|
|
65
|
+
const directory=path.join(agent.controlDir,'host-executor')
|
|
66
|
+
await writeFile(path.join(directory,'heartbeat.tmp'),JSON.stringify({at:Date.now(),pid:process.pid,version:packageVersion,plugins:await installedPluginVersions(agent.toolsHome)}),{mode:0o600})
|
|
67
|
+
await rename(path.join(directory,'heartbeat.tmp'),path.join(directory,'heartbeat.json'))
|
|
68
|
+
for (const file of await readdir(directory)) {
|
|
69
|
+
if ((!file.endsWith('.request.json') || !isHostRunId(file.slice(0,-13))) || busy.has(agent.name)) continue
|
|
70
|
+
const base=path.join(directory,file.slice(0,-13))
|
|
71
|
+
await rename(base+'.request.json',base+'.running.json')
|
|
72
|
+
busy.add(agent.name)
|
|
73
|
+
const task=(async()=>{
|
|
74
|
+
let job: Awaited<ReturnType<typeof startExecutorJob>> | undefined
|
|
75
|
+
let cancellation: ReturnType<typeof setInterval> | undefined
|
|
76
|
+
let writes=Promise.resolve()
|
|
77
|
+
const emit=(event:unknown)=>{writes=writes.then(()=>appendFile(base+'.events',JSON.stringify(event)+'\n',{mode:0o600}))}
|
|
78
|
+
try {
|
|
79
|
+
try { await readFile(base+'.cancel'); throw new Error('Cancelled') } catch(error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
|
80
|
+
const request=JSON.parse(await readFile(base+'.running.json','utf8'))
|
|
81
|
+
if (!Array.isArray(request.texts) || request.texts.some((text:unknown)=>typeof text!=='string')) throw new Error('Invalid job')
|
|
82
|
+
const opts=request.options as ExecutorOptions
|
|
83
|
+
const cli = opts.cli || installation.cli
|
|
84
|
+
resolveExecutor(cli)
|
|
85
|
+
if (cli !== installation.cli) await validateSelection({id:'selected',name:'Selected model',cli,model:opts.model,effort:opts.effort},await readModels())
|
|
86
|
+
const options:ExecutorOptions={workspace:agent.workspace,controlDir:agent.controlDir,binDir:agent.binDir,toolsHome:agent.toolsHome,cli,
|
|
87
|
+
runId:path.basename(base),timeoutMs:Math.min(Math.max(Number(opts.timeoutMs)||300000,1000),1800000),
|
|
88
|
+
sessionId:opts.sessionId,isResume:opts.isResume,eventSource:opts.eventSource,model:opts.model,effort:opts.effort}
|
|
89
|
+
job=await startExecutorJob(request.texts,options)
|
|
90
|
+
active.set(agent.name,job.child)
|
|
91
|
+
await writeFile(base+'.process.json',JSON.stringify({pid:job.child.pid}),{mode:0o600})
|
|
92
|
+
if(signal.aborted)terminateJob(job.child)
|
|
93
|
+
job.child.stdout?.on('data',chunk=>emit({stream:'stdout',text:chunk.toString()}))
|
|
94
|
+
job.child.stderr?.on('data',chunk=>emit({stream:'stderr',text:chunk.toString()}))
|
|
95
|
+
cancellation=setInterval(()=>{void readFile(base+'.cancel').then(()=>terminateJob(job!.child)).catch(()=>{})},250)
|
|
96
|
+
const code=await new Promise<number>(resolve=>job!.child.once('close',code=>resolve(code??1)))
|
|
97
|
+
emit({stream:'exit',code})
|
|
98
|
+
} catch { emit({stream:'stderr',text:'Host CLI execution failed\n'}); emit({stream:'exit',code:1}) }
|
|
99
|
+
finally {
|
|
100
|
+
if(cancellation)clearInterval(cancellation)
|
|
101
|
+
await job?.cleanup()
|
|
102
|
+
await writes
|
|
103
|
+
await rm(base+'.running.json',{force:true})
|
|
104
|
+
await rm(base+'.process.json',{force:true})
|
|
105
|
+
await rm(base+'.cancel',{force:true})
|
|
106
|
+
active.delete(agent.name)
|
|
107
|
+
busy.delete(agent.name)
|
|
108
|
+
}
|
|
109
|
+
})()
|
|
110
|
+
tasks.add(task); void task.finally(()=>tasks.delete(task))
|
|
111
|
+
// Do not claim a second job while asynchronous spawning is pending.
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await new Promise(resolve=>setTimeout(resolve,250))
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
for(const child of active.values())terminateJob(child)
|
|
119
|
+
await Promise.allSettled(tasks)
|
|
120
|
+
for(const lock of locks)await rm(lock,{force:true})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (process.argv[1] && path.resolve(process.argv[1])===fileURLToPath(import.meta.url)) {
|
|
125
|
+
const abort=new AbortController()
|
|
126
|
+
for(const signal of ['SIGTERM','SIGINT'] as const)process.once(signal,()=>abort.abort())
|
|
127
|
+
const config=JSON.parse(await readFile(process.argv[2],'utf8'))
|
|
128
|
+
await serveHostExecutor(config,abort.signal)
|
|
129
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Context } from 'grammy'
|
|
2
|
+
import type { Owner } from './control-state.js'
|
|
3
|
+
|
|
4
|
+
export const isOwner = (ctx: Pick<Context, 'from' | 'chat'>, owner: Owner | null): boolean =>
|
|
5
|
+
Boolean(
|
|
6
|
+
owner &&
|
|
7
|
+
ctx.from &&
|
|
8
|
+
!ctx.from.is_bot &&
|
|
9
|
+
ctx.chat?.type === 'private' &&
|
|
10
|
+
ctx.from.id === owner.telegramUserId &&
|
|
11
|
+
ctx.chat.id === owner.telegramChatId,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
export const assertId = (id: string): string => {
|
|
15
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error('Invalid record identifier')
|
|
16
|
+
return id
|
|
17
|
+
}
|
package/src/inbox.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { Update } from 'grammy/types'
|
|
4
|
+
import { isExecutionChoice, type ExecutionChoice } from './ai.js'
|
|
5
|
+
|
|
6
|
+
export type IncomingItem = {
|
|
7
|
+
text: string
|
|
8
|
+
messageId?: number
|
|
9
|
+
updateId: number
|
|
10
|
+
chatId: number
|
|
11
|
+
fromId: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Entry = { update: Update; receivedAt: number; execution?: ExecutionChoice }
|
|
15
|
+
export type InboxBatch = { id: string; entries: Entry[]; status: 'pending' | 'failed' | 'cancelled' }
|
|
16
|
+
type State = { version: 1; seen: number[]; waiting: Entry[]; batches: InboxBatch[] }
|
|
17
|
+
|
|
18
|
+
// One relay writer. Serialize read/modify/rename, including commands arriving during STT.
|
|
19
|
+
export class InboxStore {
|
|
20
|
+
private lock: Promise<unknown> = Promise.resolve()
|
|
21
|
+
private readonly file: string
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly directory: string,
|
|
24
|
+
private readonly now = Date.now,
|
|
25
|
+
) {
|
|
26
|
+
this.file = join(directory, 'inbox.json')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private async read(): Promise<State> {
|
|
30
|
+
try {
|
|
31
|
+
const state = JSON.parse(await readFile(this.file, 'utf8')) as State
|
|
32
|
+
const entry = (e: Entry) =>
|
|
33
|
+
e && Number.isSafeInteger(e.update?.update_id) && Number.isFinite(e.receivedAt) &&
|
|
34
|
+
(e.execution === undefined || isExecutionChoice(e.execution))
|
|
35
|
+
if (
|
|
36
|
+
state.version !== 1 ||
|
|
37
|
+
!Array.isArray(state.seen) ||
|
|
38
|
+
!state.seen.every(Number.isSafeInteger) ||
|
|
39
|
+
!Array.isArray(state.waiting) ||
|
|
40
|
+
!state.waiting.every(entry) ||
|
|
41
|
+
!Array.isArray(state.batches) ||
|
|
42
|
+
!state.batches.every(
|
|
43
|
+
(b) =>
|
|
44
|
+
/^tg_\d+$/.test(b.id) &&
|
|
45
|
+
['pending', 'failed', 'cancelled'].includes(b.status) &&
|
|
46
|
+
Array.isArray(b.entries) &&
|
|
47
|
+
b.entries.length > 0 &&
|
|
48
|
+
b.entries.every(entry),
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
throw new Error('Invalid inbox shape')
|
|
52
|
+
return state
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT')
|
|
55
|
+
return { version: 1, seen: [], waiting: [], batches: [] }
|
|
56
|
+
throw new Error('Inbox unreadable; restore the local journal before accepting messages')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private change<T>(work: (state: State) => T): Promise<T> {
|
|
61
|
+
const next = this.lock.then(async () => {
|
|
62
|
+
const state = await this.read()
|
|
63
|
+
const result = work(state)
|
|
64
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 })
|
|
65
|
+
const temporary = `${this.file}.${process.pid}.tmp`
|
|
66
|
+
await writeFile(temporary, JSON.stringify(state), { mode: 0o600 })
|
|
67
|
+
await rename(temporary, this.file)
|
|
68
|
+
return result
|
|
69
|
+
})
|
|
70
|
+
this.lock = next.catch(() => {})
|
|
71
|
+
return next
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
accept(update: Update, execution?: ExecutionChoice): Promise<boolean> {
|
|
75
|
+
if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) throw new Error('Invalid update ID')
|
|
76
|
+
return this.change((state) => {
|
|
77
|
+
if (state.seen.includes(update.update_id)) return false
|
|
78
|
+
state.seen.push(update.update_id)
|
|
79
|
+
state.waiting.push({ update, receivedAt: this.now(), execution })
|
|
80
|
+
return true
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Seal membership BEFORE normalization or run creation. Replay uses the same run ID.
|
|
85
|
+
next(force = false): Promise<InboxBatch | undefined> {
|
|
86
|
+
return this.change((state) => {
|
|
87
|
+
const pending = state.batches.find((b) => b.status === 'pending')
|
|
88
|
+
if (pending) return pending
|
|
89
|
+
if (!state.waiting.length) return
|
|
90
|
+
const first = state.waiting[0]
|
|
91
|
+
const last = state.waiting.at(-1)!
|
|
92
|
+
if (
|
|
93
|
+
!force &&
|
|
94
|
+
(state.waiting.length < 10 || last.update.message?.media_group_id) &&
|
|
95
|
+
this.now() - last.receivedAt < 2000 &&
|
|
96
|
+
this.now() - first.receivedAt < 30000
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
const boundary = state.waiting.findIndex((e) => JSON.stringify(e.execution) !== JSON.stringify(first.execution))
|
|
100
|
+
const entries = state.waiting.splice(0, boundary < 0 ? 10 : Math.min(10, boundary))
|
|
101
|
+
// Telegram albums contain at most ten items. Don't split one at the batch boundary.
|
|
102
|
+
const album = entries.at(-1)?.update.message?.media_group_id
|
|
103
|
+
while (album && state.waiting[0]?.update.message?.media_group_id === album &&
|
|
104
|
+
JSON.stringify(state.waiting[0].execution) === JSON.stringify(first.execution))
|
|
105
|
+
entries.push(state.waiting.shift()!)
|
|
106
|
+
const batch: InboxBatch = { id: `tg_${first.update.update_id}`, entries, status: 'pending' }
|
|
107
|
+
state.batches.push(batch)
|
|
108
|
+
return batch
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
finish(id: string, failed = false): Promise<void> {
|
|
113
|
+
return this.change((state) => {
|
|
114
|
+
const batch = state.batches.find((b) => b.id === id)
|
|
115
|
+
if (!batch || batch.status !== 'pending') return
|
|
116
|
+
if (failed) batch.status = 'failed'
|
|
117
|
+
else state.batches = state.batches.filter((b) => b.id !== id)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
cancel(): Promise<number> {
|
|
122
|
+
return this.change((state) => {
|
|
123
|
+
let count = state.waiting.length
|
|
124
|
+
state.waiting = []
|
|
125
|
+
for (const batch of state.batches) {
|
|
126
|
+
if (batch.status !== 'pending') continue
|
|
127
|
+
count += batch.entries.length
|
|
128
|
+
batch.status = 'cancelled'
|
|
129
|
+
}
|
|
130
|
+
return count
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
retryLatest(userId: number, chatId: number): Promise<string | undefined> {
|
|
135
|
+
return this.change((state) => {
|
|
136
|
+
const batch = [...state.batches].reverse().find(
|
|
137
|
+
(b) =>
|
|
138
|
+
b.status === 'failed' &&
|
|
139
|
+
b.entries.every((e) => {
|
|
140
|
+
const message = e.update.message || e.update.callback_query?.message
|
|
141
|
+
const from = e.update.message?.from || e.update.callback_query?.from
|
|
142
|
+
return (
|
|
143
|
+
from?.id === userId &&
|
|
144
|
+
!from.is_bot &&
|
|
145
|
+
message?.chat.type === 'private' &&
|
|
146
|
+
message.chat.id === chatId
|
|
147
|
+
)
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
if (!batch) return
|
|
151
|
+
batch.status = 'pending'
|
|
152
|
+
return batch.id
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async pending(id: string): Promise<boolean> {
|
|
157
|
+
await this.lock
|
|
158
|
+
return (await this.read()).batches.some((b) => b.id === id && b.status === 'pending')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async status(): Promise<{ pending: number; failed: number }> {
|
|
162
|
+
await this.lock
|
|
163
|
+
const state = await this.read()
|
|
164
|
+
return {
|
|
165
|
+
pending:
|
|
166
|
+
state.waiting.length +
|
|
167
|
+
state.batches.filter((b) => b.status === 'pending').reduce((n, b) => n + b.entries.length, 0),
|
|
168
|
+
failed: state.batches.filter((b) => b.status === 'failed').length,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|