@uniquedivine/tg-sai 0.0.1

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,268 @@
1
+ import { TelegramClient } from "@mtcute/bun"
2
+ import {
3
+ addChatMembers,
4
+ editAdminRights,
5
+ getChatMember,
6
+ iterDialogs,
7
+ transferChatOwnership,
8
+ } from "@mtcute/bun/methods.js"
9
+ import { convertFromTelethonSession } from "@mtcute/convert"
10
+
11
+ import type { ChatAuditInput, ChatMemberData, UserData } from "./adminAudit"
12
+ import type { SaiChatRecord } from "./ops"
13
+ import { isSaiChatTitle } from "./ops"
14
+
15
+ /**
16
+ * Connection settings used to create a mtcute Telegram client.
17
+ */
18
+ export interface MtcuteClientConfig {
19
+ /** Telegram API ID from my.telegram.org. */
20
+ apiId: number
21
+ /** Telegram API hash from my.telegram.org. */
22
+ apiHash: string
23
+ /** Telethon-compatible session string for the acting user account. */
24
+ session: string
25
+ /** Optional local mtcute storage path. */
26
+ storage?: string
27
+ }
28
+
29
+ /**
30
+ * Parameters needed to audit one target user or bot in one chat.
31
+ */
32
+ export interface ChatAuditFetchParams {
33
+ /** Telegram chat ID or resolvable chat peer. */
34
+ chatId: number | string
35
+ /** Target user or bot ID to inspect with `getChatMember`. */
36
+ targetUserId: number | string
37
+ }
38
+
39
+ /**
40
+ * Parameters for actions that add or otherwise address one target peer.
41
+ */
42
+ export interface TargetWriteParams {
43
+ /** Telegram chat ID or resolvable chat peer. */
44
+ chatId: number | string
45
+ /** Target user or bot peer used for write operations. */
46
+ targetPeer: number | string
47
+ }
48
+
49
+ /**
50
+ * Parameters for promoting a target peer to admin.
51
+ */
52
+ export interface PromoteTargetParams extends TargetWriteParams {
53
+ /** mtcute admin-rights object passed to `editAdminRights`. */
54
+ rights: Record<string, boolean>
55
+ /** Optional admin rank/title. Telegram limits this value. */
56
+ rank?: string
57
+ }
58
+
59
+ /**
60
+ * Parameters for transferring Telegram chat ownership.
61
+ */
62
+ export interface TransferOwnershipParams extends TargetWriteParams {
63
+ /** Current owner's Telegram 2FA password. Do not persist this value. */
64
+ password: string
65
+ }
66
+
67
+ type MtcuteUser = Awaited<ReturnType<TelegramClient["getMe"]>>
68
+ type MtcuteChatMember = Awaited<ReturnType<typeof getChatMember>>
69
+
70
+ /**
71
+ * Options for discovering Sai-related chats from the active Telegram session.
72
+ */
73
+ export interface FindSaiChatsParams {
74
+ /** Maximum number of dialogs to inspect. */
75
+ limit: number
76
+ /** Archive scope to pass through to mtcute dialog iteration. */
77
+ archived?: "include" | "exclude" | "only"
78
+ }
79
+
80
+ /**
81
+ * Returns true when mtcute indicates the target peer is absent or unknown.
82
+ */
83
+ function isPeerNotFound(error: unknown): boolean {
84
+ if (!(error instanceof Error)) {
85
+ return false
86
+ }
87
+ return (
88
+ error.name === "MtPeerNotFoundError" ||
89
+ error.message.includes("is not found in local cache")
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Creates and authenticates a mtcute client from a Telethon session string.
95
+ */
96
+ export async function createMtcuteClient(
97
+ config: MtcuteClientConfig,
98
+ ): Promise<TelegramClient> {
99
+ const client = new TelegramClient({
100
+ apiId: config.apiId,
101
+ apiHash: config.apiHash,
102
+ storage: config.storage ?? "sai-telegram-admin-check.session",
103
+ })
104
+
105
+ await client.importSession(convertFromTelethonSession(config.session), true)
106
+ return client
107
+ }
108
+
109
+ /**
110
+ * Converts a mtcute user object into the small fixture-friendly `UserData`
111
+ * shape used by pure audit planning.
112
+ */
113
+ export function normalizeUser(user: MtcuteUser): UserData {
114
+ return {
115
+ id: user.id,
116
+ username: user.username ?? null,
117
+ displayName: user.displayName,
118
+ isBot: user.isBot,
119
+ rawType: user.raw?._ ?? "unknown",
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Converts a mtcute chat member object into the normalized `ChatMemberData`
125
+ * shape used by pure audit planning.
126
+ */
127
+ export function normalizeChatMember(
128
+ member: MtcuteChatMember,
129
+ ): ChatMemberData | null {
130
+ if (!member) {
131
+ return null
132
+ }
133
+
134
+ return {
135
+ user: normalizeUser(member.user),
136
+ status: member.status,
137
+ title: member.title ?? null,
138
+ invitedBy: member.invitedBy ? normalizeUser(member.invitedBy) : null,
139
+ promotedBy: member.promotedBy ? normalizeUser(member.promotedBy) : null,
140
+ restrictedBy: member.restrictedBy
141
+ ? normalizeUser(member.restrictedBy)
142
+ : null,
143
+ restrictions: member.restrictions ?? null,
144
+ isMember: member.isMember,
145
+ permissions: member.permissions ? { ...member.permissions } : null,
146
+ rawType: member.raw?._ ?? "unknown",
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Looks up the target chat member and returns `null` when Telegram reports that
152
+ * the target peer is not present in the chat.
153
+ */
154
+ async function getTargetChatMember(
155
+ client: TelegramClient,
156
+ params: ChatAuditFetchParams,
157
+ ): Promise<MtcuteChatMember | null> {
158
+ try {
159
+ return await getChatMember(client, {
160
+ chatId: params.chatId,
161
+ userId: params.targetUserId,
162
+ })
163
+ } catch (error) {
164
+ if (isPeerNotFound(error)) {
165
+ return null
166
+ }
167
+ throw error
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Fetches all live Telegram state needed by `planAdminBootstrap`.
173
+ *
174
+ * The result contains the active session user, the actor's membership in the
175
+ * chat, and the target user or bot membership in the same chat.
176
+ */
177
+ export async function fetchChatAuditInput(
178
+ client: TelegramClient,
179
+ params: ChatAuditFetchParams,
180
+ ): Promise<ChatAuditInput> {
181
+ const self = await client.getMe()
182
+ const actor = await getChatMember(client, {
183
+ chatId: params.chatId,
184
+ userId: "self",
185
+ })
186
+ const target = await getTargetChatMember(client, params)
187
+
188
+ return {
189
+ chatId: params.chatId,
190
+ targetUserId: params.targetUserId,
191
+ self: normalizeUser(self),
192
+ actor: normalizeChatMember(actor),
193
+ target: normalizeChatMember(target),
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Lists visible dialogs for the active session and returns Sai-titled chats as
199
+ * JSONL-ready records.
200
+ */
201
+ export async function findSaiChats(
202
+ client: TelegramClient,
203
+ params: FindSaiChatsParams,
204
+ ): Promise<SaiChatRecord[]> {
205
+ const records: SaiChatRecord[] = []
206
+ const archived = params.archived === "include" ? "keep" : params.archived
207
+ for await (const dialog of iterDialogs(client, {
208
+ limit: params.limit,
209
+ archived: archived ?? "exclude",
210
+ })) {
211
+ const peer = dialog.peer
212
+ const title = "title" in peer ? peer.title : peer.displayName
213
+ if (!title || !isSaiChatTitle(title)) {
214
+ continue
215
+ }
216
+
217
+ records.push({
218
+ chatId: peer.id,
219
+ title,
220
+ type: "chatType" in peer ? peer.chatType : "user",
221
+ username: "username" in peer ? (peer.username ?? null) : null,
222
+ })
223
+ }
224
+
225
+ return records
226
+ }
227
+
228
+ /**
229
+ * Adds a user or bot peer to a Telegram chat.
230
+ */
231
+ export async function addTargetToChat(
232
+ client: TelegramClient,
233
+ params: TargetWriteParams,
234
+ ): Promise<void> {
235
+ await addChatMembers(client, params.chatId, params.targetPeer, {})
236
+ }
237
+
238
+ /**
239
+ * Promotes a user or bot peer using the supplied admin-rights profile.
240
+ */
241
+ export async function promoteTargetAdmin(
242
+ client: TelegramClient,
243
+ params: PromoteTargetParams,
244
+ ): Promise<void> {
245
+ await editAdminRights(client, {
246
+ chatId: params.chatId,
247
+ userId: params.targetPeer,
248
+ rights: params.rights,
249
+ rank: params.rank ?? "",
250
+ })
251
+ }
252
+
253
+ /**
254
+ * Transfers chat ownership to a target user.
255
+ *
256
+ * Telegram requires the current owner's user session and 2FA password. Bots
257
+ * cannot receive ownership.
258
+ */
259
+ export async function transferTargetOwnership(
260
+ client: TelegramClient,
261
+ params: TransferOwnershipParams,
262
+ ): Promise<void> {
263
+ await transferChatOwnership(client, {
264
+ chatId: params.chatId,
265
+ userId: params.targetPeer,
266
+ password: params.password,
267
+ })
268
+ }
package/ops.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * One chat entry consumed by command `tg-sai ops sai-bootstrap`.
3
+ *
4
+ * Inputs can come from a plain text file with one chat ID per line or from JSONL
5
+ * records produced by command `tg-sai ops sai-find`.
6
+ */
7
+ export interface ChatInput {
8
+ /** Telegram chat ID or username-like peer accepted by mtcute calls. */
9
+ chatId: number | string
10
+ /** Human-readable chat title from the input file, when present. */
11
+ title: string | null
12
+ /** Original input line for diagnostics and operator review. */
13
+ raw: string
14
+ }
15
+
16
+ /**
17
+ * JSONL record emitted by command `tg-sai ops sai-find`.
18
+ */
19
+ export interface SaiChatRecord {
20
+ /** Telegram chat ID visible to the connected user session. */
21
+ chatId: number | string
22
+ /** Chat title used for operator review and Sai-title filtering. */
23
+ title: string
24
+ /** mtcute chat type, such as `group`, `supergroup`, or `channel`. */
25
+ type: string
26
+ /** Public username when Telegram exposes one for the chat. */
27
+ username: string | null
28
+ }
29
+
30
+ /**
31
+ * Returns true when a dialog title looks like a Sai operations chat.
32
+ *
33
+ * The filter intentionally avoids matching words such as `Kudasai`; discovery
34
+ * should produce a review file, not an unreviewed execution set.
35
+ */
36
+ export function isSaiChatTitle(title: string): boolean {
37
+ return /\bsai\b|sai\.fun|sai<>|sai\s*<>/i.test(title)
38
+ }
39
+
40
+ /**
41
+ * Parses one chat input line.
42
+ *
43
+ * Supported formats:
44
+ *
45
+ * - `<chat-id>`
46
+ * - `<chat-id> Sai <> Example`
47
+ * - `{"chatId":"<chat-id>","title":"Sai <> Example"}`
48
+ * - `{"chat_id":"<chat-id>","title":"Sai <> Example"}`
49
+ *
50
+ * Empty lines and comment lines beginning with `#` are ignored.
51
+ */
52
+ export function parseChatInputLine(line: string): ChatInput | null {
53
+ const trimmed = line.trim()
54
+ if (!trimmed || trimmed.startsWith("#")) {
55
+ return null
56
+ }
57
+
58
+ if (trimmed.startsWith("{")) {
59
+ const parsed = JSON.parse(trimmed) as {
60
+ chatId?: number | string
61
+ chat_id?: number | string
62
+ title?: string
63
+ }
64
+ const chatId = parsed.chatId ?? parsed.chat_id
65
+ if (chatId === undefined) {
66
+ throw new Error(`Missing chatId in JSONL input line: ${trimmed}`)
67
+ }
68
+ return {
69
+ chatId,
70
+ title: parsed.title ?? null,
71
+ raw: line,
72
+ }
73
+ }
74
+
75
+ const [firstToken, ...titleTokens] = trimmed.split(/\s+/)
76
+ if (!firstToken) {
77
+ return null
78
+ }
79
+ const chatIdToken = firstToken.replace(/`/g, "")
80
+ const asNumber = Number(chatIdToken)
81
+ const chatId = Number.isFinite(asNumber) ? asNumber : chatIdToken
82
+
83
+ return {
84
+ chatId,
85
+ title: titleTokens.length > 0 ? titleTokens.join(" ") : null,
86
+ raw: line,
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Parses a full chat input file into ordered chat entries.
92
+ */
93
+ export function parseChatInputFile(contents: string): ChatInput[] {
94
+ return contents
95
+ .split(/\r?\n/)
96
+ .map(parseChatInputLine)
97
+ .filter((chat): chat is ChatInput => chat !== null)
98
+ }
99
+
100
+ /**
101
+ * Serializes a discovered Sai chat as one JSONL line.
102
+ */
103
+ export function formatSaiChatJsonLine(record: SaiChatRecord): string {
104
+ return JSON.stringify(record)
105
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@uniquedivine/tg-sai",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "private": false,
6
+ "description": "Sai Telegram admin audit and bootstrap CLI",
7
+ "engines": {
8
+ "bun": ">=1.0.0"
9
+ },
10
+ "bin": {
11
+ "tg-sai": "./main.ts"
12
+ },
13
+ "scripts": {
14
+ "start": "bun main.ts",
15
+ "test": "bun test",
16
+ "typecheck": "tsc --noEmit",
17
+ "py-test": "uv run pytest session_string_generator_test.py"
18
+ },
19
+ "files": [
20
+ "*.ts",
21
+ "!*.test.ts",
22
+ "!adminCheck.ts",
23
+ "!testdata.ts",
24
+ "*.py",
25
+ "!*_test.py",
26
+ "pyproject.toml",
27
+ "uv.lock",
28
+ "README.md",
29
+ "RUNBOOK.md"
30
+ ],
31
+ "license": "UNLICENSED",
32
+ "devDependencies": {
33
+ "@types/bun": "^1.3.14",
34
+ "typescript": "^6.0.3"
35
+ },
36
+ "dependencies": {
37
+ "@mtcute/bun": "^0.30.1",
38
+ "@mtcute/convert": "^0.30.1",
39
+ "commander": "^15.0.0"
40
+ }
41
+ }