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,244 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { normalizeE164 } from './utils.js';
|
|
5
|
+
import { appPath } from '../utils/paths.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_GATEWAY_PATH = appPath('gateway.json');
|
|
8
|
+
const DmPolicySchema = z.enum(['pairing', 'allowlist', 'open', 'disabled']);
|
|
9
|
+
const GroupPolicySchema = z.enum(['open', 'allowlist', 'disabled']);
|
|
10
|
+
const ReconnectSchema = z.object({
|
|
11
|
+
initialMs: z.number().optional(),
|
|
12
|
+
maxMs: z.number().optional(),
|
|
13
|
+
factor: z.number().optional(),
|
|
14
|
+
jitter: z.number().optional(),
|
|
15
|
+
maxAttempts: z.number().optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const WhatsAppAccountSchema = z.object({
|
|
19
|
+
name: z.string().optional(),
|
|
20
|
+
enabled: z.boolean().optional().default(true),
|
|
21
|
+
authDir: z.string().optional(),
|
|
22
|
+
allowFrom: z.array(z.string()).optional().default([]),
|
|
23
|
+
dmPolicy: DmPolicySchema.optional(),
|
|
24
|
+
groupPolicy: GroupPolicySchema.optional(),
|
|
25
|
+
groupAllowFrom: z.array(z.string()).optional().default([]),
|
|
26
|
+
sendReadReceipts: z.boolean().optional().default(true),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const HeartbeatConfigSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
enabled: z.boolean().optional().default(false),
|
|
32
|
+
intervalMinutes: z.number().min(5).optional().default(10),
|
|
33
|
+
// default to NYSE market hours: 9:30 AM - 4:00 PM ET, Mon-Fri
|
|
34
|
+
activeHours: z
|
|
35
|
+
.object({
|
|
36
|
+
start: z.string().optional().default('09:30'),
|
|
37
|
+
end: z.string().optional().default('16:00'),
|
|
38
|
+
timezone: z.string().optional().default('America/New_York'),
|
|
39
|
+
daysOfWeek: z.array(z.number().min(0).max(6)).optional().default([1, 2, 3, 4, 5]),
|
|
40
|
+
})
|
|
41
|
+
.optional(),
|
|
42
|
+
model: z.string().optional(),
|
|
43
|
+
modelProvider: z.string().optional(),
|
|
44
|
+
maxIterations: z.number().optional().default(6),
|
|
45
|
+
})
|
|
46
|
+
.optional();
|
|
47
|
+
|
|
48
|
+
const AlertsConfigSchema = z
|
|
49
|
+
.object({
|
|
50
|
+
minEdgeForAlert: z.number().optional().default(0.05),
|
|
51
|
+
channels: z.array(z.string()).optional().default(['terminal']),
|
|
52
|
+
whatsappTarget: z.string().optional(),
|
|
53
|
+
})
|
|
54
|
+
.optional();
|
|
55
|
+
|
|
56
|
+
const GatewayConfigSchema = z.object({
|
|
57
|
+
gateway: z
|
|
58
|
+
.object({
|
|
59
|
+
accountId: z.string().optional(),
|
|
60
|
+
logLevel: z.enum(['silent', 'error', 'info', 'debug']).optional(),
|
|
61
|
+
heartbeatSeconds: z.number().optional(),
|
|
62
|
+
reconnect: ReconnectSchema.optional(),
|
|
63
|
+
heartbeat: HeartbeatConfigSchema,
|
|
64
|
+
alerts: AlertsConfigSchema,
|
|
65
|
+
})
|
|
66
|
+
.optional(),
|
|
67
|
+
channels: z
|
|
68
|
+
.object({
|
|
69
|
+
whatsapp: z
|
|
70
|
+
.object({
|
|
71
|
+
enabled: z.boolean().optional(),
|
|
72
|
+
accounts: z.record(z.string(), WhatsAppAccountSchema).optional(),
|
|
73
|
+
allowFrom: z.array(z.string()).optional(),
|
|
74
|
+
})
|
|
75
|
+
.optional(),
|
|
76
|
+
})
|
|
77
|
+
.optional(),
|
|
78
|
+
bindings: z
|
|
79
|
+
.array(
|
|
80
|
+
z.object({
|
|
81
|
+
agentId: z.string(),
|
|
82
|
+
match: z.object({
|
|
83
|
+
channel: z.string(),
|
|
84
|
+
accountId: z.string().optional(),
|
|
85
|
+
peerId: z.string().optional(),
|
|
86
|
+
peerKind: z.enum(['direct', 'group']).optional(),
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
.optional()
|
|
91
|
+
.default([]),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export type AlertsConfig = {
|
|
95
|
+
minEdgeForAlert: number;
|
|
96
|
+
channels: string[];
|
|
97
|
+
whatsappTarget?: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type GatewayConfig = {
|
|
101
|
+
gateway: {
|
|
102
|
+
accountId: string;
|
|
103
|
+
logLevel: 'silent' | 'error' | 'info' | 'debug';
|
|
104
|
+
heartbeatSeconds?: number;
|
|
105
|
+
reconnect?: {
|
|
106
|
+
initialMs?: number;
|
|
107
|
+
maxMs?: number;
|
|
108
|
+
factor?: number;
|
|
109
|
+
jitter?: number;
|
|
110
|
+
maxAttempts?: number;
|
|
111
|
+
};
|
|
112
|
+
heartbeat?: {
|
|
113
|
+
enabled: boolean;
|
|
114
|
+
intervalMinutes: number;
|
|
115
|
+
activeHours?: { start: string; end: string; timezone?: string; daysOfWeek?: number[] };
|
|
116
|
+
model?: string;
|
|
117
|
+
modelProvider?: string;
|
|
118
|
+
maxIterations: number;
|
|
119
|
+
};
|
|
120
|
+
alerts?: AlertsConfig;
|
|
121
|
+
};
|
|
122
|
+
channels: {
|
|
123
|
+
whatsapp: {
|
|
124
|
+
enabled: boolean;
|
|
125
|
+
accounts: Record<string, z.infer<typeof WhatsAppAccountSchema>>;
|
|
126
|
+
allowFrom: string[];
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
bindings: Array<{
|
|
130
|
+
agentId: string;
|
|
131
|
+
match: {
|
|
132
|
+
channel: string;
|
|
133
|
+
accountId?: string;
|
|
134
|
+
peerId?: string;
|
|
135
|
+
peerKind?: 'direct' | 'group';
|
|
136
|
+
};
|
|
137
|
+
}>;
|
|
138
|
+
};
|
|
139
|
+
export type WhatsAppAccountConfig = {
|
|
140
|
+
accountId: string;
|
|
141
|
+
name?: string;
|
|
142
|
+
enabled: boolean;
|
|
143
|
+
authDir: string;
|
|
144
|
+
allowFrom: string[];
|
|
145
|
+
dmPolicy: 'pairing' | 'allowlist' | 'open' | 'disabled';
|
|
146
|
+
groupPolicy: 'open' | 'allowlist' | 'disabled';
|
|
147
|
+
groupAllowFrom: string[];
|
|
148
|
+
sendReadReceipts: boolean;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export function getGatewayConfigPath(overridePath?: string): string {
|
|
152
|
+
return overridePath ?? process.env.APP_GATEWAY_CONFIG ?? DEFAULT_GATEWAY_PATH;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function loadGatewayConfig(overridePath?: string): GatewayConfig {
|
|
156
|
+
const path = getGatewayConfigPath(overridePath);
|
|
157
|
+
if (!existsSync(path)) {
|
|
158
|
+
return {
|
|
159
|
+
gateway: { accountId: 'default', logLevel: 'info' },
|
|
160
|
+
channels: { whatsapp: { enabled: true, accounts: {}, allowFrom: [] } },
|
|
161
|
+
bindings: [],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const raw = readFileSync(path, 'utf8');
|
|
165
|
+
const parsed = GatewayConfigSchema.parse(JSON.parse(raw));
|
|
166
|
+
return {
|
|
167
|
+
...parsed,
|
|
168
|
+
gateway: {
|
|
169
|
+
accountId: parsed.gateway?.accountId ?? 'default',
|
|
170
|
+
logLevel: parsed.gateway?.logLevel ?? 'info',
|
|
171
|
+
heartbeatSeconds: parsed.gateway?.heartbeatSeconds,
|
|
172
|
+
reconnect: parsed.gateway?.reconnect,
|
|
173
|
+
heartbeat: parsed.gateway?.heartbeat
|
|
174
|
+
? {
|
|
175
|
+
enabled: parsed.gateway.heartbeat.enabled ?? false,
|
|
176
|
+
intervalMinutes: parsed.gateway.heartbeat.intervalMinutes ?? 30,
|
|
177
|
+
activeHours: parsed.gateway.heartbeat.activeHours,
|
|
178
|
+
model: parsed.gateway.heartbeat.model,
|
|
179
|
+
modelProvider: parsed.gateway.heartbeat.modelProvider,
|
|
180
|
+
maxIterations: parsed.gateway.heartbeat.maxIterations ?? 6,
|
|
181
|
+
}
|
|
182
|
+
: undefined,
|
|
183
|
+
alerts: parsed.gateway?.alerts
|
|
184
|
+
? {
|
|
185
|
+
minEdgeForAlert: parsed.gateway.alerts.minEdgeForAlert ?? 0.05,
|
|
186
|
+
channels: parsed.gateway.alerts.channels ?? ['terminal'],
|
|
187
|
+
whatsappTarget: parsed.gateway.alerts.whatsappTarget,
|
|
188
|
+
}
|
|
189
|
+
: undefined,
|
|
190
|
+
},
|
|
191
|
+
channels: {
|
|
192
|
+
whatsapp: {
|
|
193
|
+
enabled: parsed.channels?.whatsapp?.enabled ?? true,
|
|
194
|
+
accounts: parsed.channels?.whatsapp?.accounts ?? {},
|
|
195
|
+
allowFrom: parsed.channels?.whatsapp?.allowFrom ?? [],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
bindings: parsed.bindings ?? [],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function saveGatewayConfig(config: GatewayConfig, overridePath?: string): void {
|
|
203
|
+
const path = getGatewayConfigPath(overridePath);
|
|
204
|
+
const dir = dirname(path);
|
|
205
|
+
if (!existsSync(dir)) {
|
|
206
|
+
mkdirSync(dir, { recursive: true });
|
|
207
|
+
}
|
|
208
|
+
writeFileSync(path, JSON.stringify(config, null, 2), 'utf8');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function listWhatsAppAccountIds(cfg: GatewayConfig): string[] {
|
|
212
|
+
const accounts = cfg.channels.whatsapp.accounts ?? {};
|
|
213
|
+
const ids = Object.keys(accounts);
|
|
214
|
+
return ids.length > 0 ? ids : [cfg.gateway.accountId];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function resolveWhatsAppAccount(
|
|
218
|
+
cfg: GatewayConfig,
|
|
219
|
+
accountId: string,
|
|
220
|
+
): WhatsAppAccountConfig {
|
|
221
|
+
const account = cfg.channels.whatsapp.accounts?.[accountId] ?? {};
|
|
222
|
+
const authDir = account.authDir ?? appPath('credentials', 'whatsapp', accountId);
|
|
223
|
+
const rawAllowFrom = account.allowFrom ?? cfg.channels.whatsapp.allowFrom ?? [];
|
|
224
|
+
const allowFrom = Array.from(
|
|
225
|
+
new Set(
|
|
226
|
+
rawAllowFrom
|
|
227
|
+
.map((entry) => entry.trim())
|
|
228
|
+
.filter(Boolean)
|
|
229
|
+
.map((entry) => (entry === '*' ? '*' : normalizeE164(entry))),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
return {
|
|
233
|
+
accountId,
|
|
234
|
+
enabled: account.enabled ?? true,
|
|
235
|
+
name: account.name,
|
|
236
|
+
authDir,
|
|
237
|
+
allowFrom,
|
|
238
|
+
dmPolicy: account.dmPolicy ?? 'pairing',
|
|
239
|
+
groupPolicy: account.groupPolicy ?? 'disabled',
|
|
240
|
+
groupAllowFrom: account.groupAllowFrom ?? [],
|
|
241
|
+
sendReadReceipts: account.sendReadReceipts ?? true,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Future channel extension points:
|
|
3
|
+
*
|
|
4
|
+
* 1. Implement a channel plugin that satisfies `ChannelPlugin<TConfig, TAccount>`.
|
|
5
|
+
* 2. Provide inbound message normalization into gateway `InboundContext`.
|
|
6
|
+
* 3. Reuse `resolveRoute()` + `runAgentForMessage()` without changing gateway orchestration.
|
|
7
|
+
* 4. Register the plugin in gateway bootstrap next to WhatsApp.
|
|
8
|
+
*
|
|
9
|
+
* This keeps Layer 1 channel transport isolated from agent execution.
|
|
10
|
+
*/
|
|
11
|
+
export const GATEWAY_EXTENSION_POINTS = [
|
|
12
|
+
'ChannelPlugin lifecycle (start/stop/status)',
|
|
13
|
+
'InboundContext normalization',
|
|
14
|
+
'Outbound delivery adapter',
|
|
15
|
+
'Route/session metadata integration',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { createChannelManager } from './channels/manager.js';
|
|
2
|
+
import { createWhatsAppPlugin } from './channels/whatsapp/plugin.js';
|
|
3
|
+
import {
|
|
4
|
+
assertOutboundAllowed,
|
|
5
|
+
sendComposing,
|
|
6
|
+
sendMessageWhatsApp,
|
|
7
|
+
type WhatsAppInboundMessage,
|
|
8
|
+
} from './channels/whatsapp/index.js';
|
|
9
|
+
import { resolveRoute } from './routing/resolve-route.js';
|
|
10
|
+
import { resolveSessionStorePath, upsertSessionMeta } from './sessions/store.js';
|
|
11
|
+
import { loadGatewayConfig, type GatewayConfig } from './config.js';
|
|
12
|
+
import { runAgentForMessage } from './agent-runner.js';
|
|
13
|
+
import { cleanMarkdownForWhatsApp } from './utils.js';
|
|
14
|
+
import { startHeartbeatRunner } from './heartbeat/index.js';
|
|
15
|
+
import {
|
|
16
|
+
isBotMentioned,
|
|
17
|
+
recordGroupMessage,
|
|
18
|
+
getAndClearGroupHistory,
|
|
19
|
+
formatGroupHistoryContext,
|
|
20
|
+
noteGroupMember,
|
|
21
|
+
formatGroupMembersList,
|
|
22
|
+
} from './group/index.js';
|
|
23
|
+
import { AlertRouter, formatAlertForWhatsApp, formatAlertForTerminal } from './alerts/index.js';
|
|
24
|
+
import { parseCommand } from './commands/parser.js';
|
|
25
|
+
import { handleCommand } from './commands/handler.js';
|
|
26
|
+
import type { GroupContext } from '../agent/prompts.js';
|
|
27
|
+
import { appendFileSync } from 'node:fs';
|
|
28
|
+
import { appPath } from '../utils/paths.js';
|
|
29
|
+
import { getSetting } from '../utils/config.js';
|
|
30
|
+
|
|
31
|
+
const LOG_PATH = appPath('gateway-debug.log');
|
|
32
|
+
function debugLog(msg: string) {
|
|
33
|
+
appendFileSync(LOG_PATH, `${new Date().toISOString()} ${msg}\n`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type GatewayService = {
|
|
37
|
+
stop: () => Promise<void>;
|
|
38
|
+
snapshot: () => Record<string, { accountId: string; running: boolean; connected?: boolean }>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function elide(text: string, maxLen: number): string {
|
|
42
|
+
if (text.length <= maxLen) return text;
|
|
43
|
+
return text.slice(0, maxLen - 3) + '...';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function handleInbound(cfg: GatewayConfig, inbound: WhatsAppInboundMessage, alertRouter: AlertRouter): Promise<void> {
|
|
47
|
+
const bodyPreview = elide(inbound.body.replace(/\n/g, ' '), 50);
|
|
48
|
+
const isGroup = inbound.chatType === 'group';
|
|
49
|
+
console.log(`Inbound message ${inbound.from} (${inbound.chatType}, ${inbound.body.length} chars): "${bodyPreview}"`);
|
|
50
|
+
debugLog(`[gateway] handleInbound from=${inbound.from} isGroup=${isGroup} body="${inbound.body.slice(0, 30)}..."`);
|
|
51
|
+
|
|
52
|
+
// --- Group-specific: track member, check mention gating ---
|
|
53
|
+
if (isGroup) {
|
|
54
|
+
noteGroupMember(inbound.chatId, inbound.senderId, inbound.senderName);
|
|
55
|
+
|
|
56
|
+
const mentioned = isBotMentioned({
|
|
57
|
+
mentionedJids: inbound.mentionedJids,
|
|
58
|
+
selfJid: inbound.selfJid,
|
|
59
|
+
selfLid: inbound.selfLid,
|
|
60
|
+
selfE164: inbound.selfE164,
|
|
61
|
+
body: inbound.body,
|
|
62
|
+
});
|
|
63
|
+
debugLog(`[gateway] group mention check: mentioned=${mentioned}`);
|
|
64
|
+
|
|
65
|
+
if (!mentioned) {
|
|
66
|
+
// Buffer the message for future context but don't reply
|
|
67
|
+
recordGroupMessage(inbound.chatId, {
|
|
68
|
+
senderName: inbound.senderName ?? inbound.senderId,
|
|
69
|
+
senderId: inbound.senderId,
|
|
70
|
+
body: inbound.body,
|
|
71
|
+
timestamp: inbound.timestamp ?? Date.now(),
|
|
72
|
+
});
|
|
73
|
+
debugLog(`[gateway] group message buffered (no mention), skipping reply`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Routing: use chatId for groups (group JID), senderId for DMs ---
|
|
79
|
+
const peerId = isGroup ? inbound.chatId : inbound.senderId;
|
|
80
|
+
const route = resolveRoute({
|
|
81
|
+
cfg,
|
|
82
|
+
channel: 'whatsapp',
|
|
83
|
+
accountId: inbound.accountId,
|
|
84
|
+
peer: { kind: inbound.chatType, id: peerId },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const storePath = resolveSessionStorePath(route.agentId);
|
|
88
|
+
upsertSessionMeta({
|
|
89
|
+
storePath,
|
|
90
|
+
sessionKey: route.sessionKey,
|
|
91
|
+
channel: 'whatsapp',
|
|
92
|
+
to: inbound.from,
|
|
93
|
+
accountId: route.accountId,
|
|
94
|
+
agentId: route.agentId,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- Command interception: parse message as command before falling through to agent ---
|
|
98
|
+
const intent = parseCommand(inbound.body);
|
|
99
|
+
if (intent.type !== 'none') {
|
|
100
|
+
debugLog(`[gateway] command intercepted: ${intent.type}`);
|
|
101
|
+
try {
|
|
102
|
+
const reply = await handleCommand(intent, alertRouter, route.sessionKey);
|
|
103
|
+
if (reply) {
|
|
104
|
+
const outboundTarget = isGroup ? inbound.chatId : inbound.replyToJid;
|
|
105
|
+
try {
|
|
106
|
+
assertOutboundAllowed({ to: outboundTarget, accountId: inbound.accountId });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
109
|
+
debugLog(`[gateway] command outbound BLOCKED: ${msg}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (isGroup) {
|
|
113
|
+
await inbound.reply(reply);
|
|
114
|
+
} else {
|
|
115
|
+
await sendMessageWhatsApp({ to: inbound.replyToJid, body: reply, accountId: inbound.accountId });
|
|
116
|
+
}
|
|
117
|
+
console.log(`Command '${intent.type}' replied (${reply.length} chars)`);
|
|
118
|
+
debugLog(`[gateway] command reply sent`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
123
|
+
console.log(`Command error: ${msg}`);
|
|
124
|
+
debugLog(`[gateway] command ERROR: ${msg}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Start typing indicator loop to keep it alive during long agent runs
|
|
129
|
+
const TYPING_INTERVAL_MS = 5000; // Refresh every 5 seconds
|
|
130
|
+
let typingTimer: ReturnType<typeof setInterval> | undefined;
|
|
131
|
+
|
|
132
|
+
const startTypingLoop = async () => {
|
|
133
|
+
// For groups, use inbound.sendComposing directly (bypasses outbound strict checks)
|
|
134
|
+
if (isGroup) {
|
|
135
|
+
await inbound.sendComposing();
|
|
136
|
+
typingTimer = setInterval(() => { void inbound.sendComposing(); }, TYPING_INTERVAL_MS);
|
|
137
|
+
} else {
|
|
138
|
+
await sendComposing({ to: inbound.replyToJid, accountId: inbound.accountId });
|
|
139
|
+
typingTimer = setInterval(() => {
|
|
140
|
+
void sendComposing({ to: inbound.replyToJid, accountId: inbound.accountId });
|
|
141
|
+
}, TYPING_INTERVAL_MS);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const stopTypingLoop = () => {
|
|
146
|
+
if (typingTimer) {
|
|
147
|
+
clearInterval(typingTimer);
|
|
148
|
+
typingTimer = undefined;
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
// Defense-in-depth: verify outbound destination is allowed before any messaging
|
|
154
|
+
// For groups, use chatId (the group JID); for DMs, use replyToJid
|
|
155
|
+
const outboundTarget = isGroup ? inbound.chatId : inbound.replyToJid;
|
|
156
|
+
try {
|
|
157
|
+
assertOutboundAllowed({ to: outboundTarget, accountId: inbound.accountId });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
160
|
+
debugLog(`[gateway] outbound BLOCKED: ${msg}`);
|
|
161
|
+
console.log(msg);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await startTypingLoop();
|
|
166
|
+
|
|
167
|
+
// --- Build query: for groups, include buffered history context ---
|
|
168
|
+
let query = inbound.body;
|
|
169
|
+
let groupContext: GroupContext | undefined;
|
|
170
|
+
|
|
171
|
+
if (isGroup) {
|
|
172
|
+
const history = getAndClearGroupHistory(inbound.chatId);
|
|
173
|
+
query = formatGroupHistoryContext({
|
|
174
|
+
history,
|
|
175
|
+
currentSenderName: inbound.senderName ?? inbound.senderId,
|
|
176
|
+
currentSenderId: inbound.senderId,
|
|
177
|
+
currentBody: inbound.body,
|
|
178
|
+
});
|
|
179
|
+
debugLog(`[gateway] group query with ${history.length} history entries`);
|
|
180
|
+
|
|
181
|
+
const membersList = formatGroupMembersList({
|
|
182
|
+
groupId: inbound.chatId,
|
|
183
|
+
participants: inbound.groupParticipants,
|
|
184
|
+
});
|
|
185
|
+
groupContext = {
|
|
186
|
+
groupName: inbound.groupSubject,
|
|
187
|
+
membersList: membersList || undefined,
|
|
188
|
+
activationMode: 'mention',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.log(`Processing message with agent...`);
|
|
193
|
+
debugLog(`[gateway] running agent for session=${route.sessionKey}`);
|
|
194
|
+
const startedAt = Date.now();
|
|
195
|
+
const model = getSetting('modelId', 'gpt-5.4') as string;
|
|
196
|
+
const modelProvider = getSetting('provider', 'openai') as string;
|
|
197
|
+
const answer = await runAgentForMessage({
|
|
198
|
+
sessionKey: route.sessionKey,
|
|
199
|
+
query,
|
|
200
|
+
model,
|
|
201
|
+
modelProvider,
|
|
202
|
+
channel: 'whatsapp',
|
|
203
|
+
groupContext,
|
|
204
|
+
});
|
|
205
|
+
const durationMs = Date.now() - startedAt;
|
|
206
|
+
debugLog(`[gateway] agent answer length=${answer.length}`);
|
|
207
|
+
|
|
208
|
+
// Stop typing loop before sending reply
|
|
209
|
+
stopTypingLoop();
|
|
210
|
+
|
|
211
|
+
if (answer.trim()) {
|
|
212
|
+
const cleanedAnswer = cleanMarkdownForWhatsApp(answer);
|
|
213
|
+
|
|
214
|
+
if (isGroup) {
|
|
215
|
+
// For groups, use inbound.reply() directly (bypasses outbound strict E.164 checks)
|
|
216
|
+
debugLog(`[gateway] sending group reply to ${inbound.chatId}`);
|
|
217
|
+
await inbound.reply(cleanedAnswer);
|
|
218
|
+
} else {
|
|
219
|
+
debugLog(`[gateway] sending reply to ${inbound.replyToJid}`);
|
|
220
|
+
await sendMessageWhatsApp({
|
|
221
|
+
to: inbound.replyToJid,
|
|
222
|
+
body: cleanedAnswer,
|
|
223
|
+
accountId: inbound.accountId,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
console.log(`Sent reply (${answer.length} chars, ${durationMs}ms)`);
|
|
227
|
+
debugLog(`[gateway] reply sent`);
|
|
228
|
+
} else {
|
|
229
|
+
console.log(`Agent returned empty response (${durationMs}ms)`);
|
|
230
|
+
debugLog(`[gateway] empty answer, not sending`);
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
stopTypingLoop();
|
|
234
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
235
|
+
console.log(`Error: ${msg}`);
|
|
236
|
+
debugLog(`[gateway] ERROR: ${msg}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function startGateway(params: { configPath?: string } = {}): Promise<GatewayService> {
|
|
241
|
+
const cfg = loadGatewayConfig(params.configPath);
|
|
242
|
+
|
|
243
|
+
// --- Alert Router setup ---
|
|
244
|
+
const alertRouter = new AlertRouter();
|
|
245
|
+
|
|
246
|
+
// Terminal handler
|
|
247
|
+
alertRouter.registerChannel('terminal', async (alert) => {
|
|
248
|
+
console.log(formatAlertForTerminal(alert));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// WhatsApp handler
|
|
252
|
+
alertRouter.registerChannel('whatsapp', async (alert, target) => {
|
|
253
|
+
if (!target) return;
|
|
254
|
+
const formatted = formatAlertForWhatsApp(alert);
|
|
255
|
+
try {
|
|
256
|
+
await sendMessageWhatsApp({
|
|
257
|
+
to: target,
|
|
258
|
+
body: formatted,
|
|
259
|
+
accountId: cfg.gateway.accountId,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Set pending approval for edge alerts
|
|
263
|
+
if (alert.alertType === 'EDGE_DETECTED') {
|
|
264
|
+
alertRouter.setPending(target, {
|
|
265
|
+
ticker: alert.ticker,
|
|
266
|
+
alertId: crypto.randomUUID(),
|
|
267
|
+
edge: alert.edge,
|
|
268
|
+
createdAt: Date.now(),
|
|
269
|
+
sessionKey: target,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
274
|
+
console.log(`[AlertRouter] WhatsApp send failed: ${msg}`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const plugin = createWhatsAppPlugin({
|
|
279
|
+
loadConfig: () => loadGatewayConfig(params.configPath),
|
|
280
|
+
onMessage: async (inbound) => {
|
|
281
|
+
const current = loadGatewayConfig(params.configPath);
|
|
282
|
+
await handleInbound(current, inbound, alertRouter);
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
const manager = createChannelManager({
|
|
286
|
+
plugin,
|
|
287
|
+
loadConfig: () => loadGatewayConfig(params.configPath),
|
|
288
|
+
});
|
|
289
|
+
await manager.startAll();
|
|
290
|
+
|
|
291
|
+
const heartbeat = startHeartbeatRunner({ configPath: params.configPath });
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
stop: async () => {
|
|
295
|
+
heartbeat.stop();
|
|
296
|
+
await manager.stopAll();
|
|
297
|
+
},
|
|
298
|
+
snapshot: () => manager.getSnapshot(),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory buffer for group messages between bot replies.
|
|
3
|
+
* Records messages in each group and provides them as context when the bot is mentioned.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type GroupHistoryEntry = {
|
|
7
|
+
senderName: string;
|
|
8
|
+
senderId: string;
|
|
9
|
+
body: string;
|
|
10
|
+
timestamp: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const MAX_ENTRIES_PER_GROUP = 50;
|
|
14
|
+
const MAX_GROUPS = 200;
|
|
15
|
+
|
|
16
|
+
const buffers = new Map<string, GroupHistoryEntry[]>();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Record a group message into the history buffer.
|
|
20
|
+
*/
|
|
21
|
+
export function recordGroupMessage(groupId: string, entry: GroupHistoryEntry): void {
|
|
22
|
+
let entries = buffers.get(groupId);
|
|
23
|
+
if (!entries) {
|
|
24
|
+
// LRU eviction: if at capacity, remove the oldest-accessed group
|
|
25
|
+
if (buffers.size >= MAX_GROUPS) {
|
|
26
|
+
const oldestKey = buffers.keys().next().value as string;
|
|
27
|
+
buffers.delete(oldestKey);
|
|
28
|
+
}
|
|
29
|
+
entries = [];
|
|
30
|
+
buffers.set(groupId, entries);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
entries.push(entry);
|
|
34
|
+
|
|
35
|
+
// Trim to max entries
|
|
36
|
+
if (entries.length > MAX_ENTRIES_PER_GROUP) {
|
|
37
|
+
entries.splice(0, entries.length - MAX_ENTRIES_PER_GROUP);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get all buffered history for a group and clear it.
|
|
43
|
+
*/
|
|
44
|
+
export function getAndClearGroupHistory(groupId: string): GroupHistoryEntry[] {
|
|
45
|
+
const entries = buffers.get(groupId) ?? [];
|
|
46
|
+
buffers.delete(groupId);
|
|
47
|
+
return entries;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format group history entries and the current message into a context block
|
|
52
|
+
* for the agent's system prompt.
|
|
53
|
+
*/
|
|
54
|
+
export function formatGroupHistoryContext(params: {
|
|
55
|
+
history: GroupHistoryEntry[];
|
|
56
|
+
currentSenderName: string;
|
|
57
|
+
currentSenderId: string;
|
|
58
|
+
currentBody: string;
|
|
59
|
+
}): string {
|
|
60
|
+
const { history, currentSenderName, currentSenderId, currentBody } = params;
|
|
61
|
+
const parts: string[] = [];
|
|
62
|
+
|
|
63
|
+
if (history.length > 0) {
|
|
64
|
+
parts.push('[Group messages since your last reply - for context]');
|
|
65
|
+
for (const entry of history) {
|
|
66
|
+
parts.push(`${entry.senderName} (${entry.senderId}): ${entry.body}`);
|
|
67
|
+
}
|
|
68
|
+
parts.push('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
parts.push('[Current message - respond to this]');
|
|
72
|
+
parts.push(`${currentSenderName} (${currentSenderId}): ${currentBody}`);
|
|
73
|
+
|
|
74
|
+
return parts.join('\n');
|
|
75
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { isBotMentioned } from './mention-detection.js';
|
|
2
|
+
export {
|
|
3
|
+
recordGroupMessage,
|
|
4
|
+
getAndClearGroupHistory,
|
|
5
|
+
formatGroupHistoryContext,
|
|
6
|
+
type GroupHistoryEntry,
|
|
7
|
+
} from './history-buffer.js';
|
|
8
|
+
export { noteGroupMember, formatGroupMembersList } from './member-tracker.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track group member display names from messages.
|
|
3
|
+
* Maps e164 phone numbers to display names per group.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// groupId → Map<e164, displayName>
|
|
7
|
+
const rosters = new Map<string, Map<string, string>>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Record a group member's display name from an incoming message.
|
|
11
|
+
*/
|
|
12
|
+
export function noteGroupMember(groupId: string, senderId: string, displayName?: string): void {
|
|
13
|
+
if (!displayName) return;
|
|
14
|
+
|
|
15
|
+
let roster = rosters.get(groupId);
|
|
16
|
+
if (!roster) {
|
|
17
|
+
roster = new Map();
|
|
18
|
+
rosters.set(groupId, roster);
|
|
19
|
+
}
|
|
20
|
+
roster.set(senderId, displayName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Format a human-readable list of group members.
|
|
25
|
+
* Combines API-provided participants with observed display names from the roster.
|
|
26
|
+
*/
|
|
27
|
+
export function formatGroupMembersList(params: {
|
|
28
|
+
groupId: string;
|
|
29
|
+
participants?: string[];
|
|
30
|
+
}): string {
|
|
31
|
+
const { groupId, participants } = params;
|
|
32
|
+
const roster = rosters.get(groupId);
|
|
33
|
+
|
|
34
|
+
if (!participants?.length && !roster?.size) {
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
|
|
41
|
+
// Roster entries (have display names)
|
|
42
|
+
if (roster) {
|
|
43
|
+
for (const [id, name] of roster) {
|
|
44
|
+
lines.push(`- ${name} (${id})`);
|
|
45
|
+
seen.add(id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Participants from group metadata (fill in any not already in roster)
|
|
50
|
+
if (participants) {
|
|
51
|
+
for (const p of participants) {
|
|
52
|
+
if (p && !seen.has(p)) {
|
|
53
|
+
lines.push(`- ${p}`);
|
|
54
|
+
seen.add(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|