kalshi-trading-bot-cli 2.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 (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -0
  3. package/assets/kalshi-flow-light.png +0 -0
  4. package/assets/screenshot.png +0 -0
  5. package/env.example +43 -0
  6. package/kalshi-flow-light.png +0 -0
  7. package/package.json +66 -0
  8. package/src/agent/agent.ts +249 -0
  9. package/src/agent/channels.ts +53 -0
  10. package/src/agent/index.ts +29 -0
  11. package/src/agent/prompts.ts +171 -0
  12. package/src/agent/run-context.ts +23 -0
  13. package/src/agent/scratchpad.ts +465 -0
  14. package/src/agent/token-counter.ts +33 -0
  15. package/src/agent/tool-executor.ts +166 -0
  16. package/src/agent/types.ts +221 -0
  17. package/src/audit/index.ts +25 -0
  18. package/src/audit/reader.ts +43 -0
  19. package/src/audit/trail.ts +29 -0
  20. package/src/audit/types.ts +133 -0
  21. package/src/backtest/discovery.ts +170 -0
  22. package/src/backtest/fetcher.ts +247 -0
  23. package/src/backtest/metrics.ts +165 -0
  24. package/src/backtest/renderer.ts +196 -0
  25. package/src/backtest/types.ts +45 -0
  26. package/src/cli.ts +943 -0
  27. package/src/commands/alerts.ts +48 -0
  28. package/src/commands/analyze.ts +662 -0
  29. package/src/commands/backtest.ts +276 -0
  30. package/src/commands/clear-cache.ts +24 -0
  31. package/src/commands/config.ts +107 -0
  32. package/src/commands/dispatch.ts +473 -0
  33. package/src/commands/edge.ts +62 -0
  34. package/src/commands/formatters.ts +339 -0
  35. package/src/commands/help.ts +263 -0
  36. package/src/commands/helpers.ts +48 -0
  37. package/src/commands/index.ts +287 -0
  38. package/src/commands/json.ts +43 -0
  39. package/src/commands/parse-args.ts +229 -0
  40. package/src/commands/portfolio.ts +236 -0
  41. package/src/commands/review.ts +176 -0
  42. package/src/commands/scan-formatters.ts +98 -0
  43. package/src/commands/scan.ts +38 -0
  44. package/src/commands/search-edge.ts +139 -0
  45. package/src/commands/status.ts +70 -0
  46. package/src/commands/themes.ts +117 -0
  47. package/src/commands/watch.ts +295 -0
  48. package/src/components/answer-box.ts +57 -0
  49. package/src/components/approval-prompt.ts +34 -0
  50. package/src/components/browse-list.ts +134 -0
  51. package/src/components/chat-log.ts +291 -0
  52. package/src/components/custom-editor.ts +18 -0
  53. package/src/components/debug-panel.ts +52 -0
  54. package/src/components/index.ts +17 -0
  55. package/src/components/intro.ts +92 -0
  56. package/src/components/select-list.ts +155 -0
  57. package/src/components/tool-event.ts +127 -0
  58. package/src/components/user-query.ts +18 -0
  59. package/src/components/working-indicator.ts +87 -0
  60. package/src/controllers/agent-runner.ts +283 -0
  61. package/src/controllers/browse.ts +1013 -0
  62. package/src/controllers/index.ts +7 -0
  63. package/src/controllers/input-history.ts +76 -0
  64. package/src/controllers/model-selection.ts +244 -0
  65. package/src/db/alerts.ts +77 -0
  66. package/src/db/edge.ts +105 -0
  67. package/src/db/event-index.ts +323 -0
  68. package/src/db/events.ts +41 -0
  69. package/src/db/index.ts +60 -0
  70. package/src/db/octagon-cache.ts +118 -0
  71. package/src/db/positions.ts +71 -0
  72. package/src/db/risk.ts +51 -0
  73. package/src/db/schema.ts +227 -0
  74. package/src/db/themes.ts +34 -0
  75. package/src/db/trades.ts +50 -0
  76. package/src/eval/brier.ts +90 -0
  77. package/src/eval/index.ts +4 -0
  78. package/src/eval/performance.ts +87 -0
  79. package/src/gateway/access-control.ts +253 -0
  80. package/src/gateway/agent-runner.ts +75 -0
  81. package/src/gateway/alerts/formatter.ts +90 -0
  82. package/src/gateway/alerts/index.ts +4 -0
  83. package/src/gateway/alerts/router.ts +32 -0
  84. package/src/gateway/alerts/terminal.ts +16 -0
  85. package/src/gateway/alerts/types.ts +13 -0
  86. package/src/gateway/channels/index.ts +9 -0
  87. package/src/gateway/channels/manager.ts +153 -0
  88. package/src/gateway/channels/types.ts +48 -0
  89. package/src/gateway/channels/whatsapp/README.md +234 -0
  90. package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
  91. package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
  92. package/src/gateway/channels/whatsapp/error.ts +122 -0
  93. package/src/gateway/channels/whatsapp/inbound.ts +326 -0
  94. package/src/gateway/channels/whatsapp/index.ts +5 -0
  95. package/src/gateway/channels/whatsapp/lid.ts +56 -0
  96. package/src/gateway/channels/whatsapp/logger.ts +25 -0
  97. package/src/gateway/channels/whatsapp/login.ts +94 -0
  98. package/src/gateway/channels/whatsapp/outbound.ts +119 -0
  99. package/src/gateway/channels/whatsapp/plugin.ts +54 -0
  100. package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
  101. package/src/gateway/channels/whatsapp/runtime.ts +122 -0
  102. package/src/gateway/channels/whatsapp/session.ts +89 -0
  103. package/src/gateway/channels/whatsapp/types.ts +32 -0
  104. package/src/gateway/commands/handler.ts +64 -0
  105. package/src/gateway/commands/index.ts +7 -0
  106. package/src/gateway/commands/parser.ts +29 -0
  107. package/src/gateway/commands/wa-formatters.ts +92 -0
  108. package/src/gateway/config.ts +244 -0
  109. package/src/gateway/extension-points.ts +17 -0
  110. package/src/gateway/gateway.ts +301 -0
  111. package/src/gateway/group/history-buffer.ts +75 -0
  112. package/src/gateway/group/index.ts +8 -0
  113. package/src/gateway/group/member-tracker.ts +60 -0
  114. package/src/gateway/group/mention-detection.ts +42 -0
  115. package/src/gateway/heartbeat/index.ts +8 -0
  116. package/src/gateway/heartbeat/prompt.ts +73 -0
  117. package/src/gateway/heartbeat/runner.ts +200 -0
  118. package/src/gateway/heartbeat/suppression.ts +74 -0
  119. package/src/gateway/index.ts +138 -0
  120. package/src/gateway/routing/resolve-route.ts +119 -0
  121. package/src/gateway/sessions/store.ts +65 -0
  122. package/src/gateway/types.ts +11 -0
  123. package/src/gateway/utils.ts +82 -0
  124. package/src/index.tsx +30 -0
  125. package/src/model/llm.ts +247 -0
  126. package/src/providers.ts +94 -0
  127. package/src/risk/circuit-breaker.ts +113 -0
  128. package/src/risk/correlation.ts +40 -0
  129. package/src/risk/gate.ts +125 -0
  130. package/src/risk/index.ts +10 -0
  131. package/src/risk/kelly.ts +230 -0
  132. package/src/scan/alerter.ts +64 -0
  133. package/src/scan/edge-computer.ts +164 -0
  134. package/src/scan/invoker.ts +199 -0
  135. package/src/scan/loop.ts +184 -0
  136. package/src/scan/octagon-client.ts +627 -0
  137. package/src/scan/octagon-events-api.ts +105 -0
  138. package/src/scan/octagon-prefetch.ts +172 -0
  139. package/src/scan/theme-resolver.ts +179 -0
  140. package/src/scan/types.ts +62 -0
  141. package/src/scan/watchdog.ts +126 -0
  142. package/src/setup/wizard.ts +659 -0
  143. package/src/theme.ts +67 -0
  144. package/src/tools/fetch/cache.ts +95 -0
  145. package/src/tools/fetch/external-content.ts +200 -0
  146. package/src/tools/fetch/index.ts +1 -0
  147. package/src/tools/fetch/web-fetch-utils.ts +122 -0
  148. package/src/tools/fetch/web-fetch.ts +419 -0
  149. package/src/tools/index.ts +10 -0
  150. package/src/tools/kalshi/api.ts +251 -0
  151. package/src/tools/kalshi/dlq.ts +35 -0
  152. package/src/tools/kalshi/events.ts +84 -0
  153. package/src/tools/kalshi/exchange.ts +24 -0
  154. package/src/tools/kalshi/historical.ts +89 -0
  155. package/src/tools/kalshi/index.ts +11 -0
  156. package/src/tools/kalshi/kalshi-search.ts +437 -0
  157. package/src/tools/kalshi/kalshi-trade.ts +102 -0
  158. package/src/tools/kalshi/markets.ts +76 -0
  159. package/src/tools/kalshi/portfolio.ts +100 -0
  160. package/src/tools/kalshi/search-index.ts +198 -0
  161. package/src/tools/kalshi/series.ts +16 -0
  162. package/src/tools/kalshi/trading.ts +115 -0
  163. package/src/tools/kalshi/types.ts +199 -0
  164. package/src/tools/registry.ts +160 -0
  165. package/src/tools/search/index.ts +25 -0
  166. package/src/tools/search/tavily.ts +35 -0
  167. package/src/tools/types.ts +53 -0
  168. package/src/tools/v2/edge-query.ts +135 -0
  169. package/src/tools/v2/octagon-report.ts +112 -0
  170. package/src/tools/v2/portfolio-query.ts +79 -0
  171. package/src/tools/v2/portfolio-review.ts +59 -0
  172. package/src/tools/v2/risk-status.ts +94 -0
  173. package/src/tools/v2/scan.ts +78 -0
  174. package/src/types/qrcode-terminal.d.ts +7 -0
  175. package/src/types/whiskeysockets-baileys.d.ts +41 -0
  176. package/src/types.ts +22 -0
  177. package/src/utils/ai-message.ts +26 -0
  178. package/src/utils/bot-config.ts +219 -0
  179. package/src/utils/cache.ts +195 -0
  180. package/src/utils/config.ts +113 -0
  181. package/src/utils/env.ts +111 -0
  182. package/src/utils/errors.ts +313 -0
  183. package/src/utils/history-context.ts +32 -0
  184. package/src/utils/in-memory-chat-history.ts +268 -0
  185. package/src/utils/index.ts +28 -0
  186. package/src/utils/input-key-handlers.ts +64 -0
  187. package/src/utils/logger.ts +67 -0
  188. package/src/utils/long-term-chat-history.ts +138 -0
  189. package/src/utils/markdown-table.ts +227 -0
  190. package/src/utils/model.ts +70 -0
  191. package/src/utils/ollama.ts +37 -0
  192. package/src/utils/paths.ts +12 -0
  193. package/src/utils/progress-channel.ts +84 -0
  194. package/src/utils/telemetry.ts +103 -0
  195. package/src/utils/text-navigation.ts +81 -0
  196. package/src/utils/thinking-verbs.ts +18 -0
  197. package/src/utils/tokens.ts +36 -0
  198. package/src/utils/tool-description.ts +61 -0
@@ -0,0 +1,326 @@
1
+ import {
2
+ isJidGroup,
3
+ normalizeMessageContent,
4
+ extractMessageContent,
5
+ type ConnectionState,
6
+ type WAMessage,
7
+ type proto,
8
+ } from '@whiskeysockets/baileys';
9
+ import { createWaSocket, getStatusCode, isLoggedOutReason, waitForWaConnection } from './session.js';
10
+ import type { WhatsAppCloseReason, WhatsAppInboundMessage } from './types.js';
11
+ import { setActiveWebListener } from './outbound.js';
12
+ import { isRecentInboundMessage } from './dedupe.js';
13
+ import { readSelfId } from './auth-store.js';
14
+ import { checkInboundAccessControl } from '../../access-control.js';
15
+ import { resolveJidToPhoneJid, type LidLookup } from './lid.js';
16
+ import { appendFileSync } from 'node:fs';
17
+ import { appPath } from '../../../utils/paths.js';
18
+
19
+ const LOG_PATH = appPath('gateway-debug.log');
20
+ function debugLog(msg: string) {
21
+ appendFileSync(LOG_PATH, `${new Date().toISOString()} ${msg}\n`);
22
+ }
23
+
24
+ function extractMentionedJids(message: WAMessage): string[] {
25
+ const rawMsg = message.message;
26
+ if (!rawMsg) return [];
27
+
28
+ const normalized = normalizeMessageContent(rawMsg);
29
+ if (!normalized) return [];
30
+
31
+ // Check contextInfo.mentionedJid across message types that support mentions
32
+ const contextInfo =
33
+ normalized.extendedTextMessage?.contextInfo ??
34
+ normalized.imageMessage?.contextInfo ??
35
+ normalized.videoMessage?.contextInfo ??
36
+ normalized.documentMessage?.contextInfo;
37
+
38
+ const jids = contextInfo?.mentionedJid;
39
+ return Array.isArray(jids) ? jids.filter((j): j is string => typeof j === 'string') : [];
40
+ }
41
+
42
+ function extractText(message: WAMessage): string {
43
+ const rawMsg = message.message;
44
+ if (!rawMsg) {
45
+ debugLog(`[extractText] no message content`);
46
+ return '';
47
+ }
48
+
49
+ // Use Baileys' normalizeMessageContent to unwrap viewOnce, ephemeral, etc.
50
+ const normalized = normalizeMessageContent(rawMsg);
51
+ if (!normalized) {
52
+ debugLog(`[extractText] normalizeMessageContent returned null`);
53
+ return '';
54
+ }
55
+
56
+ // Log available message keys for debugging
57
+ const keys = Object.keys(normalized);
58
+ debugLog(`[extractText] message keys: ${keys.join(', ')}`);
59
+
60
+ // Try extractMessageContent for deeper extraction
61
+ const extracted = extractMessageContent(normalized);
62
+ const candidates = [normalized, extracted && extracted !== normalized ? extracted : undefined];
63
+
64
+ for (const candidate of candidates) {
65
+ if (!candidate) continue;
66
+
67
+ // Check conversation (simple text)
68
+ if (typeof candidate.conversation === 'string' && candidate.conversation.trim()) {
69
+ return candidate.conversation.trim();
70
+ }
71
+
72
+ // Check extended text message
73
+ const extended = candidate.extendedTextMessage?.text;
74
+ if (extended?.trim()) {
75
+ return extended.trim();
76
+ }
77
+
78
+ // Check media captions
79
+ const caption =
80
+ candidate.imageMessage?.caption ??
81
+ candidate.videoMessage?.caption ??
82
+ candidate.documentMessage?.caption;
83
+ if (caption?.trim()) {
84
+ return caption.trim();
85
+ }
86
+ }
87
+
88
+ return '';
89
+ }
90
+
91
+ function toPhoneFromJid(jid: string): string {
92
+ const base = jid.split('@')[0] ?? '';
93
+ const match = base.match(/^(\d+)(?::\d+)?$/);
94
+ const digits = match?.[1] ?? base.replace(/\D/g, '');
95
+ return digits ? `+${digits}` : '';
96
+ }
97
+
98
+ function jidToE164(jid?: string | null): string | null {
99
+ if (!jid) {
100
+ return null;
101
+ }
102
+ const phone = toPhoneFromJid(jid);
103
+ return phone || null;
104
+ }
105
+
106
+ export async function monitorWebInbox(params: {
107
+ accountId: string;
108
+ authDir: string;
109
+ verbose: boolean;
110
+ allowFrom: string[];
111
+ dmPolicy: 'pairing' | 'allowlist' | 'open' | 'disabled';
112
+ groupPolicy: 'open' | 'allowlist' | 'disabled';
113
+ groupAllowFrom: string[];
114
+ sendReadReceipts?: boolean;
115
+ onMessage: (msg: WhatsAppInboundMessage) => Promise<void>;
116
+ }): Promise<{
117
+ sock: Awaited<ReturnType<typeof createWaSocket>>;
118
+ onClose: Promise<WhatsAppCloseReason>;
119
+ close: () => Promise<void>;
120
+ }> {
121
+ const sock = await createWaSocket({
122
+ authDir: params.authDir,
123
+ printQr: false,
124
+ verbose: params.verbose,
125
+ });
126
+ await waitForWaConnection(sock);
127
+ console.log('[whatsapp] Connected');
128
+ const connectedAtMs = Date.now();
129
+ const selfJid = sock.user?.id;
130
+ const selfLid = (sock.user as unknown as Record<string, unknown>)?.lid as string | undefined ?? null;
131
+ const selfFromSock = jidToE164(selfJid);
132
+ const selfFromCreds = readSelfId(params.authDir).e164;
133
+ const selfE164 = selfFromSock ?? selfFromCreds;
134
+ debugLog(`[inbound] selfJid=${selfJid} selfLid=${selfLid} selfE164=${selfE164}`);
135
+
136
+ // Get LID lookup for resolving LID JIDs to phone JIDs
137
+ // Baileys 7.x provides signalRepository.lidMapping for LID resolution
138
+ const lidMapping = sock.signalRepository?.lidMapping;
139
+ const lidLookup: LidLookup | undefined = lidMapping ? {
140
+ getPNForLID: lidMapping.getPNForLID?.bind(lidMapping),
141
+ } : undefined;
142
+ debugLog(`[inbound] lidLookup available: ${!!lidLookup}, getPNForLID: ${typeof lidLookup?.getPNForLID}`);
143
+
144
+ let onCloseResolve: ((reason: WhatsAppCloseReason) => void) | null = null;
145
+ const onClose = new Promise<WhatsAppCloseReason>((resolve) => {
146
+ onCloseResolve = resolve;
147
+ });
148
+ const resolveClose = (reason: WhatsAppCloseReason) => {
149
+ if (!onCloseResolve) {
150
+ return;
151
+ }
152
+ const resolve = onCloseResolve;
153
+ onCloseResolve = null;
154
+ resolve(reason);
155
+ };
156
+
157
+ const onMessagesUpsert = async (upsert: { type?: string; messages?: WAMessage[] }) => {
158
+ debugLog(`[inbound] upsert type=${upsert.type} count=${upsert.messages?.length ?? 0}`);
159
+ if (upsert.type !== 'notify' && upsert.type !== 'append') {
160
+ return;
161
+ }
162
+ for (const message of upsert.messages ?? []) {
163
+ const remoteJid = message.key?.remoteJid;
164
+ debugLog(`[inbound] message remoteJid=${remoteJid} fromMe=${message.key?.fromMe}`);
165
+ if (!remoteJid) {
166
+ continue;
167
+ }
168
+
169
+ // Skip duplicate messages
170
+ const messageId = message.key?.id;
171
+ const dedupeKey = messageId ? `${params.accountId}:${remoteJid}:${messageId}` : undefined;
172
+ if (dedupeKey && isRecentInboundMessage(dedupeKey)) {
173
+ debugLog(`[inbound] skipping duplicate ${dedupeKey}`);
174
+ continue;
175
+ }
176
+
177
+ const isGroup = isJidGroup(remoteJid) === true;
178
+ const senderJid = message.key?.participant ?? remoteJid;
179
+
180
+ // For direct chats, resolve LID JID to phone JID for reliable replies
181
+ let replyToJid = remoteJid;
182
+ if (!isGroup) {
183
+ debugLog(`[inbound] attempting LID resolution for ${remoteJid}, lidLookup available: ${!!lidLookup}, getPNForLID available: ${!!lidLookup?.getPNForLID}`);
184
+ const resolvedJid = await resolveJidToPhoneJid(remoteJid, lidLookup, debugLog);
185
+ debugLog(`[inbound] resolveJidToPhoneJid result: ${resolvedJid}`);
186
+ if (resolvedJid) {
187
+ replyToJid = resolvedJid;
188
+ debugLog(`[inbound] using resolved JID ${resolvedJid} for replies`);
189
+ } else {
190
+ debugLog(`[inbound] LID resolution failed, using original ${remoteJid} for replies`);
191
+ }
192
+ }
193
+
194
+ const from = toPhoneFromJid(isGroup ? senderJid : replyToJid);
195
+ const messageTimestampMs = message.messageTimestamp
196
+ ? Number(message.messageTimestamp) * 1000
197
+ : undefined;
198
+ debugLog(`[inbound] from=${from} selfE164=${selfE164} isGroup=${isGroup} isFromMe=${message.key?.fromMe} allowFrom=${JSON.stringify(params.allowFrom)} dmPolicy=${params.dmPolicy} groupPolicy=${params.groupPolicy}`);
199
+ const access = await checkInboundAccessControl({
200
+ accountId: params.accountId,
201
+ from,
202
+ selfE164,
203
+ senderE164: isGroup ? toPhoneFromJid(senderJid) || null : from || null,
204
+ group: isGroup,
205
+ isFromMe: Boolean(message.key?.fromMe),
206
+ dmPolicy: params.dmPolicy,
207
+ groupPolicy: params.groupPolicy,
208
+ allowFrom: params.allowFrom,
209
+ groupAllowFrom: params.groupAllowFrom,
210
+ messageTimestampMs,
211
+ connectedAtMs,
212
+ reply: async (text: string) => {
213
+ await sock.sendMessage(remoteJid, { text });
214
+ },
215
+ });
216
+ debugLog(
217
+ `[inbound] access allowed=${access.allowed} denyReason=${access.denyReason ?? 'none'} isSelfChat=${access.isSelfChat} shouldMarkRead=${access.shouldMarkRead}`,
218
+ );
219
+ if (!access.allowed) {
220
+ continue;
221
+ }
222
+
223
+ let groupSubject: string | undefined;
224
+ let groupParticipants: string[] | undefined;
225
+ if (isGroup) {
226
+ try {
227
+ const meta = await sock.groupMetadata(remoteJid);
228
+ groupSubject = meta.subject;
229
+ groupParticipants = meta.participants?.map((participant: { id: string }) => toPhoneFromJid(participant.id));
230
+ } catch {
231
+ // ignore metadata fetch failures
232
+ }
233
+ }
234
+
235
+ const body = extractText(message);
236
+ debugLog(`[inbound] body="${body.slice(0, 50)}..."`);
237
+ if (!body.trim()) {
238
+ debugLog(`[inbound] skipping empty body`);
239
+ continue;
240
+ }
241
+ const mentionedJids = extractMentionedJids(message);
242
+ const inbound: WhatsAppInboundMessage = {
243
+ id: message.key?.id ?? undefined,
244
+ accountId: access.resolvedAccountId,
245
+ chatId: remoteJid,
246
+ replyToJid,
247
+ chatType: isGroup ? 'group' : 'direct',
248
+ from,
249
+ senderId: from,
250
+ senderName: message.pushName ? String(message.pushName) : undefined,
251
+ isFromMe: Boolean(message.key?.fromMe),
252
+ selfE164,
253
+ mentionedJids,
254
+ selfJid: selfJid ?? null,
255
+ selfLid,
256
+ groupSubject,
257
+ groupParticipants,
258
+ body,
259
+ timestamp: messageTimestampMs,
260
+ sendComposing: async () => {
261
+ await sock.sendPresenceUpdate('composing', replyToJid);
262
+ },
263
+ reply: async (text: string) => {
264
+ await sock.sendMessage(replyToJid, { text });
265
+ },
266
+ sendMedia: async (payload) => {
267
+ await sock.sendMessage(replyToJid, payload);
268
+ },
269
+ };
270
+ if (
271
+ params.sendReadReceipts !== false &&
272
+ message.key?.id &&
273
+ access.shouldMarkRead &&
274
+ !access.isSelfChat
275
+ ) {
276
+ await sock.readMessages([
277
+ {
278
+ remoteJid,
279
+ id: message.key.id,
280
+ participant: message.key.participant,
281
+ fromMe: false,
282
+ },
283
+ ]);
284
+ }
285
+ // History/offline catch-up: mark read above but skip auto-reply.
286
+ if (upsert.type === 'append') {
287
+ debugLog(`[inbound] skipping append message (read-only, no reply)`);
288
+ continue;
289
+ }
290
+ debugLog(`[inbound] calling onMessage for ${from}: "${body.slice(0, 30)}..."`);
291
+ await params.onMessage(inbound);
292
+ }
293
+ };
294
+
295
+ const onConnectionUpdate = (update: Partial<ConnectionState>) => {
296
+ if (update.connection === 'close') {
297
+ const status = getStatusCode(update.lastDisconnect?.error);
298
+ const isLoggedOut = isLoggedOutReason(update.lastDisconnect?.error);
299
+ console.log(`[whatsapp] Disconnected (status=${status}, loggedOut=${isLoggedOut})`);
300
+ resolveClose({
301
+ status,
302
+ isLoggedOut,
303
+ error: update.lastDisconnect?.error,
304
+ });
305
+ }
306
+ };
307
+
308
+ sock.ev.on('messages.upsert', onMessagesUpsert);
309
+ sock.ev.on('connection.update', onConnectionUpdate);
310
+
311
+ return {
312
+ sock,
313
+ onClose,
314
+ close: async () => {
315
+ resolveClose({
316
+ status: 499,
317
+ isLoggedOut: false,
318
+ });
319
+ sock.ev.off('messages.upsert', onMessagesUpsert);
320
+ sock.ev.off('connection.update', onConnectionUpdate);
321
+ setActiveWebListener(params.accountId, null);
322
+ sock.ws.close();
323
+ },
324
+ };
325
+ }
326
+
@@ -0,0 +1,5 @@
1
+ export { loginWhatsApp } from './login.js';
2
+ export { monitorWhatsAppChannel } from './runtime.js';
3
+ export { assertOutboundAllowed, sendComposing, sendMessageWhatsApp } from './outbound.js';
4
+ export type { WhatsAppInboundMessage } from './types.js';
5
+
@@ -0,0 +1,56 @@
1
+ /**
2
+ * LID (Linked ID) resolution utilities for WhatsApp.
3
+ *
4
+ * WhatsApp uses LID JIDs for self-chat which don't have Signal sessions.
5
+ * We need to resolve LID JIDs to phone number JIDs (@s.whatsapp.net) for replies.
6
+ */
7
+
8
+ export type LidLookup = {
9
+ getPNForLID?: (lid: string) => Promise<string | null>;
10
+ };
11
+
12
+ /**
13
+ * Resolve a JID to a phone number JID suitable for sending messages.
14
+ *
15
+ * - If already @s.whatsapp.net or @g.us, returns as-is
16
+ * - If @lid, attempts resolution via lidLookup.getPNForLID()
17
+ * - Returns null if resolution fails or jid is null/undefined
18
+ */
19
+ export async function resolveJidToPhoneJid(
20
+ jid: string | null | undefined,
21
+ lidLookup?: LidLookup,
22
+ debugLog?: (msg: string) => void,
23
+ ): Promise<string | null> {
24
+ const log = debugLog ?? (() => {});
25
+
26
+ if (!jid) {
27
+ log(`[lid] jid is null/undefined`);
28
+ return null;
29
+ }
30
+
31
+ // If already a phone JID or group JID, return as-is
32
+ if (jid.endsWith('@s.whatsapp.net') || jid.endsWith('@g.us')) {
33
+ log(`[lid] ${jid} is already a phone/group JID`);
34
+ return jid;
35
+ }
36
+
37
+ // Try LID resolution
38
+ if (jid.endsWith('@lid')) {
39
+ log(`[lid] ${jid} is an LID JID, attempting resolution`);
40
+ if (lidLookup?.getPNForLID) {
41
+ try {
42
+ const pnJid = await lidLookup.getPNForLID(jid);
43
+ log(`[lid] getPNForLID returned: ${pnJid}`);
44
+ if (pnJid) return pnJid;
45
+ } catch (err) {
46
+ log(`[lid] getPNForLID error: ${err instanceof Error ? err.message : String(err)}`);
47
+ }
48
+ } else {
49
+ log(`[lid] getPNForLID not available`);
50
+ }
51
+ } else {
52
+ log(`[lid] ${jid} is not an LID JID (doesn't end with @lid)`);
53
+ }
54
+
55
+ return null;
56
+ }
@@ -0,0 +1,25 @@
1
+ export type PinoLikeLogger = {
2
+ level: string;
3
+ child: (bindings?: Record<string, unknown>) => PinoLikeLogger;
4
+ trace: (...args: unknown[]) => void;
5
+ debug: (...args: unknown[]) => void;
6
+ info: (...args: unknown[]) => void;
7
+ warn: (...args: unknown[]) => void;
8
+ error: (...args: unknown[]) => void;
9
+ fatal: (...args: unknown[]) => void;
10
+ };
11
+
12
+ export function createSilentLogger(): PinoLikeLogger {
13
+ const noop = () => {};
14
+ const logger: PinoLikeLogger = {
15
+ level: 'silent',
16
+ child: () => logger,
17
+ trace: noop,
18
+ debug: noop,
19
+ info: noop,
20
+ warn: noop,
21
+ error: noop,
22
+ fatal: noop,
23
+ };
24
+ return logger;
25
+ }
@@ -0,0 +1,94 @@
1
+ import qrcode from 'qrcode-terminal';
2
+ import { createWaSocket, getStatusCode, waitForWaConnection } from './session.js';
3
+ import { formatError } from './error.js';
4
+
5
+ export type LoginResult = {
6
+ phone: string | null;
7
+ };
8
+
9
+ function extractPhoneFromJid(jid: string | undefined): string | null {
10
+ if (!jid) {
11
+ return null;
12
+ }
13
+ // Format: "1234567890:123@s.whatsapp.net" -> "+1234567890"
14
+ const match = jid.match(/^(\d+):/);
15
+ return match ? `+${match[1]}` : null;
16
+ }
17
+
18
+ function getErrorStatusCode(err: unknown): number | undefined {
19
+ return (
20
+ (err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ??
21
+ getStatusCode(err)
22
+ );
23
+ }
24
+
25
+ export async function loginWhatsApp(params: { authDir: string }): Promise<LoginResult> {
26
+ let resolved = false;
27
+ const sock = await createWaSocket({
28
+ authDir: params.authDir,
29
+ printQr: false,
30
+ onQr: (qr) => {
31
+ if (resolved) {
32
+ return;
33
+ }
34
+ console.log('Scan this QR in WhatsApp -> Linked Devices:');
35
+ qrcode.generate(qr, { small: true });
36
+ },
37
+ });
38
+
39
+ try {
40
+ await waitForWaConnection(sock);
41
+ resolved = true;
42
+ const phone = extractPhoneFromJid(sock.user?.id);
43
+ console.log('WhatsApp linked successfully.');
44
+ return { phone };
45
+ } catch (err) {
46
+ const code = getErrorStatusCode(err);
47
+
48
+ // Handle 515 "restart required" - WhatsApp asks for reconnection after pairing
49
+ if (code === 515) {
50
+ console.log('WhatsApp asked for restart (code 515); credentials saved. Retrying connection...');
51
+ try {
52
+ sock.ws.close();
53
+ } catch {
54
+ // ignore
55
+ }
56
+
57
+ // Retry without QR - credentials are already saved from the first connection
58
+ const retry = await createWaSocket({
59
+ authDir: params.authDir,
60
+ printQr: false,
61
+ });
62
+
63
+ try {
64
+ await waitForWaConnection(retry);
65
+ resolved = true;
66
+ const phone = extractPhoneFromJid(retry.user?.id);
67
+ console.log('WhatsApp linked successfully after restart.');
68
+ return { phone };
69
+ } finally {
70
+ setTimeout(() => {
71
+ try {
72
+ retry.ws.close();
73
+ } catch {
74
+ // ignore
75
+ }
76
+ }, 500);
77
+ }
78
+ }
79
+
80
+ // Other errors
81
+ const formatted = formatError(err);
82
+ console.error(`WhatsApp connection failed: ${formatted}`);
83
+ throw new Error(formatted, { cause: err });
84
+ } finally {
85
+ setTimeout(() => {
86
+ try {
87
+ sock.ws.close();
88
+ } catch {
89
+ // ignore
90
+ }
91
+ }, 500);
92
+ }
93
+ }
94
+
@@ -0,0 +1,119 @@
1
+ import type { AnyMessageContent } from '@whiskeysockets/baileys';
2
+ import fs from 'node:fs';
3
+ import type { WaSocket } from './session.js';
4
+ import { loadGatewayConfig, resolveWhatsAppAccount } from '../../config.js';
5
+ import { normalizeE164, toWhatsappJid } from '../../utils.js';
6
+ import { appPath } from '../../../utils/paths.js';
7
+
8
+ function debugLog(msg: string) {
9
+ try {
10
+ const logDir = appPath('debug', 'logs');
11
+ const logPath = appPath('debug', 'logs', 'gateway-outbound.log');
12
+ fs.mkdirSync(logDir, { recursive: true });
13
+ fs.appendFileSync(logPath, `${new Date().toISOString()} ${msg}\n`);
14
+ } catch {
15
+ // Avoid breaking outbound sends if log dir is unwritable
16
+ }
17
+ }
18
+
19
+ type ActiveListener = {
20
+ accountId: string;
21
+ sock: WaSocket;
22
+ };
23
+
24
+ const listeners = new Map<string, ActiveListener>();
25
+
26
+ export function setActiveWebListener(accountId: string, sock: WaSocket | null): void {
27
+ if (!sock) {
28
+ listeners.delete(accountId);
29
+ return;
30
+ }
31
+ listeners.set(accountId, { accountId, sock });
32
+ }
33
+
34
+ function getActive(accountId?: string): ActiveListener {
35
+ if (accountId) {
36
+ const found = listeners.get(accountId);
37
+ if (found) {
38
+ return found;
39
+ }
40
+ }
41
+ const first = listeners.values().next().value as ActiveListener | undefined;
42
+ if (!first) {
43
+ throw new Error('No active WhatsApp listener. Run the gateway.');
44
+ }
45
+ return first;
46
+ }
47
+
48
+ function extractE164FromJid(jid: string): string | null {
49
+ const localPart = jid.split('@')[0] ?? '';
50
+ const rawPhone = localPart.includes(':') ? localPart.split(':')[0] : localPart;
51
+ if (!/^\d+$/.test(rawPhone)) {
52
+ return null;
53
+ }
54
+ return normalizeE164(rawPhone);
55
+ }
56
+
57
+ export function assertOutboundAllowed(params: {
58
+ to: string;
59
+ accountId?: string;
60
+ }): { toJid: string; recipientE164: string } {
61
+ const cfg = loadGatewayConfig();
62
+ const accountId = params.accountId ?? cfg.gateway.accountId;
63
+ const account = resolveWhatsAppAccount(cfg, accountId);
64
+ const toJid = toWhatsappJid(params.to);
65
+
66
+ if (toJid.endsWith('@g.us')) {
67
+ if (account.groupPolicy === 'disabled') {
68
+ throw new Error('Outbound blocked: group destinations are disabled in strict self-chat mode.');
69
+ }
70
+ // Group JIDs don't have E.164 recipients — skip individual recipient validation
71
+ return { toJid, recipientE164: '' };
72
+ }
73
+
74
+ const recipientE164 = extractE164FromJid(toJid);
75
+ if (!recipientE164) {
76
+ throw new Error(`Outbound blocked: invalid recipient JID ${toJid}`);
77
+ }
78
+
79
+ // Strict mode: require explicit recipient allowlist entries and ignore wildcard.
80
+ const explicitAllowedRecipients = account.allowFrom
81
+ .filter((entry) => entry !== '*')
82
+ .map(normalizeE164);
83
+ if (explicitAllowedRecipients.length === 0) {
84
+ throw new Error('Outbound blocked: no explicit allowFrom recipient configured.');
85
+ }
86
+ if (!explicitAllowedRecipients.includes(recipientE164)) {
87
+ throw new Error(`Outbound blocked: ${recipientE164} is not in allowFrom.`);
88
+ }
89
+
90
+ return { toJid, recipientE164 };
91
+ }
92
+
93
+ export async function sendMessageWhatsApp(params: {
94
+ to: string;
95
+ body: string;
96
+ accountId?: string;
97
+ media?: AnyMessageContent;
98
+ }): Promise<{ messageId: string; toJid: string }> {
99
+ const active = getActive(params.accountId);
100
+ debugLog(`[outbound] input to=${params.to}`);
101
+ const { toJid: to } = assertOutboundAllowed({ to: params.to, accountId: params.accountId });
102
+ debugLog(`[outbound] normalized to=${to}`);
103
+ const payload = params.media ?? { text: params.body };
104
+ debugLog(`[outbound] sending message...`);
105
+ const startedAt = Date.now();
106
+ const result = await active.sock.sendMessage(to, payload);
107
+ const durationMs = Date.now() - startedAt;
108
+ const messageId = result?.key?.id ?? 'unknown';
109
+ console.log(`Sent message ${messageId} -> ${to} (${durationMs}ms)`);
110
+ debugLog(`[outbound] sendMessage result id=${messageId}`);
111
+ return { messageId, toJid: to };
112
+ }
113
+
114
+ export async function sendComposing(params: { to: string; accountId?: string }): Promise<void> {
115
+ const active = getActive(params.accountId);
116
+ const { toJid: to } = assertOutboundAllowed({ to: params.to, accountId: params.accountId });
117
+ await active.sock.sendPresenceUpdate('composing', to);
118
+ }
119
+
@@ -0,0 +1,54 @@
1
+ import type { GatewayConfig, WhatsAppAccountConfig } from '../../config.js';
2
+ import { listWhatsAppAccountIds, resolveWhatsAppAccount } from '../../config.js';
3
+ import type { ChannelPlugin } from '../types.js';
4
+ import { monitorWhatsAppChannel, type WhatsAppInboundMessage } from './index.js';
5
+ import { resolveReconnectPolicy } from './reconnect.js';
6
+
7
+ export function createWhatsAppPlugin(params: {
8
+ loadConfig: () => GatewayConfig;
9
+ onMessage: (msg: WhatsAppInboundMessage) => Promise<void>;
10
+ }): ChannelPlugin<GatewayConfig, WhatsAppAccountConfig> {
11
+ return {
12
+ id: 'whatsapp',
13
+ config: {
14
+ listAccountIds: (cfg) => listWhatsAppAccountIds(cfg),
15
+ resolveAccount: (cfg, accountId) => resolveWhatsAppAccount(cfg, accountId),
16
+ isEnabled: (account, cfg) => account.enabled && cfg.channels.whatsapp.enabled !== false,
17
+ isConfigured: async (account) => Boolean(account.authDir),
18
+ },
19
+ gateway: {
20
+ startAccount: async (ctx) => {
21
+ const cfg = params.loadConfig();
22
+ await monitorWhatsAppChannel({
23
+ accountId: ctx.accountId,
24
+ authDir: ctx.account.authDir,
25
+ verbose: true,
26
+ allowFrom: ctx.account.allowFrom,
27
+ dmPolicy: ctx.account.dmPolicy,
28
+ groupPolicy: ctx.account.groupPolicy,
29
+ groupAllowFrom: ctx.account.groupAllowFrom,
30
+ sendReadReceipts: ctx.account.sendReadReceipts,
31
+ heartbeatSeconds: cfg.gateway.heartbeatSeconds,
32
+ reconnect: resolveReconnectPolicy(cfg),
33
+ abortSignal: ctx.abortSignal,
34
+ onMessage: params.onMessage,
35
+ onStatus: (status) => {
36
+ ctx.setStatus({
37
+ connected: status.connected,
38
+ lastError: status.lastError ?? null,
39
+ });
40
+ },
41
+ });
42
+ },
43
+ },
44
+ status: {
45
+ defaultRuntime: {
46
+ accountId: 'default',
47
+ running: false,
48
+ connected: false,
49
+ lastError: null,
50
+ },
51
+ },
52
+ };
53
+ }
54
+