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.
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/assets/kalshi-flow-light.png +0 -0
- package/assets/screenshot.png +0 -0
- package/env.example +43 -0
- package/kalshi-flow-light.png +0 -0
- package/package.json +66 -0
- package/src/agent/agent.ts +249 -0
- package/src/agent/channels.ts +53 -0
- package/src/agent/index.ts +29 -0
- package/src/agent/prompts.ts +171 -0
- package/src/agent/run-context.ts +23 -0
- package/src/agent/scratchpad.ts +465 -0
- package/src/agent/token-counter.ts +33 -0
- package/src/agent/tool-executor.ts +166 -0
- package/src/agent/types.ts +221 -0
- package/src/audit/index.ts +25 -0
- package/src/audit/reader.ts +43 -0
- package/src/audit/trail.ts +29 -0
- package/src/audit/types.ts +133 -0
- package/src/backtest/discovery.ts +170 -0
- package/src/backtest/fetcher.ts +247 -0
- package/src/backtest/metrics.ts +165 -0
- package/src/backtest/renderer.ts +196 -0
- package/src/backtest/types.ts +45 -0
- package/src/cli.ts +943 -0
- package/src/commands/alerts.ts +48 -0
- package/src/commands/analyze.ts +662 -0
- package/src/commands/backtest.ts +276 -0
- package/src/commands/clear-cache.ts +24 -0
- package/src/commands/config.ts +107 -0
- package/src/commands/dispatch.ts +473 -0
- package/src/commands/edge.ts +62 -0
- package/src/commands/formatters.ts +339 -0
- package/src/commands/help.ts +263 -0
- package/src/commands/helpers.ts +48 -0
- package/src/commands/index.ts +287 -0
- package/src/commands/json.ts +43 -0
- package/src/commands/parse-args.ts +229 -0
- package/src/commands/portfolio.ts +236 -0
- package/src/commands/review.ts +176 -0
- package/src/commands/scan-formatters.ts +98 -0
- package/src/commands/scan.ts +38 -0
- package/src/commands/search-edge.ts +139 -0
- package/src/commands/status.ts +70 -0
- package/src/commands/themes.ts +117 -0
- package/src/commands/watch.ts +295 -0
- package/src/components/answer-box.ts +57 -0
- package/src/components/approval-prompt.ts +34 -0
- package/src/components/browse-list.ts +134 -0
- package/src/components/chat-log.ts +291 -0
- package/src/components/custom-editor.ts +18 -0
- package/src/components/debug-panel.ts +52 -0
- package/src/components/index.ts +17 -0
- package/src/components/intro.ts +92 -0
- package/src/components/select-list.ts +155 -0
- package/src/components/tool-event.ts +127 -0
- package/src/components/user-query.ts +18 -0
- package/src/components/working-indicator.ts +87 -0
- package/src/controllers/agent-runner.ts +283 -0
- package/src/controllers/browse.ts +1013 -0
- package/src/controllers/index.ts +7 -0
- package/src/controllers/input-history.ts +76 -0
- package/src/controllers/model-selection.ts +244 -0
- package/src/db/alerts.ts +77 -0
- package/src/db/edge.ts +105 -0
- package/src/db/event-index.ts +323 -0
- package/src/db/events.ts +41 -0
- package/src/db/index.ts +60 -0
- package/src/db/octagon-cache.ts +118 -0
- package/src/db/positions.ts +71 -0
- package/src/db/risk.ts +51 -0
- package/src/db/schema.ts +227 -0
- package/src/db/themes.ts +34 -0
- package/src/db/trades.ts +50 -0
- package/src/eval/brier.ts +90 -0
- package/src/eval/index.ts +4 -0
- package/src/eval/performance.ts +87 -0
- package/src/gateway/access-control.ts +253 -0
- package/src/gateway/agent-runner.ts +75 -0
- package/src/gateway/alerts/formatter.ts +90 -0
- package/src/gateway/alerts/index.ts +4 -0
- package/src/gateway/alerts/router.ts +32 -0
- package/src/gateway/alerts/terminal.ts +16 -0
- package/src/gateway/alerts/types.ts +13 -0
- package/src/gateway/channels/index.ts +9 -0
- package/src/gateway/channels/manager.ts +153 -0
- package/src/gateway/channels/types.ts +48 -0
- package/src/gateway/channels/whatsapp/README.md +234 -0
- package/src/gateway/channels/whatsapp/auth-store.ts +140 -0
- package/src/gateway/channels/whatsapp/dedupe.ts +60 -0
- package/src/gateway/channels/whatsapp/error.ts +122 -0
- package/src/gateway/channels/whatsapp/inbound.ts +326 -0
- package/src/gateway/channels/whatsapp/index.ts +5 -0
- package/src/gateway/channels/whatsapp/lid.ts +56 -0
- package/src/gateway/channels/whatsapp/logger.ts +25 -0
- package/src/gateway/channels/whatsapp/login.ts +94 -0
- package/src/gateway/channels/whatsapp/outbound.ts +119 -0
- package/src/gateway/channels/whatsapp/plugin.ts +54 -0
- package/src/gateway/channels/whatsapp/reconnect.ts +40 -0
- package/src/gateway/channels/whatsapp/runtime.ts +122 -0
- package/src/gateway/channels/whatsapp/session.ts +89 -0
- package/src/gateway/channels/whatsapp/types.ts +32 -0
- package/src/gateway/commands/handler.ts +64 -0
- package/src/gateway/commands/index.ts +7 -0
- package/src/gateway/commands/parser.ts +29 -0
- package/src/gateway/commands/wa-formatters.ts +92 -0
- package/src/gateway/config.ts +244 -0
- package/src/gateway/extension-points.ts +17 -0
- package/src/gateway/gateway.ts +301 -0
- package/src/gateway/group/history-buffer.ts +75 -0
- package/src/gateway/group/index.ts +8 -0
- package/src/gateway/group/member-tracker.ts +60 -0
- package/src/gateway/group/mention-detection.ts +42 -0
- package/src/gateway/heartbeat/index.ts +8 -0
- package/src/gateway/heartbeat/prompt.ts +73 -0
- package/src/gateway/heartbeat/runner.ts +200 -0
- package/src/gateway/heartbeat/suppression.ts +74 -0
- package/src/gateway/index.ts +138 -0
- package/src/gateway/routing/resolve-route.ts +119 -0
- package/src/gateway/sessions/store.ts +65 -0
- package/src/gateway/types.ts +11 -0
- package/src/gateway/utils.ts +82 -0
- package/src/index.tsx +30 -0
- package/src/model/llm.ts +247 -0
- package/src/providers.ts +94 -0
- package/src/risk/circuit-breaker.ts +113 -0
- package/src/risk/correlation.ts +40 -0
- package/src/risk/gate.ts +125 -0
- package/src/risk/index.ts +10 -0
- package/src/risk/kelly.ts +230 -0
- package/src/scan/alerter.ts +64 -0
- package/src/scan/edge-computer.ts +164 -0
- package/src/scan/invoker.ts +199 -0
- package/src/scan/loop.ts +184 -0
- package/src/scan/octagon-client.ts +627 -0
- package/src/scan/octagon-events-api.ts +105 -0
- package/src/scan/octagon-prefetch.ts +172 -0
- package/src/scan/theme-resolver.ts +179 -0
- package/src/scan/types.ts +62 -0
- package/src/scan/watchdog.ts +126 -0
- package/src/setup/wizard.ts +659 -0
- package/src/theme.ts +67 -0
- package/src/tools/fetch/cache.ts +95 -0
- package/src/tools/fetch/external-content.ts +200 -0
- package/src/tools/fetch/index.ts +1 -0
- package/src/tools/fetch/web-fetch-utils.ts +122 -0
- package/src/tools/fetch/web-fetch.ts +419 -0
- package/src/tools/index.ts +10 -0
- package/src/tools/kalshi/api.ts +251 -0
- package/src/tools/kalshi/dlq.ts +35 -0
- package/src/tools/kalshi/events.ts +84 -0
- package/src/tools/kalshi/exchange.ts +24 -0
- package/src/tools/kalshi/historical.ts +89 -0
- package/src/tools/kalshi/index.ts +11 -0
- package/src/tools/kalshi/kalshi-search.ts +437 -0
- package/src/tools/kalshi/kalshi-trade.ts +102 -0
- package/src/tools/kalshi/markets.ts +76 -0
- package/src/tools/kalshi/portfolio.ts +100 -0
- package/src/tools/kalshi/search-index.ts +198 -0
- package/src/tools/kalshi/series.ts +16 -0
- package/src/tools/kalshi/trading.ts +115 -0
- package/src/tools/kalshi/types.ts +199 -0
- package/src/tools/registry.ts +160 -0
- package/src/tools/search/index.ts +25 -0
- package/src/tools/search/tavily.ts +35 -0
- package/src/tools/types.ts +53 -0
- package/src/tools/v2/edge-query.ts +135 -0
- package/src/tools/v2/octagon-report.ts +112 -0
- package/src/tools/v2/portfolio-query.ts +79 -0
- package/src/tools/v2/portfolio-review.ts +59 -0
- package/src/tools/v2/risk-status.ts +94 -0
- package/src/tools/v2/scan.ts +78 -0
- package/src/types/qrcode-terminal.d.ts +7 -0
- package/src/types/whiskeysockets-baileys.d.ts +41 -0
- package/src/types.ts +22 -0
- package/src/utils/ai-message.ts +26 -0
- package/src/utils/bot-config.ts +219 -0
- package/src/utils/cache.ts +195 -0
- package/src/utils/config.ts +113 -0
- package/src/utils/env.ts +111 -0
- package/src/utils/errors.ts +313 -0
- package/src/utils/history-context.ts +32 -0
- package/src/utils/in-memory-chat-history.ts +268 -0
- package/src/utils/index.ts +28 -0
- package/src/utils/input-key-handlers.ts +64 -0
- package/src/utils/logger.ts +67 -0
- package/src/utils/long-term-chat-history.ts +138 -0
- package/src/utils/markdown-table.ts +227 -0
- package/src/utils/model.ts +70 -0
- package/src/utils/ollama.ts +37 -0
- package/src/utils/paths.ts +12 -0
- package/src/utils/progress-channel.ts +84 -0
- package/src/utils/telemetry.ts +103 -0
- package/src/utils/text-navigation.ts +81 -0
- package/src/utils/thinking-verbs.ts +18 -0
- package/src/utils/tokens.ts +36 -0
- 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,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
|
+
|