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,42 @@
1
+ /**
2
+ * Detect if the bot was @-mentioned in a group message.
3
+ */
4
+ export function isBotMentioned(params: {
5
+ mentionedJids?: string[];
6
+ selfJid?: string | null;
7
+ selfLid?: string | null;
8
+ selfE164?: string | null;
9
+ body: string;
10
+ }): boolean {
11
+ const { mentionedJids, selfJid, selfLid, selfE164, body } = params;
12
+
13
+ if (mentionedJids?.length) {
14
+ // Collect all known base identifiers for the bot (phone JID + LID)
15
+ const selfBases = new Set<string>();
16
+ for (const id of [selfJid, selfLid]) {
17
+ if (id) {
18
+ const base = id.split('@')[0]?.split(':')[0];
19
+ if (base) selfBases.add(base);
20
+ }
21
+ }
22
+
23
+ if (selfBases.size > 0) {
24
+ for (const jid of mentionedJids) {
25
+ const base = jid.split('@')[0]?.split(':')[0];
26
+ if (base && selfBases.has(base)) {
27
+ return true;
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ // Fallback: check if bot's phone digits appear in message body
34
+ if (selfE164) {
35
+ const digits = selfE164.replace(/\D/g, '');
36
+ if (digits.length >= 7 && body.includes(digits)) {
37
+ return true;
38
+ }
39
+ }
40
+
41
+ return false;
42
+ }
@@ -0,0 +1,8 @@
1
+ export { startHeartbeatRunner, type HeartbeatRunner } from './runner.js';
2
+ export { buildHeartbeatQuery, loadHeartbeatDocument, isHeartbeatContentEmpty } from './prompt.js';
3
+ export {
4
+ HEARTBEAT_OK_TOKEN,
5
+ evaluateSuppression,
6
+ type SuppressionResult,
7
+ type SuppressionState,
8
+ } from './suppression.js';
@@ -0,0 +1,73 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { HEARTBEAT_OK_TOKEN } from './suppression.js';
3
+ import { appPath } from '../../utils/paths.js';
4
+
5
+ const HEARTBEAT_MD_PATH = appPath('HEARTBEAT.md');
6
+
7
+ const DEFAULT_CHECKLIST = `- Major index moves (S&P 500, NASDAQ, Dow) — alert if any move more than 2% in a session
8
+ - Breaking financial news — major earnings surprises, Fed announcements, significant market events`;
9
+
10
+ /**
11
+ * Load HEARTBEAT.md content from the app directory.
12
+ * Returns the content string, or null if the file doesn't exist.
13
+ */
14
+ export async function loadHeartbeatDocument(): Promise<string | null> {
15
+ try {
16
+ return await readFile(HEARTBEAT_MD_PATH, 'utf-8');
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Check if heartbeat content is effectively empty
24
+ * (only headers, whitespace, or empty list items).
25
+ */
26
+ export function isHeartbeatContentEmpty(content: string): boolean {
27
+ const lines = content.split('\n');
28
+ for (const line of lines) {
29
+ const trimmed = line.trim();
30
+ // Skip empty lines, headers, and empty list items
31
+ if (!trimmed) continue;
32
+ if (/^#+\s*$/.test(trimmed)) continue;
33
+ if (/^#+\s/.test(trimmed)) continue;
34
+ if (/^[-*]\s*$/.test(trimmed)) continue;
35
+ // Non-empty content found
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+
41
+ /**
42
+ * Build the heartbeat query to send to the agent.
43
+ * Returns null if the file exists but is empty (skip heartbeat).
44
+ * Uses a default checklist if no file exists.
45
+ */
46
+ export async function buildHeartbeatQuery(): Promise<string | null> {
47
+ const content = await loadHeartbeatDocument();
48
+
49
+ let checklist: string;
50
+ if (content !== null) {
51
+ if (isHeartbeatContentEmpty(content)) {
52
+ return null; // File exists but is empty — skip heartbeat
53
+ }
54
+ checklist = content;
55
+ } else {
56
+ checklist = DEFAULT_CHECKLIST;
57
+ }
58
+
59
+ return `[HEARTBEAT CHECK]
60
+
61
+ You are running as a periodic heartbeat. Review the following checklist and check if anything noteworthy has happened that the user should know about.
62
+
63
+ ## Checklist
64
+ ${checklist}
65
+
66
+ ## Instructions
67
+ - Use your tools to check each item on the checklist
68
+ - If you find something noteworthy, write a concise alert message for the user
69
+ - If nothing noteworthy is happening, respond with exactly: ${HEARTBEAT_OK_TOKEN}
70
+ - Do NOT send a message just to say "everything is fine" — only message if there's something actionable or noteworthy
71
+ - Keep alerts brief and focused — lead with the key finding
72
+ - You may combine multiple findings into one message`;
73
+ }
@@ -0,0 +1,200 @@
1
+ import { appendFileSync } from 'node:fs';
2
+ import { loadGatewayConfig } from '../config.js';
3
+ import { runAgentForMessage } from '../agent-runner.js';
4
+ import { assertOutboundAllowed, sendMessageWhatsApp } from '../channels/whatsapp/index.js';
5
+ import { resolveSessionStorePath, loadSessionStore, type SessionEntry } from '../sessions/store.js';
6
+ import { cleanMarkdownForWhatsApp } from '../utils.js';
7
+ import { buildHeartbeatQuery } from './prompt.js';
8
+ import { evaluateSuppression, type SuppressionState } from './suppression.js';
9
+ import { appPath } from '../../utils/paths.js';
10
+ import { getSetting } from '../../utils/config.js';
11
+
12
+ const LOG_PATH = appPath('gateway-debug.log');
13
+
14
+ function debugLog(msg: string) {
15
+ appendFileSync(LOG_PATH, `${new Date().toISOString()} ${msg}\n`);
16
+ }
17
+
18
+ /**
19
+ * Check if the current time is within the configured active hours and days.
20
+ * Defaults to NYSE market hours: 9:30 AM - 4:00 PM ET, Mon-Fri.
21
+ */
22
+ function isWithinActiveHours(activeHours?: {
23
+ start: string;
24
+ end: string;
25
+ timezone?: string;
26
+ daysOfWeek?: number[];
27
+ }): boolean {
28
+ if (!activeHours) return true;
29
+
30
+ const tz = activeHours.timezone ?? 'America/New_York';
31
+ const now = new Date();
32
+
33
+ // Check day of week (0=Sun, 1=Mon, ..., 6=Sat)
34
+ const allowedDays = activeHours.daysOfWeek ?? [1, 2, 3, 4, 5];
35
+ const dayFormatter = new Intl.DateTimeFormat('en-US', {
36
+ timeZone: tz,
37
+ weekday: 'short',
38
+ });
39
+ const dayStr = dayFormatter.format(now);
40
+ const dayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
41
+ const currentDay = dayMap[dayStr] ?? new Date().getDay();
42
+ if (!allowedDays.includes(currentDay)) {
43
+ return false;
44
+ }
45
+
46
+ // Check time window
47
+ const timeFormatter = new Intl.DateTimeFormat('en-US', {
48
+ timeZone: tz,
49
+ hour: '2-digit',
50
+ minute: '2-digit',
51
+ hour12: false,
52
+ });
53
+ const currentTime = timeFormatter.format(now); // "HH:MM"
54
+
55
+ return currentTime >= activeHours.start && currentTime <= activeHours.end;
56
+ }
57
+
58
+ /**
59
+ * Find the most recently updated session that has a delivery target (lastTo).
60
+ */
61
+ function findTargetSession(): SessionEntry | null {
62
+ const storePath = resolveSessionStorePath('default');
63
+ const store = loadSessionStore(storePath);
64
+ const entries = Object.values(store).filter((e) => e.lastTo);
65
+
66
+ if (entries.length === 0) return null;
67
+
68
+ // Sort by updatedAt descending, return the most recent
69
+ entries.sort((a, b) => b.updatedAt - a.updatedAt);
70
+ return entries[0];
71
+ }
72
+
73
+ export type HeartbeatRunner = {
74
+ stop: () => void;
75
+ };
76
+
77
+ /**
78
+ * Start the heartbeat runner. Schedules periodic heartbeat checks using setTimeout.
79
+ * Re-reads config each cycle so changes take effect without restart.
80
+ * First tick fires after one full interval (no startup burst).
81
+ */
82
+ export function startHeartbeatRunner(params: { configPath?: string }): HeartbeatRunner {
83
+ let stopped = false;
84
+ let timer: ReturnType<typeof setTimeout> | undefined;
85
+ let running = false;
86
+ const suppressionState: SuppressionState = {
87
+ lastMessageText: null,
88
+ lastMessageAt: null,
89
+ };
90
+
91
+ async function tick(): Promise<void> {
92
+ if (stopped || running) return;
93
+ running = true;
94
+
95
+ try {
96
+ const cfg = loadGatewayConfig(params.configPath);
97
+ const heartbeatCfg = cfg.gateway.heartbeat;
98
+
99
+ // Check if enabled
100
+ if (!heartbeatCfg?.enabled) {
101
+ debugLog('[heartbeat] disabled in config, skipping');
102
+ return;
103
+ }
104
+
105
+ // Check active hours
106
+ if (!isWithinActiveHours(heartbeatCfg.activeHours)) {
107
+ debugLog('[heartbeat] outside active hours, skipping');
108
+ return;
109
+ }
110
+
111
+ // Find target session
112
+ const session = findTargetSession();
113
+ if (!session || !session.lastTo || !session.lastAccountId) {
114
+ debugLog('[heartbeat] no target session found (user has not messaged yet), skipping');
115
+ return;
116
+ }
117
+
118
+ // Verify outbound is allowed
119
+ try {
120
+ assertOutboundAllowed({ to: session.lastTo, accountId: session.lastAccountId });
121
+ } catch (error) {
122
+ const msg = error instanceof Error ? error.message : String(error);
123
+ debugLog(`[heartbeat] outbound BLOCKED: ${msg}`);
124
+ return;
125
+ }
126
+
127
+ // Build heartbeat query
128
+ const query = await buildHeartbeatQuery();
129
+ if (query === null) {
130
+ debugLog('[heartbeat] HEARTBEAT.md exists but is empty, skipping');
131
+ return;
132
+ }
133
+
134
+ // Run agent
135
+ debugLog(`[heartbeat] running agent for session=${session.sessionKey}`);
136
+ const model = heartbeatCfg.model ?? getSetting('modelId', 'gpt-5.4') as string;
137
+ const modelProvider = heartbeatCfg.modelProvider ?? getSetting('provider', 'openai') as string;
138
+ const answer = await runAgentForMessage({
139
+ sessionKey: session.sessionKey,
140
+ query,
141
+ model,
142
+ modelProvider,
143
+ maxIterations: heartbeatCfg.maxIterations,
144
+ isHeartbeat: true,
145
+ channel: 'whatsapp',
146
+ });
147
+ debugLog(`[heartbeat] agent answer length=${answer.length}`);
148
+
149
+ // Evaluate suppression
150
+ const result = evaluateSuppression(answer, suppressionState);
151
+ debugLog(`[heartbeat] suppression: shouldSuppress=${result.shouldSuppress} reason=${result.reason}`);
152
+
153
+ if (!result.shouldSuppress) {
154
+ const cleaned = cleanMarkdownForWhatsApp(result.cleanedText);
155
+ await sendMessageWhatsApp({
156
+ to: session.lastTo,
157
+ body: cleaned,
158
+ accountId: session.lastAccountId,
159
+ });
160
+ debugLog(`[heartbeat] sent message to ${session.lastTo}`);
161
+
162
+ // Update suppression state for duplicate detection
163
+ suppressionState.lastMessageText = result.cleanedText;
164
+ suppressionState.lastMessageAt = Date.now();
165
+ }
166
+ } catch (err) {
167
+ const msg = err instanceof Error ? err.message : String(err);
168
+ debugLog(`[heartbeat] ERROR: ${msg}`);
169
+ } finally {
170
+ running = false;
171
+ scheduleNext();
172
+ }
173
+ }
174
+
175
+ function scheduleNext(): void {
176
+ if (stopped) return;
177
+
178
+ // Re-read config for interval (may have changed)
179
+ const cfg = loadGatewayConfig(params.configPath);
180
+ const intervalMs = (cfg.gateway.heartbeat?.intervalMinutes ?? 30) * 60 * 1000;
181
+
182
+ timer = setTimeout(() => void tick(), intervalMs);
183
+ timer.unref(); // Don't block shutdown
184
+ }
185
+
186
+ // Schedule first tick after one full interval (no startup burst)
187
+ debugLog('[heartbeat] runner started');
188
+ scheduleNext();
189
+
190
+ return {
191
+ stop() {
192
+ stopped = true;
193
+ if (timer) {
194
+ clearTimeout(timer);
195
+ timer = undefined;
196
+ }
197
+ debugLog('[heartbeat] runner stopped');
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,74 @@
1
+ export const HEARTBEAT_OK_TOKEN = 'HEARTBEAT_OK';
2
+
3
+ type SuppressionReason = 'ok-token' | 'empty' | 'duplicate' | 'none';
4
+
5
+ export type SuppressionResult = {
6
+ shouldSuppress: boolean;
7
+ cleanedText: string;
8
+ reason: SuppressionReason;
9
+ };
10
+
11
+ export type SuppressionState = {
12
+ lastMessageText: string | null;
13
+ lastMessageAt: number | null;
14
+ };
15
+
16
+ const DUPLICATE_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours
17
+
18
+ /**
19
+ * Strip the HEARTBEAT_OK token from text, handling bold markdown wrappers
20
+ * and trailing punctuation.
21
+ */
22
+ function stripOkToken(text: string): string {
23
+ // Match HEARTBEAT_OK with optional bold wrappers (**) and trailing punctuation
24
+ const pattern = /^\s*(?:\*\*)?HEARTBEAT_OK(?:\*\*)?[.!]?\s*|\s*(?:\*\*)?HEARTBEAT_OK(?:\*\*)?[.!]?\s*$/gi;
25
+ return text.replace(pattern, '').trim();
26
+ }
27
+
28
+ /**
29
+ * Check if the text is essentially just the HEARTBEAT_OK token
30
+ * (possibly with bold wrappers and punctuation).
31
+ */
32
+ function isJustOkToken(text: string): boolean {
33
+ const stripped = text.trim();
34
+ return /^(?:\*\*)?HEARTBEAT_OK(?:\*\*)?[.!]?\s*$/.test(stripped);
35
+ }
36
+
37
+ /**
38
+ * Evaluate whether a heartbeat response should be suppressed.
39
+ */
40
+ export function evaluateSuppression(
41
+ text: string,
42
+ state: SuppressionState,
43
+ ): SuppressionResult {
44
+ const trimmed = text.trim();
45
+
46
+ // Empty response
47
+ if (!trimmed) {
48
+ return { shouldSuppress: true, cleanedText: '', reason: 'empty' };
49
+ }
50
+
51
+ // Response is just the HEARTBEAT_OK token
52
+ if (isJustOkToken(trimmed)) {
53
+ return { shouldSuppress: true, cleanedText: '', reason: 'ok-token' };
54
+ }
55
+
56
+ // Strip token from start/end if it appears alongside other content
57
+ const cleaned = stripOkToken(trimmed);
58
+
59
+ if (!cleaned) {
60
+ return { shouldSuppress: true, cleanedText: '', reason: 'ok-token' };
61
+ }
62
+
63
+ // Duplicate suppression: same text within 24h
64
+ if (
65
+ state.lastMessageText !== null &&
66
+ state.lastMessageAt !== null &&
67
+ Date.now() - state.lastMessageAt < DUPLICATE_WINDOW_MS &&
68
+ cleaned === state.lastMessageText
69
+ ) {
70
+ return { shouldSuppress: true, cleanedText: cleaned, reason: 'duplicate' };
71
+ }
72
+
73
+ return { shouldSuppress: false, cleanedText: cleaned, reason: 'none' };
74
+ }
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env tsx
2
+ import { existsSync } from 'node:fs';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import util from 'node:util';
5
+ import {
6
+ resolveWhatsAppAccount,
7
+ loadGatewayConfig,
8
+ saveGatewayConfig,
9
+ getGatewayConfigPath,
10
+ type GatewayConfig,
11
+ } from './config.js';
12
+ import { loginWhatsApp } from './channels/whatsapp/login.js';
13
+ import { startGateway } from './gateway.js';
14
+
15
+ // Suppress noisy Baileys Signal protocol session logs
16
+ const SUPPRESSED_PREFIXES = [
17
+ 'Closing session:',
18
+ 'Opening session:',
19
+ 'Removing old closed session:',
20
+ 'Session already closed',
21
+ 'Session already open',
22
+ ];
23
+
24
+ const originalLog = console.log;
25
+ console.log = (...args: unknown[]) => {
26
+ const formatted = util.format(...args);
27
+ if (SUPPRESSED_PREFIXES.some((prefix) => formatted.startsWith(prefix))) {
28
+ return;
29
+ }
30
+ originalLog.apply(console, args);
31
+ };
32
+
33
+ const E164_RE = /^\+\d{7,15}$/;
34
+
35
+ async function promptSetupMode(cfg: GatewayConfig, linkedPhone: string): Promise<void> {
36
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
37
+
38
+ try {
39
+ console.log('');
40
+ console.log(`Linked phone: ${linkedPhone}`);
41
+ console.log('');
42
+ console.log('How will you use the bot with WhatsApp?');
43
+ console.log(' 1) Self-chat — message yourself to talk to the bot');
44
+ console.log(' 2) Bot phone — this is a dedicated bot phone, others message it');
45
+
46
+ let mode = '';
47
+ while (mode !== '1' && mode !== '2') {
48
+ mode = (await rl.question('\nChoose (1 or 2): ')).trim();
49
+ }
50
+
51
+ const accountId = cfg.gateway.accountId ?? 'default';
52
+
53
+ if (mode === '1') {
54
+ cfg.channels.whatsapp.allowFrom = [linkedPhone];
55
+ return;
56
+ }
57
+
58
+ // Bot mode: collect allowed sender phone numbers
59
+ console.log('');
60
+ console.log('Enter the phone number(s) allowed to message the bot (E.164 format, e.g. +15551234567).');
61
+ console.log('Separate multiple numbers with commas, or type * to allow anyone.');
62
+
63
+ let phones: string[] = [];
64
+ while (phones.length === 0) {
65
+ const input = (await rl.question('Allowed number(s): ')).trim();
66
+ if (!input) continue;
67
+
68
+ if (input === '*') {
69
+ phones = ['*'];
70
+ break;
71
+ }
72
+
73
+ phones = input.split(',').map((s) => s.trim()).filter(Boolean);
74
+ const invalid = phones.filter((p) => !E164_RE.test(p));
75
+ if (invalid.length > 0) {
76
+ console.log(`Invalid format: ${invalid.join(', ')}. Use E.164 format (e.g. +15551234567).`);
77
+ phones = [];
78
+ }
79
+ }
80
+
81
+ cfg.channels.whatsapp.accounts[accountId] = {
82
+ enabled: true,
83
+ dmPolicy: 'allowlist',
84
+ allowFrom: phones,
85
+ groupPolicy: 'disabled',
86
+ groupAllowFrom: [],
87
+ sendReadReceipts: true,
88
+ };
89
+ cfg.channels.whatsapp.allowFrom = phones;
90
+ } finally {
91
+ rl.close();
92
+ }
93
+ }
94
+
95
+ async function run(): Promise<void> {
96
+ const args = process.argv.slice(2);
97
+ const command = args[0] ?? 'run';
98
+
99
+ if (command === 'login') {
100
+ const cfg = loadGatewayConfig();
101
+ const accountId = cfg.gateway.accountId ?? 'default';
102
+ const account = resolveWhatsAppAccount(cfg, accountId);
103
+ const result = await loginWhatsApp({ authDir: account.authDir });
104
+
105
+ const configPath = getGatewayConfigPath();
106
+ const configExists = existsSync(configPath);
107
+
108
+ if (result.phone && (!configExists || cfg.channels.whatsapp.allowFrom.length === 0)) {
109
+ await promptSetupMode(cfg, result.phone);
110
+ saveGatewayConfig(cfg);
111
+ console.log(`Saved gateway config to ${configPath}`);
112
+ } else if (result.phone && configExists) {
113
+ const currentAllowFrom = cfg.channels.whatsapp.allowFrom;
114
+ if (!currentAllowFrom.includes(result.phone)) {
115
+ console.log(`Config already exists at ${configPath} — no changes made.`);
116
+ console.log(`Linked phone ${result.phone} is not in allowFrom. Edit the config if needed.`);
117
+ }
118
+ } else if (!configExists) {
119
+ saveGatewayConfig(cfg);
120
+ console.log(`Created default config at ${configPath}`);
121
+ console.log('Add your phone number to channels.whatsapp.allowFrom to receive messages.');
122
+ }
123
+ return;
124
+ }
125
+
126
+ const server = await startGateway();
127
+ console.log('Gateway running. Press Ctrl+C to stop.');
128
+
129
+ const shutdown = async () => {
130
+ await server.stop();
131
+ process.exit(0);
132
+ };
133
+ process.once('SIGINT', shutdown);
134
+ process.once('SIGTERM', shutdown);
135
+ }
136
+
137
+ void run();
138
+
@@ -0,0 +1,119 @@
1
+ import type { GatewayConfig } from '../config.js';
2
+
3
+ export type RoutePeer = {
4
+ kind: 'direct' | 'group';
5
+ id: string;
6
+ };
7
+
8
+ export type ResolvedRoute = {
9
+ agentId: string;
10
+ channel: string;
11
+ accountId: string;
12
+ sessionKey: string;
13
+ mainSessionKey: string;
14
+ matchedBy: 'binding.peer' | 'binding.account' | 'binding.channel' | 'default';
15
+ };
16
+
17
+ const DEFAULT_AGENT_ID = 'default';
18
+ const DEFAULT_ACCOUNT_ID = 'default';
19
+
20
+ function normalizeToken(value: string | null | undefined): string {
21
+ return (value ?? '').trim().toLowerCase();
22
+ }
23
+
24
+ export function buildSessionKey(params: {
25
+ agentId: string;
26
+ channel: string;
27
+ accountId: string;
28
+ peer?: RoutePeer | null;
29
+ }): string {
30
+ const channel = normalizeToken(params.channel);
31
+ const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID;
32
+ if (!params.peer) {
33
+ return `agent:${params.agentId}:main`;
34
+ }
35
+ const peerKind = params.peer.kind;
36
+ const peerId = params.peer.id.trim().toLowerCase();
37
+ return `agent:${params.agentId}:${channel}:${accountId}:${peerKind}:${peerId}`;
38
+ }
39
+
40
+ export function resolveRoute(input: {
41
+ cfg: GatewayConfig;
42
+ channel: string;
43
+ accountId?: string | null;
44
+ peer?: RoutePeer | null;
45
+ }): ResolvedRoute {
46
+ const channel = normalizeToken(input.channel);
47
+ const accountId = (input.accountId ?? DEFAULT_ACCOUNT_ID).trim() || DEFAULT_ACCOUNT_ID;
48
+ const peer = input.peer ? { kind: input.peer.kind, id: input.peer.id.trim() } : null;
49
+
50
+ const bindings = input.cfg.bindings.filter((binding) => {
51
+ if (normalizeToken(binding.match.channel) !== channel) {
52
+ return false;
53
+ }
54
+ if (binding.match.accountId && binding.match.accountId !== '*' && binding.match.accountId !== accountId) {
55
+ return false;
56
+ }
57
+ return true;
58
+ });
59
+
60
+ if (peer) {
61
+ const peerMatch = bindings.find(
62
+ (binding) => binding.match.peerKind === peer.kind && binding.match.peerId === peer.id,
63
+ );
64
+ if (peerMatch) {
65
+ const agentId = peerMatch.agentId.trim() || DEFAULT_AGENT_ID;
66
+ return {
67
+ agentId,
68
+ channel,
69
+ accountId,
70
+ sessionKey: buildSessionKey({ agentId, channel, accountId, peer }),
71
+ mainSessionKey: buildSessionKey({ agentId, channel, accountId, peer: null }),
72
+ matchedBy: 'binding.peer',
73
+ };
74
+ }
75
+ }
76
+
77
+ const accountMatch = bindings.find(
78
+ (binding) => Boolean(binding.match.accountId) && !binding.match.peerId,
79
+ );
80
+ if (accountMatch) {
81
+ const agentId = accountMatch.agentId.trim() || DEFAULT_AGENT_ID;
82
+ return {
83
+ agentId,
84
+ channel,
85
+ accountId,
86
+ sessionKey: buildSessionKey({ agentId, channel, accountId, peer }),
87
+ mainSessionKey: buildSessionKey({ agentId, channel, accountId, peer: null }),
88
+ matchedBy: 'binding.account',
89
+ };
90
+ }
91
+
92
+ const channelMatch = bindings.find((binding) => !binding.match.accountId && !binding.match.peerId);
93
+ if (channelMatch) {
94
+ const agentId = channelMatch.agentId.trim() || DEFAULT_AGENT_ID;
95
+ return {
96
+ agentId,
97
+ channel,
98
+ accountId,
99
+ sessionKey: buildSessionKey({ agentId, channel, accountId, peer }),
100
+ mainSessionKey: buildSessionKey({ agentId, channel, accountId, peer: null }),
101
+ matchedBy: 'binding.channel',
102
+ };
103
+ }
104
+
105
+ return {
106
+ agentId: DEFAULT_AGENT_ID,
107
+ channel,
108
+ accountId,
109
+ sessionKey: buildSessionKey({ agentId: DEFAULT_AGENT_ID, channel, accountId, peer }),
110
+ mainSessionKey: buildSessionKey({
111
+ agentId: DEFAULT_AGENT_ID,
112
+ channel,
113
+ accountId,
114
+ peer: null,
115
+ }),
116
+ matchedBy: 'default',
117
+ };
118
+ }
119
+