@uniquedivine/tg-sai 0.0.3 → 0.0.5
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/README.md +24 -0
- package/RUNBOOK.md +137 -16
- package/actionExecutor.ts +55 -55
- package/adminAudit.ts +60 -60
- package/captureProvisioningGolden.ts +98 -0
- package/cfg.ts +12 -1
- package/custodyWorker.ts +259 -0
- package/custodyWorkerCli.ts +52 -0
- package/health.ts +32 -51
- package/main.ts +460 -106
- package/mtcuteAdapter.ts +14 -6
- package/ops.ts +12 -0
- package/package.json +5 -4
- package/profiles.ts +134 -27
- package/provisioning.ts +674 -0
- package/provisioningCli.ts +140 -0
- package/provisioningCloud.ts +214 -0
- package/provisioningGoldens.ts +70 -0
- package/provisioningRuntime.ts +380 -0
- package/runtimeAssets.d.ts +4 -0
- package/runtimeHealth.ts +125 -0
- package/runtimeHealthCli.ts +46 -0
- package/session_string_generator.py +36 -24
- package/telegramRuntime.ts +25 -0
package/adminAudit.ts
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
1
|
export interface TestData<T> {
|
|
2
|
-
goldReq: string
|
|
3
|
-
notes?: string
|
|
4
|
-
data: T
|
|
2
|
+
goldReq: string
|
|
3
|
+
notes?: string
|
|
4
|
+
data: T
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export interface UserData {
|
|
8
|
-
id: number
|
|
9
|
-
username: string | null
|
|
10
|
-
displayName: string
|
|
11
|
-
isBot: boolean
|
|
12
|
-
rawType: string
|
|
8
|
+
id: number
|
|
9
|
+
username: string | null
|
|
10
|
+
displayName: string
|
|
11
|
+
isBot: boolean
|
|
12
|
+
rawType: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface ChatMemberData {
|
|
16
|
-
user: UserData
|
|
17
|
-
status: string
|
|
18
|
-
title: string | null
|
|
19
|
-
invitedBy: UserData | null
|
|
20
|
-
promotedBy: UserData | null
|
|
21
|
-
restrictedBy: UserData | null
|
|
22
|
-
restrictions: unknown | null
|
|
23
|
-
isMember: boolean
|
|
24
|
-
permissions: AdminPermissionsData | null
|
|
25
|
-
rawType: string
|
|
16
|
+
user: UserData
|
|
17
|
+
status: string
|
|
18
|
+
title: string | null
|
|
19
|
+
invitedBy: UserData | null
|
|
20
|
+
promotedBy: UserData | null
|
|
21
|
+
restrictedBy: UserData | null
|
|
22
|
+
restrictions: unknown | null
|
|
23
|
+
isMember: boolean
|
|
24
|
+
permissions: AdminPermissionsData | null
|
|
25
|
+
rawType: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface AdminPermissionsData {
|
|
29
|
-
addAdmins?: boolean
|
|
30
|
-
add_admins?: boolean
|
|
31
|
-
[key: string]: unknown
|
|
29
|
+
addAdmins?: boolean
|
|
30
|
+
add_admins?: boolean
|
|
31
|
+
[key: string]: unknown
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export interface ChatAuditInput {
|
|
35
|
-
chatId: number | string
|
|
36
|
-
targetUserId: number | string
|
|
37
|
-
self: UserData
|
|
38
|
-
actor: ChatMemberData | null
|
|
39
|
-
target: ChatMemberData | null
|
|
35
|
+
chatId: number | string
|
|
36
|
+
targetUserId: number | string
|
|
37
|
+
self: UserData
|
|
38
|
+
actor: ChatMemberData | null
|
|
39
|
+
target: ChatMemberData | null
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export interface ChatAuditResult {
|
|
43
|
-
chatId: number | string
|
|
44
|
-
targetUserId: number | string
|
|
45
|
-
actorRole: MemberRole
|
|
46
|
-
targetRole: MemberRole
|
|
47
|
-
actorCanApply: boolean
|
|
48
|
-
actorCanTransferOwnership: boolean
|
|
49
|
-
targetCanReceiveOwnership: boolean
|
|
50
|
-
targetIsAdmin: boolean
|
|
51
|
-
proposedActions: ProposedAction[]
|
|
43
|
+
chatId: number | string
|
|
44
|
+
targetUserId: number | string
|
|
45
|
+
actorRole: MemberRole
|
|
46
|
+
targetRole: MemberRole
|
|
47
|
+
actorCanApply: boolean
|
|
48
|
+
actorCanTransferOwnership: boolean
|
|
49
|
+
targetCanReceiveOwnership: boolean
|
|
50
|
+
targetIsAdmin: boolean
|
|
51
|
+
proposedActions: ProposedAction[]
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export type MemberRole =
|
|
@@ -59,17 +59,17 @@ export type MemberRole =
|
|
|
59
59
|
| "banned"
|
|
60
60
|
| "left"
|
|
61
61
|
| "not_present"
|
|
62
|
-
| "unknown"
|
|
62
|
+
| "unknown"
|
|
63
63
|
|
|
64
64
|
export type ProposedAction =
|
|
65
65
|
| "skip: actor lacks owner/admin rights"
|
|
66
66
|
| "add target user"
|
|
67
67
|
| "promote target user admin"
|
|
68
|
-
| "noop: target user already admin"
|
|
68
|
+
| "noop: target user already admin"
|
|
69
69
|
|
|
70
70
|
export function deriveMemberRole(member: ChatMemberData | null): MemberRole {
|
|
71
71
|
if (!member) {
|
|
72
|
-
return "not_present"
|
|
72
|
+
return "not_present"
|
|
73
73
|
}
|
|
74
74
|
switch (member.status) {
|
|
75
75
|
case "creator":
|
|
@@ -78,9 +78,9 @@ export function deriveMemberRole(member: ChatMemberData | null): MemberRole {
|
|
|
78
78
|
case "restricted":
|
|
79
79
|
case "banned":
|
|
80
80
|
case "left":
|
|
81
|
-
return member.status
|
|
81
|
+
return member.status
|
|
82
82
|
default:
|
|
83
|
-
return "unknown"
|
|
83
|
+
return "unknown"
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -88,49 +88,49 @@ export function hasAddAdminsRight(
|
|
|
88
88
|
permissions: AdminPermissionsData | null,
|
|
89
89
|
): boolean {
|
|
90
90
|
if (!permissions) {
|
|
91
|
-
return false
|
|
91
|
+
return false
|
|
92
92
|
}
|
|
93
|
-
return Boolean(permissions.addAdmins || permissions.add_admins)
|
|
93
|
+
return Boolean(permissions.addAdmins || permissions.add_admins)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
export function canActorPromoteAdmins(input: ChatAuditInput): boolean {
|
|
97
|
-
const actorRole = deriveMemberRole(input.actor)
|
|
97
|
+
const actorRole = deriveMemberRole(input.actor)
|
|
98
98
|
if (actorRole === "creator") {
|
|
99
|
-
return true
|
|
99
|
+
return true
|
|
100
100
|
}
|
|
101
101
|
if (actorRole !== "admin") {
|
|
102
|
-
return false
|
|
102
|
+
return false
|
|
103
103
|
}
|
|
104
|
-
return hasAddAdminsRight(input.actor?.permissions ?? null)
|
|
104
|
+
return hasAddAdminsRight(input.actor?.permissions ?? null)
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export function canActorTransferOwnership(input: ChatAuditInput): boolean {
|
|
108
|
-
return deriveMemberRole(input.actor) === "creator"
|
|
108
|
+
return deriveMemberRole(input.actor) === "creator"
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
export function isAdminRole(role: MemberRole): boolean {
|
|
112
|
-
return role === "admin" || role === "creator"
|
|
112
|
+
return role === "admin" || role === "creator"
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export function planAdminBootstrap(input: ChatAuditInput): ChatAuditResult {
|
|
116
|
-
const actorRole = deriveMemberRole(input.actor)
|
|
117
|
-
const targetRole = deriveMemberRole(input.target)
|
|
118
|
-
const actorCanApply = canActorPromoteAdmins(input)
|
|
119
|
-
const actorCanTransferOwnership = canActorTransferOwnership(input)
|
|
120
|
-
const targetIsAdmin = isAdminRole(targetRole)
|
|
116
|
+
const actorRole = deriveMemberRole(input.actor)
|
|
117
|
+
const targetRole = deriveMemberRole(input.target)
|
|
118
|
+
const actorCanApply = canActorPromoteAdmins(input)
|
|
119
|
+
const actorCanTransferOwnership = canActorTransferOwnership(input)
|
|
120
|
+
const targetIsAdmin = isAdminRole(targetRole)
|
|
121
121
|
const targetCanReceiveOwnership =
|
|
122
|
-
targetRole === "admin" && input.target?.user.isBot !== true
|
|
122
|
+
targetRole === "admin" && input.target?.user.isBot !== true
|
|
123
123
|
|
|
124
|
-
const proposedActions: ProposedAction[] = []
|
|
124
|
+
const proposedActions: ProposedAction[] = []
|
|
125
125
|
if (!actorCanApply) {
|
|
126
|
-
proposedActions.push("skip: actor lacks owner/admin rights")
|
|
126
|
+
proposedActions.push("skip: actor lacks owner/admin rights")
|
|
127
127
|
} else if (targetIsAdmin) {
|
|
128
|
-
proposedActions.push("noop: target user already admin")
|
|
128
|
+
proposedActions.push("noop: target user already admin")
|
|
129
129
|
} else {
|
|
130
130
|
if (targetRole === "not_present" || targetRole === "left") {
|
|
131
|
-
proposedActions.push("add target user")
|
|
131
|
+
proposedActions.push("add target user")
|
|
132
132
|
}
|
|
133
|
-
proposedActions.push("promote target user admin")
|
|
133
|
+
proposedActions.push("promote target user admin")
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
return {
|
|
@@ -143,5 +143,5 @@ export function planAdminBootstrap(input: ChatAuditInput): ChatAuditResult {
|
|
|
143
143
|
targetCanReceiveOwnership,
|
|
144
144
|
targetIsAdmin,
|
|
145
145
|
proposedActions,
|
|
146
|
-
}
|
|
146
|
+
}
|
|
147
147
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { Command } from "commander"
|
|
5
|
+
import { getChatMember, iterDialogs } from "@mtcute/bun/methods.js"
|
|
6
|
+
import { createMtcuteClient } from "./mtcuteAdapter"
|
|
7
|
+
import { strictProfileCredentials } from "./profiles"
|
|
8
|
+
import { creationMarker, PROVISIONING_BOT_ID } from "./provisioning"
|
|
9
|
+
import { setupFromCloud } from "./provisioningCloud"
|
|
10
|
+
import { requireExactCaptureDialog, sanitizedProvisioningGolden } from "./provisioningGoldens"
|
|
11
|
+
import { resolveProvisioningPeer } from "./provisioningRuntime"
|
|
12
|
+
|
|
13
|
+
// Only read methods are used. An explicit persisted setup binds the target chat
|
|
14
|
+
// and identities; a fresh temporary store avoids touching worker session files.
|
|
15
|
+
export async function captureProvisioningGolden(options: {
|
|
16
|
+
profile: string; chat: string; setup: string; out: string
|
|
17
|
+
}) {
|
|
18
|
+
const chatId = Number(options.chat)
|
|
19
|
+
const setup = setupFromCloud(JSON.parse(await readFile(options.setup, "utf8")))
|
|
20
|
+
if (!Number.isSafeInteger(chatId) || chatId >= 0 || setup.chatId !== chatId ||
|
|
21
|
+
setup.expectedUserId !== 8274304175 || !setup.binding ||
|
|
22
|
+
setup.custodyProfile !== options.profile)
|
|
23
|
+
throw new Error("Capture requires the exact custody setup and group")
|
|
24
|
+
const credentials = await strictProfileCredentials(options.profile, setup.expectedUserId)
|
|
25
|
+
const directory = await mkdtemp(join(tmpdir(), "tg-sai-golden-"))
|
|
26
|
+
let client: Awaited<ReturnType<typeof createMtcuteClient>> | undefined
|
|
27
|
+
let stage = "connect"
|
|
28
|
+
try {
|
|
29
|
+
client = await createMtcuteClient({ apiId: credentials.apiId,
|
|
30
|
+
apiHash: credentials.apiHash, session: credentials.sessionString,
|
|
31
|
+
storage: join(directory, "session") })
|
|
32
|
+
stage = "self"
|
|
33
|
+
const me = await client.getMe()
|
|
34
|
+
if (me.id !== setup.expectedUserId || me.isBot)
|
|
35
|
+
throw new Error("Custody identity mismatch")
|
|
36
|
+
stage = "dialogs"
|
|
37
|
+
await requireExactCaptureDialog(iterDialogs(client, {
|
|
38
|
+
limit: 1000, archived: "keep",
|
|
39
|
+
}), chatId)
|
|
40
|
+
stage = "fullchat"
|
|
41
|
+
const chat = await client.getFullChat(chatId)
|
|
42
|
+
const marker = creationMarker(setup)
|
|
43
|
+
console.log(JSON.stringify({ stage: "group_verified_readback", exactId: chat.id === chatId,
|
|
44
|
+
chatType: chat.chatType, isForum: chat.isForum, isCreator: chat.isCreator,
|
|
45
|
+
markerMatches: chat.bio === marker }))
|
|
46
|
+
if (chat.id !== chatId || chat.chatType !== "supergroup" || chat.isForum ||
|
|
47
|
+
!chat.isCreator || chat.bio !== marker)
|
|
48
|
+
throw new Error("Capture group ownership or marker mismatch")
|
|
49
|
+
const subjects = new Map<number, string>([
|
|
50
|
+
[setup.expectedUserId, "custody"], [PROVISIONING_BOT_ID, "bot"],
|
|
51
|
+
])
|
|
52
|
+
setup.binding.admins.forEach((id, i) => {
|
|
53
|
+
if (!subjects.has(id)) subjects.set(id, `required_admin_${i}`)
|
|
54
|
+
})
|
|
55
|
+
setup.binding.participants.forEach((id, i) => {
|
|
56
|
+
if (!subjects.has(id)) subjects.set(id, `ordinary_${i}`)
|
|
57
|
+
})
|
|
58
|
+
const members = []
|
|
59
|
+
for (const [userId, subject] of subjects) {
|
|
60
|
+
stage = `member_${subject}`
|
|
61
|
+
if (subject.startsWith("ordinary_"))
|
|
62
|
+
await resolveProvisioningPeer(peer => client!.getUser(peer), userId)
|
|
63
|
+
const value = await getChatMember(client, { chatId, userId })
|
|
64
|
+
members.push({ subject, value })
|
|
65
|
+
console.log(JSON.stringify({ subject, status: value?.status ?? "absent" }))
|
|
66
|
+
}
|
|
67
|
+
stage = "invitations"
|
|
68
|
+
const invites = await client.getInviteLinks(chatId, { limit: 100 })
|
|
69
|
+
const golden = sanitizedProvisioningGolden({ capturedAt: new Date().toISOString(),
|
|
70
|
+
chat, expectedMarker: marker, members, invites })
|
|
71
|
+
await writeFile(options.out, `${JSON.stringify(golden, null, 2)}\n`, { flag: "wx", mode: 0o600 })
|
|
72
|
+
return { members: members.length, invitations: invites.length }
|
|
73
|
+
} catch (error) {
|
|
74
|
+
const record = error && typeof error === "object" ? error as Record<string, unknown> : {}
|
|
75
|
+
const code = typeof record.text === "string" && /^[A-Z0-9_]{1,100}$/.test(record.text)
|
|
76
|
+
? record.text : "unclassified"
|
|
77
|
+
console.error(JSON.stringify({ stage, code }))
|
|
78
|
+
throw new Error("Sanitized capture failure")
|
|
79
|
+
} finally {
|
|
80
|
+
try { await client?.disconnect() } finally { await rm(directory, { recursive: true, force: true }) }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (import.meta.main) {
|
|
85
|
+
const command = new Command().description("Capture sanitized read-only evidence from one authorized custody group")
|
|
86
|
+
.requiredOption("--profile <name>", "Exact configured custody profile")
|
|
87
|
+
.requiredOption("--chat <id>", "Exact negative Telegram group ID")
|
|
88
|
+
.requiredOption("--setup <path>", "Deal Desk setup readback JSON with resolved bindings")
|
|
89
|
+
.requiredOption("--out <path>", "New sanitized fixture file; existing files are refused")
|
|
90
|
+
command.parse()
|
|
91
|
+
try {
|
|
92
|
+
const counts = await captureProvisioningGolden(command.opts())
|
|
93
|
+
console.log(`Saved sanitized readback: ${counts.members} members, ${counts.invitations} invitations`)
|
|
94
|
+
} catch {
|
|
95
|
+
console.error("Capture failed. Check the exact setup, custody identity, permissions, and destination. Raw Telegram errors are suppressed.")
|
|
96
|
+
process.exitCode = 1
|
|
97
|
+
}
|
|
98
|
+
}
|
package/cfg.ts
CHANGED
|
@@ -59,6 +59,18 @@ export const USERS = {
|
|
|
59
59
|
displayName: "Corinne Antonia",
|
|
60
60
|
kind: "user",
|
|
61
61
|
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Sai custody account: the intended owner of every Sai deal chat. Same
|
|
65
|
+
* numeric ID as `corinneAlt`, renamed and verified through `getMe` on
|
|
66
|
+
* 2026-11-05. Default ownership target for `ops sai-bootstrap`.
|
|
67
|
+
*/
|
|
68
|
+
saiCustody: {
|
|
69
|
+
id: 8274304175,
|
|
70
|
+
handle: "SaiTeam_AI",
|
|
71
|
+
displayName: "Sai AI Automation",
|
|
72
|
+
kind: "user",
|
|
73
|
+
},
|
|
62
74
|
} satisfies Record<string, TelegramIdentity>
|
|
63
75
|
|
|
64
76
|
/**
|
|
@@ -70,4 +82,3 @@ export const nibiruInternal = {
|
|
|
70
82
|
title: "Nibiru | Internal",
|
|
71
83
|
kind: "channel",
|
|
72
84
|
} satisfies TelegramChatIdentity
|
|
73
|
-
|
package/custodyWorker.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdir, open, readFile } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { configDir } from "./profiles"
|
|
5
|
+
import { provisionSetup, safeProvisionError } from "./provisioning"
|
|
6
|
+
import type {
|
|
7
|
+
ProvisionCloud,
|
|
8
|
+
ProvisionJournal,
|
|
9
|
+
Setup,
|
|
10
|
+
TelegramProvisioner,
|
|
11
|
+
} from "./provisioning"
|
|
12
|
+
import { CliProvisionCloud } from "./provisioningCloud"
|
|
13
|
+
import {
|
|
14
|
+
FileProvisionJournal,
|
|
15
|
+
withAccountLock,
|
|
16
|
+
withProvisionTelegram,
|
|
17
|
+
} from "./provisioningRuntime"
|
|
18
|
+
|
|
19
|
+
export const CUSTODY_USER_ID = 8274304175
|
|
20
|
+
export const REQUIRED_UNIQUE_ADMIN_ID = 1912700300
|
|
21
|
+
export interface PendingQueue {
|
|
22
|
+
pending(input: {
|
|
23
|
+
expectedUserId: number
|
|
24
|
+
workerId: string
|
|
25
|
+
limit: number
|
|
26
|
+
after?: string
|
|
27
|
+
}): Promise<{
|
|
28
|
+
requests: Setup[]
|
|
29
|
+
nextAfter: string | null
|
|
30
|
+
blocked?: { requestId: string; state: string; reason: string }[]
|
|
31
|
+
}>
|
|
32
|
+
}
|
|
33
|
+
export interface WorkerResult {
|
|
34
|
+
requestId: string
|
|
35
|
+
state: string
|
|
36
|
+
chatId?: number
|
|
37
|
+
retryAt?: string | null
|
|
38
|
+
error?: string
|
|
39
|
+
}
|
|
40
|
+
export interface WorkerOptions {
|
|
41
|
+
custodyProfile: string
|
|
42
|
+
workerId: string
|
|
43
|
+
limit?: number
|
|
44
|
+
run?: boolean
|
|
45
|
+
now?: number
|
|
46
|
+
after?: string
|
|
47
|
+
}
|
|
48
|
+
/** Persistent random identity retains ownership of sticky cloud claims after restart. */
|
|
49
|
+
export async function stableWorkerId(root = configDir()): Promise<string> {
|
|
50
|
+
const path = join(root, "provisioning", "worker-id")
|
|
51
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
52
|
+
try {
|
|
53
|
+
const file = await open(path, "wx", 0o600)
|
|
54
|
+
try {
|
|
55
|
+
await file.writeFile(`custody:${CUSTODY_USER_ID}:${randomUUID()}`)
|
|
56
|
+
await file.sync()
|
|
57
|
+
} finally {
|
|
58
|
+
await file.close()
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
62
|
+
}
|
|
63
|
+
const id = (await readFile(path, "utf8")).trim()
|
|
64
|
+
if (!new RegExp(`^custody:${CUSTODY_USER_ID}:[a-f0-9-]{36}$`).test(id))
|
|
65
|
+
throw new Error(
|
|
66
|
+
"Invalid saved worker identity; inspect private worker state",
|
|
67
|
+
)
|
|
68
|
+
return id
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One bounded page per tick; authoritative queue and local checks both scope jobs. */
|
|
72
|
+
export async function runWorkerOnce(
|
|
73
|
+
options: WorkerOptions,
|
|
74
|
+
deps: {
|
|
75
|
+
cloud: ProvisionCloud & PendingQueue
|
|
76
|
+
journal: ProvisionJournal
|
|
77
|
+
telegram: <T>(
|
|
78
|
+
setup: Setup,
|
|
79
|
+
operation: (adapter: TelegramProvisioner) => Promise<T>,
|
|
80
|
+
) => Promise<T>
|
|
81
|
+
},
|
|
82
|
+
): Promise<{
|
|
83
|
+
dryRun: boolean
|
|
84
|
+
results: WorkerResult[]
|
|
85
|
+
nextAfter: string | null
|
|
86
|
+
}> {
|
|
87
|
+
if (!options.custodyProfile.trim() || !options.workerId.trim())
|
|
88
|
+
throw new Error("Explicit custody profile and stable worker ID required")
|
|
89
|
+
const limit = options.limit ?? 10
|
|
90
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50)
|
|
91
|
+
throw new Error("Worker limit must be 1 through 50")
|
|
92
|
+
const page = await deps.cloud.pending({
|
|
93
|
+
expectedUserId: CUSTODY_USER_ID,
|
|
94
|
+
workerId: options.workerId,
|
|
95
|
+
limit,
|
|
96
|
+
...(options.after ? { after: options.after } : {}),
|
|
97
|
+
})
|
|
98
|
+
if (page.requests.length > limit)
|
|
99
|
+
throw new Error("Pending queue exceeded requested bound")
|
|
100
|
+
if (
|
|
101
|
+
!Array.isArray(page.blocked ?? []) ||
|
|
102
|
+
(page.blocked?.length ?? 0) + page.requests.length > limit
|
|
103
|
+
)
|
|
104
|
+
throw new Error("Pending queue blocked results exceeded requested bound")
|
|
105
|
+
const results: WorkerResult[] = (page.blocked ?? []).map((row) => ({
|
|
106
|
+
requestId: row.requestId,
|
|
107
|
+
state: "blocked",
|
|
108
|
+
error: row.reason,
|
|
109
|
+
}))
|
|
110
|
+
for (const selected of page.requests) {
|
|
111
|
+
const setup = await deps.cloud.view(selected.requestId)
|
|
112
|
+
if (
|
|
113
|
+
setup.expectedUserId !== CUSTODY_USER_ID ||
|
|
114
|
+
setup.custodyProfile !== options.custodyProfile
|
|
115
|
+
)
|
|
116
|
+
throw new Error("Pending job does not match configured custody identity")
|
|
117
|
+
if (
|
|
118
|
+
!setup.admins.some(
|
|
119
|
+
(admin) => String(admin.userId) === String(REQUIRED_UNIQUE_ADMIN_ID),
|
|
120
|
+
)
|
|
121
|
+
) {
|
|
122
|
+
results.push({
|
|
123
|
+
requestId: setup.requestId,
|
|
124
|
+
state: "blocked",
|
|
125
|
+
error: "required_unique_admin_missing",
|
|
126
|
+
})
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
!["prepared", "creating", "configuring", "incomplete"].includes(
|
|
131
|
+
setup.state,
|
|
132
|
+
)
|
|
133
|
+
) {
|
|
134
|
+
results.push({ requestId: setup.requestId, state: setup.state })
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
// A permanent/unknown configuration failure needs explicit operator recovery.
|
|
138
|
+
// Only a persisted transient retry time authorizes unattended retry.
|
|
139
|
+
if (setup.state === "incomplete" && !setup.retryAt) {
|
|
140
|
+
results.push({
|
|
141
|
+
requestId: setup.requestId,
|
|
142
|
+
state: "blocked",
|
|
143
|
+
error: "operator_recovery_required",
|
|
144
|
+
})
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
setup.retryAt &&
|
|
149
|
+
Date.parse(setup.retryAt) > (options.now ?? Date.now())
|
|
150
|
+
) {
|
|
151
|
+
results.push({
|
|
152
|
+
requestId: setup.requestId,
|
|
153
|
+
state: "waiting",
|
|
154
|
+
retryAt: setup.retryAt,
|
|
155
|
+
})
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (!options.run) {
|
|
159
|
+
results.push({
|
|
160
|
+
requestId: setup.requestId,
|
|
161
|
+
state: setup.state,
|
|
162
|
+
...(setup.chatId ? { chatId: setup.chatId } : {}),
|
|
163
|
+
})
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const result = await deps.telegram(setup, (telegram) =>
|
|
168
|
+
provisionSetup({
|
|
169
|
+
setup,
|
|
170
|
+
telegram,
|
|
171
|
+
cloud: deps.cloud,
|
|
172
|
+
journal: deps.journal,
|
|
173
|
+
workerId: options.workerId,
|
|
174
|
+
}),
|
|
175
|
+
)
|
|
176
|
+
results.push({
|
|
177
|
+
requestId: setup.requestId,
|
|
178
|
+
state: result.state,
|
|
179
|
+
...("chatId" in result && result.chatId
|
|
180
|
+
? { chatId: result.chatId }
|
|
181
|
+
: {}),
|
|
182
|
+
})
|
|
183
|
+
} catch (error) {
|
|
184
|
+
// Get the actual state after an ambiguous response; never retry a creation in this tick.
|
|
185
|
+
const current = await deps.cloud.view(setup.requestId)
|
|
186
|
+
results.push({
|
|
187
|
+
requestId: setup.requestId,
|
|
188
|
+
state: current.state,
|
|
189
|
+
...(current.chatId ? { chatId: current.chatId } : {}),
|
|
190
|
+
retryAt: current.retryAt,
|
|
191
|
+
error: safeProvisionError(error),
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { dryRun: !options.run, results, nextAfter: page.nextAfter }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function runCustodyWorker(options: {
|
|
199
|
+
custodyProfile: string
|
|
200
|
+
once?: boolean
|
|
201
|
+
run?: boolean
|
|
202
|
+
limit?: number
|
|
203
|
+
intervalMs?: number
|
|
204
|
+
signal?: AbortSignal
|
|
205
|
+
}): Promise<void> {
|
|
206
|
+
const interval = options.intervalMs ?? 5000
|
|
207
|
+
if (!Number.isSafeInteger(interval) || interval < 1000 || interval > 60000)
|
|
208
|
+
throw new Error("Worker interval must be 1000 through 60000ms")
|
|
209
|
+
if (!options.once && !options.run)
|
|
210
|
+
throw new Error(
|
|
211
|
+
"Daemon mode requires --run; use --once for read-only preview",
|
|
212
|
+
)
|
|
213
|
+
const root = configDir()
|
|
214
|
+
const workerId = await stableWorkerId(root)
|
|
215
|
+
const execute = async () => {
|
|
216
|
+
const cloud = new CliProvisionCloud()
|
|
217
|
+
let after: string | undefined
|
|
218
|
+
do {
|
|
219
|
+
try {
|
|
220
|
+
const result = await runWorkerOnce(
|
|
221
|
+
{ ...options, workerId, after },
|
|
222
|
+
{
|
|
223
|
+
cloud,
|
|
224
|
+
journal: new FileProvisionJournal(root),
|
|
225
|
+
telegram: withProvisionTelegram,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
after = result.nextAfter ?? undefined
|
|
229
|
+
console.log(
|
|
230
|
+
JSON.stringify({ ...result, observedAt: new Date().toISOString() }),
|
|
231
|
+
)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (options.once) throw error
|
|
234
|
+
console.error(
|
|
235
|
+
JSON.stringify({
|
|
236
|
+
state: "worker_tick_failed",
|
|
237
|
+
observedAt: new Date().toISOString(),
|
|
238
|
+
retryInMs: interval,
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
if (options.once || options.signal?.aborted) break
|
|
243
|
+
await new Promise<void>((resolve) => {
|
|
244
|
+
const done = () => {
|
|
245
|
+
clearTimeout(timer)
|
|
246
|
+
options.signal?.removeEventListener("abort", done)
|
|
247
|
+
resolve()
|
|
248
|
+
}
|
|
249
|
+
const timer = setTimeout(done, interval)
|
|
250
|
+
options.signal?.addEventListener("abort", done, { once: true })
|
|
251
|
+
})
|
|
252
|
+
} while (!options.signal?.aborted)
|
|
253
|
+
}
|
|
254
|
+
if (!options.run) return execute()
|
|
255
|
+
await withAccountLock(
|
|
256
|
+
join(root, "provisioning", "worker-service.lock"),
|
|
257
|
+
execute,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Command } from "commander"
|
|
2
|
+
import { runCustodyWorker } from "./custodyWorker"
|
|
3
|
+
|
|
4
|
+
/** Root main.ts calls this once beside addProvisionCommands. No MCP is involved. */
|
|
5
|
+
export function addCustodyWorkerCommands(
|
|
6
|
+
program: Command,
|
|
7
|
+
run: typeof runCustodyWorker = runCustodyWorker,
|
|
8
|
+
): void {
|
|
9
|
+
program
|
|
10
|
+
.command("worker")
|
|
11
|
+
.description(
|
|
12
|
+
"Run the custody-account provisioning queue; defaults to a read-only one-page preview",
|
|
13
|
+
)
|
|
14
|
+
.requiredOption(
|
|
15
|
+
"--custody-profile <name>",
|
|
16
|
+
"Explicit saved custody profile for user 8274304175",
|
|
17
|
+
)
|
|
18
|
+
.option("--once", "Process one bounded page and exit")
|
|
19
|
+
.option("--run", "Execute approved queued Telegram setup jobs")
|
|
20
|
+
.option("--limit <count>", "Maximum jobs per tick, 1 through 50", "10")
|
|
21
|
+
.option(
|
|
22
|
+
"--interval-ms <ms>",
|
|
23
|
+
"Polling interval, 1000 through 60000",
|
|
24
|
+
"5000",
|
|
25
|
+
)
|
|
26
|
+
.action(
|
|
27
|
+
async (options: {
|
|
28
|
+
custodyProfile: string
|
|
29
|
+
once?: boolean
|
|
30
|
+
run?: boolean
|
|
31
|
+
limit: string
|
|
32
|
+
intervalMs: string
|
|
33
|
+
}) => {
|
|
34
|
+
const controller = new AbortController()
|
|
35
|
+
const stop = () => controller.abort()
|
|
36
|
+
process.once("SIGTERM", stop)
|
|
37
|
+
process.once("SIGINT", stop)
|
|
38
|
+
try {
|
|
39
|
+
await run({
|
|
40
|
+
...options,
|
|
41
|
+
once: options.once || !options.run,
|
|
42
|
+
limit: Number(options.limit),
|
|
43
|
+
intervalMs: Number(options.intervalMs),
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
})
|
|
46
|
+
} finally {
|
|
47
|
+
process.removeListener("SIGTERM", stop)
|
|
48
|
+
process.removeListener("SIGINT", stop)
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
}
|