@bruch/max-client 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.
Files changed (174) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +168 -0
  3. package/UPSTREAM.md +10 -0
  4. package/dist/api/account.d.ts +24 -0
  5. package/dist/api/account.d.ts.map +1 -0
  6. package/dist/api/auth.d.ts +57 -0
  7. package/dist/api/auth.d.ts.map +1 -0
  8. package/dist/api/bots.d.ts +8 -0
  9. package/dist/api/bots.d.ts.map +1 -0
  10. package/dist/api/chats.d.ts +41 -0
  11. package/dist/api/chats.d.ts.map +1 -0
  12. package/dist/api/facade.d.ts +19 -0
  13. package/dist/api/facade.d.ts.map +1 -0
  14. package/dist/api/index.d.ts +10 -0
  15. package/dist/api/index.d.ts.map +1 -0
  16. package/dist/api/messages.d.ts +35 -0
  17. package/dist/api/messages.d.ts.map +1 -0
  18. package/dist/api/response.d.ts +7 -0
  19. package/dist/api/response.d.ts.map +1 -0
  20. package/dist/api/runtime.d.ts +19 -0
  21. package/dist/api/runtime.d.ts.map +1 -0
  22. package/dist/api/uploads.d.ts +23 -0
  23. package/dist/api/uploads.d.ts.map +1 -0
  24. package/dist/api/users.d.ts +25 -0
  25. package/dist/api/users.d.ts.map +1 -0
  26. package/dist/app.d.ts +55 -0
  27. package/dist/app.d.ts.map +1 -0
  28. package/dist/auth/flows.d.ts +27 -0
  29. package/dist/auth/flows.d.ts.map +1 -0
  30. package/dist/auth/index.d.ts +3 -0
  31. package/dist/auth/index.d.ts.map +1 -0
  32. package/dist/auth/index.js +109 -0
  33. package/dist/auth/index.js.map +11 -0
  34. package/dist/auth/providers.d.ts +11 -0
  35. package/dist/auth/providers.d.ts.map +1 -0
  36. package/dist/client.d.ts +139 -0
  37. package/dist/client.d.ts.map +1 -0
  38. package/dist/config.d.ts +80 -0
  39. package/dist/config.d.ts.map +1 -0
  40. package/dist/connection/connection.d.ts +25 -0
  41. package/dist/connection/connection.d.ts.map +1 -0
  42. package/dist/connection/index.d.ts +4 -0
  43. package/dist/connection/index.d.ts.map +1 -0
  44. package/dist/connection/pending.d.ts +9 -0
  45. package/dist/connection/pending.d.ts.map +1 -0
  46. package/dist/connection/readers.d.ts +17 -0
  47. package/dist/connection/readers.d.ts.map +1 -0
  48. package/dist/dispatch/dispatcher.d.ts +16 -0
  49. package/dist/dispatch/dispatcher.d.ts.map +1 -0
  50. package/dist/dispatch/index.d.ts +3 -0
  51. package/dist/dispatch/index.d.ts.map +1 -0
  52. package/dist/dispatch/router.d.ts +64 -0
  53. package/dist/dispatch/router.d.ts.map +1 -0
  54. package/dist/errors.d.ts +24 -0
  55. package/dist/errors.d.ts.map +1 -0
  56. package/dist/files/file.d.ts +29 -0
  57. package/dist/files/file.d.ts.map +1 -0
  58. package/dist/files/index.d.ts +2 -0
  59. package/dist/files/index.d.ts.map +1 -0
  60. package/dist/files/index.js +95 -0
  61. package/dist/files/index.js.map +10 -0
  62. package/dist/formatting/index.d.ts +2 -0
  63. package/dist/formatting/index.d.ts.map +1 -0
  64. package/dist/formatting/markdown.d.ts +13 -0
  65. package/dist/formatting/markdown.d.ts.map +1 -0
  66. package/dist/index.d.ts +15 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +3967 -0
  69. package/dist/index.js.map +48 -0
  70. package/dist/logging.d.ts +10 -0
  71. package/dist/logging.d.ts.map +1 -0
  72. package/dist/protocol/compression.d.ts +8 -0
  73. package/dist/protocol/compression.d.ts.map +1 -0
  74. package/dist/protocol/enums.d.ts +175 -0
  75. package/dist/protocol/enums.d.ts.map +1 -0
  76. package/dist/protocol/index.d.ts +6 -0
  77. package/dist/protocol/index.d.ts.map +1 -0
  78. package/dist/protocol/index.js +438 -0
  79. package/dist/protocol/index.js.map +13 -0
  80. package/dist/protocol/tcp.d.ts +35 -0
  81. package/dist/protocol/tcp.d.ts.map +1 -0
  82. package/dist/protocol/types.d.ts +38 -0
  83. package/dist/protocol/types.d.ts.map +1 -0
  84. package/dist/protocol/websocket.d.ts +7 -0
  85. package/dist/protocol/websocket.d.ts.map +1 -0
  86. package/dist/session/index.d.ts +3 -0
  87. package/dist/session/index.d.ts.map +1 -0
  88. package/dist/session/index.js +179 -0
  89. package/dist/session/index.js.map +12 -0
  90. package/dist/session/store.d.ts +18 -0
  91. package/dist/session/store.d.ts.map +1 -0
  92. package/dist/session/types.d.ts +29 -0
  93. package/dist/session/types.d.ts.map +1 -0
  94. package/dist/telemetry/index.d.ts +4 -0
  95. package/dist/telemetry/index.d.ts.map +1 -0
  96. package/dist/telemetry/index.js +831 -0
  97. package/dist/telemetry/index.js.map +17 -0
  98. package/dist/telemetry/navigation.d.ts +41 -0
  99. package/dist/telemetry/navigation.d.ts.map +1 -0
  100. package/dist/telemetry/payloads.d.ts +30 -0
  101. package/dist/telemetry/payloads.d.ts.map +1 -0
  102. package/dist/telemetry/service.d.ts +53 -0
  103. package/dist/telemetry/service.d.ts.map +1 -0
  104. package/dist/transport/base.d.ts +18 -0
  105. package/dist/transport/base.d.ts.map +1 -0
  106. package/dist/transport/index.d.ts +4 -0
  107. package/dist/transport/index.d.ts.map +1 -0
  108. package/dist/transport/tcp.d.ts +28 -0
  109. package/dist/transport/tcp.d.ts.map +1 -0
  110. package/dist/transport/websocket.d.ts +14 -0
  111. package/dist/transport/websocket.d.ts.map +1 -0
  112. package/dist/types/attachments.d.ts +18 -0
  113. package/dist/types/attachments.d.ts.map +1 -0
  114. package/dist/types/index.d.ts +4 -0
  115. package/dist/types/index.d.ts.map +1 -0
  116. package/dist/types/index.js +376 -0
  117. package/dist/types/index.js.map +12 -0
  118. package/dist/types/models.d.ts +210 -0
  119. package/dist/types/models.d.ts.map +1 -0
  120. package/dist/types/utils.d.ts +10 -0
  121. package/dist/types/utils.d.ts.map +1 -0
  122. package/package.json +95 -0
  123. package/src/api/account.ts +93 -0
  124. package/src/api/auth.ts +312 -0
  125. package/src/api/bots.ts +21 -0
  126. package/src/api/chats.ts +234 -0
  127. package/src/api/facade.ts +28 -0
  128. package/src/api/index.ts +9 -0
  129. package/src/api/messages.ts +230 -0
  130. package/src/api/response.ts +24 -0
  131. package/src/api/runtime.ts +27 -0
  132. package/src/api/uploads.ts +91 -0
  133. package/src/api/users.ts +68 -0
  134. package/src/app.ts +271 -0
  135. package/src/auth/flows.ts +103 -0
  136. package/src/auth/index.ts +2 -0
  137. package/src/auth/providers.ts +30 -0
  138. package/src/client.ts +506 -0
  139. package/src/config.ts +161 -0
  140. package/src/connection/connection.ts +93 -0
  141. package/src/connection/index.ts +3 -0
  142. package/src/connection/pending.ts +49 -0
  143. package/src/connection/readers.ts +33 -0
  144. package/src/dispatch/dispatcher.ts +194 -0
  145. package/src/dispatch/index.ts +2 -0
  146. package/src/dispatch/router.ts +149 -0
  147. package/src/errors.ts +41 -0
  148. package/src/files/file.ts +92 -0
  149. package/src/files/index.ts +1 -0
  150. package/src/formatting/index.ts +1 -0
  151. package/src/formatting/markdown.ts +123 -0
  152. package/src/index.ts +15 -0
  153. package/src/logging.ts +67 -0
  154. package/src/protocol/compression.ts +69 -0
  155. package/src/protocol/enums.ts +175 -0
  156. package/src/protocol/index.ts +5 -0
  157. package/src/protocol/tcp.ts +139 -0
  158. package/src/protocol/types.ts +45 -0
  159. package/src/protocol/websocket.ts +47 -0
  160. package/src/session/index.ts +2 -0
  161. package/src/session/store.ts +154 -0
  162. package/src/session/types.ts +45 -0
  163. package/src/telemetry/index.ts +3 -0
  164. package/src/telemetry/navigation.ts +160 -0
  165. package/src/telemetry/payloads.ts +84 -0
  166. package/src/telemetry/service.ts +194 -0
  167. package/src/transport/base.ts +43 -0
  168. package/src/transport/index.ts +3 -0
  169. package/src/transport/tcp.ts +296 -0
  170. package/src/transport/websocket.ts +63 -0
  171. package/src/types/attachments.ts +24 -0
  172. package/src/types/index.ts +3 -0
  173. package/src/types/models.ts +461 -0
  174. package/src/types/utils.ts +45 -0
@@ -0,0 +1,154 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ import { getLogger } from "../logging.ts";
6
+ import {
7
+ DEFAULT_CONFIG_HASH,
8
+ defaultSyncState,
9
+ type SessionInfo,
10
+ type SessionStore as SessionStoreContract,
11
+ } from "./types.ts";
12
+
13
+ interface SessionRow {
14
+ token: string;
15
+ device_id: string;
16
+ phone: string;
17
+ mt_instance_id: string;
18
+ chats_sync: number;
19
+ contacts_sync: number;
20
+ drafts_sync: number;
21
+ presence_sync: number;
22
+ config_hash: string | number;
23
+ }
24
+
25
+ const columns = `token, device_id, phone, mt_instance_id, chats_sync, contacts_sync,
26
+ drafts_sync, presence_sync, config_hash`;
27
+
28
+ export class SqliteSessionStore implements SessionStoreContract {
29
+ readonly path: string;
30
+ private database: Database | null = null;
31
+
32
+ constructor(workDir: string, databaseName = "session.db") {
33
+ mkdirSync(workDir, { recursive: true });
34
+ this.path = join(workDir, databaseName);
35
+ }
36
+
37
+ async saveSession(session: SessionInfo): Promise<void> {
38
+ const db = this.getDatabase();
39
+ db.query(`INSERT OR REPLACE INTO sessions (${columns}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
40
+ session.token,
41
+ session.deviceId,
42
+ session.phone,
43
+ session.mtInstanceId,
44
+ session.sync.chatsSync,
45
+ session.sync.contactsSync,
46
+ session.sync.draftsSync,
47
+ session.sync.presenceSync,
48
+ String(session.sync.configHash),
49
+ );
50
+ getLogger().info("session saved");
51
+ }
52
+
53
+ async updateToken(oldToken: string, newToken: string): Promise<void> {
54
+ this.getDatabase()
55
+ .query("UPDATE sessions SET token = ? WHERE token = ?")
56
+ .run(newToken, oldToken);
57
+ }
58
+
59
+ async loadSession(): Promise<SessionInfo | null> {
60
+ return this.rowToSession(
61
+ this.getDatabase()
62
+ .query(`SELECT ${columns} FROM sessions LIMIT 1`)
63
+ .get() as SessionRow | null,
64
+ );
65
+ }
66
+
67
+ async loadSessionByDeviceId(deviceId: string): Promise<SessionInfo | null> {
68
+ return this.rowToSession(
69
+ this.getDatabase()
70
+ .query(`SELECT ${columns} FROM sessions WHERE device_id = ?`)
71
+ .get(deviceId) as SessionRow | null,
72
+ );
73
+ }
74
+
75
+ async loadSessionByPhone(phone: string): Promise<SessionInfo | null> {
76
+ return this.rowToSession(
77
+ this.getDatabase()
78
+ .query(`SELECT ${columns} FROM sessions WHERE phone = ?`)
79
+ .get(phone) as SessionRow | null,
80
+ );
81
+ }
82
+
83
+ async deleteSession(token: string): Promise<void> {
84
+ this.getDatabase().query("DELETE FROM sessions WHERE token = ?").run(token);
85
+ }
86
+
87
+ async deleteAllSessions(): Promise<void> {
88
+ this.getDatabase().run("DELETE FROM sessions");
89
+ }
90
+
91
+ async close(): Promise<void> {
92
+ this.database?.close();
93
+ this.database = null;
94
+ }
95
+
96
+ private getDatabase(): Database {
97
+ if (this.database) return this.database;
98
+ const database = new Database(this.path, { create: true, strict: true });
99
+ database.run(`CREATE TABLE IF NOT EXISTS sessions (
100
+ token TEXT NOT NULL PRIMARY KEY,
101
+ device_id TEXT NOT NULL,
102
+ phone TEXT NOT NULL,
103
+ mt_instance_id TEXT NOT NULL DEFAULT '',
104
+ chats_sync INTEGER NOT NULL DEFAULT -1,
105
+ contacts_sync INTEGER NOT NULL DEFAULT -1,
106
+ drafts_sync INTEGER NOT NULL DEFAULT -1,
107
+ presence_sync INTEGER NOT NULL DEFAULT -1,
108
+ config_hash TEXT NOT NULL DEFAULT ''
109
+ )`);
110
+ this.ensureColumns(database);
111
+ database
112
+ .query("UPDATE sessions SET config_hash = ? WHERE config_hash = ''")
113
+ .run(DEFAULT_CONFIG_HASH);
114
+ this.database = database;
115
+ return database;
116
+ }
117
+
118
+ private ensureColumns(database: Database): void {
119
+ const existing = new Set(
120
+ (database.query("PRAGMA table_info(sessions)").all() as { name: string }[]).map(
121
+ (row) => row.name,
122
+ ),
123
+ );
124
+ const definitions: Record<string, string> = {
125
+ mt_instance_id: "TEXT NOT NULL DEFAULT ''",
126
+ chats_sync: "INTEGER NOT NULL DEFAULT -1",
127
+ contacts_sync: "INTEGER NOT NULL DEFAULT -1",
128
+ drafts_sync: "INTEGER NOT NULL DEFAULT -1",
129
+ presence_sync: "INTEGER NOT NULL DEFAULT -1",
130
+ config_hash: "TEXT NOT NULL DEFAULT ''",
131
+ };
132
+ for (const [name, definition] of Object.entries(definitions)) {
133
+ if (!existing.has(name))
134
+ database.run(`ALTER TABLE sessions ADD COLUMN ${name} ${definition}`);
135
+ }
136
+ }
137
+
138
+ private rowToSession(row: SessionRow | null): SessionInfo | null {
139
+ if (!row) return null;
140
+ return {
141
+ token: row.token,
142
+ deviceId: row.device_id,
143
+ phone: row.phone,
144
+ mtInstanceId: row.mt_instance_id || "",
145
+ sync: defaultSyncState({
146
+ chatsSync: row.chats_sync,
147
+ contactsSync: row.contacts_sync,
148
+ draftsSync: row.drafts_sync,
149
+ presenceSync: row.presence_sync,
150
+ configHash: row.config_hash || DEFAULT_CONFIG_HASH,
151
+ }),
152
+ };
153
+ }
154
+ }
@@ -0,0 +1,45 @@
1
+ export const DEFAULT_CONFIG_HASH =
2
+ "00000000-0000000000000000-00000000-0000000000000000-0000000000000000-0-0000000000000000-00000000";
3
+
4
+ export interface SyncState {
5
+ chatsSync: number;
6
+ contactsSync: number;
7
+ draftsSync: number;
8
+ presenceSync: number;
9
+ configHash: string | number;
10
+ }
11
+
12
+ export type SyncOverrides = Partial<SyncState>;
13
+
14
+ export interface SessionInfo {
15
+ token: string;
16
+ deviceId: string;
17
+ phone: string;
18
+ mtInstanceId: string;
19
+ sync: SyncState;
20
+ }
21
+
22
+ export interface SessionStore {
23
+ saveSession(session: SessionInfo): Promise<void>;
24
+ updateToken(oldToken: string, newToken: string): Promise<void>;
25
+ loadSession(): Promise<SessionInfo | null>;
26
+ loadSessionByDeviceId(deviceId: string): Promise<SessionInfo | null>;
27
+ loadSessionByPhone(phone: string): Promise<SessionInfo | null>;
28
+ deleteSession(token: string): Promise<void>;
29
+ deleteAllSessions(): Promise<void>;
30
+ close(): Promise<void>;
31
+ }
32
+
33
+ export function defaultSyncState(overrides: SyncOverrides = {}): SyncState {
34
+ return {
35
+ chatsSync: overrides.chatsSync ?? -1,
36
+ contactsSync: overrides.contactsSync ?? -1,
37
+ draftsSync: overrides.draftsSync ?? -1,
38
+ presenceSync: overrides.presenceSync ?? -1,
39
+ configHash: overrides.configHash ?? DEFAULT_CONFIG_HASH,
40
+ };
41
+ }
42
+
43
+ export function resolveSync(saved: SyncState, overrides: SyncOverrides = {}): SyncState {
44
+ return { ...saved, ...overrides };
45
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./navigation.ts";
2
+ export * from "./payloads.ts";
3
+ export * from "./service.ts";
@@ -0,0 +1,160 @@
1
+ export const Screen = {
2
+ BACKGROUND: 1,
3
+ CONTACTS: 100,
4
+ CHATS: 150,
5
+ SEARCH: 151,
6
+ CALLS: 300,
7
+ CHAT: 350,
8
+ SETTINGS: 450,
9
+ MINIAPP: 500,
10
+ } as const;
11
+ export type Screen = (typeof Screen)[keyof typeof Screen];
12
+
13
+ export interface RandomSource {
14
+ random(): number;
15
+ }
16
+
17
+ export interface RouteProfile {
18
+ steps: number;
19
+ minPause: number;
20
+ maxPause: number;
21
+ longPauseChance: number;
22
+ minLongPause: number;
23
+ maxLongPause: number;
24
+ backChance: number;
25
+ }
26
+
27
+ interface ScreenTransition {
28
+ screen: Screen;
29
+ weight: number;
30
+ }
31
+
32
+ export const mainTabParams = { source_type: 5, source_id: 1, tab_config: 2 } as const;
33
+
34
+ export const defaultProfiles: Readonly<Record<string, RouteProfile>> = {
35
+ quick: {
36
+ steps: 2,
37
+ minPause: 35,
38
+ maxPause: 95,
39
+ longPauseChance: 0.05,
40
+ minLongPause: 180,
41
+ maxLongPause: 420,
42
+ backChance: 0.3,
43
+ },
44
+ browse: {
45
+ steps: 4,
46
+ minPause: 70,
47
+ maxPause: 210,
48
+ longPauseChance: 0.12,
49
+ minLongPause: 240,
50
+ maxLongPause: 720,
51
+ backChance: 0.22,
52
+ },
53
+ read: {
54
+ steps: 3,
55
+ minPause: 140,
56
+ maxPause: 360,
57
+ longPauseChance: 0.25,
58
+ minLongPause: 420,
59
+ maxLongPause: 1200,
60
+ backChance: 0.18,
61
+ },
62
+ };
63
+
64
+ const graph: Readonly<Record<Screen, readonly ScreenTransition[]>> = {
65
+ [Screen.BACKGROUND]: [
66
+ { screen: Screen.CHATS, weight: 10 },
67
+ { screen: Screen.SETTINGS, weight: 1 },
68
+ ],
69
+ [Screen.CHATS]: [
70
+ { screen: Screen.CHAT, weight: 7 },
71
+ { screen: Screen.CONTACTS, weight: 2 },
72
+ { screen: Screen.SEARCH, weight: 2 },
73
+ { screen: Screen.CALLS, weight: 1 },
74
+ { screen: Screen.SETTINGS, weight: 1 },
75
+ { screen: Screen.CHATS, weight: 2 },
76
+ ],
77
+ [Screen.CHAT]: [
78
+ { screen: Screen.CHATS, weight: 8 },
79
+ { screen: Screen.CHAT, weight: 2 },
80
+ { screen: Screen.SETTINGS, weight: 1 },
81
+ ],
82
+ [Screen.CONTACTS]: [
83
+ { screen: Screen.CHATS, weight: 6 },
84
+ { screen: Screen.CHAT, weight: 2 },
85
+ { screen: Screen.SEARCH, weight: 1 },
86
+ ],
87
+ [Screen.SEARCH]: [
88
+ { screen: Screen.CHATS, weight: 5 },
89
+ { screen: Screen.CHAT, weight: 3 },
90
+ { screen: Screen.CONTACTS, weight: 1 },
91
+ ],
92
+ [Screen.CALLS]: [
93
+ { screen: Screen.CHATS, weight: 5 },
94
+ { screen: Screen.CONTACTS, weight: 2 },
95
+ { screen: Screen.SETTINGS, weight: 2 },
96
+ ],
97
+ [Screen.SETTINGS]: [
98
+ { screen: Screen.CHATS, weight: 7 },
99
+ { screen: Screen.CONTACTS, weight: 2 },
100
+ { screen: Screen.CALLS, weight: 2 },
101
+ { screen: Screen.MINIAPP, weight: 1 },
102
+ ],
103
+ [Screen.MINIAPP]: [
104
+ { screen: Screen.SETTINGS, weight: 3 },
105
+ { screen: Screen.CHATS, weight: 6 },
106
+ ],
107
+ };
108
+
109
+ export class NavigationPlanner {
110
+ currentScreen: Screen = Screen.BACKGROUND;
111
+ readonly history: Screen[] = [];
112
+
113
+ constructor(private readonly random: RandomSource = Math) {}
114
+
115
+ newProfile(): RouteProfile {
116
+ const profiles = Object.values(defaultProfiles);
117
+ return profiles[Math.floor(this.random.random() * profiles.length)] as RouteProfile;
118
+ }
119
+
120
+ pause(profile: RouteProfile): number {
121
+ const long = this.random.random() < profile.longPauseChance;
122
+ return this.between(
123
+ long ? profile.minLongPause : profile.minPause,
124
+ long ? profile.maxLongPause : profile.maxPause,
125
+ );
126
+ }
127
+
128
+ nextScreen(profile: RouteProfile): Screen {
129
+ if (this.history.length > 0 && this.random.random() < profile.backChance) {
130
+ this.currentScreen = this.history.pop() as Screen;
131
+ return this.currentScreen;
132
+ }
133
+ const transitions = graph[this.currentScreen];
134
+ const total = transitions.reduce((sum, item) => sum + item.weight, 0);
135
+ let point = Math.floor(this.random.random() * total) + 1;
136
+ let selected = transitions[transitions.length - 1] as ScreenTransition;
137
+ for (const transition of transitions) {
138
+ point -= transition.weight;
139
+ if (point <= 0) {
140
+ selected = transition;
141
+ break;
142
+ }
143
+ }
144
+ if (selected.screen !== this.currentScreen) {
145
+ this.history.push(this.currentScreen);
146
+ if (this.history.length > 4) this.history.shift();
147
+ }
148
+ this.currentScreen = selected.screen;
149
+ return selected.screen;
150
+ }
151
+
152
+ resetToBackground(): void {
153
+ this.currentScreen = Screen.BACKGROUND;
154
+ this.history.length = 0;
155
+ }
156
+
157
+ private between(minimum: number, maximum: number): number {
158
+ return minimum + this.random.random() * (maximum - minimum);
159
+ }
160
+ }
@@ -0,0 +1,84 @@
1
+ import type { MaxId } from "../types/index.ts";
2
+ import type { RandomSource, Screen } from "./navigation.ts";
3
+
4
+ export interface TelemetryEvent {
5
+ time: number;
6
+ userId: MaxId;
7
+ type: "PERF" | "NAV";
8
+ event: string;
9
+ params: Record<string, unknown>;
10
+ sessionId: number;
11
+ }
12
+
13
+ export class TelemetryPayloadBuilder {
14
+ constructor(private readonly random: RandomSource = Math) {}
15
+
16
+ login(userId: MaxId, sessionId: number): TelemetryEvent {
17
+ return this.event(userId, sessionId, "PERF", "login", {
18
+ properties: { connection_type: 2, vpn: 0, class: 2, background: 1, warm_start: 1 },
19
+ errorType: 100,
20
+ });
21
+ }
22
+
23
+ navigation(options: {
24
+ userId: MaxId;
25
+ sessionId: number;
26
+ screenFrom: Screen;
27
+ screenTo: Screen;
28
+ prevTime: number;
29
+ actionId: number;
30
+ extraParams?: Record<string, MaxId | number>;
31
+ }): TelemetryEvent {
32
+ return this.event(options.userId, options.sessionId, "NAV", "GO", {
33
+ prev_time: options.prevTime,
34
+ screen_to: options.screenTo,
35
+ action_id: options.actionId,
36
+ screen_from: options.screenFrom,
37
+ ...options.extraParams,
38
+ });
39
+ }
40
+
41
+ openChat(userId: MaxId, sessionId: number): TelemetryEvent {
42
+ const messages = this.integer(60, 240);
43
+ const render = this.integer(50, 260);
44
+ return this.event(userId, sessionId, "PERF", "open_chat_to_render", {
45
+ spans: [
46
+ { duration: messages + render, name: "open_chat_to_render" },
47
+ { duration: messages, name: "messages_list_created" },
48
+ { duration: render, name: "messages_render" },
49
+ ],
50
+ properties: { class: 2, warm: 1, flow: 1 },
51
+ });
52
+ }
53
+
54
+ openChats(userId: MaxId, sessionId: number): TelemetryEvent {
55
+ const created = this.integer(50, 230);
56
+ const rendered = this.integer(180, 650);
57
+ return this.event(userId, sessionId, "PERF", "open_chats_to_render", {
58
+ spans: [
59
+ { duration: created + rendered, name: "open_chats_to_render" },
60
+ { duration: created, name: "chats_tab_created" },
61
+ { duration: rendered, name: "chat_list_render" },
62
+ ],
63
+ properties: { class: 2 },
64
+ });
65
+ }
66
+
67
+ toPayload(events: TelemetryEvent[]): Record<string, unknown> {
68
+ return { events };
69
+ }
70
+
71
+ private event(
72
+ userId: MaxId,
73
+ sessionId: number,
74
+ type: TelemetryEvent["type"],
75
+ event: string,
76
+ params: Record<string, unknown>,
77
+ ): TelemetryEvent {
78
+ return { time: Date.now(), userId, type, event, params, sessionId };
79
+ }
80
+
81
+ private integer(minimum: number, maximum: number): number {
82
+ return Math.floor(this.random.random() * (maximum - minimum + 1)) + minimum;
83
+ }
84
+ }
@@ -0,0 +1,194 @@
1
+ import { getLogger } from "../logging.ts";
2
+ import { Opcode } from "../protocol/index.ts";
3
+ import type { Chat, MaxId, Profile } from "../types/index.ts";
4
+ import {
5
+ NavigationPlanner,
6
+ Screen,
7
+ mainTabParams,
8
+ type RandomSource,
9
+ type RouteProfile,
10
+ } from "./navigation.ts";
11
+ import { TelemetryPayloadBuilder, type TelemetryEvent } from "./payloads.ts";
12
+
13
+ export interface TelemetryRuntime {
14
+ readonly config: { requestTimeout: number; device: { clientSessionId: number } };
15
+ readonly connection: { readonly isOpen?: boolean };
16
+ readonly started: boolean;
17
+ readonly me: Profile | null;
18
+ readonly chats: Chat[];
19
+ invoke(opcode: number, payload?: Record<string, unknown>, timeout?: number): Promise<unknown>;
20
+ }
21
+
22
+ export interface TelemetryTiming {
23
+ startupDelay: readonly [number, number];
24
+ sessionIdleDelay: readonly [number, number];
25
+ renderDelay: readonly [number, number];
26
+ returnDelay: readonly [number, number];
27
+ returnToBackgroundChance: number;
28
+ openChatsRenderChance: number;
29
+ }
30
+
31
+ export const defaultTelemetryTiming: TelemetryTiming = {
32
+ startupDelay: [15, 90],
33
+ sessionIdleDelay: [900, 2700],
34
+ renderDelay: [0.15, 1.2],
35
+ returnDelay: [12, 45],
36
+ returnToBackgroundChance: 0.4,
37
+ openChatsRenderChance: 0.2,
38
+ };
39
+
40
+ export class TelemetryService {
41
+ readonly planner: NavigationPlanner;
42
+ readonly payloads: TelemetryPayloadBuilder;
43
+ private controller: AbortController | null = null;
44
+ private task: Promise<void> | null = null;
45
+ private actionId = 0;
46
+ private sessionId: number;
47
+ private lastNavTime = Date.now();
48
+
49
+ constructor(
50
+ private readonly app: TelemetryRuntime,
51
+ private readonly random: RandomSource = Math,
52
+ private readonly timing: TelemetryTiming = defaultTelemetryTiming,
53
+ ) {
54
+ this.planner = new NavigationPlanner(random);
55
+ this.payloads = new TelemetryPayloadBuilder(random);
56
+ this.sessionId = app.config.device.clientSessionId;
57
+ }
58
+
59
+ get running(): boolean {
60
+ return this.task !== null;
61
+ }
62
+
63
+ get ready(): boolean {
64
+ return this.app.started && this.app.me !== null && this.app.connection.isOpen !== false;
65
+ }
66
+
67
+ start(): void {
68
+ if (this.running || !this.ready) return;
69
+ const controller = new AbortController();
70
+ this.controller = controller;
71
+ this.task = this.run(controller.signal).finally(() => {
72
+ if (this.controller === controller) {
73
+ this.controller = null;
74
+ this.task = null;
75
+ }
76
+ });
77
+ }
78
+
79
+ async stop(): Promise<void> {
80
+ const task = this.task;
81
+ this.controller?.abort();
82
+ this.controller = null;
83
+ if (task) await task.catch(() => undefined);
84
+ this.task = null;
85
+ }
86
+
87
+ async sendEvents(events: TelemetryEvent[]): Promise<void> {
88
+ if (events.length === 0 || !this.ready) return;
89
+ await this.app.invoke(
90
+ Opcode.LOG,
91
+ this.payloads.toPayload(events),
92
+ this.app.config.requestTimeout,
93
+ );
94
+ }
95
+
96
+ sourceParams(screen: Screen): Record<string, MaxId | number> {
97
+ if (screen === Screen.CHATS) return { ...mainTabParams };
98
+ if (screen !== Screen.CHAT) return {};
99
+ const chat = this.pickChat();
100
+ return chat
101
+ ? { source_type: chat.type === "DIALOG" ? 1 : 2, source_id: chat.id }
102
+ : { source_type: 1, source_id: this.userId };
103
+ }
104
+
105
+ private async run(signal: AbortSignal): Promise<void> {
106
+ try {
107
+ await sleep(this.between(this.timing.startupDelay) * 1000, signal);
108
+ await this.sendEvents([this.payloads.login(this.userId, this.sessionId)]);
109
+ while (!signal.aborted) {
110
+ this.sessionId += 1;
111
+ await this.sendEvents(await this.collectSessionEvents(this.planner.newProfile(), signal));
112
+ this.planner.resetToBackground();
113
+ await sleep(this.between(this.timing.sessionIdleDelay) * 1000, signal);
114
+ }
115
+ } catch (error) {
116
+ if (!signal.aborted) getLogger().debug("telemetry loop stopped", { error: String(error) });
117
+ }
118
+ }
119
+
120
+ private async collectSessionEvents(
121
+ profile: RouteProfile,
122
+ signal: AbortSignal,
123
+ ): Promise<TelemetryEvent[]> {
124
+ const events: TelemetryEvent[] = [];
125
+ for (let step = 0; step < profile.steps; step += 1) {
126
+ await sleep(this.planner.pause(profile) * 1000, signal);
127
+ if (!this.ready) return events;
128
+ const from = this.planner.currentScreen;
129
+ const to = this.planner.nextScreen(profile);
130
+ events.push(this.navigationEvent(from, to));
131
+ if (to === Screen.CHAT) {
132
+ await sleep(this.between(this.timing.renderDelay) * 1000, signal);
133
+ events.push(this.payloads.openChat(this.userId, this.sessionId));
134
+ } else if (to === Screen.CHATS && this.random.random() < this.timing.openChatsRenderChance) {
135
+ await sleep(this.between(this.timing.renderDelay) * 1000, signal);
136
+ events.push(this.payloads.openChats(this.userId, this.sessionId));
137
+ }
138
+ }
139
+ if (
140
+ this.planner.currentScreen !== Screen.BACKGROUND &&
141
+ this.random.random() < this.timing.returnToBackgroundChance
142
+ ) {
143
+ await sleep(this.between(this.timing.returnDelay) * 1000, signal);
144
+ const from = this.planner.currentScreen;
145
+ this.planner.resetToBackground();
146
+ events.push(this.navigationEvent(from, Screen.BACKGROUND));
147
+ }
148
+ return events;
149
+ }
150
+
151
+ private navigationEvent(from: Screen, to: Screen): TelemetryEvent {
152
+ this.actionId = (this.actionId + 1) >>> 0;
153
+ const event = this.payloads.navigation({
154
+ userId: this.userId,
155
+ sessionId: this.sessionId,
156
+ screenFrom: from,
157
+ screenTo: to,
158
+ prevTime: this.lastNavTime,
159
+ actionId: this.actionId,
160
+ extraParams: this.sourceParams(to),
161
+ });
162
+ this.lastNavTime = event.time;
163
+ return event;
164
+ }
165
+
166
+ private get userId(): MaxId {
167
+ if (!this.app.me) throw new Error("Telemetry requires an authenticated profile");
168
+ return this.app.me.contact.id;
169
+ }
170
+
171
+ private pickChat(): Chat | null {
172
+ if (this.app.chats.length === 0) return null;
173
+ return this.app.chats[Math.floor(this.random.random() * this.app.chats.length)] ?? null;
174
+ }
175
+
176
+ private between(range: readonly [number, number]): number {
177
+ return range[0] + this.random.random() * (range[1] - range[0]);
178
+ }
179
+ }
180
+
181
+ async function sleep(milliseconds: number, signal: AbortSignal): Promise<void> {
182
+ if (signal.aborted) throw signal.reason;
183
+ await new Promise<void>((resolve, reject) => {
184
+ const timer = setTimeout(resolve, milliseconds);
185
+ signal.addEventListener(
186
+ "abort",
187
+ () => {
188
+ clearTimeout(timer);
189
+ reject(signal.reason);
190
+ },
191
+ { once: true },
192
+ );
193
+ });
194
+ }
@@ -0,0 +1,43 @@
1
+ export interface Transport<Message extends string | Uint8Array = string | Uint8Array> {
2
+ readonly connected: boolean;
3
+ connect(): Promise<void>;
4
+ close(): Promise<void>;
5
+ send(data: Message): Promise<void>;
6
+ recv(length?: number): Promise<Message>;
7
+ }
8
+
9
+ export class AsyncQueue<T> {
10
+ private readonly values: T[] = [];
11
+ private readonly waiters: Array<{
12
+ resolve(value: T): void;
13
+ reject(reason: unknown): void;
14
+ }> = [];
15
+ private failure: Error | null = null;
16
+
17
+ push(value: T): void {
18
+ const waiter = this.waiters.shift();
19
+ if (waiter) waiter.resolve(value);
20
+ else this.values.push(value);
21
+ }
22
+
23
+ async shift(): Promise<T> {
24
+ const value = this.values.shift();
25
+ if (value !== undefined) return value;
26
+ if (this.failure) throw this.failure;
27
+ return await new Promise<T>((resolve, reject) => this.waiters.push({ resolve, reject }));
28
+ }
29
+
30
+ fail(reason: Error): void {
31
+ this.failure = reason;
32
+ for (const waiter of this.waiters.splice(0)) waiter.reject(reason);
33
+ }
34
+
35
+ clear(): void {
36
+ this.values.length = 0;
37
+ }
38
+
39
+ reset(): void {
40
+ this.clear();
41
+ this.failure = null;
42
+ }
43
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./base.ts";
2
+ export * from "./tcp.ts";
3
+ export * from "./websocket.ts";