@jc_stack/ez-agents 0.1.0-beta.26 → 0.1.0-beta.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -1
- package/AGENTS.md +40 -9
- package/CHANGELOG.md +35 -0
- package/CONTRIBUTING.md +31 -1
- package/Dockerfile +1 -0
- package/README.md +84 -12
- package/bin/ezenciel-agents-application +2 -0
- package/bin/ezenciel-agents-application.mjs +16 -0
- package/compose.yaml +8 -0
- package/docker/entrypoint.sh +20 -2
- package/docker/healthcheck.mjs +1 -1
- package/docker/run.ts +3 -3
- package/docker/smoke.mjs +41 -2
- package/docs/application-channel.md +366 -0
- package/docs/architecture/ai-selection.md +12 -15
- package/docs/docker-runtime.md +29 -0
- package/docs/host-service.md +5 -8
- package/docs/local-qa.md +1 -1
- package/docs/managed-applications.md +68 -0
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugin-connection.md +76 -0
- package/docs/plugins.md +54 -5
- package/docs/repair.md +26 -25
- package/docs/responsive-channels.md +13 -55
- package/docs/scheduling.md +40 -36
- package/docs/setup.md +11 -21
- package/docs/standalone-cli.md +2 -2
- package/docs/upgrades.md +43 -18
- package/package.json +8 -4
- package/src/agent-guidance.ts +32 -3
- package/src/ai-cli.ts +5 -1
- package/src/ai.ts +6 -28
- package/src/application-channel.ts +308 -0
- package/src/application-cli.ts +41 -0
- package/src/application-client.mjs +87 -0
- package/src/application-origin.ts +15 -0
- package/src/codex-session.ts +7 -10
- package/src/config.ts +23 -5
- package/src/control-state.ts +274 -21
- package/src/conversation-menu.ts +89 -0
- package/src/delivery-context.d.mts +5 -0
- package/src/delivery-context.mjs +25 -0
- package/src/desktop-bridge.ts +11 -43
- package/src/event-sources.ts +2 -2
- package/src/execution-authority.ts +2 -0
- package/src/executor.ts +29 -58
- package/src/host-executor.ts +11 -9
- package/src/identity.ts +11 -3
- package/src/index.ts +191 -93
- package/src/menu.ts +76 -55
- package/src/message-history.ts +52 -0
- package/src/message-send.ts +1 -1
- package/src/message.ts +49 -7
- package/src/model-policy.ts +5 -15
- package/src/owner.ts +7 -1
- package/src/plugins/connection-artifacts.mjs +31 -0
- package/src/plugins/connection.mjs +124 -0
- package/src/plugins/manager.mjs +93 -23
- package/src/plugins/native-tasks.d.mts +4 -0
- package/src/plugins/native-tasks.mjs +66 -0
- package/src/plugins/workspace-lease.d.mts +3 -0
- package/src/plugins/workspace-lease.mjs +44 -0
- package/src/repair-policy.ts +0 -8
- package/src/reply-context.ts +3 -29
- package/src/runs.ts +67 -9
- package/src/schedule-cli.ts +33 -15
- package/src/scheduled-tasks.ts +20 -21
- package/src/scheduler.ts +55 -22
- package/src/task-executor.ts +4 -5
- package/src/task-workspace.ts +2 -11
- package/src/update-attention.ts +1 -1
- package/src/updates/binding.mjs +2 -6
- package/src/updates/control.mjs +4 -0
- package/src/updates/supervisor.mjs +10 -4
- package/src/web-launcher.ts +19 -0
- package/src/workspace.ts +3 -1
- package/templates/agent/AGENTS.md +13 -55
- package/templates/agent-guidance.md +90 -37
- package/templates/deployments.md +24 -0
- package/templates/failure-review.md +6 -0
- package/templates/maintainer-purpose.md +12 -6
- package/test/agent-guidance.test.ts +29 -39
- package/test/ai-cli.test.ts +9 -0
- package/test/ai.test.ts +66 -22
- package/test/application-channel.test.ts +283 -0
- package/test/application-client.test.mjs +84 -0
- package/test/application-controls.test.ts +224 -0
- package/test/application-only.test.ts +100 -0
- package/test/busy-reply-relay.test.ts +11 -7
- package/test/channel-delivery.test.ts +63 -0
- package/test/channel-owner.test.ts +161 -0
- package/test/client-defaults.test.ts +1 -1
- package/test/codex-session.test.ts +18 -10
- package/test/config.test.ts +16 -1
- package/test/connection-artifacts.test.mjs +32 -0
- package/test/conversation-menu.test.ts +67 -0
- package/test/conversations.test.ts +84 -0
- package/test/desktop-bridge.test.ts +17 -11
- package/test/engine-handoff.test.ts +73 -0
- package/test/event-sources.test.ts +5 -8
- package/test/executor.test.ts +68 -16
- package/test/failure.test.ts +64 -0
- package/test/host-executor.test.ts +58 -17
- package/test/install-config.test.ts +1 -1
- package/test/intake-relay.test.ts +169 -25
- package/test/message-history.test.ts +127 -0
- package/test/model-policy.test.ts +23 -48
- package/test/native-tasks.test.ts +36 -0
- package/test/plugin-connection.test.mjs +124 -0
- package/test/plugin-manager.test.mjs +70 -10
- package/test/repair-policy.test.ts +8 -12
- package/test/runs.test.ts +13 -0
- package/test/runtime-identity.test.mjs +18 -0
- package/test/schedule-cli.test.ts +34 -5
- package/test/scheduled-tasks.test.ts +79 -8
- package/test/scheduler.test.ts +30 -1
- package/test/task-native.test.ts +5 -2
- package/test/update-attention.test.ts +1 -2
- package/test/updates.test.mjs +44 -5
- package/test/workspace.test.ts +2 -3
- package/scripts/smoke-busy-reply.ts +0 -58
- package/src/reply-executor.ts +0 -55
- package/src/reply-mcp.ts +0 -23
- package/templates/agent/TOOLS.md +0 -105
- package/templates/chat-guidance.md +0 -23
- package/templates/standalone-tools.md +0 -20
- package/templates/updates.md +0 -45
- package/test/reply.test.ts +0 -159
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseWebLauncher, type WebLauncher } from './web-launcher.js'
|
|
1
2
|
import { repairEnabled } from './repair-policy.js'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import { homedir } from 'node:os'
|
|
@@ -9,13 +10,18 @@ export type ControlConfig = {
|
|
|
9
10
|
|
|
10
11
|
export type Config = ControlConfig & {
|
|
11
12
|
repairEnabled?: boolean
|
|
13
|
+
telegramEnabled?: boolean
|
|
14
|
+
webLauncher?: WebLauncher
|
|
12
15
|
telegramBotToken: string
|
|
13
16
|
workspace: string
|
|
14
17
|
executorTimeoutMs: number
|
|
18
|
+
codexSandbox?: 'external'
|
|
15
19
|
codexAutoCompactTokens?: number
|
|
16
20
|
executorCli: string
|
|
17
21
|
channelBackendUrl?: string
|
|
18
22
|
channelBackendToken?: string
|
|
23
|
+
applicationPort?: number
|
|
24
|
+
applicationHost?: string
|
|
19
25
|
geminiApiKey?: string
|
|
20
26
|
openaiApiKey?: string
|
|
21
27
|
pagerDutyRoutingKey?: string
|
|
@@ -24,8 +30,8 @@ export type Config = ControlConfig & {
|
|
|
24
30
|
pagerDutyFailureThreshold?: number
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
const positiveInteger = (value: string | undefined, name: string, fallback
|
|
28
|
-
if (!value) return fallback
|
|
33
|
+
const positiveInteger = (value: string | undefined, name: string, fallback?: number): number => {
|
|
34
|
+
if (!value && fallback !== undefined) return fallback
|
|
29
35
|
const parsed = Number(value)
|
|
30
36
|
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
31
37
|
throw new Error(`${name} must be a positive integer`)
|
|
@@ -42,10 +48,17 @@ export const loadControlConfig = (env: NodeJS.ProcessEnv = process.env): Control
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
if (env.EZ_TELEGRAM_ENABLED !== undefined && !['true', 'false'].includes(env.EZ_TELEGRAM_ENABLED)) throw new Error('EZ_TELEGRAM_ENABLED must be true or false')
|
|
52
|
+
const telegramEnabled = env.EZ_TELEGRAM_ENABLED !== 'false'
|
|
53
|
+
const telegramBotToken = telegramEnabled ? env.TELEGRAM_BOT_TOKEN?.trim() || '' : ''
|
|
54
|
+
if (!telegramEnabled && !env.EZ_APPLICATION_PORT) throw new Error('Application-only execution requires EZ_APPLICATION_PORT')
|
|
55
|
+
if (telegramEnabled && !telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
|
|
47
56
|
|
|
48
57
|
if (env.EZ_CHANNEL_BACKEND_URL && !env.EZ_CHANNEL_BACKEND_TOKEN?.trim()) throw new Error('EZ_CHANNEL_BACKEND_TOKEN is required')
|
|
58
|
+
if (env.EZ_APPLICATION_PORT && env.EZ_CHANNEL_BACKEND_URL) throw new Error('Application input requires the native Ez executor, not a channel backend')
|
|
59
|
+
const codexSandbox = env.EZ_CODEX_SANDBOX?.trim()
|
|
60
|
+
if (codexSandbox && codexSandbox !== 'external') throw new Error('EZ_CODEX_SANDBOX must be external or unset')
|
|
61
|
+
if (codexSandbox && (env.EZ_CHANNEL_BACKEND_URL || env.EZ_EXECUTOR_TRANSPORT !== 'local')) throw new Error('External Codex sandbox requires native local execution')
|
|
49
62
|
const pagerDutyRoutingKey = env.PAGERDUTY_ROUTING_KEY?.trim()
|
|
50
63
|
const pagerDutyStocksHealthUrl = env.EZ_PAGERDUTY_STOCKS_HEALTH_URL?.trim()
|
|
51
64
|
if (pagerDutyStocksHealthUrl && !pagerDutyRoutingKey)
|
|
@@ -59,12 +72,17 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
|
59
72
|
}
|
|
60
73
|
return {
|
|
61
74
|
...loadControlConfig(env),
|
|
75
|
+
telegramEnabled,
|
|
76
|
+
webLauncher: parseWebLauncher(env.EZ_TELEGRAM_WEB_APP),
|
|
62
77
|
telegramBotToken,
|
|
63
78
|
repairEnabled: repairEnabled(env.EZ_REPAIR_ENABLED),
|
|
64
79
|
workspace: path.resolve(env.EZ_AGENT_WORKSPACE?.trim() || './agent'),
|
|
65
80
|
executorTimeoutMs: 0,
|
|
66
|
-
|
|
81
|
+
codexSandbox: codexSandbox === 'external' ? 'external' : undefined,
|
|
82
|
+
codexAutoCompactTokens: !env.EZ_CODEX_AUTO_COMPACT_TOKENS?.trim() ? undefined : positiveInteger(env.EZ_CODEX_AUTO_COMPACT_TOKENS, 'EZ_CODEX_AUTO_COMPACT_TOKENS'),
|
|
67
83
|
executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
|
|
84
|
+
applicationPort: env.EZ_APPLICATION_PORT ? positiveInteger(env.EZ_APPLICATION_PORT, 'EZ_APPLICATION_PORT') : undefined,
|
|
85
|
+
applicationHost: env.EZ_APPLICATION_HOST?.trim() || '127.0.0.1',
|
|
68
86
|
channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
|
|
69
87
|
channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
|
|
70
88
|
geminiApiKey: env.GEMINI_API_KEY?.trim(),
|
package/src/control-state.ts
CHANGED
|
@@ -4,12 +4,34 @@ import path from 'node:path'
|
|
|
4
4
|
import { isPreset, persistedPreset, type AiPreset, type ExecutionChoice } from './ai.js'
|
|
5
5
|
|
|
6
6
|
export type Owner = {
|
|
7
|
+
id?: string
|
|
8
|
+
generation?: string
|
|
7
9
|
kind?: 'group'
|
|
8
|
-
telegramUserId
|
|
9
|
-
telegramChatId
|
|
10
|
+
telegramUserId?: number
|
|
11
|
+
telegramChatId?: number
|
|
12
|
+
telegramLinkedAt?: string
|
|
10
13
|
pairedAt: string
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
export type TelegramOwner = Owner & { telegramUserId: number; telegramChatId: number }
|
|
17
|
+
export const telegramOwner = (owner: Owner | null): TelegramOwner | null =>
|
|
18
|
+
owner && Number.isSafeInteger(owner.telegramUserId) && Number.isSafeInteger(owner.telegramChatId) ? owner as TelegramOwner : null
|
|
19
|
+
export const ownerId = (owner: Owner): string => owner.id ?? `telegram:${owner.telegramUserId}:${owner.telegramChatId}`
|
|
20
|
+
export const ownerEpoch = (owner: Owner): string => owner.generation ?? owner.pairedAt
|
|
21
|
+
export const sameOwner = (left: Owner, right: Owner | null): boolean =>
|
|
22
|
+
!!right && ownerId(left) === ownerId(right) && ownerEpoch(left) === ownerEpoch(right)
|
|
23
|
+
export const validOwner = (owner: unknown): owner is Owner => {
|
|
24
|
+
if (!owner || typeof owner !== 'object') return false
|
|
25
|
+
const p = owner as Owner
|
|
26
|
+
const linked = p.telegramUserId !== undefined || p.telegramChatId !== undefined
|
|
27
|
+
return typeof p.pairedAt === 'string' && Number.isFinite(Date.parse(p.pairedAt)) &&
|
|
28
|
+
(p.generation === undefined || typeof p.generation === 'string' && /^[a-f0-9-]{36}$/.test(p.generation)) &&
|
|
29
|
+
(p.telegramLinkedAt === undefined || typeof p.telegramLinkedAt === 'string') &&
|
|
30
|
+
(p.id === undefined ? linked : typeof p.id === 'string' && /^[a-zA-Z0-9_:.-]{1,200}$/.test(p.id)) &&
|
|
31
|
+
(!linked ? p.kind === undefined : Number.isSafeInteger(p.telegramUserId) && p.telegramUserId! > 0 &&
|
|
32
|
+
Number.isSafeInteger(p.telegramChatId) && (p.kind === 'group' ? p.telegramChatId! < 0 : p.kind === undefined && p.telegramChatId! > 0))
|
|
33
|
+
}
|
|
34
|
+
|
|
13
35
|
export type PairingRequest = {
|
|
14
36
|
kind?: 'group'
|
|
15
37
|
title?: string
|
|
@@ -24,14 +46,21 @@ export type SessionState = {
|
|
|
24
46
|
hasStarted: boolean
|
|
25
47
|
cli?: string
|
|
26
48
|
nativeSessionId?: string
|
|
49
|
+
title?: string
|
|
50
|
+
archived?: boolean
|
|
51
|
+
preset?: AiPreset
|
|
52
|
+
applicationScope?: string
|
|
53
|
+
telegramShared?: boolean
|
|
27
54
|
}
|
|
28
55
|
|
|
56
|
+
export type ControlGuard = { owner: Owner; authorize: () => Promise<unknown>; expectedSession?: string | null; applicationScope?: string }
|
|
57
|
+
|
|
29
58
|
type ControlState = {
|
|
30
59
|
version: 1
|
|
31
60
|
owner: Owner | null
|
|
32
61
|
pending: PairingRequest[]
|
|
33
62
|
activeSession?: SessionState | null
|
|
34
|
-
ai?: { presets: AiPreset[]; defaultId: string; selectedId: string }
|
|
63
|
+
ai?: { presets: AiPreset[]; defaultId: string; selectedId: string; recentIds?: string[] }
|
|
35
64
|
sessions?: SessionState[]
|
|
36
65
|
}
|
|
37
66
|
|
|
@@ -42,6 +71,10 @@ const emptyState = (): ControlState => ({ version: 1, owner: null, pending: [] }
|
|
|
42
71
|
const isPositiveId = (value: unknown): value is number =>
|
|
43
72
|
typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
|
44
73
|
|
|
74
|
+
const isRecentIds = (value: unknown): value is string[] =>
|
|
75
|
+
Array.isArray(value) && value.length <= 3 && new Set(value).size === value.length &&
|
|
76
|
+
value.every((id) => typeof id === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(id))
|
|
77
|
+
|
|
45
78
|
const isState = (value: unknown): value is ControlState => {
|
|
46
79
|
if (!value || typeof value !== 'object') return false
|
|
47
80
|
const candidate = value as Partial<ControlState>
|
|
@@ -49,27 +82,56 @@ const isState = (value: unknown): value is ControlState => {
|
|
|
49
82
|
if (!person || typeof person !== 'object') return false
|
|
50
83
|
const p = person as Owner
|
|
51
84
|
return isPositiveId(p.telegramUserId) && (p.kind === 'group'
|
|
52
|
-
? Number.isSafeInteger(p.telegramChatId) && p.telegramChatId < 0
|
|
85
|
+
? Number.isSafeInteger(p.telegramChatId) && p.telegramChatId! < 0
|
|
53
86
|
: p.kind === undefined && isPositiveId(p.telegramChatId))
|
|
54
87
|
}
|
|
88
|
+
const session = (s: SessionState) => s && /^[0-9a-f-]{36}$/i.test(s.sessionId) && typeof s.hasStarted === 'boolean' &&
|
|
89
|
+
(s.title === undefined || (typeof s.title === 'string' && s.title.length <= 80)) &&
|
|
90
|
+
(s.archived === undefined || typeof s.archived === 'boolean') &&
|
|
91
|
+
(s.applicationScope === undefined || /^[a-f0-9]{64}$/.test(s.applicationScope)) &&
|
|
92
|
+
(s.telegramShared === undefined || typeof s.telegramShared === 'boolean') &&
|
|
93
|
+
(s.preset === undefined || (isPreset(s.preset) && s.preset.cli === s.cli))
|
|
55
94
|
return (
|
|
56
95
|
candidate.version === 1 &&
|
|
57
96
|
Array.isArray(candidate.pending) &&
|
|
58
97
|
candidate.pending.every((p) => identity(p) && Number.isFinite(Date.parse(p.expiresAt))) &&
|
|
59
|
-
(candidate.owner === null ||
|
|
98
|
+
(candidate.owner === null || validOwner(candidate.owner)) &&
|
|
60
99
|
(candidate.ai === undefined || (Array.isArray(candidate.ai.presets) &&
|
|
61
100
|
candidate.ai.presets.every(isPreset) &&
|
|
62
101
|
candidate.ai.presets.some((p) => p.id === candidate.ai!.defaultId) &&
|
|
63
|
-
candidate.ai.presets.some((p) => p.id === candidate.ai!.selectedId)
|
|
102
|
+
candidate.ai.presets.some((p) => p.id === candidate.ai!.selectedId) &&
|
|
103
|
+
(candidate.ai.recentIds === undefined || isRecentIds(candidate.ai.recentIds)))) &&
|
|
64
104
|
(candidate.sessions === undefined || (Array.isArray(candidate.sessions) && candidate.sessions.every(
|
|
65
|
-
|
|
105
|
+
session))) &&
|
|
66
106
|
(candidate.activeSession == null ||
|
|
67
|
-
(
|
|
68
|
-
/^[0-9a-f-]{36}$/i.test(candidate.activeSession.sessionId) &&
|
|
69
|
-
typeof candidate.activeSession.hasStarted === 'boolean'))
|
|
107
|
+
session(candidate.activeSession))
|
|
70
108
|
)
|
|
71
109
|
}
|
|
72
110
|
|
|
111
|
+
export const sessionTitle = (session: SessionState): string =>
|
|
112
|
+
session.title || `Conversation ${session.sessionId.slice(0, 8)}`
|
|
113
|
+
|
|
114
|
+
const rememberPreset = (state: ControlState) => {
|
|
115
|
+
const preset = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
|
|
116
|
+
if (state.activeSession && preset && state.activeSession.cli === preset.cli)
|
|
117
|
+
state.activeSession.preset = preset
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const currentApplicationSession = (state: ControlState, scope: string) =>
|
|
121
|
+
[state.activeSession, ...(state.sessions ?? [])].find(session => session?.applicationScope === scope && !session.archived)
|
|
122
|
+
|
|
123
|
+
const requireControlGuard = async (state: ControlState, guard?: ControlGuard) => {
|
|
124
|
+
if (!guard) return
|
|
125
|
+
// The callback may inspect binding authority, but must not acquire this store's lock.
|
|
126
|
+
await guard.authorize()
|
|
127
|
+
const expected = guard.owner
|
|
128
|
+
if (!sameOwner(expected, state.owner)) throw new Error('Control owner changed. Refresh the connection.')
|
|
129
|
+
const current = guard.applicationScope ? currentApplicationSession(state, guard.applicationScope) : state.activeSession
|
|
130
|
+
if (guard.applicationScope && current && (current.telegramShared || current === state.activeSession))
|
|
131
|
+
throw new Error('Application scope is shared; use shared controls')
|
|
132
|
+
if (guard.expectedSession !== undefined && (current?.sessionId ?? null) !== guard.expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
|
|
133
|
+
}
|
|
134
|
+
|
|
73
135
|
const wait = (milliseconds: number): Promise<void> =>
|
|
74
136
|
new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
75
137
|
|
|
@@ -94,7 +156,12 @@ export class ControlStore {
|
|
|
94
156
|
try {
|
|
95
157
|
const parsed: unknown = JSON.parse(await readFile(this.statePath, 'utf8'))
|
|
96
158
|
if (!isState(parsed)) throw new Error('Control state has an unsupported shape')
|
|
97
|
-
if (parsed.ai)
|
|
159
|
+
if (parsed.ai) {
|
|
160
|
+
parsed.ai.presets = parsed.ai.presets.map(persistedPreset)
|
|
161
|
+
parsed.ai.recentIds = (parsed.ai.recentIds ?? [parsed.ai.selectedId])
|
|
162
|
+
.filter((id) => parsed.ai!.presets.some((preset) => preset.id === id))
|
|
163
|
+
.slice(0, 3)
|
|
164
|
+
}
|
|
98
165
|
return parsed
|
|
99
166
|
} catch (error: unknown) {
|
|
100
167
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyState()
|
|
@@ -151,7 +218,7 @@ export class ControlStore {
|
|
|
151
218
|
throw new Error('Telegram identity must be a positive numeric ID')
|
|
152
219
|
return this.withLock(async () => {
|
|
153
220
|
const state = this.prune(await this.readState())
|
|
154
|
-
if (state.owner) return 'owner-exists'
|
|
221
|
+
if (telegramOwner(state.owner)) return 'owner-exists'
|
|
155
222
|
if (
|
|
156
223
|
state.pending.some(
|
|
157
224
|
(request) => request.telegramUserId === telegramUserId && request.telegramChatId === telegramChatId,
|
|
@@ -174,20 +241,63 @@ export class ControlStore {
|
|
|
174
241
|
})
|
|
175
242
|
}
|
|
176
243
|
|
|
244
|
+
async bootstrapApplicationOwner(operatorId: number): Promise<Owner> {
|
|
245
|
+
if (!isPositiveId(operatorId)) throw new Error('Supply the real administrator Telegram user ID')
|
|
246
|
+
return this.withLock(async () => {
|
|
247
|
+
const state = await this.readState()
|
|
248
|
+
if (state.owner) throw new Error('An owner already exists; application bootstrap cannot replace it')
|
|
249
|
+
const owner: Owner = { generation: crypto.randomUUID(), telegramUserId: operatorId, telegramChatId: operatorId, pairedAt: new Date(this.clock()).toISOString() }
|
|
250
|
+
state.owner = owner
|
|
251
|
+
state.pending = []
|
|
252
|
+
await this.writeState(state)
|
|
253
|
+
return owner
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async registerOwner(id: string): Promise<Owner> {
|
|
258
|
+
if (!/^[a-zA-Z0-9_:.-]{1,200}$/.test(id)) throw new Error('Invalid owner ID')
|
|
259
|
+
return this.withLock(async () => {
|
|
260
|
+
const state = await this.readState()
|
|
261
|
+
if (state.owner) {
|
|
262
|
+
if (ownerId(state.owner) !== id) throw new Error('Installation already has a different owner')
|
|
263
|
+
return state.owner
|
|
264
|
+
}
|
|
265
|
+
const bindings = await readFile(path.join(path.dirname(this.statePath), 'application-bindings.json'), 'utf8')
|
|
266
|
+
.then(text => JSON.parse(text), error => { if (error.code === 'ENOENT') return []; throw error })
|
|
267
|
+
if (!Array.isArray(bindings) || bindings.some(binding => !validOwner(binding?.owner)) || state.activeSession || state.sessions?.length)
|
|
268
|
+
throw new Error('Existing unowned state requires explicit ownership recovery')
|
|
269
|
+
state.owner = {id, generation: crypto.randomUUID(), pairedAt: new Date(this.clock()).toISOString()}
|
|
270
|
+
await this.writeState(state)
|
|
271
|
+
return state.owner
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async unlinkTelegram(): Promise<void> {
|
|
276
|
+
await this.withLock(async () => {
|
|
277
|
+
const state = await this.readState()
|
|
278
|
+
if (!state.owner) throw new Error('No installation owner')
|
|
279
|
+
state.owner = {id: ownerId(state.owner), generation: state.owner.generation, pairedAt: state.owner.pairedAt}
|
|
280
|
+
state.pending = []
|
|
281
|
+
await this.writeState(state)
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
177
285
|
async approveOwner(telegramUserId: number, group = false): Promise<Owner> {
|
|
178
286
|
if (!(group ? Number.isSafeInteger(telegramUserId) && telegramUserId < 0 : isPositiveId(telegramUserId))) throw new Error('Supply a positive user ID or negative group ID')
|
|
179
287
|
return this.withLock(async () => {
|
|
180
288
|
const state = this.prune(await this.readState())
|
|
181
|
-
if (state.owner) throw new Error('An owner is already paired;
|
|
289
|
+
if (telegramOwner(state.owner)) throw new Error('An owner is already paired; unlink locally before replacing its Telegram channel')
|
|
182
290
|
const request = state.pending.find((candidate) => group
|
|
183
291
|
? candidate.kind === 'group' && candidate.telegramChatId === telegramUserId
|
|
184
292
|
: candidate.kind === undefined && candidate.telegramUserId === telegramUserId)
|
|
185
293
|
if (!request) throw new Error('No active pairing request exists for that Telegram user ID')
|
|
186
294
|
const owner: Owner = {
|
|
295
|
+
generation: state.owner ? state.owner.generation : crypto.randomUUID(),
|
|
296
|
+
...(state.owner ? {id: ownerId(state.owner), telegramLinkedAt: crypto.randomUUID()} : {}),
|
|
187
297
|
...(group ? {kind: 'group' as const} : {}),
|
|
188
298
|
telegramUserId: request.telegramUserId,
|
|
189
299
|
telegramChatId: request.telegramChatId,
|
|
190
|
-
pairedAt: new Date(this.clock()).toISOString(),
|
|
300
|
+
pairedAt: state.owner?.pairedAt ?? new Date(this.clock()).toISOString(),
|
|
191
301
|
}
|
|
192
302
|
state.owner = owner
|
|
193
303
|
state.pending = []
|
|
@@ -243,14 +353,81 @@ export class ControlStore {
|
|
|
243
353
|
})
|
|
244
354
|
}
|
|
245
355
|
|
|
246
|
-
async
|
|
356
|
+
async listSessions(): Promise<SessionState[]> {
|
|
357
|
+
const state = await this.status()
|
|
358
|
+
return [...(state.activeSession ? [state.activeSession] : []), ...(state.sessions ?? []).filter(session => !session.applicationScope || session.telegramShared).slice().reverse()]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async switchSession(sessionId: string, expectedSession?: string | null, guard?: ControlGuard): Promise<SessionState> {
|
|
362
|
+
return this.withLock(async () => {
|
|
363
|
+
const state = await this.readState()
|
|
364
|
+
await requireControlGuard(state, guard)
|
|
365
|
+
if (expectedSession !== undefined && (state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
|
|
366
|
+
if (state.activeSession?.sessionId === sessionId) return state.activeSession
|
|
367
|
+
const session = state.sessions?.find(s => s.sessionId === sessionId)
|
|
368
|
+
if (!session || session.archived || (session.applicationScope && !session.telegramShared)) throw new Error('Conversation unavailable. Open /chats again.')
|
|
369
|
+
if (session.cli === 'agy')
|
|
370
|
+
throw new Error('Antigravity only resumes its latest conversation; selecting an older session is not supported.')
|
|
371
|
+
const ai = state.ai
|
|
372
|
+
// Older sessions did not record their model. Reuse a known preset for the
|
|
373
|
+
// same CLI; never resume an engine ID through a different client.
|
|
374
|
+
const preset = session.preset ?? ai?.presets.find(p => p.cli === session.cli)
|
|
375
|
+
if (!ai || !preset || preset.cli !== session.cli)
|
|
376
|
+
throw new Error('This older conversation has no saved AI binding. Start a new conversation.')
|
|
377
|
+
if (session.hasStarted && ['codex', 'codex-gui', 'opencode'].includes(session.cli!) && !session.nativeSessionId)
|
|
378
|
+
throw new Error('This conversation has no native session ID. Start a new conversation.')
|
|
379
|
+
assertEffort(preset.effort, preset.model, preset.cli)
|
|
380
|
+
rememberPreset(state)
|
|
381
|
+
state.sessions = state.sessions!.filter(s => s.sessionId !== sessionId)
|
|
382
|
+
if (state.activeSession) state.sessions.push(state.activeSession)
|
|
383
|
+
state.activeSession = session
|
|
384
|
+
ai.presets = [...ai.presets.filter(p => p.id !== preset.id), persistedPreset(preset)]
|
|
385
|
+
ai.selectedId = preset.id
|
|
386
|
+
await this.writeState(state)
|
|
387
|
+
return session
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async archiveSession(sessionId: string, archived: boolean): Promise<void> {
|
|
392
|
+
await this.withLock(async () => {
|
|
393
|
+
const state = await this.readState()
|
|
394
|
+
const session = state.activeSession?.sessionId === sessionId ? state.activeSession
|
|
395
|
+
: state.sessions?.find(s => s.sessionId === sessionId)
|
|
396
|
+
if (!session) throw new Error('Conversation unavailable. Open /chats again.')
|
|
397
|
+
if (archived && state.activeSession === session) {
|
|
398
|
+
rememberPreset(state)
|
|
399
|
+
state.sessions ??= []
|
|
400
|
+
state.sessions.push(session)
|
|
401
|
+
state.activeSession = null
|
|
402
|
+
if (state.ai) state.ai.selectedId = state.ai.defaultId
|
|
403
|
+
}
|
|
404
|
+
session.archived = archived
|
|
405
|
+
await this.writeState(state)
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async renameSession(title: string): Promise<void> {
|
|
410
|
+
title = title.replace(/\s+/g, ' ').trim()
|
|
411
|
+
if (!title || title.length > 80) throw new Error('Use /rename followed by a name of 1–80 characters.')
|
|
412
|
+
await this.withLock(async () => {
|
|
413
|
+
const state = await this.readState()
|
|
414
|
+
if (!state.activeSession) throw new Error('Open a conversation first with /chats or /new.')
|
|
415
|
+
state.activeSession.title = title
|
|
416
|
+
await this.writeState(state)
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async resetSession(expectedSession?: string | null, guard?: ControlGuard): Promise<SessionState> {
|
|
247
421
|
return this.withLock(async () => {
|
|
248
422
|
const state = await this.readState()
|
|
423
|
+
await requireControlGuard(state, guard)
|
|
424
|
+
if (expectedSession !== undefined && (state.activeSession?.sessionId ?? null) !== expectedSession) throw new Error('Conversation changed. Refresh controls before trying again.')
|
|
249
425
|
const next: SessionState = {
|
|
250
426
|
sessionId: crypto.randomUUID(),
|
|
251
427
|
hasStarted: false,
|
|
252
428
|
cli: state.ai?.presets.find((p) => p.id === state.ai!.defaultId)?.cli,
|
|
253
429
|
}
|
|
430
|
+
rememberPreset(state)
|
|
254
431
|
if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
|
|
255
432
|
if (state.ai) state.ai.selectedId = state.ai.defaultId
|
|
256
433
|
state.activeSession = next
|
|
@@ -262,7 +439,7 @@ export class ControlStore {
|
|
|
262
439
|
async aiState(initial: AiPreset) {
|
|
263
440
|
return this.withLock(async () => {
|
|
264
441
|
const state = await this.readState()
|
|
265
|
-
state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
|
|
442
|
+
state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
|
|
266
443
|
await this.writeState(state)
|
|
267
444
|
return state.ai
|
|
268
445
|
})
|
|
@@ -274,29 +451,101 @@ export class ControlStore {
|
|
|
274
451
|
const state = await this.readState()
|
|
275
452
|
const first = initial.cli === 'codex' || initial.cli === 'codex-gui'
|
|
276
453
|
? initial : discovered.find((p) => p.cli === initial.cli) ?? initial
|
|
277
|
-
state.ai ??= { presets: [persistedPreset(first)], defaultId: first.id, selectedId: first.id }
|
|
454
|
+
state.ai ??= { presets: [persistedPreset(first)], defaultId: first.id, selectedId: first.id, recentIds: [] }
|
|
278
455
|
const ai = state.ai
|
|
279
456
|
// Refresh discovery entries, but never rewrite an active/default or user-saved choice.
|
|
280
457
|
const preserved = ai.presets.filter((p) => !p.id.startsWith('detected_') ||
|
|
281
458
|
p.id === ai.selectedId || p.id === ai.defaultId).map(persistedPreset)
|
|
282
459
|
ai.presets = [...preserved, ...discovered.map(persistedPreset).filter((p) => !preserved.some((old) => old.id === p.id))]
|
|
460
|
+
ai.recentIds = (ai.recentIds ?? []).filter((id) => ai.presets.some((preset) => preset.id === id)).slice(0, 3)
|
|
283
461
|
if (initial.id === 'chat-default' && !ai.presets.some(p => p.id === initial.id)) ai.presets.push(persistedPreset(initial))
|
|
284
462
|
await this.writeState(state)
|
|
285
463
|
})
|
|
286
464
|
}
|
|
287
465
|
|
|
288
|
-
async captureChoice(initial: AiPreset): Promise<ExecutionChoice> {
|
|
466
|
+
async captureChoice(initial: AiPreset, title?: string): Promise<ExecutionChoice> {
|
|
289
467
|
return this.withLock(async () => {
|
|
290
468
|
const state = await this.readState()
|
|
291
|
-
state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
|
|
469
|
+
state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id, recentIds: [] }
|
|
292
470
|
const preset = state.ai.presets.find((p) => p.id === state.ai!.selectedId)!
|
|
293
471
|
state.activeSession ??= { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
|
|
294
472
|
if (!state.activeSession.cli && !state.activeSession.hasStarted) state.activeSession.cli = preset.cli
|
|
473
|
+
if (state.activeSession.cli === preset.cli) state.activeSession.preset = preset
|
|
474
|
+
if (!state.activeSession.title && !state.activeSession.hasStarted && title?.trim())
|
|
475
|
+
state.activeSession.title = title.replace(/\s+/g, ' ').trim().slice(0, 80)
|
|
295
476
|
await this.writeState(state)
|
|
296
477
|
return { sessionId: state.activeSession.sessionId, preset }
|
|
297
478
|
})
|
|
298
479
|
}
|
|
299
480
|
|
|
481
|
+
async captureApplicationChoice(initial: AiPreset, scope: string, shareTelegram = false, requested?: AiPreset, expectedNativeSessionId?: string): Promise<ExecutionChoice> {
|
|
482
|
+
if (!/^[a-f0-9]{64}$/.test(scope)) throw new Error('Invalid application scope')
|
|
483
|
+
return this.withLock(async () => {
|
|
484
|
+
const state = await this.readState()
|
|
485
|
+
state.ai ??= { presets: [persistedPreset(initial)], defaultId: initial.id, selectedId: initial.id }
|
|
486
|
+
state.sessions ??= []
|
|
487
|
+
const previous = currentApplicationSession(state, scope)
|
|
488
|
+
if (expectedNativeSessionId !== undefined && previous?.nativeSessionId !== expectedNativeSessionId) throw new Error('Application request conflicts with native session; import the existing scope before cutover')
|
|
489
|
+
const activate = (session: SessionState) => {
|
|
490
|
+
if (!shareTelegram) return
|
|
491
|
+
session.telegramShared = true
|
|
492
|
+
session.archived = false
|
|
493
|
+
if (state.activeSession?.sessionId !== session.sessionId) {
|
|
494
|
+
state.sessions = state.sessions!.filter(item => item.sessionId !== session.sessionId)
|
|
495
|
+
if (state.activeSession) state.sessions.push(state.activeSession)
|
|
496
|
+
state.activeSession = session
|
|
497
|
+
}
|
|
498
|
+
state.ai!.presets = [...state.ai!.presets.filter(item => item.id !== session.preset!.id), session.preset!]
|
|
499
|
+
state.ai!.selectedId = session.preset!.id
|
|
500
|
+
}
|
|
501
|
+
if (previous?.preset) {
|
|
502
|
+
if (requested && requested.cli !== previous.cli) throw new Error('Application request conflicts with existing session engine')
|
|
503
|
+
if (requested) previous.preset = requested
|
|
504
|
+
activate(previous)
|
|
505
|
+
if (shareTelegram || requested) await this.writeState(state)
|
|
506
|
+
return { sessionId: previous.sessionId, preset: previous.preset }
|
|
507
|
+
}
|
|
508
|
+
const preset = requested ?? state.ai.presets.find(item => item.id === state.ai!.selectedId)!
|
|
509
|
+
if (preset.cli === 'agy') throw new Error('Application scopes require an engine with explicit session selection')
|
|
510
|
+
const session: SessionState = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli, preset, applicationScope: scope }
|
|
511
|
+
state.sessions.push(session)
|
|
512
|
+
activate(session)
|
|
513
|
+
await this.writeState(state)
|
|
514
|
+
return { sessionId: session.sessionId, preset }
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async applicationSession(scope: string): Promise<SessionState | undefined> {
|
|
519
|
+
return currentApplicationSession(await this.status(), scope) ?? undefined
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async changeApplicationSession(scope: string, guard: ControlGuard, preset?: AiPreset): Promise<SessionState> {
|
|
523
|
+
if (!/^[a-f0-9]{64}$/.test(scope) || guard.applicationScope !== scope || guard.expectedSession === undefined)
|
|
524
|
+
throw new Error('Invalid application scope control')
|
|
525
|
+
return this.withLock(async () => {
|
|
526
|
+
const state = await this.readState()
|
|
527
|
+
await requireControlGuard(state, guard)
|
|
528
|
+
const previous = currentApplicationSession(state, scope)
|
|
529
|
+
if (previous && (previous.telegramShared || previous === state.activeSession))
|
|
530
|
+
throw new Error('Application scope is shared; use shared controls')
|
|
531
|
+
const nextPreset = preset ?? state.ai?.presets.find(item => item.id === state.ai!.defaultId)
|
|
532
|
+
if (!nextPreset || !isPreset(nextPreset) || nextPreset.cli === 'agy') throw new Error('Invalid application AI selection')
|
|
533
|
+
if (preset && previous?.cli === preset.cli) {
|
|
534
|
+
previous.preset = persistedPreset(preset)
|
|
535
|
+
await this.writeState(state)
|
|
536
|
+
return previous
|
|
537
|
+
}
|
|
538
|
+
// Retire the binding, not its native session. Admitted work still resolves
|
|
539
|
+
// the old immutable session ID; private history stays absent from /chats.
|
|
540
|
+
if (previous) previous.archived = true
|
|
541
|
+
const next: SessionState = {sessionId:crypto.randomUUID(), hasStarted:false,
|
|
542
|
+
cli:nextPreset.cli, preset:persistedPreset(nextPreset), applicationScope:scope}
|
|
543
|
+
;(state.sessions ??= []).push(next)
|
|
544
|
+
await this.writeState(state)
|
|
545
|
+
return next
|
|
546
|
+
})
|
|
547
|
+
}
|
|
548
|
+
|
|
300
549
|
async executionSession(choice: ExecutionChoice): Promise<SessionState> {
|
|
301
550
|
const state = await this.status()
|
|
302
551
|
const session = state.activeSession?.sessionId === choice.sessionId ? state.activeSession
|
|
@@ -324,11 +573,12 @@ export class ControlStore {
|
|
|
324
573
|
})
|
|
325
574
|
}
|
|
326
575
|
|
|
327
|
-
async savePreset(preset: AiPreset): Promise<void> {
|
|
576
|
+
async savePreset(preset: AiPreset, guard?: ControlGuard): Promise<void> {
|
|
328
577
|
if (!isPreset(preset)) throw new Error('Invalid AI preset')
|
|
329
578
|
assertEffort(preset.effort, preset.model, preset.cli)
|
|
330
579
|
await this.withLock(async () => {
|
|
331
580
|
const state = await this.readState()
|
|
581
|
+
await requireControlGuard(state, guard)
|
|
332
582
|
if (!state.ai) throw new Error('AI settings not initialized')
|
|
333
583
|
if (state.ai.presets.length >= 12 && !state.ai.presets.some((p) => p.id === preset.id))
|
|
334
584
|
throw new Error('Keep it small: at most 12 saved AIs.')
|
|
@@ -337,9 +587,10 @@ export class ControlStore {
|
|
|
337
587
|
})
|
|
338
588
|
}
|
|
339
589
|
|
|
340
|
-
async selectPreset(id: string, expectedSession: string | null, fresh = false): Promise<boolean> {
|
|
590
|
+
async selectPreset(id: string, expectedSession: string | null, fresh = false, guard?: ControlGuard): Promise<boolean> {
|
|
341
591
|
return this.withLock(async () => {
|
|
342
592
|
const state = await this.readState()
|
|
593
|
+
await requireControlGuard(state, guard)
|
|
343
594
|
const ai = state.ai
|
|
344
595
|
const preset = ai?.presets.find((p) => p.id === id)
|
|
345
596
|
if (!ai || !preset) throw new Error('Saved AI no longer exists')
|
|
@@ -348,10 +599,12 @@ export class ControlStore {
|
|
|
348
599
|
const current = ai.presets.find((p) => p.id === ai.selectedId)!
|
|
349
600
|
if (state.activeSession && (current.cli !== preset.cli || !state.activeSession.cli) && !fresh) return false
|
|
350
601
|
if (fresh || !state.activeSession) {
|
|
602
|
+
rememberPreset(state)
|
|
351
603
|
if (state.activeSession) (state.sessions ??= []).push(state.activeSession)
|
|
352
604
|
state.activeSession = { sessionId: crypto.randomUUID(), hasStarted: false, cli: preset.cli }
|
|
353
605
|
}
|
|
354
606
|
ai.selectedId = id
|
|
607
|
+
ai.recentIds = [id, ...(ai.recentIds ?? []).filter((recentId) => recentId !== id)].slice(0, 3)
|
|
355
608
|
await this.writeState(state)
|
|
356
609
|
return true
|
|
357
610
|
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { InlineKeyboard, type Context } from 'grammy'
|
|
2
|
+
import { ControlStore, sessionTitle } from './control-state.js'
|
|
3
|
+
import type { RunStore } from './runs.js'
|
|
4
|
+
|
|
5
|
+
// IDs identify existing relay bindings only; the engine still owns all context.
|
|
6
|
+
export const createConversationMenu = (control: ControlStore, runs: Pick<RunStore, 'list'>) => {
|
|
7
|
+
const render = async (ctx: Context, text: string, keyboard: InlineKeyboard) => {
|
|
8
|
+
if (!ctx.callbackQuery?.message) { await ctx.reply(text, { reply_markup: keyboard }); return }
|
|
9
|
+
try { await ctx.editMessageText(text, { reply_markup: keyboard }) }
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (!String((error as {description?: string})?.description).includes('message is not modified')) throw error
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const sessionsWithNames = async () => {
|
|
15
|
+
const sessions = await control.listSessions()
|
|
16
|
+
if (sessions.every(s => s.title)) return sessions
|
|
17
|
+
// Display existing owner input only. Do not create another history store or
|
|
18
|
+
// ask an engine to generate titles just to render a menu. Commands and JSON
|
|
19
|
+
// event records (including approval callbacks) are not readable chat names.
|
|
20
|
+
const history = await runs.list()
|
|
21
|
+
return sessions.flatMap((session, index) => {
|
|
22
|
+
if (session.title) return [session]
|
|
23
|
+
const first = history.find(run => run.execution?.sessionId === session.sessionId &&
|
|
24
|
+
run.messageId && !run.taskId && !run.scheduled && !run.external && !run.replyOnly &&
|
|
25
|
+
run.texts[0]?.trim() && !/^[/{]/.test(run.texts[0].trim()))
|
|
26
|
+
// New only reserves a routing ID. Do not present empty routing placeholders
|
|
27
|
+
// as engine conversations. Retain the records for already accepted work.
|
|
28
|
+
if (!session.hasStarted && !session.nativeSessionId && !first) return []
|
|
29
|
+
const title = first
|
|
30
|
+
? `${first.texts[0].replace(/\s+/g, ' ').trim().slice(0, 40)} · ${first.createdAt.slice(0, 16).replace('T', ' ')} UTC`
|
|
31
|
+
: `Untitled conversation ${index + 1}`
|
|
32
|
+
return [{ ...session, title }]
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
const list = async (ctx: Context, archived = false, page = 0) => {
|
|
36
|
+
const all = await sessionsWithNames()
|
|
37
|
+
const sessions = all.filter(s => Boolean(s.archived) === archived)
|
|
38
|
+
page = Math.min(page, Math.max(0, Math.ceil(sessions.length / 8) - 1))
|
|
39
|
+
const active = await control.getActiveSession()
|
|
40
|
+
const keyboard = new InlineKeyboard()
|
|
41
|
+
for (const session of sessions.slice(page * 8, page * 8 + 8))
|
|
42
|
+
keyboard.text(`${session.sessionId === active?.sessionId ? '✓ ' : ''}${sessionTitle(session)}`.slice(0, 64), `chat:open:${session.sessionId}`).row()
|
|
43
|
+
if (page > 0) keyboard.text('Previous', `chat:list:${Number(archived)}:${page - 1}`)
|
|
44
|
+
if ((page + 1) * 8 < sessions.length) keyboard.text('Next', `chat:list:${Number(archived)}:${page + 1}`)
|
|
45
|
+
if (page > 0 || (page + 1) * 8 < sessions.length) keyboard.row()
|
|
46
|
+
keyboard.text(archived ? 'Conversations' : 'Archived conversations', `chat:list:${Number(!archived)}:0`)
|
|
47
|
+
.text('+ New conversation', 'menu:new')
|
|
48
|
+
await render(ctx, archived
|
|
49
|
+
? sessions.length ? 'Archived conversations' : 'No archived conversations.'
|
|
50
|
+
: sessions.length ? 'Conversations\nSelect a name to continue.' : 'No active conversations.\nSend a message to start a chat, or open Archived conversations.', keyboard)
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
list,
|
|
54
|
+
async handle(ctx: Context): Promise<boolean> {
|
|
55
|
+
const data = ctx.callbackQuery?.data
|
|
56
|
+
if (!data?.startsWith('chat:')) return false
|
|
57
|
+
await ctx.answerCallbackQuery().catch(() => {})
|
|
58
|
+
try {
|
|
59
|
+
const page = /^chat:list:([01]):(\d{1,6})$/.exec(data)
|
|
60
|
+
if (page) { await list(ctx, page[1] === '1', Number(page[2])); return true }
|
|
61
|
+
const action = /^chat:(open|archive|restore):([0-9a-f-]{36})$/i.exec(data)
|
|
62
|
+
if (!action) throw new Error('Conversation unavailable. Open /chats again.')
|
|
63
|
+
const [, verb, id] = action
|
|
64
|
+
const session = (await sessionsWithNames()).find(s => s.sessionId === id)
|
|
65
|
+
if (!session) throw new Error('Conversation unavailable. Open /chats again.')
|
|
66
|
+
if (verb === 'archive' || verb === 'restore') {
|
|
67
|
+
await control.archiveSession(id, verb === 'archive')
|
|
68
|
+
await ctx.reply(`${verb === 'archive' ? 'Archived' : 'Restored'}: ${sessionTitle(session)}${verb === 'archive' ? '\nExisting work keeps its conversation. Archiving does not stop it.' : ''}`)
|
|
69
|
+
await list(ctx, verb === 'restore' ? false : true)
|
|
70
|
+
} else if (session.archived) {
|
|
71
|
+
await render(ctx, sessionTitle(session), new InlineKeyboard().text('Restore conversation', `chat:restore:${id}`).row().text('Back', 'chat:list:1:0'))
|
|
72
|
+
} else {
|
|
73
|
+
const keyboard = new InlineKeyboard().text('Archive this conversation', `chat:archive:${id}`).row()
|
|
74
|
+
.text('Back to conversations', 'chat:list:0:0')
|
|
75
|
+
try { await control.switchSession(id) }
|
|
76
|
+
catch (error) {
|
|
77
|
+
// Even an older session that cannot resume can still be archived.
|
|
78
|
+
await render(ctx, `${sessionTitle(session)}\n${error instanceof Error ? error.message : 'Unable to continue.'}`, keyboard)
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
await render(ctx, `Current conversation: ${sessionTitle(session)}\nSend a message to continue. To rename it, use /rename followed by a name.`, keyboard)
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
await ctx.reply(error instanceof Error ? error.message : 'Conversation selection failed.')
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type {Owner} from './control-state.js';
|
|
2
|
+
export type DeliveryContext={version:1;connectionId:string;plugin:string;revision:string;owner:Owner};
|
|
3
|
+
export function authorizeDeliveryContext(context:unknown,owner:unknown):DeliveryContext;
|
|
4
|
+
export function currentDeliveryOwner(controlDir:string):Promise<Owner|null>;
|
|
5
|
+
export function captureDeliveryContext(controlDir:string,plugin:string,revision:string):Promise<DeliveryContext|undefined>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const validOwner=owner=>owner&&typeof owner==='object'&&Number.isSafeInteger(owner.telegramUserId)&&owner.telegramUserId>0&&Number.isSafeInteger(owner.telegramChatId)&&(owner.kind==='group'?owner.telegramChatId<0:owner.kind===undefined&&owner.telegramChatId>0)&&typeof owner.pairedAt==='string'&&Number.isFinite(Date.parse(owner.pairedAt))&&(owner.id===undefined||typeof owner.id==='string'&&/^[a-zA-Z0-9_:.-]{1,200}$/.test(owner.id))&&(owner.generation===undefined||typeof owner.generation==='string'&&/^[a-f0-9-]{36}$/.test(owner.generation))&&(owner.telegramLinkedAt===undefined||typeof owner.telegramLinkedAt==='string');
|
|
6
|
+
const ownerId=owner=>owner.id??`telegram:${owner.telegramUserId}:${owner.telegramChatId}`;
|
|
7
|
+
const ownerEpoch=owner=>owner.generation??owner.pairedAt;
|
|
8
|
+
const telegramEpoch=owner=>owner.telegramLinkedAt??owner.pairedAt;
|
|
9
|
+
const sameDeliveryOwner=(left,right)=>validOwner(left)&&validOwner(right)&&ownerId(left)===ownerId(right)&&ownerEpoch(left)===ownerEpoch(right)&&left.kind===right.kind&&left.telegramUserId===right.telegramUserId&&left.telegramChatId===right.telegramChatId&&telegramEpoch(left)===telegramEpoch(right);
|
|
10
|
+
export function authorizeDeliveryContext(context,owner) {
|
|
11
|
+
if(!context||context.version!==1||typeof context.connectionId!=='string'||!/^[a-zA-Z0-9_-]{1,100}$/.test(context.connectionId)||typeof context.plugin!=='string'||!/^[a-z][a-z0-9-]{0,39}$/.test(context.plugin)||typeof context.revision!=='string'||!context.revision||!sameDeliveryOwner(context.owner,owner))throw Error('Owner delivery context is invalid or revoked');
|
|
12
|
+
return context;
|
|
13
|
+
}
|
|
14
|
+
export async function currentDeliveryOwner(controlDir) {
|
|
15
|
+
const state=JSON.parse(await readFile(path.join(controlDir,'control-state.json'),'utf8'));
|
|
16
|
+
if(state.version!==1)throw Error('Invalid owner control state');
|
|
17
|
+
return state.owner;
|
|
18
|
+
}
|
|
19
|
+
export async function captureDeliveryContext(controlDir,plugin,revision) {
|
|
20
|
+
const owner=await currentDeliveryOwner(controlDir).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
|
|
21
|
+
if(!owner)return undefined;
|
|
22
|
+
// A channel-neutral installation has no Telegram delivery destination.
|
|
23
|
+
if(owner.telegramUserId===undefined&&owner.telegramChatId===undefined)return undefined;
|
|
24
|
+
return authorizeDeliveryContext({version:1,connectionId:randomUUID(),plugin,revision,owner},owner);
|
|
25
|
+
}
|