@lobu/cli 6.0.0 → 6.1.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.
Files changed (222) hide show
  1. package/README.md +20 -27
  2. package/dist/bundled-skills/lobu/SKILL.md +12 -12
  3. package/dist/commands/_lib/apply/apply-cmd.d.ts +2 -0
  4. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  5. package/dist/commands/_lib/apply/apply-cmd.js +26 -0
  6. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  7. package/dist/commands/_lib/apply/client.d.ts +1 -1
  8. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  9. package/dist/commands/_lib/apply/desired-state.js +6 -6
  10. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  11. package/dist/commands/agent.d.ts +7 -0
  12. package/dist/commands/agent.d.ts.map +1 -1
  13. package/dist/commands/agent.js +65 -1
  14. package/dist/commands/agent.js.map +1 -1
  15. package/dist/commands/chat.d.ts +12 -9
  16. package/dist/commands/chat.d.ts.map +1 -1
  17. package/dist/commands/chat.js +117 -56
  18. package/dist/commands/chat.js.map +1 -1
  19. package/dist/commands/dev.d.ts +15 -7
  20. package/dist/commands/dev.d.ts.map +1 -1
  21. package/dist/commands/dev.js +79 -44
  22. package/dist/commands/dev.js.map +1 -1
  23. package/dist/commands/doctor.d.ts +1 -0
  24. package/dist/commands/doctor.d.ts.map +1 -1
  25. package/dist/commands/doctor.js +136 -0
  26. package/dist/commands/doctor.js.map +1 -1
  27. package/dist/commands/eval.d.ts +8 -0
  28. package/dist/commands/eval.d.ts.map +1 -1
  29. package/dist/commands/eval.js +56 -1
  30. package/dist/commands/eval.js.map +1 -1
  31. package/dist/commands/init.d.ts +20 -5
  32. package/dist/commands/init.d.ts.map +1 -1
  33. package/dist/commands/init.js +332 -183
  34. package/dist/commands/init.js.map +1 -1
  35. package/dist/commands/link.d.ts +11 -0
  36. package/dist/commands/link.d.ts.map +1 -0
  37. package/dist/commands/link.js +28 -0
  38. package/dist/commands/link.js.map +1 -0
  39. package/dist/commands/login.d.ts.map +1 -1
  40. package/dist/commands/login.js +14 -2
  41. package/dist/commands/login.js.map +1 -1
  42. package/dist/commands/memory/_lib/browser-auth-cmd.d.ts.map +1 -1
  43. package/dist/commands/memory/_lib/browser-auth-cmd.js +4 -4
  44. package/dist/commands/memory/_lib/browser-auth-cmd.js.map +1 -1
  45. package/dist/commands/memory/_lib/install-targets.d.ts.map +1 -1
  46. package/dist/commands/memory/_lib/install-targets.js +1 -5
  47. package/dist/commands/memory/_lib/install-targets.js.map +1 -1
  48. package/dist/commands/memory/_lib/mcp.d.ts +2 -2
  49. package/dist/commands/memory/_lib/mcp.d.ts.map +1 -1
  50. package/dist/commands/memory/_lib/mcp.js +24 -12
  51. package/dist/commands/memory/_lib/mcp.js.map +1 -1
  52. package/dist/commands/memory/_lib/openclaw-auth.d.ts +1 -0
  53. package/dist/commands/memory/_lib/openclaw-auth.d.ts.map +1 -1
  54. package/dist/commands/memory/_lib/openclaw-auth.js +14 -3
  55. package/dist/commands/memory/_lib/openclaw-auth.js.map +1 -1
  56. package/dist/commands/memory/_lib/openclaw-cmd.js +1 -1
  57. package/dist/commands/memory/_lib/openclaw-cmd.js.map +1 -1
  58. package/dist/commands/memory/_lib/schema.d.ts +2 -2
  59. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  60. package/dist/commands/memory/_lib/schema.js +3 -3
  61. package/dist/commands/memory/_lib/schema.js.map +1 -1
  62. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  63. package/dist/commands/memory/_lib/seed-cmd.js +5 -6
  64. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  65. package/dist/commands/memory/run.d.ts.map +1 -1
  66. package/dist/commands/memory/run.js +2 -2
  67. package/dist/commands/memory/run.js.map +1 -1
  68. package/dist/commands/platforms/platform-prompts.d.ts +0 -1
  69. package/dist/commands/platforms/platform-prompts.d.ts.map +1 -1
  70. package/dist/commands/platforms/platform-prompts.js +54 -8
  71. package/dist/commands/platforms/platform-prompts.js.map +1 -1
  72. package/dist/commands/telemetry.d.ts +10 -0
  73. package/dist/commands/telemetry.d.ts.map +1 -0
  74. package/dist/commands/telemetry.js +68 -0
  75. package/dist/commands/telemetry.js.map +1 -0
  76. package/dist/commands/whoami.d.ts.map +1 -1
  77. package/dist/commands/whoami.js +1 -1
  78. package/dist/commands/whoami.js.map +1 -1
  79. package/dist/connectors/README.md +534 -0
  80. package/dist/connectors/__tests__/browser-scraper-utils.test.ts +186 -0
  81. package/dist/connectors/browser-scraper-utils.ts +214 -0
  82. package/dist/connectors/capterra.ts +273 -0
  83. package/dist/connectors/g2.ts +286 -0
  84. package/dist/connectors/github.ts +1553 -0
  85. package/dist/connectors/glassdoor.ts +291 -0
  86. package/dist/connectors/gmaps.ts +197 -0
  87. package/dist/connectors/google_calendar.ts +631 -0
  88. package/dist/connectors/google_gmail.ts +751 -0
  89. package/dist/connectors/google_photos.ts +776 -0
  90. package/dist/connectors/google_play.ts +342 -0
  91. package/dist/connectors/hackernews.ts +471 -0
  92. package/dist/connectors/index.ts +23 -0
  93. package/dist/connectors/ios_appstore.ts +226 -0
  94. package/dist/connectors/linkedin.ts +471 -0
  95. package/dist/connectors/microsoft_outlook.ts +410 -0
  96. package/dist/connectors/producthunt.ts +471 -0
  97. package/dist/connectors/reddit.ts +600 -0
  98. package/dist/connectors/rss.ts +448 -0
  99. package/dist/connectors/spotify.ts +590 -0
  100. package/dist/connectors/trustpilot.ts +199 -0
  101. package/dist/connectors/website.ts +629 -0
  102. package/dist/connectors/whatsapp.ts +1073 -0
  103. package/dist/connectors/x.ts +526 -0
  104. package/dist/connectors/youtube.ts +666 -0
  105. package/dist/db/migrations/00000000000000_baseline.sql +4867 -0
  106. package/dist/db/migrations/20260405193000_add_mcp_sessions.sql +33 -0
  107. package/dist/db/migrations/20260408120000_remove_system_connectors.sql +48 -0
  108. package/dist/db/migrations/20260408120001_optional_compiled_code.sql +6 -0
  109. package/dist/db/migrations/20260409110000_add_active_watcher_run_index.sql +9 -0
  110. package/dist/db/migrations/20260409130000_connector_default_config.sql +5 -0
  111. package/dist/db/migrations/20260410120000_add_agent_secrets.sql +25 -0
  112. package/dist/db/migrations/20260413170000_add_watcher_group_id.sql +67 -0
  113. package/dist/db/migrations/20260416120000_add_entity_wa_jid_index.sql +14 -0
  114. package/dist/db/migrations/20260417100000_add_entity_identities.sql +77 -0
  115. package/dist/db/migrations/20260418100000_add_auth_runs.sql +83 -0
  116. package/dist/db/migrations/20260418110000_add_runs_created_by_user.sql +18 -0
  117. package/dist/db/migrations/20260419120000_add_event_identity_indexes.sql +56 -0
  118. package/dist/db/migrations/20260420120000_extend_reserved_org_slugs.sql +56 -0
  119. package/dist/db/migrations/20260424030000_add_watcher_run_correlation.sql +52 -0
  120. package/dist/db/migrations/20260424130000_relax_events_client_id_fk.sql +47 -0
  121. package/dist/db/migrations/20260425100000_normalize_watcher_feedback.sql +91 -0
  122. package/dist/db/migrations/20260425120000_add_run_diagnostics.sql +20 -0
  123. package/dist/db/migrations/20260425130000_add_repair_agent_plumbing.sql +46 -0
  124. package/dist/db/migrations/20260426120000_entities_entity_type_fk.sql +101 -0
  125. package/dist/db/migrations/20260426130000_db_integrity_cleanup.sql +104 -0
  126. package/dist/db/migrations/20260426130001_db_integrity_cleanup_concurrent.sql +187 -0
  127. package/dist/db/migrations/20260427133000_events_created_by_nullable.sql +74 -0
  128. package/dist/db/migrations/20260427140000_identity_engine_indexes.sql +140 -0
  129. package/dist/db/migrations/20260427150000_drop_events_source_id.sql +177 -0
  130. package/dist/db/migrations/20260427160000_drop_dead_schema.sql +76 -0
  131. package/dist/db/migrations/20260427170000_market_founder_to_member.sql +364 -0
  132. package/dist/db/migrations/20260428040000_cascade_events_watchers_org_fk.sql +66 -0
  133. package/dist/db/migrations/20260428050000_add_runs_approved_input.sql +9 -0
  134. package/dist/db/migrations/20260429010000_auth_profile_tenant_scoped_fk.sql +79 -0
  135. package/dist/db/migrations/20260429060000_extend_runs_for_lobu_queue.sql +108 -0
  136. package/dist/db/migrations/20260429120000_agent_changed_notify.sql +97 -0
  137. package/dist/db/migrations/20260429120100_user_auth_profiles_and_model_prefs.sql +36 -0
  138. package/dist/db/migrations/20260429120200_fix_notify_old_keys.sql +130 -0
  139. package/dist/db/migrations/20260429130000_oauth_states_cli_sessions_rate_limits.sql +83 -0
  140. package/dist/db/migrations/20260429140000_phase8_grants_chat_connections_mcp_sessions.sql +84 -0
  141. package/dist/db/migrations/20260429140100_runs_priority_expires_at_retry_delay.sql +44 -0
  142. package/dist/db/migrations/20260429180000_drop_invalidatable_cache_triggers.sql +25 -0
  143. package/dist/db/migrations/20260430005614_agents_apply_fields.sql +21 -0
  144. package/dist/db/migrations/20260430022231_fix_connection_config_encryption.sql +69 -0
  145. package/dist/db/migrations/20260430151215_add_task_run_type.sql +77 -0
  146. package/dist/db/migrations/20260501000000_drop_cli_sessions.sql +27 -0
  147. package/dist/db/migrations/20260501133000_lobu_memory_mcp_id.sql +117 -0
  148. package/dist/db/migrations/20260502000000_drop_chat_connections.sql +60 -0
  149. package/dist/db/migrations/20260503000000_agent_secrets_org_scope.sql +56 -0
  150. package/dist/db/migrations/20260504000000_flatten_agents_drop_sandbox_model.sql +48 -0
  151. package/dist/index.d.ts.map +1 -1
  152. package/dist/index.js +147 -23
  153. package/dist/index.js.map +1 -1
  154. package/dist/internal/api-client.d.ts +4 -8
  155. package/dist/internal/api-client.d.ts.map +1 -1
  156. package/dist/internal/api-client.js +1 -1
  157. package/dist/internal/api-client.js.map +1 -1
  158. package/dist/internal/context.js +2 -2
  159. package/dist/internal/context.js.map +1 -1
  160. package/dist/internal/credentials.d.ts.map +1 -1
  161. package/dist/internal/credentials.js +6 -1
  162. package/dist/internal/credentials.js.map +1 -1
  163. package/dist/internal/index.d.ts +2 -3
  164. package/dist/internal/index.d.ts.map +1 -1
  165. package/dist/internal/index.js +2 -2
  166. package/dist/internal/index.js.map +1 -1
  167. package/dist/internal/oauth.d.ts +7 -6
  168. package/dist/internal/oauth.d.ts.map +1 -1
  169. package/dist/internal/oauth.js +3 -3
  170. package/dist/internal/project-link.d.ts +10 -0
  171. package/dist/internal/project-link.d.ts.map +1 -0
  172. package/dist/internal/project-link.js +48 -0
  173. package/dist/internal/project-link.js.map +1 -0
  174. package/dist/providers.json +2 -2
  175. package/dist/server.bundle.mjs +3173 -4404
  176. package/dist/start-local.bundle.mjs +71481 -0
  177. package/dist/templates/README.md.tmpl +10 -11
  178. package/package.json +14 -12
  179. package/dist/__tests__/chat.integration.test.d.ts +0 -2
  180. package/dist/__tests__/chat.integration.test.d.ts.map +0 -1
  181. package/dist/__tests__/chat.integration.test.js +0 -337
  182. package/dist/__tests__/chat.integration.test.js.map +0 -1
  183. package/dist/__tests__/dev.test.d.ts +0 -2
  184. package/dist/__tests__/dev.test.d.ts.map +0 -1
  185. package/dist/__tests__/dev.test.js +0 -25
  186. package/dist/__tests__/dev.test.js.map +0 -1
  187. package/dist/__tests__/init-memory.test.d.ts +0 -2
  188. package/dist/__tests__/init-memory.test.d.ts.map +0 -1
  189. package/dist/__tests__/init-memory.test.js +0 -45
  190. package/dist/__tests__/init-memory.test.js.map +0 -1
  191. package/dist/__tests__/token.test.d.ts +0 -2
  192. package/dist/__tests__/token.test.d.ts.map +0 -1
  193. package/dist/__tests__/token.test.js +0 -52
  194. package/dist/__tests__/token.test.js.map +0 -1
  195. package/dist/commands/_lib/apply/__tests__/client.test.d.ts +0 -2
  196. package/dist/commands/_lib/apply/__tests__/client.test.d.ts.map +0 -1
  197. package/dist/commands/_lib/apply/__tests__/client.test.js +0 -23
  198. package/dist/commands/_lib/apply/__tests__/client.test.js.map +0 -1
  199. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts +0 -2
  200. package/dist/commands/_lib/apply/__tests__/desired-state.test.d.ts.map +0 -1
  201. package/dist/commands/_lib/apply/__tests__/desired-state.test.js +0 -140
  202. package/dist/commands/_lib/apply/__tests__/desired-state.test.js.map +0 -1
  203. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts +0 -2
  204. package/dist/commands/_lib/apply/__tests__/diff.test.d.ts.map +0 -1
  205. package/dist/commands/_lib/apply/__tests__/diff.test.js +0 -378
  206. package/dist/commands/_lib/apply/__tests__/diff.test.js.map +0 -1
  207. package/dist/commands/apply.d.ts +0 -3
  208. package/dist/commands/apply.d.ts.map +0 -1
  209. package/dist/commands/apply.js +0 -5
  210. package/dist/commands/apply.js.map +0 -1
  211. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts +0 -2
  212. package/dist/commands/memory/_lib/openclaw-auth.test.d.ts.map +0 -1
  213. package/dist/commands/memory/_lib/openclaw-auth.test.js +0 -9
  214. package/dist/commands/memory/_lib/openclaw-auth.test.js.map +0 -1
  215. package/dist/internal/__tests__/api-client.test.d.ts +0 -2
  216. package/dist/internal/__tests__/api-client.test.d.ts.map +0 -1
  217. package/dist/internal/__tests__/api-client.test.js +0 -95
  218. package/dist/internal/__tests__/api-client.test.js.map +0 -1
  219. package/dist/internal/__tests__/context.test.d.ts +0 -2
  220. package/dist/internal/__tests__/context.test.d.ts.map +0 -1
  221. package/dist/internal/__tests__/context.test.js +0 -77
  222. package/dist/internal/__tests__/context.test.js.map +0 -1
@@ -0,0 +1,1073 @@
1
+ /**
2
+ * WhatsApp Connector (V1 runtime)
3
+ *
4
+ * Syncs personal WhatsApp messages via Baileys (unofficial WA Web protocol).
5
+ * Pairing happens in authenticate() via QR scan; creds are persisted to the
6
+ * linked auth profile. sync() assumes a valid session.
7
+ *
8
+ * Risks:
9
+ * - Violates WhatsApp ToS; personal number may be banned.
10
+ * - Linked devices auto-unlink if the phone is offline ~14 days.
11
+ */
12
+
13
+ import {
14
+ type ActionContext,
15
+ type ActionResult,
16
+ type AuthContext,
17
+ type AuthResult,
18
+ type ConnectorDefinition,
19
+ ConnectorRuntime,
20
+ type EventEnvelope,
21
+ IDENTITY,
22
+ type SyncContext,
23
+ type SyncResult,
24
+ } from '@lobu/connector-sdk';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Baileys (pinned)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ import {
31
+ type AuthenticationCreds,
32
+ type AuthenticationState,
33
+ Browsers,
34
+ BufferJSON,
35
+ type ConnectionState,
36
+ DisconnectReason,
37
+ fetchLatestBaileysVersion,
38
+ initAuthCreds,
39
+ makeWASocket,
40
+ type SignalDataSet,
41
+ type SignalDataTypeMap,
42
+ type SignalKeyStore,
43
+ type WAMessage,
44
+ type WAMessageContent,
45
+ type WAMessageKey,
46
+ } from 'baileys';
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Types
50
+ // ---------------------------------------------------------------------------
51
+
52
+ interface SerializedSession {
53
+ creds?: string; // JSON w/ BufferJSON
54
+ keys?: string; // JSON w/ BufferJSON
55
+ /** Events collected during the paired-socket drain; emitted on first sync. */
56
+ pending_events?: SerializedEvent[] | null;
57
+ }
58
+
59
+ interface SerializedEvent extends Omit<EventEnvelope, 'occurred_at'> {
60
+ occurred_at: string;
61
+ }
62
+
63
+ interface ChatFrontier {
64
+ /** Message ID + timestamp of the oldest message we've fetched in this chat. */
65
+ oldest_id: string;
66
+ oldest_ts: number;
67
+ /** True once fetchMessageHistory stops returning older messages for this chat. */
68
+ exhausted?: boolean;
69
+ }
70
+
71
+ interface WhatsAppCheckpoint {
72
+ last_message_at?: string;
73
+ /**
74
+ * Per-chat backward-walk frontiers for incremental deep-history sync. Each
75
+ * sync run advances these by fetchMessageHistory calls and persists the
76
+ * new frontier. Next sync resumes from the same state — crash-resilient.
77
+ */
78
+ chat_frontiers?: Record<string, ChatFrontier>;
79
+ paginated_at?: string;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Connector
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export default class WhatsAppConnector extends ConnectorRuntime {
87
+ readonly definition: ConnectorDefinition = {
88
+ key: 'whatsapp',
89
+ name: 'WhatsApp',
90
+ description:
91
+ 'Syncs personal WhatsApp messages via the WA Web linked-device protocol. Pair by scanning a QR from WhatsApp → Linked Devices.',
92
+ version: '2.4.0',
93
+ faviconDomain: 'whatsapp.com',
94
+ authSchema: {
95
+ methods: [
96
+ {
97
+ type: 'interactive',
98
+ required: true,
99
+ scope: 'connection',
100
+ expectedArtifact: 'qr',
101
+ timeoutSec: 180,
102
+ description:
103
+ 'Open WhatsApp → Settings → Linked Devices → Link a Device and scan the QR shown after you click Connect.',
104
+ },
105
+ ],
106
+ },
107
+ feeds: {
108
+ messages: {
109
+ key: 'messages',
110
+ name: 'Messages',
111
+ description: 'Personal WhatsApp messages from 1:1 and group chats.',
112
+ configSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ chat_filter: {
116
+ type: 'string',
117
+ enum: ['all', 'individual', 'group'],
118
+ default: 'all',
119
+ description: 'Which chats to include.',
120
+ },
121
+ max_messages_per_sync: {
122
+ type: 'integer',
123
+ minimum: 1,
124
+ maximum: 500000,
125
+ default: 100000,
126
+ description:
127
+ 'Safety cap on messages collected per sync. Set high enough to accept full history — the phone will stop streaming on its own.',
128
+ },
129
+ history_wait_seconds: {
130
+ type: 'integer',
131
+ minimum: 5,
132
+ maximum: 1800,
133
+ default: 600,
134
+ description:
135
+ 'Seconds to wait for the phone to stream history after connecting. Large mailboxes need more time — initial pair can push 30k+ messages across many batches.',
136
+ },
137
+ pagination_budget_seconds: {
138
+ type: 'integer',
139
+ minimum: 0,
140
+ maximum: 540,
141
+ default: 300,
142
+ description:
143
+ 'Max seconds per sync run spent on per-chat history pagination. Pagination state is checkpointed so each run resumes where the last left off. Set to 0 to disable.',
144
+ },
145
+ pages_per_chat_per_sync: {
146
+ type: 'integer',
147
+ minimum: 1,
148
+ maximum: 100,
149
+ default: 5,
150
+ description:
151
+ 'How many 50-msg pages to pull per chat in a single sync run. Keep low to respect phone rate limits; raise if you want faster backfill.',
152
+ },
153
+ sync_full_history: {
154
+ type: 'boolean',
155
+ default: true,
156
+ description:
157
+ 'When true (default), the one-shot post-pairing socket asks WhatsApp for a full history dump. Disable for fast pairing with recent messages only — deeper history still flows via per-sync pagination.',
158
+ },
159
+ },
160
+ },
161
+ eventKinds: {
162
+ message: {
163
+ description: 'A WhatsApp message (text, caption, or system).',
164
+ metadataSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ chat_jid: { type: 'string' },
168
+ is_group: { type: 'boolean' },
169
+ from_me: { type: 'boolean' },
170
+ participant: { type: 'string' },
171
+ sender_jid: { type: 'string' },
172
+ sender_phone: { type: 'string' },
173
+ push_name: { type: 'string' },
174
+ media_type: { type: 'string' },
175
+ quoted_id: { type: 'string' },
176
+ is_forwarded: { type: 'boolean' },
177
+ },
178
+ },
179
+ entityLinks: [
180
+ {
181
+ entityType: '$member',
182
+ autoCreate: true,
183
+ titlePath: 'metadata.push_name',
184
+ identities: [
185
+ { namespace: IDENTITY.WA_JID, eventPath: 'metadata.sender_jid' },
186
+ { namespace: IDENTITY.PHONE, eventPath: 'metadata.sender_phone' },
187
+ ],
188
+ traits: {
189
+ push_name: {
190
+ eventPath: 'metadata.push_name',
191
+ behavior: 'prefer_non_empty',
192
+ },
193
+ last_seen_at: {
194
+ eventPath: 'occurred_at',
195
+ behavior: 'overwrite',
196
+ },
197
+ },
198
+ },
199
+ ],
200
+ },
201
+ },
202
+ },
203
+ },
204
+ };
205
+
206
+ async authenticate(ctx: AuthContext): Promise<AuthResult> {
207
+ // Baileys hands out a fixed batch of QR refs per socket (~5) then closes
208
+ // with DisconnectReason.timedOut. If the user hasn't scanned yet we open a
209
+ // new socket to get fresh refs — the auth run stays alive as long as the
210
+ // pairing sheet is open. The loop terminates on success, abort, or a hard
211
+ // rejection from WhatsApp (loggedOut).
212
+ const creds = initAuthCreds();
213
+ const keyStore = makeInMemoryKeyStore({});
214
+ const authState: AuthenticationState = { creds, keys: keyStore };
215
+ const { version } = await fetchLatestBaileysVersion();
216
+
217
+ while (true) {
218
+ if (ctx.signal.aborted) throw new Error('Pairing cancelled.');
219
+
220
+ const outcome = await attemptPairing(ctx, authState, version);
221
+ if (outcome === 'opened') break;
222
+ if (outcome === 'aborted') throw new Error('Pairing cancelled.');
223
+ if (outcome === 'loggedOut') {
224
+ throw new Error('WhatsApp declined pairing. Please try again from your phone.');
225
+ }
226
+ // 'refsExpired' — open a fresh socket and keep the user in the flow.
227
+ }
228
+
229
+ // Baileys' QR flow leaves registered=false — mark it so the restart socket
230
+ // (and later sync()) takes the re-login path instead of re-pairing.
231
+ authState.creds.registered = true;
232
+
233
+ // WhatsApp only pushes the history-sync notification once, shortly after
234
+ // pairing. If we disconnect before draining it, subsequent syncs won't get
235
+ // it back. Hold a fresh socket open until history arrives and quiets down,
236
+ // then stash the collected events so sync() can emit them on first run.
237
+ const maxMessages = (ctx.config.max_messages_per_sync as number) ?? 100_000;
238
+ const historyWaitMs = ((ctx.config.history_wait_seconds as number) ?? 600) * 1000;
239
+ const chatFilter = (ctx.config.chat_filter as 'all' | 'individual' | 'group') ?? 'all';
240
+ const syncFullHistory = (ctx.config.sync_full_history as boolean | undefined) ?? true;
241
+
242
+ // Auth only drains WhatsApp's one-shot history-sync dump. Deeper backfill
243
+ // via per-chat fetchMessageHistory is handled by sync() with a checkpointed
244
+ // frontier per chat, so a crashed pagination run can resume on the next
245
+ // schedule rather than losing all its progress.
246
+ const drainedEvents = await drainHistory(ctx, authState, version, {
247
+ maxMessages,
248
+ historyWaitMs,
249
+ chatFilter,
250
+ syncFullHistory,
251
+ });
252
+
253
+ const accountId = authState.creds.me?.id;
254
+ const displayName = authState.creds.me?.name;
255
+ const phone = accountId ? jidToPhone(accountId) : undefined;
256
+
257
+ return {
258
+ credentials: {
259
+ ...dumpSession(authState.creds, keyStore.snapshot()),
260
+ pending_events: drainedEvents.map(serializeEvent),
261
+ },
262
+ metadata: {
263
+ account_id: accountId,
264
+ display_name: displayName ?? (phone ? `+${phone}` : undefined),
265
+ paired_at: new Date().toISOString(),
266
+ history_drained: drainedEvents.length,
267
+ sync_full_history: syncFullHistory,
268
+ ...(phone ? { phone } : {}),
269
+ },
270
+ };
271
+ }
272
+
273
+ async sync(ctx: SyncContext): Promise<SyncResult> {
274
+ const session = (ctx.sessionState ?? {}) as SerializedSession;
275
+ if (!session.creds) {
276
+ throw new Error('WhatsApp auth profile missing — re-pair required.');
277
+ }
278
+
279
+ const checkpoint = (ctx.checkpoint ?? {}) as WhatsAppCheckpoint;
280
+ const maxMessages = (ctx.config.max_messages_per_sync as number) ?? 100_000;
281
+ const historyWaitMs = ((ctx.config.history_wait_seconds as number) ?? 600) * 1000;
282
+ const chatFilter = (ctx.config.chat_filter as 'all' | 'individual' | 'group') ?? 'all';
283
+ const paginationBudgetMs = ((ctx.config.pagination_budget_seconds as number) ?? 300) * 1000;
284
+ const pagesPerChat = (ctx.config.pages_per_chat_per_sync as number) ?? 5;
285
+
286
+ // Pending events from the pairing drain — emit them on the first sync and
287
+ // clear the field via auth_update so they aren't re-emitted.
288
+ const pendingEvents: EventEnvelope[] = Array.isArray(session.pending_events)
289
+ ? session.pending_events.map(deserializeEvent)
290
+ : [];
291
+
292
+ const { creds, keys } = loadAuthState(session);
293
+ if (!creds.registered) {
294
+ throw new Error('WhatsApp auth profile is unregistered — re-pair required.');
295
+ }
296
+ const keyStore = makeInMemoryKeyStore(keys);
297
+ const authState: AuthenticationState = { creds, keys: keyStore };
298
+
299
+ const { version } = await fetchLatestBaileysVersion();
300
+ // Sync socket never re-requests full history — the post-pairing drain
301
+ // covers that, and ongoing depth is handled by paginateIncremental.
302
+ // Leaving this `true` would tempt WhatsApp to push a fresh history dump
303
+ // on each reconnect, duplicating work the pagination path already owns.
304
+ const sock = makeWASocket({
305
+ version,
306
+ auth: authState,
307
+ browser: Browsers.ubuntu('Chrome'),
308
+ printQRInTerminal: false,
309
+ logger: silentLogger,
310
+ syncFullHistory: false,
311
+ markOnlineOnConnect: false,
312
+ });
313
+
314
+ const chatNames = new Map<string, string>();
315
+ const collected: WAMessage[] = [];
316
+ let lastEventAt = Date.now();
317
+ let loggedOut = false;
318
+
319
+ sock.ev.on('creds.update', (partial) => {
320
+ Object.assign(authState.creds, partial);
321
+ });
322
+
323
+ sock.ev.on('chats.upsert', (chats) => {
324
+ for (const c of chats) {
325
+ const name = (c.name as string | undefined) ?? (c.subject as string | undefined);
326
+ if (c.id && name) chatNames.set(c.id, name);
327
+ }
328
+ });
329
+
330
+ sock.ev.on('messaging-history.set', ({ messages, chats }) => {
331
+ for (const c of chats) {
332
+ const name = (c.name as string | undefined) ?? (c.subject as string | undefined);
333
+ if (c.id && name) chatNames.set(c.id, name);
334
+ }
335
+ for (const m of messages) collected.push(m);
336
+ lastEventAt = Date.now();
337
+ });
338
+
339
+ sock.ev.on('messages.upsert', ({ messages }) => {
340
+ for (const m of messages) collected.push(m);
341
+ lastEventAt = Date.now();
342
+ });
343
+
344
+ sock.ev.on('connection.update', (u: Partial<ConnectionState>) => {
345
+ const err = u.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined;
346
+ if (err?.output?.statusCode === DisconnectReason.loggedOut) loggedOut = true;
347
+ });
348
+
349
+ try {
350
+ const opened = await waitForOpen(sock, 30_000);
351
+ if (!opened) {
352
+ sock.end(undefined);
353
+ if (loggedOut) {
354
+ return {
355
+ events: pendingEvents,
356
+ checkpoint: {} as Record<string, unknown>,
357
+ auth_update: { creds: null, keys: null, pending_events: null }, // wipe
358
+ metadata: { logged_out: true },
359
+ };
360
+ }
361
+ throw new Error('Timed out waiting for WhatsApp connection (30s).');
362
+ }
363
+
364
+ // Phase 1: live drain for new messages + any residual history-sync tail.
365
+ // Sync subprocess timeout is 10min, so we split the budget: ~2min live
366
+ // drain, ~5min pagination, buffer for cleanup.
367
+ const phase1Start = Date.now();
368
+ const quietMs = 15_000;
369
+ const liveDrainSoftCapMs = 2 * 60_000;
370
+ while (Date.now() - phase1Start < liveDrainSoftCapMs && collected.length < maxMessages) {
371
+ const sinceQuiet = Date.now() - lastEventAt;
372
+ const totalElapsed = Date.now() - phase1Start;
373
+ const effectiveHistoryWait = Math.min(historyWaitMs, liveDrainSoftCapMs);
374
+ if (sinceQuiet >= quietMs && totalElapsed >= effectiveHistoryWait) break;
375
+ await delay(500);
376
+ }
377
+
378
+ // Phase 2: per-chat incremental backward walk using fetchMessageHistory.
379
+ // Seed frontiers from checkpoint, updating with anything newly collected
380
+ // (pending_events on first sync, live drain since). Reuse listeners that
381
+ // are already pushing to `collected`.
382
+ const frontiers: Record<string, ChatFrontier> = {
383
+ ...(checkpoint.chat_frontiers ?? {}),
384
+ };
385
+ seedFrontiersFromEvents(frontiers, pendingEvents);
386
+ seedFrontiersFromMessages(frontiers, collected);
387
+
388
+ const paginationResult = await paginateIncremental(sock, collected, frontiers, {
389
+ budgetMs: paginationBudgetMs,
390
+ maxPagesPerChat: pagesPerChat,
391
+ pageSize: 50,
392
+ abortSignal: ctx.signal,
393
+ });
394
+
395
+ // Turn everything collected this run into events. Don't filter by
396
+ // `last_message_at` — pagination fetches OLDER messages on purpose, and
397
+ // we dedupe downstream by origin_id.
398
+ const newEvents = collectEvents(collected, chatNames, chatFilter, maxMessages, 0);
399
+ const events = mergeEvents(pendingEvents, newEvents, maxMessages);
400
+
401
+ const authUpdate = {
402
+ ...dumpSession(authState.creds, keyStore.snapshot()),
403
+ pending_events: null, // consumed
404
+ };
405
+ sock.end(undefined);
406
+
407
+ const activeChats = Object.values(frontiers).filter((f) => !f.exhausted).length;
408
+
409
+ return {
410
+ events,
411
+ checkpoint: {
412
+ last_message_at: newestTimestamp(events) ?? checkpoint.last_message_at,
413
+ chat_frontiers: frontiers,
414
+ paginated_at: new Date().toISOString(),
415
+ } satisfies WhatsAppCheckpoint as Record<string, unknown>,
416
+ auth_update: authUpdate,
417
+ metadata: {
418
+ items_found: events.length,
419
+ pending_drained: pendingEvents.length,
420
+ pagination_advanced: paginationResult.advanced,
421
+ pagination_exhausted: paginationResult.exhausted,
422
+ chats_remaining: activeChats,
423
+ },
424
+ };
425
+ } catch (error) {
426
+ try {
427
+ sock.end(undefined);
428
+ } catch {
429
+ /* ignore */
430
+ }
431
+ throw error;
432
+ }
433
+ }
434
+
435
+ async execute(_ctx: ActionContext): Promise<ActionResult> {
436
+ return { success: false, error: 'Actions not supported in v1.' };
437
+ }
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Helpers
442
+ // ---------------------------------------------------------------------------
443
+
444
+ type PairingOutcome = 'opened' | 'refsExpired' | 'loggedOut' | 'aborted';
445
+
446
+ async function attemptPairing(
447
+ ctx: AuthContext,
448
+ authState: AuthenticationState,
449
+ version: [number, number, number]
450
+ ): Promise<PairingOutcome> {
451
+ // Start from a fresh socket on each attempt so Baileys hands out a new batch
452
+ // of QR refs. Reuse the same authState so credentials accumulate across
453
+ // restarts (relevant once pairing actually succeeds).
454
+ // This socket only waits for `connection: 'open'` and closes — history
455
+ // drainage happens on a separate, post-pairing socket in drainHistory.
456
+ const sock = makeWASocket({
457
+ version,
458
+ auth: authState,
459
+ browser: Browsers.ubuntu('Chrome'),
460
+ printQRInTerminal: false,
461
+ logger: silentLogger,
462
+ syncFullHistory: false,
463
+ markOnlineOnConnect: false,
464
+ });
465
+
466
+ const credsListener = (partial: Partial<AuthenticationCreds>): void => {
467
+ Object.assign(authState.creds, partial);
468
+ };
469
+ sock.ev.on('creds.update', credsListener);
470
+
471
+ return await new Promise<PairingOutcome>((resolve) => {
472
+ let newLogin = false;
473
+ let settled = false;
474
+ const settle = (outcome: PairingOutcome) => {
475
+ if (settled) return;
476
+ settled = true;
477
+ sock.ev.off('connection.update', handler);
478
+ sock.ev.off('creds.update', credsListener);
479
+ ctx.signal.removeEventListener('abort', onAbort);
480
+ try {
481
+ sock.end(undefined);
482
+ } catch {
483
+ /* ignore */
484
+ }
485
+ resolve(outcome);
486
+ };
487
+
488
+ const onAbort = () => settle('aborted');
489
+
490
+ const handler = (u: Partial<ConnectionState>) => {
491
+ if (u.qr) {
492
+ // Baileys keeps the first QR live for 60s and subsequent ones for 20s.
493
+ // Use the longer window so the UI never flashes "Expired" between
494
+ // legitimate rotations; the next emit will replace this value.
495
+ void ctx.emit({
496
+ type: 'qr',
497
+ value: u.qr,
498
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
499
+ instructions: 'Open WhatsApp → Settings → Linked Devices → Link a Device → scan this QR.',
500
+ });
501
+ }
502
+ if (u.isNewLogin) newLogin = true;
503
+ if (u.connection === 'open') {
504
+ settle('opened');
505
+ return;
506
+ }
507
+ if (u.connection === 'close') {
508
+ const err = u.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined;
509
+ const statusCode = err?.output?.statusCode;
510
+ if (newLogin && statusCode === DisconnectReason.restartRequired) {
511
+ settle('opened');
512
+ } else if (statusCode === DisconnectReason.loggedOut) {
513
+ settle('loggedOut');
514
+ } else {
515
+ // timedOut, connectionClosed, connectionLost, etc. — refs ran out
516
+ // or server dropped us; let the outer loop spin up a fresh socket.
517
+ settle('refsExpired');
518
+ }
519
+ }
520
+ };
521
+
522
+ sock.ev.on('connection.update', handler);
523
+ ctx.signal.addEventListener('abort', onAbort);
524
+ });
525
+ }
526
+
527
+ /**
528
+ * Open a fresh (post-pairing) socket, listen for `messaging-history.set` and
529
+ * live `messages.upsert` events, and return once history has quieted. WhatsApp
530
+ * only delivers the history-sync notification once after pairing — if we
531
+ * disconnect before draining it, subsequent re-login sockets won't see it.
532
+ */
533
+ async function drainHistory(
534
+ ctx: AuthContext,
535
+ authState: AuthenticationState,
536
+ version: [number, number, number],
537
+ opts: {
538
+ maxMessages: number;
539
+ historyWaitMs: number;
540
+ chatFilter: 'all' | 'individual' | 'group';
541
+ syncFullHistory: boolean;
542
+ }
543
+ ): Promise<EventEnvelope[]> {
544
+ const sock = makeWASocket({
545
+ version,
546
+ auth: authState,
547
+ browser: Browsers.ubuntu('Chrome'),
548
+ printQRInTerminal: false,
549
+ logger: silentLogger,
550
+ syncFullHistory: opts.syncFullHistory,
551
+ markOnlineOnConnect: false,
552
+ });
553
+
554
+ const chatNames = new Map<string, string>();
555
+ const collected: WAMessage[] = [];
556
+ let lastEventAt = Date.now();
557
+
558
+ const credsListener = (partial: Partial<AuthenticationCreds>) => {
559
+ Object.assign(authState.creds, partial);
560
+ };
561
+ const chatsListener = (chats: Array<Record<string, unknown>>) => {
562
+ for (const c of chats) {
563
+ const id = c.id as string | undefined;
564
+ const name = (c.name as string | undefined) ?? (c.subject as string | undefined);
565
+ if (id && name) chatNames.set(id, name);
566
+ }
567
+ };
568
+ const historyListener = ({
569
+ messages,
570
+ chats,
571
+ }: {
572
+ messages: WAMessage[];
573
+ chats: Array<Record<string, unknown>>;
574
+ }) => {
575
+ chatsListener(chats);
576
+ for (const m of messages) collected.push(m);
577
+ lastEventAt = Date.now();
578
+ };
579
+ const messagesListener = ({ messages }: { messages: WAMessage[] }) => {
580
+ for (const m of messages) collected.push(m);
581
+ lastEventAt = Date.now();
582
+ };
583
+
584
+ sock.ev.on('creds.update', credsListener);
585
+ sock.ev.on('chats.upsert', chatsListener);
586
+ sock.ev.on('messaging-history.set', historyListener);
587
+ sock.ev.on('messages.upsert', messagesListener);
588
+
589
+ const cleanup = () => {
590
+ sock.ev.off('creds.update', credsListener);
591
+ sock.ev.off('chats.upsert', chatsListener);
592
+ sock.ev.off('messaging-history.set', historyListener);
593
+ sock.ev.off('messages.upsert', messagesListener);
594
+ try {
595
+ sock.end(undefined);
596
+ } catch {
597
+ /* ignore */
598
+ }
599
+ };
600
+
601
+ try {
602
+ const opened = await waitForOpen(sock, 30_000);
603
+ if (!opened) {
604
+ // Socket never reached open during drain — nothing collected, fall back
605
+ // to letting the first sync() pick up history if any remains.
606
+ return [];
607
+ }
608
+
609
+ // Auth runs have no subprocess timeout — we can wait as long as needed for
610
+ // the phone to finish streaming. Quiet period detects "done"; the hard
611
+ // ceiling is just a safety net for stuck sockets.
612
+ const start = Date.now();
613
+ const quietMs = 30_000;
614
+ const softCapMs = 45 * 60_000;
615
+ while (Date.now() - start < softCapMs && collected.length < opts.maxMessages) {
616
+ if (ctx.signal.aborted) break;
617
+ const sinceQuiet = Date.now() - lastEventAt;
618
+ const totalElapsed = Date.now() - start;
619
+ if (sinceQuiet >= quietMs && totalElapsed >= opts.historyWaitMs) break;
620
+ await delay(500);
621
+ }
622
+
623
+ return collectEvents(collected, chatNames, opts.chatFilter, opts.maxMessages, 0);
624
+ } finally {
625
+ cleanup();
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Per-sync incremental backward walk. Reads `frontiers` (the oldest message
631
+ * key/timestamp seen per chat so far), issues `fetchMessageHistory` to pull
632
+ * older pages, and returns updated frontiers. Callers persist the result in
633
+ * `checkpoint.chat_frontiers` so the next sync run resumes from where this
634
+ * one stopped — crash-resilient. Bounded by time budget and per-chat page
635
+ * count to stay well inside the subprocess timeout.
636
+ */
637
+ async function paginateIncremental(
638
+ sock: ReturnType<typeof makeWASocket>,
639
+ collected: WAMessage[],
640
+ frontiers: Record<string, ChatFrontier>,
641
+ opts: {
642
+ budgetMs: number;
643
+ maxPagesPerChat: number;
644
+ pageSize: number;
645
+ abortSignal?: AbortSignal;
646
+ }
647
+ ): Promise<{ advanced: number; exhausted: number }> {
648
+ if (opts.budgetMs <= 0) return { advanced: 0, exhausted: 0 };
649
+
650
+ type FetchHistoryFn = (
651
+ count: number,
652
+ oldestKey: WAMessageKey,
653
+ oldestTs: number
654
+ ) => Promise<string>;
655
+ const fetchHistory = (sock as unknown as { fetchMessageHistory?: FetchHistoryFn })
656
+ .fetchMessageHistory;
657
+ if (typeof fetchHistory !== 'function') return { advanced: 0, exhausted: 0 };
658
+
659
+ const start = Date.now();
660
+ const perRequestWaitMs = 2500;
661
+ const responseWindowMs = 8000;
662
+ let advanced = 0;
663
+ let exhausted = 0;
664
+
665
+ // Round-robin: one request per chat per round, so no chat monopolizes the
666
+ // budget. Stop when we've done maxPagesPerChat per chat, the budget runs
667
+ // out, or every chat is exhausted.
668
+ for (let round = 0; round < opts.maxPagesPerChat; round++) {
669
+ let madeProgressThisRound = false;
670
+
671
+ for (const [chat, frontier] of Object.entries(frontiers)) {
672
+ if (frontier.exhausted) continue;
673
+ if (chat === 'status@broadcast') {
674
+ frontier.exhausted = true;
675
+ exhausted++;
676
+ continue;
677
+ }
678
+ if (Date.now() - start >= opts.budgetMs) return { advanced, exhausted };
679
+ if (opts.abortSignal?.aborted) return { advanced, exhausted };
680
+
681
+ const frontierKey: WAMessageKey = {
682
+ remoteJid: chat,
683
+ fromMe: false,
684
+ id: frontier.oldest_id,
685
+ };
686
+
687
+ const beforeLen = collected.length;
688
+ try {
689
+ await fetchHistory.call(sock, opts.pageSize, frontierKey, frontier.oldest_ts);
690
+ } catch {
691
+ frontier.exhausted = true;
692
+ exhausted++;
693
+ continue;
694
+ }
695
+
696
+ // Wait for the async response. Success = we see messages in this chat
697
+ // older than the current frontier; failure = nothing older arrives
698
+ // within the response window, so the chat is done.
699
+ const waitStart = Date.now();
700
+ let newOldestTs = frontier.oldest_ts;
701
+ let newOldestId = frontier.oldest_id;
702
+ while (Date.now() - waitStart < responseWindowMs) {
703
+ await delay(250);
704
+ for (let i = beforeLen; i < collected.length; i++) {
705
+ const m = collected[i];
706
+ const k = m.key as WAMessageKey | undefined;
707
+ if (!k?.id || k.remoteJid !== chat) continue;
708
+ const ts = extractTs(m);
709
+ if (!ts) continue;
710
+ if (ts < newOldestTs) {
711
+ newOldestTs = ts;
712
+ newOldestId = k.id;
713
+ }
714
+ }
715
+ if (newOldestTs < frontier.oldest_ts) break;
716
+ }
717
+
718
+ if (newOldestTs < frontier.oldest_ts) {
719
+ frontier.oldest_ts = newOldestTs;
720
+ frontier.oldest_id = newOldestId;
721
+ advanced++;
722
+ madeProgressThisRound = true;
723
+ } else {
724
+ frontier.exhausted = true;
725
+ exhausted++;
726
+ }
727
+
728
+ await delay(perRequestWaitMs);
729
+ }
730
+
731
+ if (!madeProgressThisRound) break;
732
+ }
733
+
734
+ return { advanced, exhausted };
735
+ }
736
+
737
+ /**
738
+ * Initialize pagination frontiers from already-ingested events. For chats
739
+ * without an entry yet, seed with the oldest event we have for that chat so
740
+ * the next fetchMessageHistory call reaches back from there.
741
+ */
742
+ function seedFrontiersFromEvents(
743
+ frontiers: Record<string, ChatFrontier>,
744
+ events: EventEnvelope[]
745
+ ): void {
746
+ for (const e of events) {
747
+ const chat = (e.metadata as { chat_jid?: string } | undefined)?.chat_jid;
748
+ if (!chat || chat === 'status@broadcast') continue;
749
+ const ts = Math.floor(e.occurred_at.getTime() / 1000);
750
+ if (!ts) continue;
751
+ const cur = frontiers[chat];
752
+ if (!cur) {
753
+ frontiers[chat] = { oldest_id: e.origin_id, oldest_ts: ts };
754
+ } else if (!cur.exhausted && ts < cur.oldest_ts) {
755
+ cur.oldest_id = e.origin_id;
756
+ cur.oldest_ts = ts;
757
+ }
758
+ }
759
+ }
760
+
761
+ function seedFrontiersFromMessages(
762
+ frontiers: Record<string, ChatFrontier>,
763
+ messages: WAMessage[]
764
+ ): void {
765
+ for (const m of messages) {
766
+ const key = m.key as WAMessageKey | undefined;
767
+ const chat = key?.remoteJid;
768
+ if (!chat || !key?.id || chat === 'status@broadcast') continue;
769
+ const ts = extractTs(m);
770
+ if (!ts) continue;
771
+ const cur = frontiers[chat];
772
+ if (!cur) {
773
+ frontiers[chat] = { oldest_id: key.id, oldest_ts: ts };
774
+ } else if (!cur.exhausted && ts < cur.oldest_ts) {
775
+ cur.oldest_id = key.id;
776
+ cur.oldest_ts = ts;
777
+ }
778
+ }
779
+ }
780
+
781
+ function extractTs(m: WAMessage): number | null {
782
+ const raw = m.messageTimestamp as
783
+ | number
784
+ | { low?: number; toNumber?: () => number }
785
+ | null
786
+ | undefined;
787
+ if (typeof raw === 'number') return raw;
788
+ if (raw && typeof raw === 'object') {
789
+ if (typeof raw.toNumber === 'function') return raw.toNumber();
790
+ if (typeof raw.low === 'number') return raw.low;
791
+ }
792
+ return null;
793
+ }
794
+
795
+ function serializeEvent(e: EventEnvelope): SerializedEvent {
796
+ return { ...e, occurred_at: e.occurred_at.toISOString() };
797
+ }
798
+
799
+ function deserializeEvent(e: SerializedEvent): EventEnvelope {
800
+ return { ...e, occurred_at: new Date(e.occurred_at) };
801
+ }
802
+
803
+ function mergeEvents(a: EventEnvelope[], b: EventEnvelope[], maxMessages: number): EventEnvelope[] {
804
+ const seen = new Set<string>();
805
+ const out: EventEnvelope[] = [];
806
+ for (const e of [...a, ...b]) {
807
+ if (seen.has(e.origin_id)) continue;
808
+ seen.add(e.origin_id);
809
+ out.push(e);
810
+ if (out.length >= maxMessages) break;
811
+ }
812
+ out.sort((x, y) => y.occurred_at.getTime() - x.occurred_at.getTime());
813
+ return out;
814
+ }
815
+
816
+ const silentLogger = {
817
+ level: 'silent',
818
+ child: () => silentLogger,
819
+ trace: () => {},
820
+ debug: () => {},
821
+ info: () => {},
822
+ warn: () => {},
823
+ error: () => {},
824
+ fatal: () => {},
825
+ } as const;
826
+
827
+ function delay(ms: number): Promise<void> {
828
+ return new Promise((r) => setTimeout(r, ms));
829
+ }
830
+
831
+ function waitForOpen(sock: ReturnType<typeof makeWASocket>, timeoutMs: number): Promise<boolean> {
832
+ return new Promise((resolve) => {
833
+ let newLogin = false;
834
+ const timer = setTimeout(() => {
835
+ sock.ev.off('connection.update', handler);
836
+ resolve(false);
837
+ }, timeoutMs);
838
+ const handler = (u: Partial<ConnectionState>) => {
839
+ if (u.isNewLogin) newLogin = true;
840
+ if (u.connection === 'open') {
841
+ clearTimeout(timer);
842
+ sock.ev.off('connection.update', handler);
843
+ resolve(true);
844
+ } else if (u.connection === 'close') {
845
+ const err = u.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined;
846
+ const statusCode = err?.output?.statusCode;
847
+ if (newLogin && statusCode === DisconnectReason.restartRequired) {
848
+ clearTimeout(timer);
849
+ sock.ev.off('connection.update', handler);
850
+ resolve(true);
851
+ return;
852
+ }
853
+ if (statusCode === DisconnectReason.loggedOut) {
854
+ clearTimeout(timer);
855
+ sock.ev.off('connection.update', handler);
856
+ resolve(false);
857
+ }
858
+ }
859
+ };
860
+ sock.ev.on('connection.update', handler);
861
+ });
862
+ }
863
+
864
+ function loadAuthState(session: SerializedSession): {
865
+ creds: AuthenticationCreds;
866
+ keys: SignalDataSet;
867
+ } {
868
+ if (!session.creds) return { creds: initAuthCreds(), keys: {} };
869
+ try {
870
+ const creds = JSON.parse(session.creds, BufferJSON.reviver) as AuthenticationCreds;
871
+ const keys = session.keys
872
+ ? (JSON.parse(session.keys, BufferJSON.reviver) as SignalDataSet)
873
+ : {};
874
+ return { creds, keys };
875
+ } catch {
876
+ return { creds: initAuthCreds(), keys: {} };
877
+ }
878
+ }
879
+
880
+ function dumpSession(creds: AuthenticationCreds, keys: SignalDataSet): Record<string, unknown> {
881
+ return {
882
+ creds: JSON.stringify(creds, BufferJSON.replacer),
883
+ keys: JSON.stringify(keys, BufferJSON.replacer),
884
+ };
885
+ }
886
+
887
+ function makeInMemoryKeyStore(initial: SignalDataSet): SignalKeyStore & {
888
+ snapshot: () => SignalDataSet;
889
+ } {
890
+ const store: SignalDataSet = structuredClone(initial);
891
+ return {
892
+ get: async <T extends keyof SignalDataTypeMap>(type: T, ids: string[]) => {
893
+ const out: { [id: string]: SignalDataTypeMap[T] } = {};
894
+ const bucket = (store[type] ?? {}) as Record<string, SignalDataTypeMap[T]>;
895
+ for (const id of ids) {
896
+ if (bucket[id]) out[id] = bucket[id];
897
+ }
898
+ return out;
899
+ },
900
+ set: async (data) => {
901
+ for (const rawType of Object.keys(data) as Array<keyof SignalDataTypeMap>) {
902
+ const typeData = data[rawType] as Record<string, unknown> | undefined;
903
+ if (!typeData) continue;
904
+ const bucket = (store[rawType] ??= {} as never) as Record<string, unknown>;
905
+ for (const id of Object.keys(typeData)) {
906
+ const value = typeData[id];
907
+ if (value === null || value === undefined) delete bucket[id];
908
+ else bucket[id] = value;
909
+ }
910
+ }
911
+ },
912
+ snapshot: () => store,
913
+ };
914
+ }
915
+
916
+ function collectEvents(
917
+ messages: WAMessage[],
918
+ chatNames: Map<string, string>,
919
+ filter: 'all' | 'individual' | 'group',
920
+ maxMessages: number,
921
+ sinceMs: number
922
+ ): EventEnvelope[] {
923
+ const events: EventEnvelope[] = [];
924
+ const seen = new Set<string>();
925
+ for (const m of messages) {
926
+ const event = toEvent(m, chatNames, filter);
927
+ if (!event) continue;
928
+ if (seen.has(event.origin_id)) continue;
929
+ const ts = event.occurred_at.getTime();
930
+ if (ts <= sinceMs) continue;
931
+ seen.add(event.origin_id);
932
+ events.push(event);
933
+ if (events.length >= maxMessages) break;
934
+ }
935
+ events.sort((a, b) => b.occurred_at.getTime() - a.occurred_at.getTime());
936
+ return events;
937
+ }
938
+
939
+ function newestTimestamp(events: EventEnvelope[]): string | undefined {
940
+ let newest = 0;
941
+ for (const e of events) {
942
+ const ts = e.occurred_at.getTime();
943
+ if (ts > newest) newest = ts;
944
+ }
945
+ return newest ? new Date(newest).toISOString() : undefined;
946
+ }
947
+
948
+ export function toEvent(
949
+ m: WAMessage,
950
+ chatNames: Map<string, string>,
951
+ filter: 'all' | 'individual' | 'group'
952
+ ): EventEnvelope | null {
953
+ const key = m.key as WAMessageKey | undefined;
954
+ const chatJid = key?.remoteJid;
955
+ const msgId = key?.id;
956
+ if (!chatJid || !msgId) return null;
957
+
958
+ const isGroup = chatJid.endsWith('@g.us');
959
+ if (filter === 'individual' && isGroup) return null;
960
+ if (filter === 'group' && !isGroup) return null;
961
+
962
+ const text = extractText(m.message);
963
+ if (!text) return null;
964
+
965
+ const tsRaw =
966
+ typeof m.messageTimestamp === 'number'
967
+ ? m.messageTimestamp
968
+ : ((m.messageTimestamp as { low?: number; toNumber?: () => number } | null)?.toNumber?.() ??
969
+ (m.messageTimestamp as { low?: number } | null)?.low ??
970
+ 0);
971
+ if (!tsRaw) return null;
972
+ const occurredAt = new Date(tsRaw * 1000);
973
+
974
+ const chatName = chatNames.get(chatJid) ?? jidToDisplay(chatJid);
975
+ const authorName = m.pushName ?? (key.participant ? jidToDisplay(key.participant) : chatName);
976
+ const fromMe = !!key.fromMe;
977
+ const participant = key.participant ?? (isGroup ? undefined : chatJid);
978
+
979
+ let senderJid: string | undefined;
980
+ if (!fromMe) {
981
+ if (isGroup) {
982
+ senderJid = key.participant ?? undefined;
983
+ } else if (isPersonJid(chatJid)) {
984
+ senderJid = chatJid;
985
+ }
986
+ }
987
+ const senderPhone = senderJid ? jidToPhone(senderJid) : undefined;
988
+ const pushName = m.pushName ?? undefined;
989
+
990
+ const quoted = m.message?.extendedTextMessage?.contextInfo?.stanzaId;
991
+ const isForwarded = !!m.message?.extendedTextMessage?.contextInfo?.isForwarded;
992
+ const mediaType = detectMediaType(m.message);
993
+
994
+ return {
995
+ origin_id: msgId,
996
+ origin_type: 'message',
997
+ payload_text: text,
998
+ title: chatName,
999
+ author_name: authorName,
1000
+ source_url: sourceUrlForChat(chatJid),
1001
+ occurred_at: occurredAt,
1002
+ origin_parent_id: chatJid,
1003
+ metadata: {
1004
+ chat_jid: chatJid,
1005
+ is_group: isGroup,
1006
+ from_me: fromMe,
1007
+ participant,
1008
+ ...(senderJid ? { sender_jid: senderJid } : {}),
1009
+ ...(senderPhone ? { sender_phone: senderPhone } : {}),
1010
+ ...(pushName ? { push_name: pushName } : {}),
1011
+ ...(mediaType ? { media_type: mediaType } : {}),
1012
+ ...(quoted ? { quoted_id: quoted } : {}),
1013
+ ...(isForwarded ? { is_forwarded: true } : {}),
1014
+ },
1015
+ };
1016
+ }
1017
+
1018
+ function jidDomain(jid: string): string | null {
1019
+ const at = jid.indexOf('@');
1020
+ if (at <= 0) return null;
1021
+ return jid.slice(at + 1).toLowerCase();
1022
+ }
1023
+
1024
+ function isPersonJid(jid: string): boolean {
1025
+ const domain = jidDomain(jid);
1026
+ return domain === 's.whatsapp.net' || domain === 'c.us' || domain === 'lid';
1027
+ }
1028
+
1029
+ export function jidToPhone(jid: string): string | undefined {
1030
+ const at = jid.indexOf('@');
1031
+ if (at <= 0) return undefined;
1032
+ const domain = jid.slice(at + 1).toLowerCase();
1033
+ if (domain !== 's.whatsapp.net' && domain !== 'c.us') return undefined;
1034
+ const user = jid.slice(0, at).split(':')[0];
1035
+ if (!/^\d+$/.test(user)) return undefined;
1036
+ return user;
1037
+ }
1038
+
1039
+ function extractText(msg: WAMessageContent | null | undefined): string | null {
1040
+ if (!msg) return null;
1041
+ if (msg.conversation) return msg.conversation;
1042
+ if (msg.extendedTextMessage?.text) return msg.extendedTextMessage.text;
1043
+ if (msg.imageMessage?.caption) return msg.imageMessage.caption;
1044
+ if (msg.videoMessage?.caption) return msg.videoMessage.caption;
1045
+ if (msg.documentMessage?.caption) return msg.documentMessage.caption;
1046
+ if (msg.ephemeralMessage?.message) return extractText(msg.ephemeralMessage.message);
1047
+ if (msg.viewOnceMessage?.message) return extractText(msg.viewOnceMessage.message);
1048
+ if (msg.viewOnceMessageV2?.message) return extractText(msg.viewOnceMessageV2.message);
1049
+ return null;
1050
+ }
1051
+
1052
+ function detectMediaType(msg: WAMessageContent | null | undefined): string | null {
1053
+ if (!msg) return null;
1054
+ if (msg.imageMessage) return 'image';
1055
+ if (msg.videoMessage) return 'video';
1056
+ if (msg.audioMessage) return 'audio';
1057
+ if (msg.documentMessage) return 'document';
1058
+ if (msg.stickerMessage) return 'sticker';
1059
+ if (msg.locationMessage) return 'location';
1060
+ return null;
1061
+ }
1062
+
1063
+ function jidToDisplay(jid: string): string {
1064
+ const at = jid.indexOf('@');
1065
+ return at > 0 ? jid.slice(0, at) : jid;
1066
+ }
1067
+
1068
+ function sourceUrlForChat(jid: string): string | undefined {
1069
+ const domain = jidDomain(jid);
1070
+ if (domain !== 's.whatsapp.net' && domain !== 'c.us') return undefined;
1071
+ const number = jidToDisplay(jid).split(':')[0].replace(/[^\d]/g, '');
1072
+ return number ? `https://wa.me/${number}` : undefined;
1073
+ }