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,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
|
+
|