@vaultgradient/pipequery-cli 0.6.1 → 0.10.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/README.md +98 -13
- package/__tests__/clickhouse-pushdown-compile.test.ts +121 -0
- package/__tests__/event-log.test.ts +267 -0
- package/__tests__/mongo-pushdown-compile.test.ts +160 -0
- package/__tests__/mysql-pushdown-compile.test.ts +122 -0
- package/__tests__/mysql-pushdown.smoke.test.ts +77 -0
- package/__tests__/postgres-pushdown-compile.test.ts +240 -0
- package/__tests__/postgres-pushdown.smoke.test.ts +158 -0
- package/__tests__/pushdown-routing.test.ts +144 -0
- package/__tests__/rest-source.test.ts +191 -0
- package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
- package/__tests__/websocket-source.test.ts +187 -0
- package/dist/index.js +3570 -2044
- package/package.json +11 -2
- package/src/commands/telegram.ts +10 -0
- package/src/config/schema.ts +90 -1
- package/src/index.ts +2 -1
- package/src/mcp/provider.ts +4 -7
- package/src/server/routes/control.ts +1 -3
- package/src/server/routes/dynamic.ts +2 -4
- package/src/server/sources/clickhouse.ts +153 -0
- package/src/server/sources/kafka.ts +4 -16
- package/src/server/sources/manager.ts +71 -1
- package/src/server/sources/mongodb.ts +165 -0
- package/src/server/sources/mysql.ts +46 -14
- package/src/server/sources/postgres.ts +52 -20
- package/src/server/sources/pushdown/clickhouse.ts +17 -0
- package/src/server/sources/pushdown/compile.ts +328 -0
- package/src/server/sources/pushdown/dialect.ts +88 -0
- package/src/server/sources/pushdown/mongodb.ts +450 -0
- package/src/server/sources/pushdown/mysql.ts +17 -0
- package/src/server/sources/pushdown/postgres.ts +19 -0
- package/src/server/sources/pushdown/snowflake.ts +17 -0
- package/src/server/sources/rest.ts +33 -5
- package/src/server/sources/snowflake.ts +158 -0
- package/src/server/sources/types.ts +27 -0
- package/src/server/sources/websocket.ts +73 -9
- package/src/telegram/bot.ts +198 -12
- package/src/telegram/event-log.ts +234 -0
- package/src/utils/expandEnv.ts +23 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import snowflake, { type Connection } from 'snowflake-sdk';
|
|
2
|
+
import type { SnowflakeSourceConfig } from '../../config/schema.js';
|
|
3
|
+
import { parseDuration } from '../../utils/parseDuration.js';
|
|
4
|
+
import { expandEnv } from '../../utils/expandEnv.js';
|
|
5
|
+
import { parseQuery } from '../../engine.js';
|
|
6
|
+
import { compileSnowflakePushdown } from './pushdown/snowflake.js';
|
|
7
|
+
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_INTERVAL_MS = 60_000;
|
|
10
|
+
const DEFAULT_MAX_ROWS = 10_000;
|
|
11
|
+
|
|
12
|
+
export class SnowflakeSourceAdapter implements SourceAdapter {
|
|
13
|
+
private conn: Connection | null = null;
|
|
14
|
+
private data: unknown[] = [];
|
|
15
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
16
|
+
private lastFetch: Date | null = null;
|
|
17
|
+
private error?: string;
|
|
18
|
+
private listeners = new Set<(data: unknown[]) => void>();
|
|
19
|
+
private readonly intervalMs: number;
|
|
20
|
+
private readonly maxRows: number;
|
|
21
|
+
|
|
22
|
+
constructor(private config: SnowflakeSourceConfig) {
|
|
23
|
+
this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
|
|
24
|
+
this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async start(): Promise<void> {
|
|
28
|
+
this.conn = snowflake.createConnection({
|
|
29
|
+
account: expandEnv(this.config.account, 'snowflake source account'),
|
|
30
|
+
username: expandEnv(this.config.username, 'snowflake source username'),
|
|
31
|
+
password: expandEnv(this.config.password, 'snowflake source password'),
|
|
32
|
+
...(this.config.database ? { database: expandEnv(this.config.database, 'snowflake source database') } : {}),
|
|
33
|
+
...(this.config.schema ? { schema: expandEnv(this.config.schema, 'snowflake source schema') } : {}),
|
|
34
|
+
...(this.config.warehouse ? { warehouse: expandEnv(this.config.warehouse, 'snowflake source warehouse') } : {}),
|
|
35
|
+
...(this.config.role ? { role: expandEnv(this.config.role, 'snowflake source role') } : {}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await new Promise<void>((resolve, reject) => {
|
|
39
|
+
this.conn!.connect((err) => (err ? reject(err) : resolve()));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await this.fetch();
|
|
43
|
+
this.timer = setInterval(() => {
|
|
44
|
+
void this.fetch();
|
|
45
|
+
}, this.intervalMs);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
stop(): void {
|
|
49
|
+
if (this.timer) {
|
|
50
|
+
clearInterval(this.timer);
|
|
51
|
+
this.timer = null;
|
|
52
|
+
}
|
|
53
|
+
if (this.conn) {
|
|
54
|
+
this.conn.destroy(() => undefined);
|
|
55
|
+
this.conn = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getData(): unknown[] {
|
|
60
|
+
return this.data;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getStatus(): SourceStatus {
|
|
64
|
+
return {
|
|
65
|
+
healthy: !this.error,
|
|
66
|
+
rowCount: this.data.length,
|
|
67
|
+
lastFetch: this.lastFetch,
|
|
68
|
+
error: this.error,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onUpdate(callback: (data: unknown[]) => void): () => void {
|
|
73
|
+
this.listeners.add(callback);
|
|
74
|
+
return () => this.listeners.delete(callback);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async refresh(): Promise<void> {
|
|
78
|
+
await this.fetch();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Adapter-native push-down. SourceManager.runQuery dispatches here when a
|
|
83
|
+
* pipeline targets a Snowflake source; declines fall back to in-process.
|
|
84
|
+
*/
|
|
85
|
+
async runPushdown(expression: string): Promise<
|
|
86
|
+
| { ok: true; rows: unknown[]; sql: string; params: unknown[] }
|
|
87
|
+
| { ok: false; reason: string }
|
|
88
|
+
> {
|
|
89
|
+
if (!this.conn) {
|
|
90
|
+
return { ok: false, reason: 'Snowflake connection not initialized — call start() first' };
|
|
91
|
+
}
|
|
92
|
+
let pipeline;
|
|
93
|
+
try {
|
|
94
|
+
pipeline = parseQuery(expression);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
|
|
97
|
+
}
|
|
98
|
+
const compiled = compileSnowflakePushdown(pipeline, this.config.query);
|
|
99
|
+
if (!compiled.ok) {
|
|
100
|
+
return { ok: false, reason: compiled.reason };
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const rows = await this.executeSql(compiled.compiled.sql, compiled.compiled.params);
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
rows,
|
|
107
|
+
sql: compiled.compiled.sql,
|
|
108
|
+
params: compiled.compiled.params,
|
|
109
|
+
};
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return { ok: false, reason: `snowflake error: ${err instanceof Error ? err.message : String(err)}` };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async fetch(): Promise<void> {
|
|
116
|
+
if (!this.conn) {
|
|
117
|
+
this.error = 'Snowflake connection not initialized';
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const capQuery = `SELECT * FROM (${stripTrailingSemicolon(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
|
|
122
|
+
const rows = await this.executeSql(capQuery, []);
|
|
123
|
+
if (rows.length > this.maxRows) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Query returned more than maxRows=${this.maxRows}. ` +
|
|
126
|
+
`Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
this.data = rows;
|
|
130
|
+
this.lastFetch = new Date();
|
|
131
|
+
this.error = undefined;
|
|
132
|
+
this.notify();
|
|
133
|
+
} catch (err) {
|
|
134
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private executeSql(sqlText: string, params: unknown[]): Promise<unknown[]> {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
this.conn!.execute({
|
|
141
|
+
sqlText,
|
|
142
|
+
binds: params as snowflake.Binds,
|
|
143
|
+
complete: (err, _stmt, rows) => {
|
|
144
|
+
if (err) reject(err);
|
|
145
|
+
else resolve(rows ?? []);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private notify(): void {
|
|
152
|
+
for (const cb of this.listeners) cb(this.data);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function stripTrailingSemicolon(sql: string): string {
|
|
157
|
+
return sql.trim().replace(/;+\s*$/, '');
|
|
158
|
+
}
|
|
@@ -5,6 +5,26 @@ export interface SourceStatus {
|
|
|
5
5
|
error?: string;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Result of an adapter-native push-down attempt.
|
|
10
|
+
*
|
|
11
|
+
* `ok: true` means the adapter executed the pipe expression natively (e.g.
|
|
12
|
+
* Postgres compiled it to SQL and ran it on the database) and returned rows
|
|
13
|
+
* without materializing the source query in memory. `sql` / `params` are
|
|
14
|
+
* optional telemetry — useful for audit logs and debugging, not required
|
|
15
|
+
* for callers to act on.
|
|
16
|
+
*
|
|
17
|
+
* `ok: false` is the explicit decline channel: the adapter recognised the
|
|
18
|
+
* shape but declined (unsupported op, multi-source pipeline, etc.). The
|
|
19
|
+
* caller should fall back to in-process execution against `getContext()`.
|
|
20
|
+
*
|
|
21
|
+
* Any thrown error means *neither* path succeeded — let it propagate; the
|
|
22
|
+
* caller has no useful fallback.
|
|
23
|
+
*/
|
|
24
|
+
export type PushdownResult =
|
|
25
|
+
| { ok: true; rows: unknown[]; sql?: string; params?: unknown[] }
|
|
26
|
+
| { ok: false; reason: string };
|
|
27
|
+
|
|
8
28
|
export interface SourceAdapter {
|
|
9
29
|
start(): Promise<void>;
|
|
10
30
|
stop(): void;
|
|
@@ -12,4 +32,11 @@ export interface SourceAdapter {
|
|
|
12
32
|
getStatus(): SourceStatus;
|
|
13
33
|
onUpdate(callback: (data: unknown[]) => void): () => void;
|
|
14
34
|
refresh?(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Optional: compile + execute the pipe expression natively against the
|
|
37
|
+
* underlying engine instead of materializing into memory. When absent
|
|
38
|
+
* (or when the adapter declines), SourceManager falls back to in-process
|
|
39
|
+
* execution. Today only PostgresSourceAdapter implements this.
|
|
40
|
+
*/
|
|
41
|
+
runPushdown?(expression: string): Promise<PushdownResult>;
|
|
15
42
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
2
|
import type { WebSocketSourceConfig } from '../../config/schema.js';
|
|
3
3
|
import type { SourceAdapter, SourceStatus } from './types.js';
|
|
4
|
+
import { parseDuration } from '../../utils/parseDuration.js';
|
|
4
5
|
|
|
5
6
|
export class WebSocketSourceAdapter implements SourceAdapter {
|
|
6
7
|
private data: unknown[] = [];
|
|
@@ -9,21 +10,44 @@ export class WebSocketSourceAdapter implements SourceAdapter {
|
|
|
9
10
|
private error?: string;
|
|
10
11
|
private listeners = new Set<(data: unknown[]) => void>();
|
|
11
12
|
private maxBuffer: number;
|
|
13
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
14
|
+
/** Tracks whether stop() was called so reconnect respects intent. */
|
|
15
|
+
private stopped = false;
|
|
12
16
|
|
|
13
17
|
constructor(private config: WebSocketSourceConfig) {
|
|
14
18
|
this.maxBuffer = config.maxBuffer ?? 1000;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
async start(): Promise<void> {
|
|
22
|
+
this.stopped = false;
|
|
18
23
|
return new Promise((resolve, reject) => {
|
|
19
|
-
|
|
24
|
+
const ws = new WebSocket(this.config.url);
|
|
25
|
+
this.ws = ws;
|
|
26
|
+
let settled = false;
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
ws.on('open', () => {
|
|
22
29
|
this.error = undefined;
|
|
23
|
-
|
|
30
|
+
try {
|
|
31
|
+
this.sendSubscribePayloads(ws);
|
|
32
|
+
this.startHeartbeat(ws);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
// A failure to send the subscribe frame leaves the socket open
|
|
35
|
+
// but useless; record it and reject the start() promise.
|
|
36
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
37
|
+
ws.close();
|
|
38
|
+
if (!settled) {
|
|
39
|
+
settled = true;
|
|
40
|
+
reject(err instanceof Error ? err : new Error(this.error));
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!settled) {
|
|
45
|
+
settled = true;
|
|
46
|
+
resolve();
|
|
47
|
+
}
|
|
24
48
|
});
|
|
25
49
|
|
|
26
|
-
|
|
50
|
+
ws.on('message', (raw) => {
|
|
27
51
|
try {
|
|
28
52
|
const parsed = JSON.parse(raw.toString());
|
|
29
53
|
if (Array.isArray(parsed)) {
|
|
@@ -42,21 +66,30 @@ export class WebSocketSourceAdapter implements SourceAdapter {
|
|
|
42
66
|
}
|
|
43
67
|
});
|
|
44
68
|
|
|
45
|
-
|
|
69
|
+
ws.on('error', (err) => {
|
|
46
70
|
this.error = err.message;
|
|
47
|
-
|
|
71
|
+
if (!settled) {
|
|
72
|
+
settled = true;
|
|
73
|
+
reject(err);
|
|
74
|
+
}
|
|
48
75
|
});
|
|
49
76
|
|
|
50
|
-
|
|
51
|
-
|
|
77
|
+
ws.on('close', () => {
|
|
78
|
+
this.stopHeartbeat();
|
|
79
|
+
// Auto-reconnect after 5s unless stop() was explicitly called.
|
|
80
|
+
// Re-running start() also re-sends the subscribe payloads, which
|
|
81
|
+
// is exactly what every exchange expects after a connection drop.
|
|
82
|
+
if (this.stopped) return;
|
|
52
83
|
setTimeout(() => {
|
|
53
|
-
if (this.
|
|
84
|
+
if (!this.stopped) this.start().catch(() => {});
|
|
54
85
|
}, 5000);
|
|
55
86
|
});
|
|
56
87
|
});
|
|
57
88
|
}
|
|
58
89
|
|
|
59
90
|
stop(): void {
|
|
91
|
+
this.stopped = true;
|
|
92
|
+
this.stopHeartbeat();
|
|
60
93
|
const ws = this.ws;
|
|
61
94
|
this.ws = null;
|
|
62
95
|
ws?.close();
|
|
@@ -83,4 +116,35 @@ export class WebSocketSourceAdapter implements SourceAdapter {
|
|
|
83
116
|
private notify(): void {
|
|
84
117
|
for (const cb of this.listeners) cb(this.data);
|
|
85
118
|
}
|
|
119
|
+
|
|
120
|
+
private sendSubscribePayloads(ws: WebSocket): void {
|
|
121
|
+
const subscribe = this.config.subscribe;
|
|
122
|
+
if (subscribe === undefined || subscribe === null) return;
|
|
123
|
+
const list = Array.isArray(subscribe) ? subscribe : [subscribe];
|
|
124
|
+
for (const payload of list) {
|
|
125
|
+
ws.send(JSON.stringify(payload));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private startHeartbeat(ws: WebSocket): void {
|
|
130
|
+
this.stopHeartbeat();
|
|
131
|
+
const hb = this.config.heartbeat;
|
|
132
|
+
if (!hb) return;
|
|
133
|
+
const intervalMs = parseDuration(hb.interval);
|
|
134
|
+
this.heartbeatTimer = setInterval(() => {
|
|
135
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
136
|
+
try {
|
|
137
|
+
ws.send(JSON.stringify(hb.payload));
|
|
138
|
+
} catch {
|
|
139
|
+
// Ignore — next 'close' / 'error' will surface anything fatal.
|
|
140
|
+
}
|
|
141
|
+
}, intervalMs);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private stopHeartbeat(): void {
|
|
145
|
+
if (this.heartbeatTimer) {
|
|
146
|
+
clearInterval(this.heartbeatTimer);
|
|
147
|
+
this.heartbeatTimer = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
86
150
|
}
|
package/src/telegram/bot.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { Bot, type Context, GrammyError, HttpError } from 'grammy';
|
|
|
10
10
|
import type { Provider } from '../mcp/provider.js';
|
|
11
11
|
import { formatError, formatResult, escapeHtml } from './format.js';
|
|
12
12
|
import type { NLTranslator } from './nl.js';
|
|
13
|
+
import type { EventLogger, UserRef } from './event-log.js';
|
|
13
14
|
|
|
14
15
|
export interface BotOptions {
|
|
15
16
|
/** Whitelist of Telegram user IDs (numeric) or @usernames allowed to query.
|
|
@@ -18,12 +19,16 @@ export interface BotOptions {
|
|
|
18
19
|
/** Optional natural-language translator. If present, plain-text messages
|
|
19
20
|
* (no leading slash) are routed through it. */
|
|
20
21
|
nl?: NLTranslator;
|
|
22
|
+
/** Optional structured event logger. When present, every command,
|
|
23
|
+
* NL query, and unauthorized attempt is recorded. */
|
|
24
|
+
logger?: EventLogger;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export function buildBot(token: string, provider: Provider, opts: BotOptions = {}): Bot {
|
|
24
28
|
const bot = new Bot(token);
|
|
25
29
|
const allowList = normalizeAllowList(opts.allowUsers ?? []);
|
|
26
30
|
const nl = opts.nl;
|
|
31
|
+
const logger = opts.logger;
|
|
27
32
|
|
|
28
33
|
// Auth middleware — runs before any handler.
|
|
29
34
|
bot.use(async (ctx, next) => {
|
|
@@ -36,6 +41,7 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
|
|
|
36
41
|
return;
|
|
37
42
|
}
|
|
38
43
|
if (!isAllowed(ctx, allowList)) {
|
|
44
|
+
logger?.log({ kind: 'unauthorized', user: userOf(ctx) });
|
|
39
45
|
await ctx.reply('🔒 You are not authorized to use this bot.');
|
|
40
46
|
return;
|
|
41
47
|
}
|
|
@@ -43,6 +49,7 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
|
|
|
43
49
|
});
|
|
44
50
|
|
|
45
51
|
bot.command('start', async (ctx) => {
|
|
52
|
+
const start = Date.now();
|
|
46
53
|
await ctx.reply(
|
|
47
54
|
[
|
|
48
55
|
'👋 <b>pipequery bot</b>',
|
|
@@ -59,9 +66,11 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
|
|
|
59
66
|
].join('\n'),
|
|
60
67
|
{ parse_mode: 'HTML' },
|
|
61
68
|
);
|
|
69
|
+
logger?.log({ kind: 'command', user: userOf(ctx), cmd: '/start', outcome: 'success', latencyMs: Date.now() - start });
|
|
62
70
|
});
|
|
63
71
|
|
|
64
72
|
bot.command('help', async (ctx) => {
|
|
73
|
+
const start = Date.now();
|
|
65
74
|
const lines = [
|
|
66
75
|
'<b>Examples</b>',
|
|
67
76
|
'<code>/sources</code>',
|
|
@@ -86,37 +95,73 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
|
|
|
86
95
|
'• Results > 30 rows are truncated with a footer; tighten your query to see all of them.',
|
|
87
96
|
);
|
|
88
97
|
await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
|
|
98
|
+
logger?.log({ kind: 'command', user: userOf(ctx), cmd: '/help', outcome: 'success', latencyMs: Date.now() - start });
|
|
89
99
|
});
|
|
90
100
|
|
|
91
101
|
bot.command('sources', async (ctx) => {
|
|
102
|
+
const start = Date.now();
|
|
92
103
|
try {
|
|
93
104
|
const sources = await provider.listSources();
|
|
94
105
|
if (sources.length === 0) {
|
|
95
106
|
await ctx.reply('<i>No sources configured.</i>', { parse_mode: 'HTML' });
|
|
96
|
-
|
|
107
|
+
} else {
|
|
108
|
+
const lines = sources.map((s) => {
|
|
109
|
+
const dot = s.status.healthy ? '🟢' : '🔴';
|
|
110
|
+
const rowCount = s.status.rowCount.toLocaleString();
|
|
111
|
+
const err = s.status.error ? ` <i>(${escapeHtml(s.status.error)})</i>` : '';
|
|
112
|
+
return `${dot} <b>${escapeHtml(s.name)}</b> — ${rowCount} rows${err}`;
|
|
113
|
+
});
|
|
114
|
+
await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
|
|
97
115
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
logger?.log({
|
|
117
|
+
kind: 'command',
|
|
118
|
+
user: userOf(ctx),
|
|
119
|
+
cmd: '/sources',
|
|
120
|
+
outcome: 'success',
|
|
121
|
+
rowCount: sources.length,
|
|
122
|
+
latencyMs: Date.now() - start,
|
|
103
123
|
});
|
|
104
|
-
await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
|
|
105
124
|
} catch (err) {
|
|
106
125
|
await ctx.reply(formatError(err), { parse_mode: 'HTML' });
|
|
126
|
+
logger?.log({
|
|
127
|
+
kind: 'command',
|
|
128
|
+
user: userOf(ctx),
|
|
129
|
+
cmd: '/sources',
|
|
130
|
+
outcome: 'error',
|
|
131
|
+
latencyMs: Date.now() - start,
|
|
132
|
+
error: errMessage(err),
|
|
133
|
+
});
|
|
107
134
|
}
|
|
108
135
|
});
|
|
109
136
|
|
|
110
137
|
bot.command('describe', async (ctx) => {
|
|
138
|
+
const start = Date.now();
|
|
111
139
|
const name = ctx.match.trim();
|
|
112
140
|
if (!name) {
|
|
113
141
|
await ctx.reply('Usage: <code>/describe <source-name></code>', { parse_mode: 'HTML' });
|
|
142
|
+
logger?.log({
|
|
143
|
+
kind: 'command',
|
|
144
|
+
user: userOf(ctx),
|
|
145
|
+
cmd: '/describe',
|
|
146
|
+
outcome: 'error',
|
|
147
|
+
latencyMs: Date.now() - start,
|
|
148
|
+
error: 'missing source name',
|
|
149
|
+
});
|
|
114
150
|
return;
|
|
115
151
|
}
|
|
116
152
|
try {
|
|
117
153
|
const desc = await provider.describeSource(name, 5);
|
|
118
154
|
if (!desc) {
|
|
119
155
|
await ctx.reply(`Source <code>${escapeHtml(name)}</code> not found.`, { parse_mode: 'HTML' });
|
|
156
|
+
logger?.log({
|
|
157
|
+
kind: 'command',
|
|
158
|
+
user: userOf(ctx),
|
|
159
|
+
cmd: '/describe',
|
|
160
|
+
args: name,
|
|
161
|
+
outcome: 'error',
|
|
162
|
+
latencyMs: Date.now() - start,
|
|
163
|
+
error: 'source not found',
|
|
164
|
+
});
|
|
120
165
|
return;
|
|
121
166
|
}
|
|
122
167
|
const fields = desc.fields.length ? desc.fields.map((f) => `<code>${escapeHtml(f)}</code>`).join(', ') : '<i>(none inferred)</i>';
|
|
@@ -125,52 +170,141 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
|
|
|
125
170
|
`<b>${escapeHtml(name)}</b> — ${desc.status.rowCount.toLocaleString()} rows\n<b>Fields:</b> ${fields}\n\n<b>Sample:</b>\n${sample}`,
|
|
126
171
|
{ parse_mode: 'HTML' },
|
|
127
172
|
);
|
|
173
|
+
logger?.log({
|
|
174
|
+
kind: 'command',
|
|
175
|
+
user: userOf(ctx),
|
|
176
|
+
cmd: '/describe',
|
|
177
|
+
args: name,
|
|
178
|
+
outcome: 'success',
|
|
179
|
+
rowCount: desc.sample.length,
|
|
180
|
+
latencyMs: Date.now() - start,
|
|
181
|
+
});
|
|
128
182
|
} catch (err) {
|
|
129
183
|
await ctx.reply(formatError(err), { parse_mode: 'HTML' });
|
|
184
|
+
logger?.log({
|
|
185
|
+
kind: 'command',
|
|
186
|
+
user: userOf(ctx),
|
|
187
|
+
cmd: '/describe',
|
|
188
|
+
args: name,
|
|
189
|
+
outcome: 'error',
|
|
190
|
+
latencyMs: Date.now() - start,
|
|
191
|
+
error: errMessage(err),
|
|
192
|
+
});
|
|
130
193
|
}
|
|
131
194
|
});
|
|
132
195
|
|
|
133
196
|
bot.command('endpoints', async (ctx) => {
|
|
197
|
+
const start = Date.now();
|
|
134
198
|
try {
|
|
135
199
|
const endpoints = await provider.listEndpoints();
|
|
136
200
|
if (endpoints.length === 0) {
|
|
137
201
|
await ctx.reply('<i>No endpoints configured.</i>', { parse_mode: 'HTML' });
|
|
138
|
-
|
|
202
|
+
} else {
|
|
203
|
+
const lines = endpoints.map(
|
|
204
|
+
(e) => `<b>${escapeHtml(e.path)}</b>\n<code>${escapeHtml(e.config.query)}</code>`,
|
|
205
|
+
);
|
|
206
|
+
await ctx.reply(lines.join('\n\n'), { parse_mode: 'HTML' });
|
|
139
207
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
208
|
+
logger?.log({
|
|
209
|
+
kind: 'command',
|
|
210
|
+
user: userOf(ctx),
|
|
211
|
+
cmd: '/endpoints',
|
|
212
|
+
outcome: 'success',
|
|
213
|
+
rowCount: endpoints.length,
|
|
214
|
+
latencyMs: Date.now() - start,
|
|
215
|
+
});
|
|
144
216
|
} catch (err) {
|
|
145
217
|
await ctx.reply(formatError(err), { parse_mode: 'HTML' });
|
|
218
|
+
logger?.log({
|
|
219
|
+
kind: 'command',
|
|
220
|
+
user: userOf(ctx),
|
|
221
|
+
cmd: '/endpoints',
|
|
222
|
+
outcome: 'error',
|
|
223
|
+
latencyMs: Date.now() - start,
|
|
224
|
+
error: errMessage(err),
|
|
225
|
+
});
|
|
146
226
|
}
|
|
147
227
|
});
|
|
148
228
|
|
|
149
229
|
bot.command('call', async (ctx) => {
|
|
230
|
+
const start = Date.now();
|
|
150
231
|
const path = ctx.match.trim();
|
|
151
232
|
if (!path) {
|
|
152
233
|
await ctx.reply('Usage: <code>/call <endpoint-path></code>', { parse_mode: 'HTML' });
|
|
234
|
+
logger?.log({
|
|
235
|
+
kind: 'command',
|
|
236
|
+
user: userOf(ctx),
|
|
237
|
+
cmd: '/call',
|
|
238
|
+
outcome: 'error',
|
|
239
|
+
latencyMs: Date.now() - start,
|
|
240
|
+
error: 'missing path',
|
|
241
|
+
});
|
|
153
242
|
return;
|
|
154
243
|
}
|
|
155
244
|
try {
|
|
156
245
|
const result = await provider.callEndpoint(path);
|
|
157
246
|
await ctx.reply(formatResult(result), { parse_mode: 'HTML' });
|
|
247
|
+
logger?.log({
|
|
248
|
+
kind: 'command',
|
|
249
|
+
user: userOf(ctx),
|
|
250
|
+
cmd: '/call',
|
|
251
|
+
args: path,
|
|
252
|
+
outcome: 'success',
|
|
253
|
+
rowCount: rowCountOf(result),
|
|
254
|
+
latencyMs: Date.now() - start,
|
|
255
|
+
});
|
|
158
256
|
} catch (err) {
|
|
159
257
|
await ctx.reply(formatError(err), { parse_mode: 'HTML' });
|
|
258
|
+
logger?.log({
|
|
259
|
+
kind: 'command',
|
|
260
|
+
user: userOf(ctx),
|
|
261
|
+
cmd: '/call',
|
|
262
|
+
args: path,
|
|
263
|
+
outcome: 'error',
|
|
264
|
+
latencyMs: Date.now() - start,
|
|
265
|
+
error: errMessage(err),
|
|
266
|
+
});
|
|
160
267
|
}
|
|
161
268
|
});
|
|
162
269
|
|
|
163
270
|
bot.command('query', async (ctx) => {
|
|
271
|
+
const start = Date.now();
|
|
164
272
|
const expr = ctx.match.trim();
|
|
165
273
|
if (!expr) {
|
|
166
274
|
await ctx.reply('Usage: <code>/query <pipequery expression></code>', { parse_mode: 'HTML' });
|
|
275
|
+
logger?.log({
|
|
276
|
+
kind: 'command',
|
|
277
|
+
user: userOf(ctx),
|
|
278
|
+
cmd: '/query',
|
|
279
|
+
outcome: 'error',
|
|
280
|
+
latencyMs: Date.now() - start,
|
|
281
|
+
error: 'missing expression',
|
|
282
|
+
});
|
|
167
283
|
return;
|
|
168
284
|
}
|
|
169
285
|
try {
|
|
170
286
|
const result = await provider.runQuery(expr);
|
|
171
287
|
await ctx.reply(formatResult(result), { parse_mode: 'HTML' });
|
|
288
|
+
logger?.log({
|
|
289
|
+
kind: 'command',
|
|
290
|
+
user: userOf(ctx),
|
|
291
|
+
cmd: '/query',
|
|
292
|
+
args: expr,
|
|
293
|
+
outcome: 'success',
|
|
294
|
+
rowCount: rowCountOf(result),
|
|
295
|
+
latencyMs: Date.now() - start,
|
|
296
|
+
});
|
|
172
297
|
} catch (err) {
|
|
173
298
|
await ctx.reply(formatError(err), { parse_mode: 'HTML' });
|
|
299
|
+
logger?.log({
|
|
300
|
+
kind: 'command',
|
|
301
|
+
user: userOf(ctx),
|
|
302
|
+
cmd: '/query',
|
|
303
|
+
args: expr,
|
|
304
|
+
outcome: 'error',
|
|
305
|
+
latencyMs: Date.now() - start,
|
|
306
|
+
error: errMessage(err),
|
|
307
|
+
});
|
|
174
308
|
}
|
|
175
309
|
});
|
|
176
310
|
|
|
@@ -180,12 +314,21 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
|
|
|
180
314
|
bot.on('message:text', async (ctx) => {
|
|
181
315
|
const text = ctx.message.text.trim();
|
|
182
316
|
if (!text || text.startsWith('/')) return;
|
|
317
|
+
const start = Date.now();
|
|
183
318
|
if (!nl) {
|
|
184
319
|
await ctx.reply(
|
|
185
320
|
'I don\'t recognise that. Use <code>/help</code> to see the available commands. ' +
|
|
186
321
|
'Natural-language queries are disabled — start the bot with <code>--anthropic-key</code> to enable them.',
|
|
187
322
|
{ parse_mode: 'HTML' },
|
|
188
323
|
);
|
|
324
|
+
logger?.log({
|
|
325
|
+
kind: 'nl',
|
|
326
|
+
user: userOf(ctx),
|
|
327
|
+
text,
|
|
328
|
+
outcome: 'error',
|
|
329
|
+
latencyMs: Date.now() - start,
|
|
330
|
+
error: 'NL translation disabled',
|
|
331
|
+
});
|
|
189
332
|
return;
|
|
190
333
|
}
|
|
191
334
|
try {
|
|
@@ -194,13 +337,39 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
|
|
|
194
337
|
if (!expression) {
|
|
195
338
|
const reason = explanation || 'I couldn\'t translate that into a pipequery expression.';
|
|
196
339
|
await ctx.reply(`🤔 ${escapeHtml(reason)}`, { parse_mode: 'HTML' });
|
|
340
|
+
logger?.log({
|
|
341
|
+
kind: 'nl',
|
|
342
|
+
user: userOf(ctx),
|
|
343
|
+
text,
|
|
344
|
+
explanation,
|
|
345
|
+
outcome: 'not_answerable',
|
|
346
|
+
latencyMs: Date.now() - start,
|
|
347
|
+
});
|
|
197
348
|
return;
|
|
198
349
|
}
|
|
199
350
|
const result = await provider.runQuery(expression);
|
|
200
351
|
const header = `<i>${escapeHtml(explanation || 'Translated query')}</i>\n<code>${escapeHtml(expression)}</code>\n\n`;
|
|
201
352
|
await ctx.reply(header + formatResult(result), { parse_mode: 'HTML' });
|
|
353
|
+
logger?.log({
|
|
354
|
+
kind: 'nl',
|
|
355
|
+
user: userOf(ctx),
|
|
356
|
+
text,
|
|
357
|
+
expression,
|
|
358
|
+
explanation,
|
|
359
|
+
outcome: 'success',
|
|
360
|
+
rowCount: rowCountOf(result),
|
|
361
|
+
latencyMs: Date.now() - start,
|
|
362
|
+
});
|
|
202
363
|
} catch (err) {
|
|
203
364
|
await ctx.reply(formatError(err), { parse_mode: 'HTML' });
|
|
365
|
+
logger?.log({
|
|
366
|
+
kind: 'nl',
|
|
367
|
+
user: userOf(ctx),
|
|
368
|
+
text,
|
|
369
|
+
outcome: 'error',
|
|
370
|
+
latencyMs: Date.now() - start,
|
|
371
|
+
error: errMessage(err),
|
|
372
|
+
});
|
|
204
373
|
}
|
|
205
374
|
});
|
|
206
375
|
|
|
@@ -245,3 +414,20 @@ function trackUnauthSeen(id: number, username?: string): void {
|
|
|
245
414
|
`Restart with --allow-user @yourname to lock it down.\n`,
|
|
246
415
|
);
|
|
247
416
|
}
|
|
417
|
+
|
|
418
|
+
// ─── Logging helpers ────────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
function userOf(ctx: Context): UserRef {
|
|
421
|
+
return {
|
|
422
|
+
id: ctx.from?.id,
|
|
423
|
+
username: ctx.from?.username,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function rowCountOf(value: unknown): number | undefined {
|
|
428
|
+
return Array.isArray(value) ? value.length : undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function errMessage(err: unknown): string {
|
|
432
|
+
return err instanceof Error ? err.message : String(err);
|
|
433
|
+
}
|