@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.
- package/README.md +21 -0
- package/RUNBOOK.md +343 -0
- package/actionExecutor.ts +219 -0
- package/adminAudit.ts +147 -0
- package/cfg.ts +29 -0
- package/health.ts +479 -0
- package/main.ts +1290 -0
- package/mtcuteAdapter.ts +268 -0
- package/ops.ts +105 -0
- package/package.json +41 -0
- package/profiles.ts +726 -0
- package/pyproject.toml +20 -0
- package/session_string_generator.py +267 -0
- package/uv.lock +227 -0
package/adminAudit.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
export interface TestData<T> {
|
|
2
|
+
goldReq: string;
|
|
3
|
+
notes?: string;
|
|
4
|
+
data: T;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface UserData {
|
|
8
|
+
id: number;
|
|
9
|
+
username: string | null;
|
|
10
|
+
displayName: string;
|
|
11
|
+
isBot: boolean;
|
|
12
|
+
rawType: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
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;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AdminPermissionsData {
|
|
29
|
+
addAdmins?: boolean;
|
|
30
|
+
add_admins?: boolean;
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ChatAuditInput {
|
|
35
|
+
chatId: number | string;
|
|
36
|
+
targetUserId: number | string;
|
|
37
|
+
self: UserData;
|
|
38
|
+
actor: ChatMemberData | null;
|
|
39
|
+
target: ChatMemberData | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
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[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type MemberRole =
|
|
55
|
+
| "creator"
|
|
56
|
+
| "admin"
|
|
57
|
+
| "member"
|
|
58
|
+
| "restricted"
|
|
59
|
+
| "banned"
|
|
60
|
+
| "left"
|
|
61
|
+
| "not_present"
|
|
62
|
+
| "unknown";
|
|
63
|
+
|
|
64
|
+
export type ProposedAction =
|
|
65
|
+
| "skip: actor lacks owner/admin rights"
|
|
66
|
+
| "add target user"
|
|
67
|
+
| "promote target user admin"
|
|
68
|
+
| "noop: target user already admin";
|
|
69
|
+
|
|
70
|
+
export function deriveMemberRole(member: ChatMemberData | null): MemberRole {
|
|
71
|
+
if (!member) {
|
|
72
|
+
return "not_present";
|
|
73
|
+
}
|
|
74
|
+
switch (member.status) {
|
|
75
|
+
case "creator":
|
|
76
|
+
case "admin":
|
|
77
|
+
case "member":
|
|
78
|
+
case "restricted":
|
|
79
|
+
case "banned":
|
|
80
|
+
case "left":
|
|
81
|
+
return member.status;
|
|
82
|
+
default:
|
|
83
|
+
return "unknown";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function hasAddAdminsRight(
|
|
88
|
+
permissions: AdminPermissionsData | null,
|
|
89
|
+
): boolean {
|
|
90
|
+
if (!permissions) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return Boolean(permissions.addAdmins || permissions.add_admins);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function canActorPromoteAdmins(input: ChatAuditInput): boolean {
|
|
97
|
+
const actorRole = deriveMemberRole(input.actor);
|
|
98
|
+
if (actorRole === "creator") {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (actorRole !== "admin") {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
return hasAddAdminsRight(input.actor?.permissions ?? null);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function canActorTransferOwnership(input: ChatAuditInput): boolean {
|
|
108
|
+
return deriveMemberRole(input.actor) === "creator";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function isAdminRole(role: MemberRole): boolean {
|
|
112
|
+
return role === "admin" || role === "creator";
|
|
113
|
+
}
|
|
114
|
+
|
|
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);
|
|
121
|
+
const targetCanReceiveOwnership =
|
|
122
|
+
targetRole === "admin" && input.target?.user.isBot !== true;
|
|
123
|
+
|
|
124
|
+
const proposedActions: ProposedAction[] = [];
|
|
125
|
+
if (!actorCanApply) {
|
|
126
|
+
proposedActions.push("skip: actor lacks owner/admin rights");
|
|
127
|
+
} else if (targetIsAdmin) {
|
|
128
|
+
proposedActions.push("noop: target user already admin");
|
|
129
|
+
} else {
|
|
130
|
+
if (targetRole === "not_present" || targetRole === "left") {
|
|
131
|
+
proposedActions.push("add target user");
|
|
132
|
+
}
|
|
133
|
+
proposedActions.push("promote target user admin");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
chatId: input.chatId,
|
|
138
|
+
targetUserId: input.targetUserId,
|
|
139
|
+
actorRole,
|
|
140
|
+
targetRole,
|
|
141
|
+
actorCanApply,
|
|
142
|
+
actorCanTransferOwnership,
|
|
143
|
+
targetCanReceiveOwnership,
|
|
144
|
+
targetIsAdmin,
|
|
145
|
+
proposedActions,
|
|
146
|
+
};
|
|
147
|
+
}
|
package/cfg.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface TelegramIdentity {
|
|
2
|
+
id: number
|
|
3
|
+
handle: string
|
|
4
|
+
displayName: string
|
|
5
|
+
kind: "user" | "bot"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Telegram bot identity for the Sai team bot. The bot is the automation target
|
|
10
|
+
* to add and promote in candidate chats.
|
|
11
|
+
*/
|
|
12
|
+
export const saiBot = {
|
|
13
|
+
id: 8983431101,
|
|
14
|
+
handle: "SaiTeam_bot",
|
|
15
|
+
displayName: "Sai Team",
|
|
16
|
+
kind: "bot",
|
|
17
|
+
} satisfies TelegramIdentity
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Telegram user identity for Unique (`uniquedivine`). In this workflow, Unique
|
|
21
|
+
* is the target user to promote to admin or prepare for ownership transfer.
|
|
22
|
+
*/
|
|
23
|
+
export const uniqueDivine = {
|
|
24
|
+
id: 1912700300,
|
|
25
|
+
handle: "uniquedivine",
|
|
26
|
+
displayName: "Unique (NIBIRU)",
|
|
27
|
+
kind: "user",
|
|
28
|
+
} satisfies TelegramIdentity
|
|
29
|
+
|
package/health.ts
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import { constants } from "node:fs"
|
|
2
|
+
import { access, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
import { configDir, configPath, readConfig } from "./profiles"
|
|
6
|
+
import type {
|
|
7
|
+
ProfileStoreEnv,
|
|
8
|
+
TelegramCredentials,
|
|
9
|
+
TgSaiProfile,
|
|
10
|
+
} from "./profiles"
|
|
11
|
+
|
|
12
|
+
const ERROR_PREFIX = "❌😭"
|
|
13
|
+
const WARNING_PREFIX = "⚠️"
|
|
14
|
+
const GOOD_PREFIX = "✅🔥"
|
|
15
|
+
|
|
16
|
+
export interface HealthUser {
|
|
17
|
+
id: number
|
|
18
|
+
username: string | null
|
|
19
|
+
displayName: string
|
|
20
|
+
isBot: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HealthClient {
|
|
24
|
+
getMe(): Promise<HealthUser>
|
|
25
|
+
disconnect(): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type HealthClientFactory = (
|
|
29
|
+
credentials: TelegramCredentials,
|
|
30
|
+
) => Promise<HealthClient>
|
|
31
|
+
|
|
32
|
+
export interface HealthResult {
|
|
33
|
+
exitCode: 0 | 1 | 2
|
|
34
|
+
lines: string[]
|
|
35
|
+
errorCount: number
|
|
36
|
+
warningCount: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface HealthCollector {
|
|
40
|
+
lines: string[]
|
|
41
|
+
problems: HealthProblem[]
|
|
42
|
+
errorCount: number
|
|
43
|
+
warningCount: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface HealthProblem {
|
|
47
|
+
key: string
|
|
48
|
+
severity: "error" | "warning"
|
|
49
|
+
fix: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createCollector(): HealthCollector {
|
|
53
|
+
return {
|
|
54
|
+
lines: [],
|
|
55
|
+
problems: [],
|
|
56
|
+
errorCount: 0,
|
|
57
|
+
warningCount: 0,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function info(collector: HealthCollector, line: string): void {
|
|
62
|
+
collector.lines.push(line)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function good(collector: HealthCollector, line: string): void {
|
|
66
|
+
collector.lines.push(`${GOOD_PREFIX} ${line}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function warning(
|
|
70
|
+
collector: HealthCollector,
|
|
71
|
+
key: string,
|
|
72
|
+
line: string,
|
|
73
|
+
fix: string[],
|
|
74
|
+
): void {
|
|
75
|
+
collector.warningCount += 1
|
|
76
|
+
collector.lines.push(`${WARNING_PREFIX} ${line}`)
|
|
77
|
+
collector.problems.push({ key, severity: "warning", fix })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function error(
|
|
81
|
+
collector: HealthCollector,
|
|
82
|
+
key: string,
|
|
83
|
+
line: string,
|
|
84
|
+
fix: string[],
|
|
85
|
+
): void {
|
|
86
|
+
collector.errorCount += 1
|
|
87
|
+
collector.lines.push(`${ERROR_PREFIX} ${line}`)
|
|
88
|
+
collector.problems.push({ key, severity: "error", fix })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function exitCode(collector: HealthCollector): 0 | 1 | 2 {
|
|
92
|
+
if (collector.errorCount > 0) {
|
|
93
|
+
return 1
|
|
94
|
+
}
|
|
95
|
+
if (collector.warningCount > 0) {
|
|
96
|
+
return 2
|
|
97
|
+
}
|
|
98
|
+
return 0
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function errorMessage(value: unknown): string {
|
|
102
|
+
return value instanceof Error ? value.message : String(value)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function directoryFix(path: string): string[] {
|
|
106
|
+
return [
|
|
107
|
+
`Make sure the directory exists and is writable: ${path}`,
|
|
108
|
+
`mkdir -p ${path}`,
|
|
109
|
+
`chmod 700 ${path}`,
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function missingProfileFix(profileName?: string): string[] {
|
|
114
|
+
const addCommand = profileName
|
|
115
|
+
? `tg-sai profile add --name ${profileName} --src <number>`
|
|
116
|
+
: "tg-sai profile add --name <name> --src <number>"
|
|
117
|
+
return [
|
|
118
|
+
"Find a Telegram credential source and save it as a tg-sai profile.",
|
|
119
|
+
"tg-sai profile find",
|
|
120
|
+
addCommand,
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function incompleteProfileFix(profileName: string, fields: string[]): string[] {
|
|
125
|
+
return [
|
|
126
|
+
`Fill missing profile field(s): ${fields.join(",")}`,
|
|
127
|
+
"Check the redacted active profile:",
|
|
128
|
+
`tg-sai profile show ${profileName}`,
|
|
129
|
+
"Then refresh it from a complete credential source:",
|
|
130
|
+
"tg-sai profile find",
|
|
131
|
+
`tg-sai profile add --name ${profileName} --src <number>`,
|
|
132
|
+
]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function connectionFix(profileName: string): string[] {
|
|
136
|
+
return [
|
|
137
|
+
"Check the active profile API credentials and Telegram session string.",
|
|
138
|
+
`tg-sai profile show ${profileName}`,
|
|
139
|
+
"If apiId, apiHash, or sessionString are stale, refresh from a known-good source:",
|
|
140
|
+
"tg-sai profile find",
|
|
141
|
+
`tg-sai profile add --name ${profileName} --src <number>`,
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function passwordFix(profileName?: string): string[] {
|
|
146
|
+
const saveCommand = profileName
|
|
147
|
+
? `tg-sai profile add --name ${profileName} --password '<password>'`
|
|
148
|
+
: "tg-sai profile add --name <name> --password '<password>'"
|
|
149
|
+
return [
|
|
150
|
+
"Save the Telegram 2FA password in the active tg-sai profile:",
|
|
151
|
+
saveCommand,
|
|
152
|
+
"You can also override it for one command with TELEGRAM_PASSWORD.",
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderSuggestedFixes(collector: HealthCollector): void {
|
|
157
|
+
if (collector.problems.length === 0) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
info(collector, "")
|
|
162
|
+
info(collector, "suggested_fixes:")
|
|
163
|
+
for (const problem of collector.problems) {
|
|
164
|
+
info(collector, `- ${problem.key} (${problem.severity}):`)
|
|
165
|
+
for (const line of problem.fix) {
|
|
166
|
+
info(collector, ` ${line}`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function ensureWritableDirectory(
|
|
172
|
+
collector: HealthCollector,
|
|
173
|
+
label: string,
|
|
174
|
+
path: string,
|
|
175
|
+
): Promise<boolean> {
|
|
176
|
+
try {
|
|
177
|
+
await mkdir(path, { recursive: true, mode: 0o700 })
|
|
178
|
+
await access(path, constants.W_OK)
|
|
179
|
+
info(collector, `${label}: ${path}`)
|
|
180
|
+
return true
|
|
181
|
+
} catch (caught) {
|
|
182
|
+
error(
|
|
183
|
+
collector,
|
|
184
|
+
label,
|
|
185
|
+
`${label}: ${path} not_writable ${errorMessage(caught)}`,
|
|
186
|
+
directoryFix(path),
|
|
187
|
+
)
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function profileFileExists(env: ProfileStoreEnv): Promise<boolean> {
|
|
193
|
+
try {
|
|
194
|
+
const fileStat = await stat(configPath(env))
|
|
195
|
+
return fileStat.isFile()
|
|
196
|
+
} catch (caught) {
|
|
197
|
+
if (
|
|
198
|
+
caught instanceof Error &&
|
|
199
|
+
"code" in caught &&
|
|
200
|
+
(caught as NodeJS.ErrnoException).code === "ENOENT"
|
|
201
|
+
) {
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
throw caught
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function missingProfileFields(profile: TgSaiProfile): string[] {
|
|
209
|
+
const missing: string[] = []
|
|
210
|
+
if (!profile.apiId) {
|
|
211
|
+
missing.push("apiId")
|
|
212
|
+
}
|
|
213
|
+
if (!profile.apiHash) {
|
|
214
|
+
missing.push("apiHash")
|
|
215
|
+
}
|
|
216
|
+
if (!profile.sessionString) {
|
|
217
|
+
missing.push("sessionString")
|
|
218
|
+
}
|
|
219
|
+
return missing
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function credentialsFromCompleteProfile(
|
|
223
|
+
profile: TgSaiProfile,
|
|
224
|
+
): TelegramCredentials {
|
|
225
|
+
return {
|
|
226
|
+
apiId: profile.apiId ?? 0,
|
|
227
|
+
apiHash: profile.apiHash,
|
|
228
|
+
sessionString: profile.sessionString,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function checkTelegramConnection(params: {
|
|
233
|
+
collector: HealthCollector
|
|
234
|
+
profile: TgSaiProfile | null
|
|
235
|
+
missingFields: string[]
|
|
236
|
+
createClient: HealthClientFactory
|
|
237
|
+
}): Promise<void> {
|
|
238
|
+
const { collector, profile, missingFields, createClient } = params
|
|
239
|
+
if (!profile) {
|
|
240
|
+
error(
|
|
241
|
+
collector,
|
|
242
|
+
"telegram_connection",
|
|
243
|
+
"telegram_connection: skipped because no active profile is configured",
|
|
244
|
+
missingProfileFix(),
|
|
245
|
+
)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
if (missingFields.length > 0) {
|
|
249
|
+
error(
|
|
250
|
+
collector,
|
|
251
|
+
"telegram_connection",
|
|
252
|
+
"telegram_connection: skipped because active profile is incomplete",
|
|
253
|
+
incompleteProfileFix(profile.name, missingFields),
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let client: HealthClient | null = null
|
|
259
|
+
try {
|
|
260
|
+
client = await createClient(credentialsFromCompleteProfile(profile))
|
|
261
|
+
const user = await client.getMe()
|
|
262
|
+
good(collector, "telegram_connection: ok")
|
|
263
|
+
info(
|
|
264
|
+
collector,
|
|
265
|
+
`session_user: ${user.id} ${user.username ?? ""} "${user.displayName}"`,
|
|
266
|
+
)
|
|
267
|
+
if (user.isBot) {
|
|
268
|
+
warning(
|
|
269
|
+
collector,
|
|
270
|
+
"session_user_is_bot",
|
|
271
|
+
"session_user_is_bot: true; tg-sai is designed for user-account sessions",
|
|
272
|
+
[
|
|
273
|
+
"Use a Telegram user-account session for tg-sai admin operations.",
|
|
274
|
+
"Switch to a user profile:",
|
|
275
|
+
"tg-sai profile list",
|
|
276
|
+
"tg-sai profile use <user-profile-name>",
|
|
277
|
+
],
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
} catch (caught) {
|
|
281
|
+
error(
|
|
282
|
+
collector,
|
|
283
|
+
"telegram_connection",
|
|
284
|
+
`telegram_connection: failed ${errorMessage(caught)}`,
|
|
285
|
+
connectionFix(profile.name),
|
|
286
|
+
)
|
|
287
|
+
} finally {
|
|
288
|
+
if (client) {
|
|
289
|
+
await client.disconnect()
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function checkPassword(
|
|
295
|
+
collector: HealthCollector,
|
|
296
|
+
env: ProfileStoreEnv,
|
|
297
|
+
profile: TgSaiProfile | null,
|
|
298
|
+
): void {
|
|
299
|
+
if (env.TELEGRAM_PASSWORD || profile?.password) {
|
|
300
|
+
info(collector, "password_status: present")
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
warning(
|
|
304
|
+
collector,
|
|
305
|
+
"password_status",
|
|
306
|
+
"password_status: missing",
|
|
307
|
+
passwordFix(profile?.name),
|
|
308
|
+
)
|
|
309
|
+
info(
|
|
310
|
+
collector,
|
|
311
|
+
"No profile password or TELEGRAM_PASSWORD is set; ownership transfer will be skipped",
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function latestNumberedJsonl(files: string[], prefix: string): string | null {
|
|
316
|
+
const matcher = new RegExp(`^${prefix}-(\\d+)\\.jsonl$`)
|
|
317
|
+
let latest: { file: string; index: number } | null = null
|
|
318
|
+
for (const file of files) {
|
|
319
|
+
const match = file.match(matcher)
|
|
320
|
+
if (!match) {
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
const index = Number(match[1])
|
|
324
|
+
if (!latest || index > latest.index) {
|
|
325
|
+
latest = { file, index }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return latest?.file ?? null
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function countJsonlRecords(path: string): Promise<number> {
|
|
332
|
+
const text = await readFile(path, "utf8")
|
|
333
|
+
return text.split(/\r?\n/).filter((line) => line.trim().length > 0).length
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function checkLogs(
|
|
337
|
+
collector: HealthCollector,
|
|
338
|
+
logsPath: string,
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
let files: string[] = []
|
|
341
|
+
try {
|
|
342
|
+
files = await readdir(logsPath)
|
|
343
|
+
} catch (caught) {
|
|
344
|
+
error(
|
|
345
|
+
collector,
|
|
346
|
+
"logs_scan",
|
|
347
|
+
`logs_scan: failed ${errorMessage(caught)}`,
|
|
348
|
+
directoryFix(logsPath),
|
|
349
|
+
)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const saiFind = latestNumberedJsonl(files, "sai-find")
|
|
354
|
+
if (saiFind) {
|
|
355
|
+
const path = join(logsPath, saiFind)
|
|
356
|
+
info(collector, `latest_sai_find: ${path}`)
|
|
357
|
+
info(
|
|
358
|
+
collector,
|
|
359
|
+
`latest_sai_find_chat_count: ${await countJsonlRecords(path)}`,
|
|
360
|
+
)
|
|
361
|
+
} else {
|
|
362
|
+
info(collector, "latest_sai_find: none")
|
|
363
|
+
info(collector, "next: tg-sai ops sai-find")
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const saiBootstrap = latestNumberedJsonl(files, "sai-bootstrap")
|
|
367
|
+
if (saiBootstrap) {
|
|
368
|
+
info(collector, `latest_sai_bootstrap: ${join(logsPath, saiBootstrap)}`)
|
|
369
|
+
} else {
|
|
370
|
+
info(collector, "latest_sai_bootstrap: none")
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function runHealthCheck(params: {
|
|
375
|
+
env?: ProfileStoreEnv
|
|
376
|
+
createClient: HealthClientFactory
|
|
377
|
+
}): Promise<HealthResult> {
|
|
378
|
+
const env = params.env ?? process.env
|
|
379
|
+
const collector = createCollector()
|
|
380
|
+
const toolPath = configDir(env)
|
|
381
|
+
const logsPath = join(toolPath, "logs")
|
|
382
|
+
|
|
383
|
+
await ensureWritableDirectory(collector, "tool_dir", toolPath)
|
|
384
|
+
const logsWritable = await ensureWritableDirectory(
|
|
385
|
+
collector,
|
|
386
|
+
"logs_dir",
|
|
387
|
+
logsPath,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if (await profileFileExists(env)) {
|
|
391
|
+
info(collector, `profile_file: ${configPath(env)}`)
|
|
392
|
+
} else {
|
|
393
|
+
info(collector, `profile_file: missing ${configPath(env)}`)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const config = await readConfig(env)
|
|
397
|
+
const activeProfileName = config.activeProfile
|
|
398
|
+
const activeProfile = activeProfileName
|
|
399
|
+
? (config.profiles[activeProfileName] ?? null)
|
|
400
|
+
: null
|
|
401
|
+
const missingFields = activeProfile ? missingProfileFields(activeProfile) : []
|
|
402
|
+
|
|
403
|
+
if (!activeProfileName) {
|
|
404
|
+
error(
|
|
405
|
+
collector,
|
|
406
|
+
"active_profile",
|
|
407
|
+
"active_profile: missing",
|
|
408
|
+
missingProfileFix(),
|
|
409
|
+
)
|
|
410
|
+
error(
|
|
411
|
+
collector,
|
|
412
|
+
"profile_status",
|
|
413
|
+
"profile_status: missing",
|
|
414
|
+
missingProfileFix(),
|
|
415
|
+
)
|
|
416
|
+
} else if (!activeProfile) {
|
|
417
|
+
error(
|
|
418
|
+
collector,
|
|
419
|
+
"active_profile",
|
|
420
|
+
`active_profile: ${activeProfileName}`,
|
|
421
|
+
missingProfileFix(activeProfileName),
|
|
422
|
+
)
|
|
423
|
+
error(
|
|
424
|
+
collector,
|
|
425
|
+
"profile_status",
|
|
426
|
+
"profile_status: missing",
|
|
427
|
+
missingProfileFix(activeProfileName),
|
|
428
|
+
)
|
|
429
|
+
} else {
|
|
430
|
+
info(collector, `active_profile: ${activeProfile.name}`)
|
|
431
|
+
if (missingFields.length > 0) {
|
|
432
|
+
error(
|
|
433
|
+
collector,
|
|
434
|
+
"profile_status",
|
|
435
|
+
"profile_status: incomplete",
|
|
436
|
+
incompleteProfileFix(activeProfile.name, missingFields),
|
|
437
|
+
)
|
|
438
|
+
error(
|
|
439
|
+
collector,
|
|
440
|
+
"missing_fields",
|
|
441
|
+
`missing_fields: ${missingFields.join(",")}`,
|
|
442
|
+
incompleteProfileFix(activeProfile.name, missingFields),
|
|
443
|
+
)
|
|
444
|
+
} else {
|
|
445
|
+
good(collector, "profile_status: complete")
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
await checkTelegramConnection({
|
|
450
|
+
collector,
|
|
451
|
+
profile: activeProfile,
|
|
452
|
+
missingFields,
|
|
453
|
+
createClient: params.createClient,
|
|
454
|
+
})
|
|
455
|
+
checkPassword(collector, env, activeProfile)
|
|
456
|
+
if (logsWritable) {
|
|
457
|
+
await checkLogs(collector, logsPath)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!activeProfileName) {
|
|
461
|
+
info(collector, "next: tg-sai profile find")
|
|
462
|
+
info(collector, "next: tg-sai profile add --name <name> --src <number>")
|
|
463
|
+
} else if (!activeProfile || missingFields.length > 0) {
|
|
464
|
+
info(
|
|
465
|
+
collector,
|
|
466
|
+
`next: tg-sai profile add --name ${activeProfileName} --src <number>`,
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
renderSuggestedFixes(collector)
|
|
471
|
+
const code = exitCode(collector)
|
|
472
|
+
info(collector, `exit_code: ${code}`)
|
|
473
|
+
return {
|
|
474
|
+
exitCode: code,
|
|
475
|
+
lines: collector.lines,
|
|
476
|
+
errorCount: collector.errorCount,
|
|
477
|
+
warningCount: collector.warningCount,
|
|
478
|
+
}
|
|
479
|
+
}
|