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