@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.
@@ -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
+ }