@vaultgradient/pipequery-cli 0.4.0 → 0.6.1
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 +64 -0
- package/__tests__/loader-watches.test.ts +125 -0
- package/__tests__/nl-translator.test.ts +228 -0
- package/__tests__/telegram-format.test.ts +62 -0
- package/__tests__/watch-manager.test.ts +202 -0
- package/dist/index.js +932 -58
- package/package.json +3 -1
- package/src/commands/telegram.ts +65 -0
- package/src/commands/watch.ts +84 -0
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +45 -0
- package/src/index.ts +43 -0
- package/src/server/app.ts +10 -1
- package/src/server/watches/manager.ts +177 -0
- package/src/telegram/bot.ts +247 -0
- package/src/telegram/format.ts +158 -0
- package/src/telegram/nl.ts +318 -0
- package/src/telegram/notifier.ts +110 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vaultgradient/pipequery-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "CLI for PipeQuery — data sources, aggregations, APIs, and terminal dashboards",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"test:watch": "vitest"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
+
"@anthropic-ai/sdk": "^0.91.1",
|
|
16
17
|
"@fastify/cors": "^11.2.0",
|
|
17
18
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
18
19
|
"better-sqlite3": "^12.9.0",
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
"commander": "^14.0.3",
|
|
22
23
|
"csv-parse": "^6.2.1",
|
|
23
24
|
"fastify": "^5.8.5",
|
|
25
|
+
"grammy": "^1.42.0",
|
|
24
26
|
"ink": "^7.0.1",
|
|
25
27
|
"kafkajs": "^2.2.4",
|
|
26
28
|
"mysql2": "^3.22.2",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { loadConfig } from '../config/loader.js';
|
|
2
|
+
import { log } from '../utils/logger.js';
|
|
3
|
+
import { LocalProvider, AttachedProvider, type Provider } from '../mcp/provider.js';
|
|
4
|
+
import { buildBot } from '../telegram/bot.js';
|
|
5
|
+
import { createNLTranslator } from '../telegram/nl.js';
|
|
6
|
+
|
|
7
|
+
interface TelegramServeOptions {
|
|
8
|
+
botToken?: string;
|
|
9
|
+
attach?: string;
|
|
10
|
+
allowUser?: string[];
|
|
11
|
+
anthropicKey?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function telegramServeCommand(opts: TelegramServeOptions): Promise<void> {
|
|
15
|
+
const token = opts.botToken ?? process.env.PIPEQUERY_TG_BOT_TOKEN;
|
|
16
|
+
if (!token) {
|
|
17
|
+
log.error(
|
|
18
|
+
'Bot token required. Pass --bot-token <token> or set PIPEQUERY_TG_BOT_TOKEN.',
|
|
19
|
+
);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const anthropicKey = opts.anthropicKey ?? process.env.ANTHROPIC_API_KEY;
|
|
24
|
+
const provider = await buildProvider(opts.attach);
|
|
25
|
+
const nl = anthropicKey
|
|
26
|
+
? createNLTranslator(provider, { apiKey: anthropicKey })
|
|
27
|
+
: undefined;
|
|
28
|
+
|
|
29
|
+
const bot = buildBot(token, provider, {
|
|
30
|
+
allowUsers: opts.allowUser ?? [],
|
|
31
|
+
nl,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const me = await bot.api.getMe();
|
|
35
|
+
log.success(`Telegram bot listening as @${me.username} (id ${me.id})`);
|
|
36
|
+
if (nl) {
|
|
37
|
+
log.info('Natural-language translation enabled (claude-haiku-4-5).');
|
|
38
|
+
} else {
|
|
39
|
+
log.dim('Natural-language translation disabled. Pass --anthropic-key or set ANTHROPIC_API_KEY to enable.');
|
|
40
|
+
}
|
|
41
|
+
if (!opts.allowUser || opts.allowUser.length === 0) {
|
|
42
|
+
log.warn('No --allow-user set: anyone with the bot username can query. Add allowlist for production.');
|
|
43
|
+
}
|
|
44
|
+
log.info('Press Ctrl+C to stop.');
|
|
45
|
+
|
|
46
|
+
const shutdown = async () => {
|
|
47
|
+
log.info('Stopping bot...');
|
|
48
|
+
await bot.stop();
|
|
49
|
+
await provider.dispose();
|
|
50
|
+
process.exit(0);
|
|
51
|
+
};
|
|
52
|
+
process.on('SIGINT', () => void shutdown());
|
|
53
|
+
process.on('SIGTERM', () => void shutdown());
|
|
54
|
+
|
|
55
|
+
await bot.start();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function buildProvider(attachUrl?: string): Promise<Provider> {
|
|
59
|
+
if (attachUrl) {
|
|
60
|
+
return new AttachedProvider(attachUrl);
|
|
61
|
+
}
|
|
62
|
+
const cwd = process.cwd();
|
|
63
|
+
const { config } = await loadConfig(cwd);
|
|
64
|
+
return new LocalProvider(config, cwd);
|
|
65
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig, saveConfig } from '../config/loader.js';
|
|
3
|
+
import { log } from '../utils/logger.js';
|
|
4
|
+
import type { WatchConfig } from '../config/schema.js';
|
|
5
|
+
|
|
6
|
+
export async function watchListCommand(): Promise<void> {
|
|
7
|
+
const cwd = process.cwd();
|
|
8
|
+
const { config } = await loadConfig(cwd);
|
|
9
|
+
const watches = config.watches ?? {};
|
|
10
|
+
const entries = Object.entries(watches);
|
|
11
|
+
if (entries.length === 0) {
|
|
12
|
+
log.dim('No watches configured. Add one with: pq watch add <name>');
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
console.log(chalk.bold('Watches:\n'));
|
|
16
|
+
for (const [name, w] of entries) {
|
|
17
|
+
const interval = w.interval ?? '60s';
|
|
18
|
+
const fireWhen = w.fireWhen ?? 'when_non_empty';
|
|
19
|
+
const channels = Object.keys(w.notify).join(', ') || '<none>';
|
|
20
|
+
console.log(` ${chalk.bold(name)} ${chalk.dim(`every ${interval} • ${fireWhen}`)}`);
|
|
21
|
+
console.log(` ${chalk.dim('query: ')}${w.query}`);
|
|
22
|
+
console.log(` ${chalk.dim('notify: ')}${channels}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function watchAddCommand(name: string, opts: {
|
|
27
|
+
query: string;
|
|
28
|
+
interval?: string;
|
|
29
|
+
fireWhen?: string;
|
|
30
|
+
telegramChatId?: string;
|
|
31
|
+
telegramMessage?: string;
|
|
32
|
+
telegramBotToken?: string;
|
|
33
|
+
}): Promise<void> {
|
|
34
|
+
const cwd = process.cwd();
|
|
35
|
+
const { config, path: configPath } = await loadConfig(cwd);
|
|
36
|
+
|
|
37
|
+
if (!opts.telegramChatId) {
|
|
38
|
+
throw new Error('--telegram-chat-id is required (only Telegram notifier supported in v1)');
|
|
39
|
+
}
|
|
40
|
+
const fireWhen = opts.fireWhen ?? 'when_non_empty';
|
|
41
|
+
if (!['when_non_empty', 'when_empty', 'on_change'].includes(fireWhen)) {
|
|
42
|
+
throw new Error(`--fire-when must be one of: when_non_empty, when_empty, on_change`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const watch: WatchConfig = {
|
|
46
|
+
query: opts.query,
|
|
47
|
+
...(opts.interval ? { interval: opts.interval } : {}),
|
|
48
|
+
fireWhen: fireWhen as WatchConfig['fireWhen'],
|
|
49
|
+
notify: {
|
|
50
|
+
telegram: {
|
|
51
|
+
chatId: parseChatId(opts.telegramChatId),
|
|
52
|
+
...(opts.telegramBotToken ? { botToken: opts.telegramBotToken } : {}),
|
|
53
|
+
...(opts.telegramMessage ? { message: opts.telegramMessage } : {}),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
config.watches = config.watches ?? {};
|
|
59
|
+
config.watches[name] = watch;
|
|
60
|
+
await saveConfig(config, configPath);
|
|
61
|
+
log.success(`Watch "${name}" added. Restart \`pq serve\` to activate.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function watchRemoveCommand(name: string): Promise<void> {
|
|
65
|
+
const cwd = process.cwd();
|
|
66
|
+
const { config, path: configPath } = await loadConfig(cwd);
|
|
67
|
+
if (!config.watches?.[name]) {
|
|
68
|
+
log.error(`Watch "${name}" not found`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
delete config.watches[name];
|
|
72
|
+
await saveConfig(config, configPath);
|
|
73
|
+
log.success(`Watch "${name}" removed. Restart \`pq serve\` to apply.`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseChatId(input: string): string | number {
|
|
77
|
+
// Numeric Telegram chat IDs (e.g. -1001234567890) are large negative
|
|
78
|
+
// integers that fit in safe-integer range. Keep negatives as numbers.
|
|
79
|
+
if (/^-?\d+$/.test(input)) {
|
|
80
|
+
const n = Number(input);
|
|
81
|
+
if (Number.isFinite(n) && Number.isSafeInteger(n)) return n;
|
|
82
|
+
}
|
|
83
|
+
return input;
|
|
84
|
+
}
|
package/src/config/loader.ts
CHANGED
|
@@ -27,6 +27,7 @@ export async function loadConfig(cwd = process.cwd()): Promise<{ config: PipeQue
|
|
|
27
27
|
sources: raw.sources ?? {},
|
|
28
28
|
endpoints: raw.endpoints ?? {},
|
|
29
29
|
dashboards: raw.dashboards ?? {},
|
|
30
|
+
watches: raw.watches ?? {},
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
return { config, path: configPath };
|
package/src/config/schema.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface PipeQueryConfig {
|
|
|
4
4
|
sources: Record<string, SourceConfig>;
|
|
5
5
|
endpoints: Record<string, EndpointConfig>;
|
|
6
6
|
dashboards: Record<string, DashboardConfig>;
|
|
7
|
+
watches?: Record<string, WatchConfig>;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export interface ServerConfig {
|
|
@@ -178,3 +179,47 @@ export interface PanelConfig {
|
|
|
178
179
|
viz: 'table' | 'bar' | 'sparkline' | 'stat' | 'orderbook' | 'heatmap' | 'candle' | 'auto';
|
|
179
180
|
size?: 'full' | 'half' | 'stat';
|
|
180
181
|
}
|
|
182
|
+
|
|
183
|
+
// ─── Watches (alerts triggered by a query result) ────────────────────────────
|
|
184
|
+
|
|
185
|
+
export interface WatchConfig {
|
|
186
|
+
/** PipeQuery expression to evaluate on each tick. */
|
|
187
|
+
query: string;
|
|
188
|
+
/** Poll interval, e.g. "30s", "5m". Defaults to "60s". */
|
|
189
|
+
interval?: string;
|
|
190
|
+
/**
|
|
191
|
+
* When to fire a notification:
|
|
192
|
+
* - `when_non_empty` (default): fires on the transition from empty/null
|
|
193
|
+
* to non-empty. Doesn't re-fire while still non-empty; re-fires after
|
|
194
|
+
* going empty and then non-empty again. Standard alerting shape.
|
|
195
|
+
* - `when_empty`: inverse — fires when transitioning to empty (e.g., a
|
|
196
|
+
* liveness check that fails when no rows match).
|
|
197
|
+
* - `on_change`: fires on any change in the result content. Useful for
|
|
198
|
+
* "tell me when this number moves" but can be chatty.
|
|
199
|
+
*/
|
|
200
|
+
fireWhen?: 'when_non_empty' | 'when_empty' | 'on_change';
|
|
201
|
+
/** Notification channels. At least one must be configured. */
|
|
202
|
+
notify: WatchNotify;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface WatchNotify {
|
|
206
|
+
telegram?: TelegramNotifyConfig;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface TelegramNotifyConfig {
|
|
210
|
+
/**
|
|
211
|
+
* Bot token; supports `${ENV_VAR}` interpolation. If omitted, defaults
|
|
212
|
+
* to the `PIPEQUERY_TG_BOT_TOKEN` environment variable.
|
|
213
|
+
*/
|
|
214
|
+
botToken?: string;
|
|
215
|
+
/** Chat / channel / group ID to post to. */
|
|
216
|
+
chatId: string | number;
|
|
217
|
+
/**
|
|
218
|
+
* Optional message template. `{{ .field }}` expands to the named field
|
|
219
|
+
* of the FIRST row of the result (most alerts care about a single
|
|
220
|
+
* triggering row). `{{ .count }}` is special and yields the total row
|
|
221
|
+
* count. If omitted, the bot posts a default summary line plus the
|
|
222
|
+
* full result rendered as a Markdown table.
|
|
223
|
+
*/
|
|
224
|
+
message?: string;
|
|
225
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,11 +9,16 @@ import { dashboardCommand } from './commands/dashboard.js';
|
|
|
9
9
|
import { monitorCommand } from './commands/monitor.js';
|
|
10
10
|
import { remoteDeployCommand, remoteConnectCommand, remoteStatusCommand } from './commands/remote.js';
|
|
11
11
|
import { mcpServeCommand, mcpInspectCommand } from './commands/mcp.js';
|
|
12
|
+
import { telegramServeCommand } from './commands/telegram.js';
|
|
13
|
+
import { watchListCommand, watchAddCommand, watchRemoveCommand } from './commands/watch.js';
|
|
12
14
|
import { completionCommand } from './commands/completion.js';
|
|
13
15
|
import { stopCommand } from './commands/stop.js';
|
|
14
16
|
import { startRepl } from './commands/repl.js';
|
|
15
17
|
import { printBanner } from './utils/banner.js';
|
|
16
18
|
|
|
19
|
+
// Commander helper: accumulate `--allow-user @a --allow-user @b` into an array.
|
|
20
|
+
const collectRepeated = (value: string, acc: string[]): string[] => [...acc, value];
|
|
21
|
+
|
|
17
22
|
const program = new Command();
|
|
18
23
|
|
|
19
24
|
program
|
|
@@ -169,6 +174,44 @@ mcp
|
|
|
169
174
|
.option('--attach <url>', 'Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally')
|
|
170
175
|
.action(mcpInspectCommand);
|
|
171
176
|
|
|
177
|
+
// ─── pq telegram ─────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
const telegram = program.command('telegram').description('Telegram bot transport for pipequery');
|
|
180
|
+
|
|
181
|
+
telegram
|
|
182
|
+
.command('serve')
|
|
183
|
+
.description('Run a Telegram bot that exposes pipequery commands (/query, /sources, /describe, /endpoints, /call) plus natural-language queries when --anthropic-key is set')
|
|
184
|
+
.option('-t, --bot-token <token>', 'Telegram bot token (or set $PIPEQUERY_TG_BOT_TOKEN)')
|
|
185
|
+
.option('--attach <url>', 'Attach to a running `pq serve` instance instead of loading pipequery.yaml locally')
|
|
186
|
+
.option('--allow-user <handle>', 'Allowlist a user (numeric id or @username); repeatable. With no allowlist, anyone with the bot username can query.', collectRepeated, [])
|
|
187
|
+
.option('--anthropic-key <key>', 'Anthropic API key (or set $ANTHROPIC_API_KEY) — enables natural-language → pipequery translation on plain-text messages via claude-haiku-4-5.')
|
|
188
|
+
.action(telegramServeCommand);
|
|
189
|
+
|
|
190
|
+
// ─── pq watch ────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
const watch = program.command('watch').description('Manage alert watches (query → notification when condition fires)');
|
|
193
|
+
|
|
194
|
+
watch
|
|
195
|
+
.command('list')
|
|
196
|
+
.description('List configured watches')
|
|
197
|
+
.action(watchListCommand);
|
|
198
|
+
|
|
199
|
+
watch
|
|
200
|
+
.command('add <name>')
|
|
201
|
+
.description('Add a watch that fires a notification when its query result triggers the configured condition')
|
|
202
|
+
.requiredOption('-q, --query <expression>', 'PipeQuery expression to evaluate on each tick')
|
|
203
|
+
.option('-i, --interval <duration>', 'Poll interval (e.g., 30s, 5m). Default 60s.')
|
|
204
|
+
.option('-f, --fire-when <mode>', 'Fire condition: when_non_empty (default), when_empty, on_change')
|
|
205
|
+
.option('--telegram-chat-id <id>', 'Telegram chat / channel / group ID to notify')
|
|
206
|
+
.option('--telegram-bot-token <token>', 'Bot token override (defaults to $PIPEQUERY_TG_BOT_TOKEN)')
|
|
207
|
+
.option('--telegram-message <template>', 'Optional message template; supports {{ .field }} from the first row, plus {{ .count }}, {{ .watchName }}, {{ .reason }}')
|
|
208
|
+
.action(watchAddCommand);
|
|
209
|
+
|
|
210
|
+
watch
|
|
211
|
+
.command('remove <name>')
|
|
212
|
+
.description('Remove a watch')
|
|
213
|
+
.action(watchRemoveCommand);
|
|
214
|
+
|
|
172
215
|
// ─── pq stop ────────────────────────────────────────────────────────────────
|
|
173
216
|
|
|
174
217
|
program
|
package/src/server/app.ts
CHANGED
|
@@ -2,6 +2,7 @@ import Fastify from 'fastify';
|
|
|
2
2
|
import cors from '@fastify/cors';
|
|
3
3
|
import type { PipeQueryConfig, EndpointConfig, SourceConfig } from '../config/schema.js';
|
|
4
4
|
import { SourceManager } from './sources/manager.js';
|
|
5
|
+
import { WatchManager } from './watches/manager.js';
|
|
5
6
|
import { registerHealthRoutes } from './routes/health.js';
|
|
6
7
|
import { registerControlRoutes } from './routes/control.js';
|
|
7
8
|
import { registerDynamicRoutes } from './routes/dynamic.js';
|
|
@@ -14,6 +15,7 @@ export interface ServerContext {
|
|
|
14
15
|
app: ReturnType<typeof Fastify>;
|
|
15
16
|
sourceManager: SourceManager;
|
|
16
17
|
endpoints: Map<string, EndpointConfig>;
|
|
18
|
+
watchManager: WatchManager;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export async function createServer(
|
|
@@ -95,7 +97,14 @@ export async function createServer(
|
|
|
95
97
|
registerSSERoutes(app, sourceManager);
|
|
96
98
|
registerDynamicRoutes(app, sourceManager, endpoints);
|
|
97
99
|
|
|
100
|
+
// Watches: register and start polling. Failures in any single watch are
|
|
101
|
+
// isolated — see WatchManager for the per-watch error handling.
|
|
102
|
+
const watchManager = new WatchManager(sourceManager);
|
|
103
|
+
if (config.watches && Object.keys(config.watches).length > 0) {
|
|
104
|
+
watchManager.start(config.watches);
|
|
105
|
+
}
|
|
106
|
+
|
|
98
107
|
activity.info('Server ready');
|
|
99
108
|
|
|
100
|
-
return { app, sourceManager, endpoints };
|
|
109
|
+
return { app, sourceManager, endpoints, watchManager };
|
|
101
110
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch manager — polls each configured watch on its interval, evaluates
|
|
3
|
+
* the pipequery expression against the live source data, and dispatches
|
|
4
|
+
* notifications when the watch's fire condition is met.
|
|
5
|
+
*
|
|
6
|
+
* Lives alongside the existing endpoint registry in createServer(). Same
|
|
7
|
+
* lifecycle: start when the server boots, stop when it disposes.
|
|
8
|
+
*
|
|
9
|
+
* Idempotency model:
|
|
10
|
+
* - For `when_non_empty`: track per-watch `lastWasEmpty` boolean. Fire
|
|
11
|
+
* only when the previous tick was empty (or first run) and the current
|
|
12
|
+
* tick is non-empty. Re-fires after the result goes empty and back.
|
|
13
|
+
* - For `when_empty`: the inverse.
|
|
14
|
+
* - For `on_change`: track a hash of the last result; fire on hash change.
|
|
15
|
+
*
|
|
16
|
+
* All firing is best-effort. If a notifier throws, the error is logged on
|
|
17
|
+
* stderr and the watch state advances anyway — we don't want one stuck
|
|
18
|
+
* notifier to wedge an alert pipeline.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
import { query } from '../../engine.js';
|
|
22
|
+
import { parseDuration } from '../../utils/parseDuration.js';
|
|
23
|
+
import * as activity from '../activity-log.js';
|
|
24
|
+
import type { WatchConfig, WatchNotify } from '../../config/schema.js';
|
|
25
|
+
import type { SourceManager } from '../sources/manager.js';
|
|
26
|
+
import type { Notifier } from '../../telegram/notifier.js';
|
|
27
|
+
import { TelegramNotifier } from '../../telegram/notifier.js';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_INTERVAL_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
interface WatchState {
|
|
32
|
+
config: WatchConfig;
|
|
33
|
+
notifiers: Notifier[];
|
|
34
|
+
intervalMs: number;
|
|
35
|
+
timer: ReturnType<typeof setInterval> | null;
|
|
36
|
+
lastWasEmpty: boolean | null;
|
|
37
|
+
lastHash: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class WatchManager {
|
|
41
|
+
private watches = new Map<string, WatchState>();
|
|
42
|
+
private disposed = false;
|
|
43
|
+
|
|
44
|
+
constructor(private sourceManager: SourceManager) {}
|
|
45
|
+
|
|
46
|
+
start(configs: Record<string, WatchConfig>): void {
|
|
47
|
+
for (const [name, cfg] of Object.entries(configs)) {
|
|
48
|
+
try {
|
|
49
|
+
this.startOne(name, cfg);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
52
|
+
activity.error(`Watch "${name}" failed to start: ${msg}`);
|
|
53
|
+
process.stderr.write(`[pipequery-watch] "${name}" failed to start: ${msg}\n`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private startOne(name: string, cfg: WatchConfig): void {
|
|
59
|
+
const notifiers = buildNotifiers(cfg.notify);
|
|
60
|
+
if (notifiers.length === 0) {
|
|
61
|
+
throw new Error(`watch has no notification channels configured`);
|
|
62
|
+
}
|
|
63
|
+
const intervalMs = cfg.interval ? parseDuration(cfg.interval) : DEFAULT_INTERVAL_MS;
|
|
64
|
+
const state: WatchState = {
|
|
65
|
+
config: cfg,
|
|
66
|
+
notifiers,
|
|
67
|
+
intervalMs,
|
|
68
|
+
timer: null,
|
|
69
|
+
lastWasEmpty: null,
|
|
70
|
+
lastHash: null,
|
|
71
|
+
};
|
|
72
|
+
this.watches.set(name, state);
|
|
73
|
+
activity.info(`Watch "${name}" registered (every ${cfg.interval ?? '60s'})`);
|
|
74
|
+
|
|
75
|
+
// Run once immediately to seed `lastWasEmpty`/`lastHash` — we don't fire
|
|
76
|
+
// on the very first tick, since "transitioned" requires a previous state.
|
|
77
|
+
void this.tick(name);
|
|
78
|
+
state.timer = setInterval(() => void this.tick(name), intervalMs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async tick(name: string): Promise<void> {
|
|
82
|
+
if (this.disposed) return;
|
|
83
|
+
const state = this.watches.get(name);
|
|
84
|
+
if (!state) return;
|
|
85
|
+
|
|
86
|
+
let result: unknown;
|
|
87
|
+
try {
|
|
88
|
+
const ctx = this.sourceManager.getContext();
|
|
89
|
+
result = query(ctx, state.config.query);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
92
|
+
activity.error(`Watch "${name}" query failed: ${msg}`);
|
|
93
|
+
// Don't advance state on query failure — wait for the next tick.
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fireWhen = state.config.fireWhen ?? 'when_non_empty';
|
|
98
|
+
const isEmpty = isResultEmpty(result);
|
|
99
|
+
const hash = hashResult(result);
|
|
100
|
+
|
|
101
|
+
let shouldFire = false;
|
|
102
|
+
let reason = '';
|
|
103
|
+
if (fireWhen === 'when_non_empty') {
|
|
104
|
+
if (state.lastWasEmpty !== false && !isEmpty) {
|
|
105
|
+
shouldFire = true;
|
|
106
|
+
reason = 'result transitioned to non-empty';
|
|
107
|
+
}
|
|
108
|
+
} else if (fireWhen === 'when_empty') {
|
|
109
|
+
if (state.lastWasEmpty !== true && isEmpty) {
|
|
110
|
+
shouldFire = true;
|
|
111
|
+
reason = 'result transitioned to empty';
|
|
112
|
+
}
|
|
113
|
+
} else if (fireWhen === 'on_change') {
|
|
114
|
+
if (state.lastHash !== null && state.lastHash !== hash) {
|
|
115
|
+
shouldFire = true;
|
|
116
|
+
reason = 'result content changed';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
state.lastWasEmpty = isEmpty;
|
|
121
|
+
state.lastHash = hash;
|
|
122
|
+
|
|
123
|
+
if (!shouldFire) return;
|
|
124
|
+
|
|
125
|
+
activity.info(`Watch "${name}" fired (${reason})`);
|
|
126
|
+
await Promise.all(
|
|
127
|
+
state.notifiers.map((n) =>
|
|
128
|
+
n.notify({ watchName: name, result, reason }).catch((err) => {
|
|
129
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
130
|
+
activity.error(`Watch "${name}" notify failed: ${msg}`);
|
|
131
|
+
process.stderr.write(`[pipequery-watch] "${name}" notify failed: ${msg}\n`);
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
stop(name: string): boolean {
|
|
138
|
+
const state = this.watches.get(name);
|
|
139
|
+
if (!state) return false;
|
|
140
|
+
if (state.timer) clearInterval(state.timer);
|
|
141
|
+
this.watches.delete(name);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
list(): string[] {
|
|
146
|
+
return [...this.watches.keys()];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
dispose(): void {
|
|
150
|
+
this.disposed = true;
|
|
151
|
+
for (const state of this.watches.values()) {
|
|
152
|
+
if (state.timer) clearInterval(state.timer);
|
|
153
|
+
}
|
|
154
|
+
this.watches.clear();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
function buildNotifiers(notify: WatchNotify): Notifier[] {
|
|
161
|
+
const out: Notifier[] = [];
|
|
162
|
+
if (notify.telegram) out.push(new TelegramNotifier(notify.telegram));
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isResultEmpty(result: unknown): boolean {
|
|
167
|
+
if (result === null || result === undefined) return true;
|
|
168
|
+
if (Array.isArray(result)) return result.length === 0;
|
|
169
|
+
if (typeof result === 'object') return Object.keys(result as object).length === 0;
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function hashResult(result: unknown): string {
|
|
174
|
+
// Stable JSON for hashing — skip undefined / sort keys.
|
|
175
|
+
const json = JSON.stringify(result, (_, v) => (v === undefined ? null : v));
|
|
176
|
+
return createHash('sha256').update(json).digest('hex');
|
|
177
|
+
}
|