@uniquedivine/tg-sai 0.0.4 → 0.1.0
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 +41 -0
- package/RUNBOOK.md +204 -24
- package/actionExecutor.ts +183 -131
- package/adminAudit.ts +60 -60
- package/captureProvisioningGolden.ts +98 -0
- package/cfg.ts +12 -1
- package/custodyWorker.ts +501 -0
- package/custodyWorkerCli.ts +58 -0
- package/health.ts +31 -50
- package/main.ts +860 -214
- package/mtcuteAdapter.ts +266 -10
- package/ops.ts +12 -0
- package/package.json +14 -5
- package/profiles.ts +157 -32
- package/provisioning.ts +805 -0
- package/provisioningCli.ts +147 -0
- package/provisioningCloud.ts +214 -0
- package/provisioningGoldens.ts +70 -0
- package/provisioningRuntime.ts +417 -0
- package/runtimeAssets.d.ts +4 -0
- package/runtimeHealth.ts +132 -0
- package/runtimeHealthCli.ts +48 -0
- package/session_string_generator.py +36 -24
- package/state/cli.ts +641 -0
- package/state/db.ts +72 -0
- package/state/guard.ts +270 -0
- package/state/holds.ts +381 -0
- package/state/migrations/0001-flood-hold.sql +16 -0
- package/telegramRuntime.ts +25 -0
- package/telemetry/cli.ts +698 -0
- package/telemetry/db.ts +471 -0
- package/telemetry/middleware.ts +475 -0
- package/telemetry/migrations/0001-event.sql +33 -0
- package/telemetry/migrations/0002-event-job-index.sql +2 -0
- package/telemetry/migrations/0003-event-log.sql +10 -0
- package/telemetry/queries.ts +475 -0
- package/telemetry/run.ts +491 -0
- package/telemetry/sqlAssets.d.ts +4 -0
- package/telemetry/summarize.ts +219 -0
package/actionExecutor.ts
CHANGED
|
@@ -1,61 +1,44 @@
|
|
|
1
|
+
import { HOLD_ACCOUNT_UNKNOWN, holdErrorSeconds } from "./state/guard"
|
|
2
|
+
import { parseWaitSeconds, rpcErrorText } from "./telemetry/middleware"
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
* Structured event emitted by the batch action executor.
|
|
5
|
+
* Structured progress event emitted by the batch action executor.
|
|
3
6
|
*
|
|
4
|
-
* These
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
+
* These events are on-screen CLI progress (and copied to the run log). They do
|
|
8
|
+
* not retry: mtcute's flood waiter is the only retry layer, and every Telegram
|
|
9
|
+
* request, attempt, and flood wait is recorded by telemetry instead.
|
|
7
10
|
*/
|
|
8
11
|
export type BatchActionEvent =
|
|
9
12
|
| {
|
|
10
13
|
/** Operation attempt started. */
|
|
11
|
-
type: "action_start"
|
|
14
|
+
type: "action_start"
|
|
12
15
|
/** Telegram chat ID or peer for the attempted action. */
|
|
13
|
-
chatId: number | string
|
|
16
|
+
chatId: number | string
|
|
14
17
|
/** Stable action name, for example `bot_add`. */
|
|
15
|
-
action: string
|
|
16
|
-
/** One-based attempt number for this action. */
|
|
17
|
-
attempt: number;
|
|
18
|
-
}
|
|
19
|
-
| {
|
|
20
|
-
/** Telegram returned `FLOOD_WAIT_X` and the executor will retry later. */
|
|
21
|
-
type: "flood_wait";
|
|
22
|
-
/** Telegram chat ID or peer for the rate-limited action. */
|
|
23
|
-
chatId: number | string;
|
|
24
|
-
/** Stable action name, for example `bot_promote_admin`. */
|
|
25
|
-
action: string;
|
|
26
|
-
/** One-based attempt number that received the flood-wait error. */
|
|
27
|
-
attempt: number;
|
|
28
|
-
/** Seconds Telegram requested in `FLOOD_WAIT_X`. */
|
|
29
|
-
waitSeconds: number;
|
|
30
|
-
/** Milliseconds the executor sleeps, including configured buffer. */
|
|
31
|
-
sleepMs: number;
|
|
18
|
+
action: string
|
|
32
19
|
}
|
|
33
20
|
| {
|
|
34
21
|
/** Operation completed successfully. */
|
|
35
|
-
type: "action_success"
|
|
22
|
+
type: "action_success"
|
|
36
23
|
/** Telegram chat ID or peer for the successful action. */
|
|
37
|
-
chatId: number | string
|
|
24
|
+
chatId: number | string
|
|
38
25
|
/** Stable action name, for example `owner_target_add`. */
|
|
39
|
-
action: string
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
/** Elapsed milliseconds from first attempt to success. */
|
|
43
|
-
elapsedMs: number;
|
|
26
|
+
action: string
|
|
27
|
+
/** Elapsed milliseconds from start to success, flood sleeps included. */
|
|
28
|
+
elapsedMs: number
|
|
44
29
|
}
|
|
45
30
|
| {
|
|
46
|
-
/** Operation failed
|
|
47
|
-
type: "action_error"
|
|
31
|
+
/** Operation failed; mtcute already retried any short flood wait. */
|
|
32
|
+
type: "action_error"
|
|
48
33
|
/** Telegram chat ID or peer for the failed action. */
|
|
49
|
-
chatId: number | string
|
|
34
|
+
chatId: number | string
|
|
50
35
|
/** Stable action name, for example `transfer_owner`. */
|
|
51
|
-
action: string
|
|
52
|
-
/** One-based attempt number that produced the final error. */
|
|
53
|
-
attempt: number;
|
|
36
|
+
action: string
|
|
54
37
|
/** Error name normalized for logs. */
|
|
55
|
-
errorName: string
|
|
38
|
+
errorName: string
|
|
56
39
|
/** Error message normalized for logs. */
|
|
57
|
-
errorMessage: string
|
|
58
|
-
}
|
|
40
|
+
errorMessage: string
|
|
41
|
+
}
|
|
59
42
|
|
|
60
43
|
/**
|
|
61
44
|
* Receives structured batch action events.
|
|
@@ -64,7 +47,7 @@ export type BatchActionEvent =
|
|
|
64
47
|
* Tests can use `MemoryBatchLogger` to inspect event order without parsing text.
|
|
65
48
|
*/
|
|
66
49
|
export interface BatchLogger {
|
|
67
|
-
event(event: BatchActionEvent): void | Promise<void
|
|
50
|
+
event(event: BatchActionEvent): void | Promise<void>
|
|
68
51
|
}
|
|
69
52
|
|
|
70
53
|
/**
|
|
@@ -72,74 +55,44 @@ export interface BatchLogger {
|
|
|
72
55
|
*/
|
|
73
56
|
export interface TelegramActionContext {
|
|
74
57
|
/** Telegram chat ID or resolvable chat peer for the action. */
|
|
75
|
-
chatId: number | string
|
|
58
|
+
chatId: number | string
|
|
76
59
|
/** Stable action name such as `bot_add` or `owner_target_promote_admin`. */
|
|
77
|
-
action: string
|
|
60
|
+
action: string
|
|
78
61
|
}
|
|
79
62
|
|
|
80
63
|
/**
|
|
81
|
-
* Runtime dependencies for `
|
|
82
|
-
*
|
|
83
|
-
* Passing these in keeps retry behavior deterministic in tests: sleep, time, and
|
|
84
|
-
* logging can all be faked without importing mtcute or touching Telegram.
|
|
64
|
+
* Runtime dependencies for `runTelegramAction`. Passing these in keeps event
|
|
65
|
+
* output deterministic in tests.
|
|
85
66
|
*/
|
|
86
67
|
export interface TelegramActionExecutorDeps {
|
|
87
|
-
/** Event sink for action start, success,
|
|
88
|
-
logger: BatchLogger
|
|
89
|
-
/** Sleep function used after Telegram returns `FLOOD_WAIT_X`. */
|
|
90
|
-
sleep: (ms: number) => Promise<void>;
|
|
68
|
+
/** Event sink for action start, success, and error events. */
|
|
69
|
+
logger: BatchLogger
|
|
91
70
|
/** Clock function used to compute elapsed action time. */
|
|
92
|
-
now: () => number
|
|
93
|
-
/** Number of retries after the first failed attempt. */
|
|
94
|
-
maxRetries: number;
|
|
95
|
-
/** Extra milliseconds added to Telegram's required flood-wait duration. */
|
|
96
|
-
bufferMs: number;
|
|
71
|
+
now: () => number
|
|
97
72
|
}
|
|
98
73
|
|
|
99
74
|
/**
|
|
100
75
|
* Test logger that stores every event in insertion order.
|
|
101
76
|
*/
|
|
102
77
|
export class MemoryBatchLogger implements BatchLogger {
|
|
103
|
-
readonly events: BatchActionEvent[] = []
|
|
78
|
+
readonly events: BatchActionEvent[] = []
|
|
104
79
|
|
|
105
80
|
/**
|
|
106
81
|
* Appends an event to the in-memory event list.
|
|
107
82
|
*/
|
|
108
83
|
event(event: BatchActionEvent): void {
|
|
109
|
-
this.events.push(event)
|
|
84
|
+
this.events.push(event)
|
|
110
85
|
}
|
|
111
86
|
}
|
|
112
87
|
|
|
113
|
-
/**
|
|
114
|
-
* Extracts the wait duration from Telegram-style `FLOOD_WAIT_X` errors.
|
|
115
|
-
*
|
|
116
|
-
* mtcute and related libraries may expose the Telegram error string in the error
|
|
117
|
-
* name, message, or both. This helper checks both fields and returns `null` for
|
|
118
|
-
* non-flood errors so callers can distinguish retryable rate limits from normal
|
|
119
|
-
* failures.
|
|
120
|
-
*/
|
|
121
|
-
export function parseFloodWaitSeconds(error: unknown): number | null {
|
|
122
|
-
if (!(error instanceof Error)) {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const haystack = `${error.name} ${error.message}`;
|
|
127
|
-
const match = haystack.match(/FLOOD_WAIT_(\d+)/);
|
|
128
|
-
if (!match) {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return Number(match[1]);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
88
|
/**
|
|
136
89
|
* Returns a stable display name for unknown thrown values.
|
|
137
90
|
*/
|
|
138
91
|
export function errorName(error: unknown): string {
|
|
139
92
|
if (error instanceof Error && error.name) {
|
|
140
|
-
return error.name
|
|
93
|
+
return error.name
|
|
141
94
|
}
|
|
142
|
-
return "UnknownError"
|
|
95
|
+
return "UnknownError"
|
|
143
96
|
}
|
|
144
97
|
|
|
145
98
|
/**
|
|
@@ -147,73 +100,172 @@ export function errorName(error: unknown): string {
|
|
|
147
100
|
*/
|
|
148
101
|
export function errorMessage(error: unknown): string {
|
|
149
102
|
if (error instanceof Error) {
|
|
150
|
-
return error.message
|
|
103
|
+
return error.message
|
|
151
104
|
}
|
|
152
|
-
return String(error)
|
|
105
|
+
return String(error)
|
|
153
106
|
}
|
|
154
107
|
|
|
155
108
|
/**
|
|
156
|
-
* Runs one Telegram operation
|
|
109
|
+
* Runs one Telegram operation once and reports start, success, or error.
|
|
157
110
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* at the chat level.
|
|
111
|
+
* There is no retry here: short `FLOOD_WAIT_X` waits are slept through by
|
|
112
|
+
* mtcute's flood waiter, and longer ones surface as errors. Errors are logged
|
|
113
|
+
* as `action_error` and rethrown for the batch runner to record per chat.
|
|
162
114
|
*/
|
|
163
|
-
export async function
|
|
115
|
+
export async function runTelegramAction<T>(
|
|
164
116
|
context: TelegramActionContext,
|
|
165
117
|
operation: () => Promise<T>,
|
|
166
118
|
deps: TelegramActionExecutorDeps,
|
|
167
119
|
): Promise<T> {
|
|
168
|
-
const startedAt = deps.now()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
120
|
+
const startedAt = deps.now()
|
|
121
|
+
await deps.logger.event({
|
|
122
|
+
type: "action_start",
|
|
123
|
+
chatId: context.chatId,
|
|
124
|
+
action: context.action,
|
|
125
|
+
})
|
|
126
|
+
try {
|
|
127
|
+
const result = await operation()
|
|
128
|
+
await deps.logger.event({
|
|
129
|
+
type: "action_success",
|
|
130
|
+
chatId: context.chatId,
|
|
131
|
+
action: context.action,
|
|
132
|
+
elapsedMs: deps.now() - startedAt,
|
|
133
|
+
})
|
|
134
|
+
return result
|
|
135
|
+
} catch (error) {
|
|
172
136
|
await deps.logger.event({
|
|
173
|
-
type: "
|
|
137
|
+
type: "action_error",
|
|
174
138
|
chatId: context.chatId,
|
|
175
139
|
action: context.action,
|
|
176
|
-
|
|
177
|
-
|
|
140
|
+
errorName: errorName(error),
|
|
141
|
+
errorMessage: errorMessage(error),
|
|
142
|
+
})
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Text of `HoldStoreUnavailableError` (state/holds.ts). */
|
|
148
|
+
const HOLD_STATE_UNAVAILABLE = "TG_SAI_HOLD_STATE_UNAVAILABLE"
|
|
149
|
+
|
|
150
|
+
/** Why a batch stopped early on a Telegram rate limit. */
|
|
151
|
+
export interface FloodStop {
|
|
152
|
+
/**
|
|
153
|
+
* Telegram error code, e.g. `FLOOD_WAIT_15382` or `PEER_FLOOD`, or the
|
|
154
|
+
* local guard's `TG_SAI_HOLD_<s>`, `TG_SAI_HOLD_STATE_UNAVAILABLE`, or
|
|
155
|
+
* `TG_SAI_HOLD_ACCOUNT_UNKNOWN`.
|
|
156
|
+
*/
|
|
157
|
+
code: string
|
|
158
|
+
/** Wait Telegram asked for; `null` for `PEER_FLOOD` and unreadable holds. */
|
|
159
|
+
waitS: number | null
|
|
160
|
+
/** When the wait ends (ISO); `null` when no time is known. */
|
|
161
|
+
resumeAt: string | null
|
|
162
|
+
/**
|
|
163
|
+
* The local guard refused before sending (a saved hold, or holds that
|
|
164
|
+
* cannot be checked). Such stops exit 75; Telegram's own errors exit 1.
|
|
165
|
+
*/
|
|
166
|
+
hold: boolean
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function holdUntilMs(error: unknown): number | null {
|
|
170
|
+
const untilMs = (error as { untilMs?: unknown } | null)?.untilMs
|
|
171
|
+
return typeof untilMs === "number" && Number.isFinite(untilMs)
|
|
172
|
+
? untilMs
|
|
173
|
+
: null
|
|
174
|
+
}
|
|
178
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Returns a stop when `error` means the rest of a batch would only hit the
|
|
178
|
+
* same limit: any `PEER_FLOOD`, a `FLOOD_WAIT_X` (and premium/test-phone
|
|
179
|
+
* variants) longer than `maxWaitMs`, which mtcute did not sleep through, or
|
|
180
|
+
* a refusal by the local flood guard (`TG_SAI_HOLD_<s>`, unreadable hold
|
|
181
|
+
* state, unknown session account). `SLOWMODE_WAIT_X` is per chat, so it
|
|
182
|
+
* returns `null` and the batch goes on.
|
|
183
|
+
*/
|
|
184
|
+
export function floodStopFor(
|
|
185
|
+
error: unknown,
|
|
186
|
+
maxWaitMs: number,
|
|
187
|
+
nowMs: number,
|
|
188
|
+
): FloodStop | null {
|
|
189
|
+
const code = rpcErrorText(error)
|
|
190
|
+
if (code === null) return null
|
|
191
|
+
if (code === HOLD_STATE_UNAVAILABLE || code === HOLD_ACCOUNT_UNKNOWN) {
|
|
192
|
+
return { code, waitS: null, resumeAt: null, hold: true }
|
|
193
|
+
}
|
|
194
|
+
const holdS = holdErrorSeconds(code)
|
|
195
|
+
if (holdS !== null) {
|
|
196
|
+
const untilMs = holdUntilMs(error) ?? nowMs + holdS * 1000
|
|
197
|
+
return {
|
|
198
|
+
code,
|
|
199
|
+
waitS: holdS,
|
|
200
|
+
resumeAt: new Date(untilMs).toISOString(),
|
|
201
|
+
hold: true,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (code === "PEER_FLOOD") {
|
|
205
|
+
return { code, waitS: null, resumeAt: null, hold: false }
|
|
206
|
+
}
|
|
207
|
+
if (code.startsWith("SLOWMODE_WAIT_")) return null
|
|
208
|
+
const waitS = parseWaitSeconds(code)
|
|
209
|
+
if (waitS === null || waitS * 1000 <= maxWaitMs) return null
|
|
210
|
+
return {
|
|
211
|
+
code,
|
|
212
|
+
waitS,
|
|
213
|
+
resumeAt: new Date(nowMs + waitS * 1000).toISOString(),
|
|
214
|
+
hold: false,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A flood stop plus where in the batch it happened. */
|
|
219
|
+
export interface BatchStop<T> extends FloodStop {
|
|
220
|
+
item: T
|
|
221
|
+
/** Zero-based index of the item that hit the limit. */
|
|
222
|
+
index: number
|
|
223
|
+
/** Items after it that were not started. */
|
|
224
|
+
remaining: number
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface ChatBatchOptions<T> {
|
|
228
|
+
/** Runs one item; a throw is reported to `onItemError`. */
|
|
229
|
+
processItem: (item: T, index: number) => Promise<void>
|
|
230
|
+
/** Records a per-item failure; the batch continues unless it stops. */
|
|
231
|
+
onItemError: (item: T, error: unknown, index: number) => void | Promise<void>
|
|
232
|
+
/** Runs between items (e.g. the inter-chat delay), not after the last. */
|
|
233
|
+
betweenItems?: (item: T, index: number) => void | Promise<void>
|
|
234
|
+
/** Flood waits longer than this stop the batch. */
|
|
235
|
+
maxWaitMs: number
|
|
236
|
+
now?: () => number
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Processes `items` in order, isolating per-item errors, and stops at the
|
|
241
|
+
* first error `floodStopFor` treats as batch-wide.
|
|
242
|
+
*/
|
|
243
|
+
export async function runChatBatch<T>(
|
|
244
|
+
items: readonly T[],
|
|
245
|
+
options: ChatBatchOptions<T>,
|
|
246
|
+
): Promise<{ processed: number; floodStop: BatchStop<T> | null }> {
|
|
247
|
+
const now = options.now ?? Date.now
|
|
248
|
+
let processed = 0
|
|
249
|
+
for (const [index, item] of items.entries()) {
|
|
250
|
+
processed += 1
|
|
179
251
|
try {
|
|
180
|
-
|
|
181
|
-
await deps.logger.event({
|
|
182
|
-
type: "action_success",
|
|
183
|
-
chatId: context.chatId,
|
|
184
|
-
action: context.action,
|
|
185
|
-
attempts: attempt,
|
|
186
|
-
elapsedMs: deps.now() - startedAt,
|
|
187
|
-
});
|
|
188
|
-
return result;
|
|
252
|
+
await options.processItem(item, index)
|
|
189
253
|
} catch (error) {
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
throw error;
|
|
254
|
+
await options.onItemError(item, error, index)
|
|
255
|
+
const stop = floodStopFor(error, options.maxWaitMs, now())
|
|
256
|
+
if (stop) {
|
|
257
|
+
return {
|
|
258
|
+
processed,
|
|
259
|
+
floodStop: {
|
|
260
|
+
...stop,
|
|
261
|
+
item,
|
|
262
|
+
index,
|
|
263
|
+
remaining: items.length - index - 1,
|
|
264
|
+
},
|
|
265
|
+
}
|
|
203
266
|
}
|
|
204
|
-
|
|
205
|
-
const sleepMs = waitSeconds * 1000 + deps.bufferMs;
|
|
206
|
-
await deps.logger.event({
|
|
207
|
-
type: "flood_wait",
|
|
208
|
-
chatId: context.chatId,
|
|
209
|
-
action: context.action,
|
|
210
|
-
attempt,
|
|
211
|
-
waitSeconds,
|
|
212
|
-
sleepMs,
|
|
213
|
-
});
|
|
214
|
-
await deps.sleep(sleepMs);
|
|
215
267
|
}
|
|
268
|
+
if (index < items.length - 1) await options.betweenItems?.(item, index)
|
|
216
269
|
}
|
|
217
|
-
|
|
218
|
-
throw new Error(`unreachable action executor state: ${context.action}`);
|
|
270
|
+
return { processed, floodStop: null }
|
|
219
271
|
}
|
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
|
}
|