@uniquedivine/tg-sai 0.0.4 → 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 +31 -50
- package/main.ts +442 -100
- 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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { readFile } from "node:fs/promises"
|
|
3
|
+
import { hostname } from "node:os"
|
|
4
|
+
import type { Command } from "commander"
|
|
5
|
+
import {
|
|
6
|
+
creationMarker,
|
|
7
|
+
provisionSetup,
|
|
8
|
+
requestFingerprint,
|
|
9
|
+
validateRequest,
|
|
10
|
+
} from "./provisioning"
|
|
11
|
+
import type { ProvisionRequest } from "./provisioning"
|
|
12
|
+
import { CliProvisionCloud } from "./provisioningCloud"
|
|
13
|
+
import {
|
|
14
|
+
FileProvisionJournal,
|
|
15
|
+
withProvisionTelegram,
|
|
16
|
+
} from "./provisioningRuntime"
|
|
17
|
+
|
|
18
|
+
function print(value: unknown): void {
|
|
19
|
+
console.log(
|
|
20
|
+
JSON.stringify(
|
|
21
|
+
value,
|
|
22
|
+
(key, item) => (key === "claimToken" ? undefined : item),
|
|
23
|
+
2,
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
function chatId(value: string): number {
|
|
28
|
+
const parsed = Number(value)
|
|
29
|
+
if (!Number.isSafeInteger(parsed) || parsed >= 0)
|
|
30
|
+
throw new Error("Explicit negative Telegram supergroup ID required")
|
|
31
|
+
return parsed
|
|
32
|
+
}
|
|
33
|
+
export function addProvisionCommands(program: Command): void {
|
|
34
|
+
const provision = program
|
|
35
|
+
.command("chat")
|
|
36
|
+
.description("Sai deal chat operations")
|
|
37
|
+
.command("provision")
|
|
38
|
+
.description(
|
|
39
|
+
"Prepare and resume custody-account setup through Deal Desk cloud jobs",
|
|
40
|
+
)
|
|
41
|
+
provision
|
|
42
|
+
.command("prepare")
|
|
43
|
+
.requiredOption("--file <path>", "Provisioning request JSON file")
|
|
44
|
+
.option("--run", "Persist the request to authoritative cloud state")
|
|
45
|
+
.action(async (options: { file: string; run?: boolean }) => {
|
|
46
|
+
const raw = JSON.parse(await readFile(options.file, "utf8"))
|
|
47
|
+
const request = validateRequest({
|
|
48
|
+
...raw,
|
|
49
|
+
expectedUserId: Number(raw.expectedUserId),
|
|
50
|
+
} as ProvisionRequest)
|
|
51
|
+
if (!options.run)
|
|
52
|
+
return print({
|
|
53
|
+
dryRun: true,
|
|
54
|
+
request,
|
|
55
|
+
fingerprint: requestFingerprint(request),
|
|
56
|
+
})
|
|
57
|
+
print(await new CliProvisionCloud().prepare(request))
|
|
58
|
+
})
|
|
59
|
+
provision
|
|
60
|
+
.command("status")
|
|
61
|
+
.requiredOption("--request-id <id>", "Stable setup request ID")
|
|
62
|
+
.action(async (options: { requestId: string }) =>
|
|
63
|
+
print(await new CliProvisionCloud().view(options.requestId)),
|
|
64
|
+
)
|
|
65
|
+
for (const name of ["run", "resume"]) {
|
|
66
|
+
provision
|
|
67
|
+
.command(name)
|
|
68
|
+
.requiredOption("--request-id <id>", "Stable setup request ID")
|
|
69
|
+
.option(
|
|
70
|
+
"--run",
|
|
71
|
+
"Execute Telegram setup writes and authoritative cloud progress",
|
|
72
|
+
)
|
|
73
|
+
.action(async (options: { requestId: string; run?: boolean }) => {
|
|
74
|
+
const cloud = new CliProvisionCloud()
|
|
75
|
+
const setup = await cloud.view(options.requestId)
|
|
76
|
+
if (!options.run)
|
|
77
|
+
return print({
|
|
78
|
+
dryRun: true,
|
|
79
|
+
setup,
|
|
80
|
+
next: "Pass --run to execute this request with its explicit custody profile",
|
|
81
|
+
})
|
|
82
|
+
print(
|
|
83
|
+
await withProvisionTelegram(setup, (telegram) =>
|
|
84
|
+
provisionSetup({
|
|
85
|
+
setup,
|
|
86
|
+
telegram,
|
|
87
|
+
cloud,
|
|
88
|
+
journal: new FileProvisionJournal(),
|
|
89
|
+
workerId: `${hostname()}:${process.pid}:${randomUUID()}`,
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
provision
|
|
96
|
+
.command("resolve")
|
|
97
|
+
.requiredOption("--request-id <id>", "Stable setup request ID")
|
|
98
|
+
.requiredOption("--chat <id>", "Exact created supergroup ID", chatId)
|
|
99
|
+
.requiredOption(
|
|
100
|
+
"--reason <text>",
|
|
101
|
+
"Operator evidence for explicit adoption",
|
|
102
|
+
)
|
|
103
|
+
.option("--run", "Verify and adopt the exact group in cloud state")
|
|
104
|
+
.action(
|
|
105
|
+
async (options: {
|
|
106
|
+
requestId: string
|
|
107
|
+
chat: number
|
|
108
|
+
reason: string
|
|
109
|
+
run?: boolean
|
|
110
|
+
}) => {
|
|
111
|
+
const cloud = new CliProvisionCloud()
|
|
112
|
+
const setup = await cloud.view(options.requestId)
|
|
113
|
+
if (!options.reason.trim())
|
|
114
|
+
throw new Error("Resolution evidence required")
|
|
115
|
+
if (!options.run)
|
|
116
|
+
return print({
|
|
117
|
+
dryRun: true,
|
|
118
|
+
requestId: setup.requestId,
|
|
119
|
+
chatId: options.chat,
|
|
120
|
+
reason: options.reason,
|
|
121
|
+
})
|
|
122
|
+
if (!["creating", "creation_unknown"].includes(setup.state))
|
|
123
|
+
throw new Error("Only an unresolved creation can be adopted")
|
|
124
|
+
print(
|
|
125
|
+
await withProvisionTelegram(setup, async (telegram) => {
|
|
126
|
+
if (
|
|
127
|
+
!(await telegram.verifyMarker(
|
|
128
|
+
options.chat,
|
|
129
|
+
creationMarker(setup),
|
|
130
|
+
))
|
|
131
|
+
)
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Resolution chat does not match custody owner and request marker",
|
|
134
|
+
)
|
|
135
|
+
return cloud.resolve(setup.requestId, options.chat, options.reason)
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import type {
|
|
3
|
+
Progress,
|
|
4
|
+
ProvisionCloud,
|
|
5
|
+
ProvisionRequest,
|
|
6
|
+
Setup,
|
|
7
|
+
} from "./provisioning"
|
|
8
|
+
import { validateRequest } from "./provisioning"
|
|
9
|
+
|
|
10
|
+
export type CloudOperation =
|
|
11
|
+
| "prepare"
|
|
12
|
+
| "view"
|
|
13
|
+
| "list"
|
|
14
|
+
| "pending"
|
|
15
|
+
| "claim"
|
|
16
|
+
| "progress"
|
|
17
|
+
| "resolve"
|
|
18
|
+
export type CloudCall = (
|
|
19
|
+
operation: CloudOperation,
|
|
20
|
+
input: Record<string, unknown>,
|
|
21
|
+
) => Promise<unknown>
|
|
22
|
+
|
|
23
|
+
export function cloudRequest(
|
|
24
|
+
request: ProvisionRequest,
|
|
25
|
+
): Record<string, unknown> {
|
|
26
|
+
validateRequest(request)
|
|
27
|
+
return {
|
|
28
|
+
requestId: request.requestId,
|
|
29
|
+
custodyProfile: request.custodyProfile,
|
|
30
|
+
title: request.title,
|
|
31
|
+
botRightsProfile: request.botRightsProfile,
|
|
32
|
+
dealIds: request.dealIds,
|
|
33
|
+
expectedUserId: String(request.expectedUserId),
|
|
34
|
+
participants: request.participants.map(String),
|
|
35
|
+
...(request.optionalParticipants === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { optionalParticipants: request.optionalParticipants.map(String) }),
|
|
38
|
+
admins: request.admins.map((admin) => ({
|
|
39
|
+
userId: String(admin.userId),
|
|
40
|
+
rightsProfile: admin.rightsProfile,
|
|
41
|
+
})),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function numericId(value: unknown): number {
|
|
45
|
+
if (typeof value !== "string" && typeof value !== "number")
|
|
46
|
+
throw new Error("Cloud returned invalid Telegram identity")
|
|
47
|
+
const id = Number(value)
|
|
48
|
+
if (!Number.isSafeInteger(id) || id === 0)
|
|
49
|
+
throw new Error("Cloud returned invalid Telegram identity")
|
|
50
|
+
return id
|
|
51
|
+
}
|
|
52
|
+
export function setupFromCloud(raw: unknown): Setup {
|
|
53
|
+
if (!raw || typeof raw !== "object")
|
|
54
|
+
throw new Error("Cloud returned invalid setup")
|
|
55
|
+
const value = raw as Record<string, unknown>
|
|
56
|
+
const setup = {
|
|
57
|
+
...value,
|
|
58
|
+
expectedUserId: numericId(value.expectedUserId),
|
|
59
|
+
} as unknown as Setup
|
|
60
|
+
if (value.chatId !== undefined && value.chatId !== null)
|
|
61
|
+
setup.chatId = numericId(value.chatId)
|
|
62
|
+
else delete setup.chatId
|
|
63
|
+
if (value.resolvedParticipants != null || value.resolvedAdmins != null) {
|
|
64
|
+
if (
|
|
65
|
+
!Array.isArray(value.resolvedParticipants) ||
|
|
66
|
+
!Array.isArray(value.resolvedAdmins)
|
|
67
|
+
)
|
|
68
|
+
throw new Error("Cloud returned incomplete identity bindings")
|
|
69
|
+
setup.binding = {
|
|
70
|
+
participants: value.resolvedParticipants.map(numericId),
|
|
71
|
+
admins: value.resolvedAdmins.map(numericId),
|
|
72
|
+
...(value.resolvedOptionalParticipants == null
|
|
73
|
+
? {}
|
|
74
|
+
: {
|
|
75
|
+
optionalParticipants: (
|
|
76
|
+
value.resolvedOptionalParticipants as unknown[]
|
|
77
|
+
).map(numericId),
|
|
78
|
+
}),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
validateRequest(setup)
|
|
82
|
+
if (
|
|
83
|
+
![
|
|
84
|
+
"prepared",
|
|
85
|
+
"creating",
|
|
86
|
+
"creation_unknown",
|
|
87
|
+
"configuring",
|
|
88
|
+
"incomplete",
|
|
89
|
+
"ready",
|
|
90
|
+
"cancelled",
|
|
91
|
+
].includes(setup.state)
|
|
92
|
+
)
|
|
93
|
+
throw new Error("Cloud returned invalid setup state")
|
|
94
|
+
return setup
|
|
95
|
+
}
|
|
96
|
+
export class CliProvisionCloud implements ProvisionCloud {
|
|
97
|
+
constructor(private call: CloudCall = callDealDesk) {}
|
|
98
|
+
async pending(input: {
|
|
99
|
+
expectedUserId: number
|
|
100
|
+
workerId: string
|
|
101
|
+
limit: number
|
|
102
|
+
after?: string
|
|
103
|
+
}): Promise<{
|
|
104
|
+
requests: Setup[]
|
|
105
|
+
nextAfter: string | null
|
|
106
|
+
blocked: { requestId: string; state: string; reason: string }[]
|
|
107
|
+
}> {
|
|
108
|
+
const raw = (await this.call("pending", {
|
|
109
|
+
...input,
|
|
110
|
+
expectedUserId: String(input.expectedUserId),
|
|
111
|
+
})) as {
|
|
112
|
+
requests: unknown[]
|
|
113
|
+
nextAfter?: string | null
|
|
114
|
+
blocked?: { requestId: string; state: string; reason: string }[]
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(raw?.requests))
|
|
117
|
+
throw new Error("Cloud returned invalid pending queue")
|
|
118
|
+
return {
|
|
119
|
+
requests: raw.requests.map(setupFromCloud),
|
|
120
|
+
nextAfter: raw.nextAfter ?? null,
|
|
121
|
+
blocked: raw.blocked ?? [],
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async prepare(request: ProvisionRequest): Promise<Setup> {
|
|
125
|
+
return setupFromCloud(await this.call("prepare", cloudRequest(request)))
|
|
126
|
+
}
|
|
127
|
+
async view(requestId: string): Promise<Setup> {
|
|
128
|
+
return setupFromCloud(await this.call("view", { requestId }))
|
|
129
|
+
}
|
|
130
|
+
async claim(requestId: string, workerId: string): Promise<Setup> {
|
|
131
|
+
return setupFromCloud(await this.call("claim", { requestId, workerId }))
|
|
132
|
+
}
|
|
133
|
+
async progress(
|
|
134
|
+
requestId: string,
|
|
135
|
+
claimToken: string,
|
|
136
|
+
progress: Progress,
|
|
137
|
+
): Promise<Setup> {
|
|
138
|
+
const { binding, ...rest } = progress
|
|
139
|
+
return setupFromCloud(
|
|
140
|
+
await this.call("progress", {
|
|
141
|
+
requestId,
|
|
142
|
+
claimToken,
|
|
143
|
+
...rest,
|
|
144
|
+
...(progress.chatId === undefined
|
|
145
|
+
? {}
|
|
146
|
+
: { chatId: String(progress.chatId) }),
|
|
147
|
+
...(binding
|
|
148
|
+
? {
|
|
149
|
+
resolvedParticipants: binding.participants.map(String),
|
|
150
|
+
resolvedAdmins: binding.admins.map(String),
|
|
151
|
+
...(binding.optionalParticipants === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: {
|
|
154
|
+
resolvedOptionalParticipants:
|
|
155
|
+
binding.optionalParticipants.map(String),
|
|
156
|
+
}),
|
|
157
|
+
}
|
|
158
|
+
: {}),
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
async resolve(
|
|
163
|
+
requestId: string,
|
|
164
|
+
chatId: number,
|
|
165
|
+
reason: string,
|
|
166
|
+
): Promise<Setup> {
|
|
167
|
+
return setupFromCloud(
|
|
168
|
+
await this.call("resolve", { requestId, chatId: String(chatId), reason }),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function dealDeskRequestKey(
|
|
174
|
+
operation: CloudOperation,
|
|
175
|
+
input: Record<string, unknown>,
|
|
176
|
+
): string {
|
|
177
|
+
// The same payload can be a new intent after operator recovery or an explicit
|
|
178
|
+
// creation rejection. Receipts must only deduplicate one invocation.
|
|
179
|
+
if (operation === "claim" || operation === "progress")
|
|
180
|
+
return `tg-provision:${operation}:${randomUUID()}`
|
|
181
|
+
return `tg-provision:${createHash("sha256").update(JSON.stringify({ operation, input })).digest("hex")}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Spawn argv directly; credentials stay in the existing Deal Desk client environment. */
|
|
185
|
+
export async function callDealDesk(
|
|
186
|
+
operation: CloudOperation,
|
|
187
|
+
input: Record<string, unknown>,
|
|
188
|
+
): Promise<unknown> {
|
|
189
|
+
const key = dealDeskRequestKey(operation, input)
|
|
190
|
+
const command = ["sai-deal-desk", "chat-setups", operation, "--json-stdin"]
|
|
191
|
+
if (operation !== "view" && operation !== "list" && operation !== "pending")
|
|
192
|
+
command.push("--request-key", key)
|
|
193
|
+
const child = Bun.spawn(command, {
|
|
194
|
+
stdin: "pipe",
|
|
195
|
+
stdout: "pipe",
|
|
196
|
+
stderr: "pipe",
|
|
197
|
+
})
|
|
198
|
+
child.stdin.write(JSON.stringify(input))
|
|
199
|
+
child.stdin.end()
|
|
200
|
+
const [stdout, , exit] = await Promise.all([
|
|
201
|
+
new Response(child.stdout).text(),
|
|
202
|
+
new Response(child.stderr).text(),
|
|
203
|
+
child.exited,
|
|
204
|
+
])
|
|
205
|
+
if (exit !== 0)
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Deal Desk chat-setups ${operation} failed; inspect its operator status separately`,
|
|
208
|
+
)
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(stdout)
|
|
211
|
+
} catch {
|
|
212
|
+
throw new Error("Deal Desk returned non-JSON setup response")
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ChatInviteLink, ChatMember, FullChat } from "@mtcute/bun"
|
|
2
|
+
|
|
3
|
+
/** Numeric IDs need an access hash cached from an exact dialog readback. */
|
|
4
|
+
export async function requireExactCaptureDialog(
|
|
5
|
+
dialogs: AsyncIterable<{ peer: { id: number } }>,
|
|
6
|
+
chatId: number,
|
|
7
|
+
limit = 1000,
|
|
8
|
+
): Promise<void> {
|
|
9
|
+
let inspected = 0
|
|
10
|
+
for await (const dialog of dialogs) {
|
|
11
|
+
inspected++
|
|
12
|
+
if (dialog.peer.id === chatId) return
|
|
13
|
+
if (inspected >= limit) break
|
|
14
|
+
}
|
|
15
|
+
throw new Error("Exact capture group was not found in bounded custody dialogs")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type MemberShape = Pick<ChatMember, "status" | "isMember" | "permissions">
|
|
19
|
+
export type InviteShape = Pick<ChatInviteLink,
|
|
20
|
+
"isMyLink" | "isRevoked" | "approvalNeeded" | "isPrimary" | "endDate" |
|
|
21
|
+
"usageLimit" | "usage" | "pendingApprovals">
|
|
22
|
+
|
|
23
|
+
/** Copy only fields consumed by the adapter. Never serialize Telegram objects. */
|
|
24
|
+
export function memberShape(value: MemberShape | null): MemberShape | null {
|
|
25
|
+
if (!value) return null
|
|
26
|
+
return {
|
|
27
|
+
status: value.status,
|
|
28
|
+
isMember: value.isMember,
|
|
29
|
+
permissions: value.permissions === null ? null : {
|
|
30
|
+
_: "chatAdminRights",
|
|
31
|
+
...Object.fromEntries(Object.entries(value.permissions).filter(
|
|
32
|
+
([, v]) => typeof v === "boolean",
|
|
33
|
+
)),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function sanitizedProvisioningGolden(input: {
|
|
39
|
+
capturedAt: string
|
|
40
|
+
chat: Pick<FullChat, "chatType" | "isForum" | "isCreator" | "bio">
|
|
41
|
+
expectedMarker: string
|
|
42
|
+
members: { subject: string; value: MemberShape | null }[]
|
|
43
|
+
invites: InviteShape[]
|
|
44
|
+
}) {
|
|
45
|
+
return {
|
|
46
|
+
format: "tg-sai-provisioning-golden/v1" as const,
|
|
47
|
+
provenance: { kind: "live-readback" as const, capturedAt: input.capturedAt },
|
|
48
|
+
chat: {
|
|
49
|
+
chatType: input.chat.chatType,
|
|
50
|
+
isForum: input.chat.isForum,
|
|
51
|
+
isCreator: input.chat.isCreator,
|
|
52
|
+
markerMatches: input.chat.bio === input.expectedMarker,
|
|
53
|
+
},
|
|
54
|
+
members: input.members.map(({ subject, value }) => ({ subject, value: memberShape(value) })),
|
|
55
|
+
invitations: {
|
|
56
|
+
limit: 100,
|
|
57
|
+
mayHaveMore: input.invites.length === 100,
|
|
58
|
+
entries: input.invites.map(invite => ({
|
|
59
|
+
isMyLink: invite.isMyLink,
|
|
60
|
+
isRevoked: invite.isRevoked,
|
|
61
|
+
approvalNeeded: invite.approvalNeeded,
|
|
62
|
+
isPrimary: invite.isPrimary,
|
|
63
|
+
expiresAt: invite.endDate?.toISOString() ?? null,
|
|
64
|
+
usageLimit: Number.isFinite(invite.usageLimit) ? invite.usageLimit : null,
|
|
65
|
+
usage: invite.usage,
|
|
66
|
+
pendingApprovals: invite.pendingApprovals,
|
|
67
|
+
})),
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|