@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/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
5
|
+
import chalk9 from "chalk";
|
|
6
6
|
|
|
7
7
|
// src/commands/init.ts
|
|
8
8
|
import { writeFile } from "fs/promises";
|
|
@@ -123,7 +123,8 @@ async function loadConfig(cwd = process.cwd()) {
|
|
|
123
123
|
remote: raw.remote,
|
|
124
124
|
sources: raw.sources ?? {},
|
|
125
125
|
endpoints: raw.endpoints ?? {},
|
|
126
|
-
dashboards: raw.dashboards ?? {}
|
|
126
|
+
dashboards: raw.dashboards ?? {},
|
|
127
|
+
watches: raw.watches ?? {}
|
|
127
128
|
};
|
|
128
129
|
return { config, path: configPath };
|
|
129
130
|
}
|
|
@@ -942,19 +943,8 @@ var SourceManager = class {
|
|
|
942
943
|
}
|
|
943
944
|
};
|
|
944
945
|
|
|
945
|
-
// src/server/
|
|
946
|
-
|
|
947
|
-
app.get("/health", async () => {
|
|
948
|
-
return { status: "ok", uptime: Date.now() - startedAt.getTime() };
|
|
949
|
-
});
|
|
950
|
-
app.get("/status", async () => {
|
|
951
|
-
return {
|
|
952
|
-
status: "ok",
|
|
953
|
-
uptime: Date.now() - startedAt.getTime(),
|
|
954
|
-
sources: sourceManager.getAllStatuses()
|
|
955
|
-
};
|
|
956
|
-
});
|
|
957
|
-
}
|
|
946
|
+
// src/server/watches/manager.ts
|
|
947
|
+
import { createHash } from "crypto";
|
|
958
948
|
|
|
959
949
|
// ../src/engine/types.ts
|
|
960
950
|
var AGGREGATE_NAMES = /* @__PURE__ */ new Set([
|
|
@@ -2782,6 +2772,333 @@ function query(context, expression) {
|
|
|
2782
2772
|
return fn(context);
|
|
2783
2773
|
}
|
|
2784
2774
|
|
|
2775
|
+
// src/server/activity-log.ts
|
|
2776
|
+
var MAX_ENTRIES = 100;
|
|
2777
|
+
var entries = [];
|
|
2778
|
+
function logActivity(level, message) {
|
|
2779
|
+
entries.push({
|
|
2780
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2781
|
+
level,
|
|
2782
|
+
message
|
|
2783
|
+
});
|
|
2784
|
+
if (entries.length > MAX_ENTRIES) {
|
|
2785
|
+
entries.splice(0, entries.length - MAX_ENTRIES);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
function getActivityLog(since) {
|
|
2789
|
+
if (!since) return [...entries];
|
|
2790
|
+
return entries.filter((e) => e.time > since);
|
|
2791
|
+
}
|
|
2792
|
+
function info(msg) {
|
|
2793
|
+
logActivity("info", msg);
|
|
2794
|
+
}
|
|
2795
|
+
function warn(msg) {
|
|
2796
|
+
logActivity("warn", msg);
|
|
2797
|
+
}
|
|
2798
|
+
function error(msg) {
|
|
2799
|
+
logActivity("error", msg);
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
// src/telegram/notifier.ts
|
|
2803
|
+
import { Bot } from "grammy";
|
|
2804
|
+
|
|
2805
|
+
// src/telegram/format.ts
|
|
2806
|
+
var TELEGRAM_TEXT_LIMIT = 4096;
|
|
2807
|
+
var FENCE_OVERHEAD = 32;
|
|
2808
|
+
var TRUNCATE_BUDGET = TELEGRAM_TEXT_LIMIT - FENCE_OVERHEAD;
|
|
2809
|
+
var MAX_ROWS_INLINE = 30;
|
|
2810
|
+
var MAX_COL_WIDTH = 24;
|
|
2811
|
+
function formatResult(result, opts = {}) {
|
|
2812
|
+
const maxRows = opts.maxRows ?? MAX_ROWS_INLINE;
|
|
2813
|
+
if (Array.isArray(result)) {
|
|
2814
|
+
if (result.length === 0) return "<i>(no rows)</i>";
|
|
2815
|
+
const allObjects = result.every(
|
|
2816
|
+
(r) => r !== null && typeof r === "object" && !Array.isArray(r)
|
|
2817
|
+
);
|
|
2818
|
+
if (allObjects) {
|
|
2819
|
+
return renderTable(result, result.length, maxRows, opts);
|
|
2820
|
+
}
|
|
2821
|
+
return renderJson(result.slice(0, maxRows), result.length, maxRows);
|
|
2822
|
+
}
|
|
2823
|
+
return renderJson(result, 1, 1);
|
|
2824
|
+
}
|
|
2825
|
+
function formatError(err) {
|
|
2826
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2827
|
+
return `\u274C <b>Error</b>
|
|
2828
|
+
<pre>${escapeHtml(msg)}</pre>`;
|
|
2829
|
+
}
|
|
2830
|
+
function renderTable(rows, totalCount, maxRows, opts) {
|
|
2831
|
+
const maxColWidth = opts.maxColWidth ?? MAX_COL_WIDTH;
|
|
2832
|
+
const sample = rows.slice(0, maxRows);
|
|
2833
|
+
const columns = [];
|
|
2834
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2835
|
+
for (const r of sample) {
|
|
2836
|
+
for (const k of Object.keys(r)) {
|
|
2837
|
+
if (!seen.has(k)) {
|
|
2838
|
+
seen.add(k);
|
|
2839
|
+
columns.push(k);
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
const widths = columns.map(
|
|
2844
|
+
(c) => Math.min(
|
|
2845
|
+
maxColWidth,
|
|
2846
|
+
Math.max(c.length, ...sample.map((r) => stringifyCell(r[c]).length))
|
|
2847
|
+
)
|
|
2848
|
+
);
|
|
2849
|
+
const headerRow = columns.map((c, i) => padCell(c, widths[i])).join(" ");
|
|
2850
|
+
const sep = widths.map((w) => "\u2500".repeat(w)).join(" ");
|
|
2851
|
+
const dataRows = sample.map(
|
|
2852
|
+
(r) => columns.map((c, i) => padCell(stringifyCell(r[c]), widths[i])).join(" ")
|
|
2853
|
+
);
|
|
2854
|
+
const lines = [headerRow, sep, ...dataRows];
|
|
2855
|
+
let body = lines.join("\n");
|
|
2856
|
+
if (body.length > TRUNCATE_BUDGET) {
|
|
2857
|
+
body = body.slice(0, TRUNCATE_BUDGET - 30) + "\n... (truncated)";
|
|
2858
|
+
}
|
|
2859
|
+
let out = `<pre>${escapeHtml(body)}</pre>`;
|
|
2860
|
+
if (totalCount > maxRows) {
|
|
2861
|
+
out += `
|
|
2862
|
+
<i>showing ${maxRows} of ${totalCount} rows \u2014 narrow your query</i>`;
|
|
2863
|
+
} else {
|
|
2864
|
+
out += `
|
|
2865
|
+
<i>${totalCount} row${totalCount === 1 ? "" : "s"}</i>`;
|
|
2866
|
+
}
|
|
2867
|
+
return out;
|
|
2868
|
+
}
|
|
2869
|
+
function stringifyCell(value) {
|
|
2870
|
+
if (value === null || value === void 0) return "";
|
|
2871
|
+
if (typeof value === "string") return value;
|
|
2872
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
2873
|
+
return JSON.stringify(value);
|
|
2874
|
+
}
|
|
2875
|
+
function padCell(s, width) {
|
|
2876
|
+
if (s.length > width) return s.slice(0, width - 1) + "\u2026";
|
|
2877
|
+
return s.padEnd(width, " ");
|
|
2878
|
+
}
|
|
2879
|
+
function renderJson(data, totalCount, maxRows) {
|
|
2880
|
+
const text = JSON.stringify(data, null, 2);
|
|
2881
|
+
let body = text;
|
|
2882
|
+
if (body.length > TRUNCATE_BUDGET) {
|
|
2883
|
+
body = body.slice(0, TRUNCATE_BUDGET - 30) + "\n... (truncated)";
|
|
2884
|
+
}
|
|
2885
|
+
let out = `<pre>${escapeHtml(body)}</pre>`;
|
|
2886
|
+
if (Array.isArray(data) && totalCount > maxRows) {
|
|
2887
|
+
out += `
|
|
2888
|
+
<i>showing ${maxRows} of ${totalCount} rows \u2014 narrow your query</i>`;
|
|
2889
|
+
}
|
|
2890
|
+
return out;
|
|
2891
|
+
}
|
|
2892
|
+
var HTML_ENTITIES = {
|
|
2893
|
+
"&": "&",
|
|
2894
|
+
"<": "<",
|
|
2895
|
+
">": ">"
|
|
2896
|
+
};
|
|
2897
|
+
function escapeHtml(s) {
|
|
2898
|
+
return s.replace(/[&<>]/g, (c) => HTML_ENTITIES[c] ?? c);
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
// src/telegram/notifier.ts
|
|
2902
|
+
var botCache = /* @__PURE__ */ new Map();
|
|
2903
|
+
function expandEnv4(input) {
|
|
2904
|
+
return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => process.env[name] ?? "");
|
|
2905
|
+
}
|
|
2906
|
+
function getBot(token) {
|
|
2907
|
+
let bot = botCache.get(token);
|
|
2908
|
+
if (!bot) {
|
|
2909
|
+
bot = new Bot(token);
|
|
2910
|
+
botCache.set(token, bot);
|
|
2911
|
+
}
|
|
2912
|
+
return bot;
|
|
2913
|
+
}
|
|
2914
|
+
var TelegramNotifier = class {
|
|
2915
|
+
token;
|
|
2916
|
+
chatId;
|
|
2917
|
+
template;
|
|
2918
|
+
constructor(cfg) {
|
|
2919
|
+
const tokenSource = cfg.botToken ?? process.env.PIPEQUERY_TG_BOT_TOKEN;
|
|
2920
|
+
if (!tokenSource) {
|
|
2921
|
+
throw new Error(
|
|
2922
|
+
"TelegramNotifier: no bot token. Set notify.telegram.botToken or PIPEQUERY_TG_BOT_TOKEN."
|
|
2923
|
+
);
|
|
2924
|
+
}
|
|
2925
|
+
this.token = expandEnv4(tokenSource);
|
|
2926
|
+
this.chatId = cfg.chatId;
|
|
2927
|
+
this.template = cfg.message;
|
|
2928
|
+
}
|
|
2929
|
+
async notify(payload) {
|
|
2930
|
+
const bot = getBot(this.token);
|
|
2931
|
+
const text = this.renderMessage(payload);
|
|
2932
|
+
await bot.api.sendMessage(this.chatId, text, {
|
|
2933
|
+
parse_mode: "HTML",
|
|
2934
|
+
link_preview_options: { is_disabled: true }
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2937
|
+
renderMessage(payload) {
|
|
2938
|
+
if (this.template) {
|
|
2939
|
+
return renderTemplate(this.template, payload);
|
|
2940
|
+
}
|
|
2941
|
+
const header = `\u{1F514} <b>Watch fired:</b> <code>${escapeHtml(payload.watchName)}</code>`;
|
|
2942
|
+
const reason = `<i>${escapeHtml(payload.reason)}</i>`;
|
|
2943
|
+
const body = formatResult(payload.result);
|
|
2944
|
+
return [header, reason, body].join("\n");
|
|
2945
|
+
}
|
|
2946
|
+
};
|
|
2947
|
+
function renderTemplate(tmpl, payload) {
|
|
2948
|
+
const result = payload.result;
|
|
2949
|
+
const rows = Array.isArray(result) ? result : [result];
|
|
2950
|
+
const firstRow = rows[0];
|
|
2951
|
+
const count = rows.length;
|
|
2952
|
+
return tmpl.replace(/\{\{\s*\.([a-zA-Z0-9_]+)\s*\}\}/g, (match, name) => {
|
|
2953
|
+
if (name === "count") return String(count);
|
|
2954
|
+
if (name === "watchName") return payload.watchName;
|
|
2955
|
+
if (name === "reason") return payload.reason;
|
|
2956
|
+
if (firstRow && typeof firstRow === "object" && firstRow !== null) {
|
|
2957
|
+
const value = firstRow[name];
|
|
2958
|
+
if (value === void 0) return match;
|
|
2959
|
+
return String(value);
|
|
2960
|
+
}
|
|
2961
|
+
return match;
|
|
2962
|
+
});
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
// src/server/watches/manager.ts
|
|
2966
|
+
var DEFAULT_INTERVAL_MS4 = 6e4;
|
|
2967
|
+
var WatchManager = class {
|
|
2968
|
+
constructor(sourceManager) {
|
|
2969
|
+
this.sourceManager = sourceManager;
|
|
2970
|
+
}
|
|
2971
|
+
sourceManager;
|
|
2972
|
+
watches = /* @__PURE__ */ new Map();
|
|
2973
|
+
disposed = false;
|
|
2974
|
+
start(configs) {
|
|
2975
|
+
for (const [name, cfg] of Object.entries(configs)) {
|
|
2976
|
+
try {
|
|
2977
|
+
this.startOne(name, cfg);
|
|
2978
|
+
} catch (err) {
|
|
2979
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2980
|
+
error(`Watch "${name}" failed to start: ${msg}`);
|
|
2981
|
+
process.stderr.write(`[pipequery-watch] "${name}" failed to start: ${msg}
|
|
2982
|
+
`);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
startOne(name, cfg) {
|
|
2987
|
+
const notifiers = buildNotifiers(cfg.notify);
|
|
2988
|
+
if (notifiers.length === 0) {
|
|
2989
|
+
throw new Error(`watch has no notification channels configured`);
|
|
2990
|
+
}
|
|
2991
|
+
const intervalMs = cfg.interval ? parseDuration(cfg.interval) : DEFAULT_INTERVAL_MS4;
|
|
2992
|
+
const state = {
|
|
2993
|
+
config: cfg,
|
|
2994
|
+
notifiers,
|
|
2995
|
+
intervalMs,
|
|
2996
|
+
timer: null,
|
|
2997
|
+
lastWasEmpty: null,
|
|
2998
|
+
lastHash: null
|
|
2999
|
+
};
|
|
3000
|
+
this.watches.set(name, state);
|
|
3001
|
+
info(`Watch "${name}" registered (every ${cfg.interval ?? "60s"})`);
|
|
3002
|
+
void this.tick(name);
|
|
3003
|
+
state.timer = setInterval(() => void this.tick(name), intervalMs);
|
|
3004
|
+
}
|
|
3005
|
+
async tick(name) {
|
|
3006
|
+
if (this.disposed) return;
|
|
3007
|
+
const state = this.watches.get(name);
|
|
3008
|
+
if (!state) return;
|
|
3009
|
+
let result;
|
|
3010
|
+
try {
|
|
3011
|
+
const ctx = this.sourceManager.getContext();
|
|
3012
|
+
result = query(ctx, state.config.query);
|
|
3013
|
+
} catch (err) {
|
|
3014
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3015
|
+
error(`Watch "${name}" query failed: ${msg}`);
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
const fireWhen = state.config.fireWhen ?? "when_non_empty";
|
|
3019
|
+
const isEmpty = isResultEmpty(result);
|
|
3020
|
+
const hash = hashResult(result);
|
|
3021
|
+
let shouldFire = false;
|
|
3022
|
+
let reason = "";
|
|
3023
|
+
if (fireWhen === "when_non_empty") {
|
|
3024
|
+
if (state.lastWasEmpty !== false && !isEmpty) {
|
|
3025
|
+
shouldFire = true;
|
|
3026
|
+
reason = "result transitioned to non-empty";
|
|
3027
|
+
}
|
|
3028
|
+
} else if (fireWhen === "when_empty") {
|
|
3029
|
+
if (state.lastWasEmpty !== true && isEmpty) {
|
|
3030
|
+
shouldFire = true;
|
|
3031
|
+
reason = "result transitioned to empty";
|
|
3032
|
+
}
|
|
3033
|
+
} else if (fireWhen === "on_change") {
|
|
3034
|
+
if (state.lastHash !== null && state.lastHash !== hash) {
|
|
3035
|
+
shouldFire = true;
|
|
3036
|
+
reason = "result content changed";
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
state.lastWasEmpty = isEmpty;
|
|
3040
|
+
state.lastHash = hash;
|
|
3041
|
+
if (!shouldFire) return;
|
|
3042
|
+
info(`Watch "${name}" fired (${reason})`);
|
|
3043
|
+
await Promise.all(
|
|
3044
|
+
state.notifiers.map(
|
|
3045
|
+
(n) => n.notify({ watchName: name, result, reason }).catch((err) => {
|
|
3046
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3047
|
+
error(`Watch "${name}" notify failed: ${msg}`);
|
|
3048
|
+
process.stderr.write(`[pipequery-watch] "${name}" notify failed: ${msg}
|
|
3049
|
+
`);
|
|
3050
|
+
})
|
|
3051
|
+
)
|
|
3052
|
+
);
|
|
3053
|
+
}
|
|
3054
|
+
stop(name) {
|
|
3055
|
+
const state = this.watches.get(name);
|
|
3056
|
+
if (!state) return false;
|
|
3057
|
+
if (state.timer) clearInterval(state.timer);
|
|
3058
|
+
this.watches.delete(name);
|
|
3059
|
+
return true;
|
|
3060
|
+
}
|
|
3061
|
+
list() {
|
|
3062
|
+
return [...this.watches.keys()];
|
|
3063
|
+
}
|
|
3064
|
+
dispose() {
|
|
3065
|
+
this.disposed = true;
|
|
3066
|
+
for (const state of this.watches.values()) {
|
|
3067
|
+
if (state.timer) clearInterval(state.timer);
|
|
3068
|
+
}
|
|
3069
|
+
this.watches.clear();
|
|
3070
|
+
}
|
|
3071
|
+
};
|
|
3072
|
+
function buildNotifiers(notify) {
|
|
3073
|
+
const out = [];
|
|
3074
|
+
if (notify.telegram) out.push(new TelegramNotifier(notify.telegram));
|
|
3075
|
+
return out;
|
|
3076
|
+
}
|
|
3077
|
+
function isResultEmpty(result) {
|
|
3078
|
+
if (result === null || result === void 0) return true;
|
|
3079
|
+
if (Array.isArray(result)) return result.length === 0;
|
|
3080
|
+
if (typeof result === "object") return Object.keys(result).length === 0;
|
|
3081
|
+
return false;
|
|
3082
|
+
}
|
|
3083
|
+
function hashResult(result) {
|
|
3084
|
+
const json = JSON.stringify(result, (_, v) => v === void 0 ? null : v);
|
|
3085
|
+
return createHash("sha256").update(json).digest("hex");
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// src/server/routes/health.ts
|
|
3089
|
+
function registerHealthRoutes(app, sourceManager, startedAt) {
|
|
3090
|
+
app.get("/health", async () => {
|
|
3091
|
+
return { status: "ok", uptime: Date.now() - startedAt.getTime() };
|
|
3092
|
+
});
|
|
3093
|
+
app.get("/status", async () => {
|
|
3094
|
+
return {
|
|
3095
|
+
status: "ok",
|
|
3096
|
+
uptime: Date.now() - startedAt.getTime(),
|
|
3097
|
+
sources: sourceManager.getAllStatuses()
|
|
3098
|
+
};
|
|
3099
|
+
});
|
|
3100
|
+
}
|
|
3101
|
+
|
|
2785
3102
|
// src/server/routes/control.ts
|
|
2786
3103
|
function registerControlRoutes(app, sourceManager, endpointRegistry, configCallbacks) {
|
|
2787
3104
|
app.get("/api/_control/sources", async () => {
|
|
@@ -2969,33 +3286,6 @@ function sendAllPanels(res, queries, sourceManager) {
|
|
|
2969
3286
|
}
|
|
2970
3287
|
}
|
|
2971
3288
|
|
|
2972
|
-
// src/server/activity-log.ts
|
|
2973
|
-
var MAX_ENTRIES = 100;
|
|
2974
|
-
var entries = [];
|
|
2975
|
-
function logActivity(level, message) {
|
|
2976
|
-
entries.push({
|
|
2977
|
-
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2978
|
-
level,
|
|
2979
|
-
message
|
|
2980
|
-
});
|
|
2981
|
-
if (entries.length > MAX_ENTRIES) {
|
|
2982
|
-
entries.splice(0, entries.length - MAX_ENTRIES);
|
|
2983
|
-
}
|
|
2984
|
-
}
|
|
2985
|
-
function getActivityLog(since) {
|
|
2986
|
-
if (!since) return [...entries];
|
|
2987
|
-
return entries.filter((e) => e.time > since);
|
|
2988
|
-
}
|
|
2989
|
-
function info(msg) {
|
|
2990
|
-
logActivity("info", msg);
|
|
2991
|
-
}
|
|
2992
|
-
function warn(msg) {
|
|
2993
|
-
logActivity("warn", msg);
|
|
2994
|
-
}
|
|
2995
|
-
function error(msg) {
|
|
2996
|
-
logActivity("error", msg);
|
|
2997
|
-
}
|
|
2998
|
-
|
|
2999
3289
|
// src/server/app.ts
|
|
3000
3290
|
async function createServer(config, configPath, cwd) {
|
|
3001
3291
|
const app = Fastify({ logger: false });
|
|
@@ -3056,8 +3346,12 @@ async function createServer(config, configPath, cwd) {
|
|
|
3056
3346
|
registerControlRoutes(app, sourceManager, { endpoints }, configCallbacks);
|
|
3057
3347
|
registerSSERoutes(app, sourceManager);
|
|
3058
3348
|
registerDynamicRoutes(app, sourceManager, endpoints);
|
|
3349
|
+
const watchManager = new WatchManager(sourceManager);
|
|
3350
|
+
if (config.watches && Object.keys(config.watches).length > 0) {
|
|
3351
|
+
watchManager.start(config.watches);
|
|
3352
|
+
}
|
|
3059
3353
|
info("Server ready");
|
|
3060
|
-
return { app, sourceManager, endpoints };
|
|
3354
|
+
return { app, sourceManager, endpoints, watchManager };
|
|
3061
3355
|
}
|
|
3062
3356
|
|
|
3063
3357
|
// src/utils/banner.ts
|
|
@@ -3262,14 +3556,14 @@ async function queryCommand(expression, opts) {
|
|
|
3262
3556
|
return;
|
|
3263
3557
|
}
|
|
3264
3558
|
if (Array.isArray(result) && result.length > 0 && typeof result[0] === "object") {
|
|
3265
|
-
|
|
3559
|
+
renderTable2(result);
|
|
3266
3560
|
} else if (typeof result === "object" && result !== null && !Array.isArray(result)) {
|
|
3267
|
-
|
|
3561
|
+
renderTable2([result]);
|
|
3268
3562
|
} else {
|
|
3269
3563
|
console.log(result);
|
|
3270
3564
|
}
|
|
3271
3565
|
}
|
|
3272
|
-
function
|
|
3566
|
+
function renderTable2(rows) {
|
|
3273
3567
|
if (rows.length === 0) {
|
|
3274
3568
|
log.dim("(empty result)");
|
|
3275
3569
|
return;
|
|
@@ -5170,6 +5464,579 @@ async function buildProvider(attachUrl) {
|
|
|
5170
5464
|
return new LocalProvider(config, cwd);
|
|
5171
5465
|
}
|
|
5172
5466
|
|
|
5467
|
+
// src/telegram/bot.ts
|
|
5468
|
+
import { Bot as Bot2, GrammyError, HttpError } from "grammy";
|
|
5469
|
+
function buildBot(token, provider, opts = {}) {
|
|
5470
|
+
const bot = new Bot2(token);
|
|
5471
|
+
const allowList = normalizeAllowList(opts.allowUsers ?? []);
|
|
5472
|
+
const nl = opts.nl;
|
|
5473
|
+
bot.use(async (ctx, next) => {
|
|
5474
|
+
if (allowList.size === 0) {
|
|
5475
|
+
const id = ctx.from?.id;
|
|
5476
|
+
if (id) trackUnauthSeen(id, ctx.from?.username);
|
|
5477
|
+
await next();
|
|
5478
|
+
return;
|
|
5479
|
+
}
|
|
5480
|
+
if (!isAllowed(ctx, allowList)) {
|
|
5481
|
+
await ctx.reply("\u{1F512} You are not authorized to use this bot.");
|
|
5482
|
+
return;
|
|
5483
|
+
}
|
|
5484
|
+
await next();
|
|
5485
|
+
});
|
|
5486
|
+
bot.command("start", async (ctx) => {
|
|
5487
|
+
await ctx.reply(
|
|
5488
|
+
[
|
|
5489
|
+
"\u{1F44B} <b>pipequery bot</b>",
|
|
5490
|
+
"",
|
|
5491
|
+
"Run pipequery expressions against your configured data sources from chat.",
|
|
5492
|
+
"",
|
|
5493
|
+
"<b>Commands</b>",
|
|
5494
|
+
"/sources \u2014 list configured sources and their health",
|
|
5495
|
+
"/describe <name> \u2014 sample rows + field names",
|
|
5496
|
+
"/endpoints \u2014 list pre-configured endpoints",
|
|
5497
|
+
"/call <path> \u2014 execute a pre-configured endpoint",
|
|
5498
|
+
"/query <expression> \u2014 run an arbitrary pipequery expression",
|
|
5499
|
+
"/help \u2014 this message"
|
|
5500
|
+
].join("\n"),
|
|
5501
|
+
{ parse_mode: "HTML" }
|
|
5502
|
+
);
|
|
5503
|
+
});
|
|
5504
|
+
bot.command("help", async (ctx) => {
|
|
5505
|
+
const lines = [
|
|
5506
|
+
"<b>Examples</b>",
|
|
5507
|
+
"<code>/sources</code>",
|
|
5508
|
+
"<code>/describe products</code>",
|
|
5509
|
+
"<code>/query products | sort(price desc) | first(5)</code>",
|
|
5510
|
+
"<code>/query crypto | where(price > 100) | rollup(avg(price) as avg)</code>",
|
|
5511
|
+
"<code>/call /api/top-coins</code>"
|
|
5512
|
+
];
|
|
5513
|
+
if (nl) {
|
|
5514
|
+
lines.push(
|
|
5515
|
+
"",
|
|
5516
|
+
"<b>Natural language</b>",
|
|
5517
|
+
"Send any plain-text message (no leading <code>/</code>) and I'll translate it into a pipequery expression and run it.",
|
|
5518
|
+
'Example: <i>"top 5 most expensive paid orders"</i>'
|
|
5519
|
+
);
|
|
5520
|
+
}
|
|
5521
|
+
lines.push(
|
|
5522
|
+
"",
|
|
5523
|
+
"<b>Tips</b>",
|
|
5524
|
+
"\u2022 Wrap quoted strings carefully \u2014 Telegram's autocorrect can mangle them. Send long expressions as a separate message.",
|
|
5525
|
+
"\u2022 Use <code>/describe <source></code> to discover field names before constructing a where clause.",
|
|
5526
|
+
"\u2022 Results > 30 rows are truncated with a footer; tighten your query to see all of them."
|
|
5527
|
+
);
|
|
5528
|
+
await ctx.reply(lines.join("\n"), { parse_mode: "HTML" });
|
|
5529
|
+
});
|
|
5530
|
+
bot.command("sources", async (ctx) => {
|
|
5531
|
+
try {
|
|
5532
|
+
const sources = await provider.listSources();
|
|
5533
|
+
if (sources.length === 0) {
|
|
5534
|
+
await ctx.reply("<i>No sources configured.</i>", { parse_mode: "HTML" });
|
|
5535
|
+
return;
|
|
5536
|
+
}
|
|
5537
|
+
const lines = sources.map((s) => {
|
|
5538
|
+
const dot = s.status.healthy ? "\u{1F7E2}" : "\u{1F534}";
|
|
5539
|
+
const rowCount = s.status.rowCount.toLocaleString();
|
|
5540
|
+
const err = s.status.error ? ` <i>(${escapeHtml(s.status.error)})</i>` : "";
|
|
5541
|
+
return `${dot} <b>${escapeHtml(s.name)}</b> \u2014 ${rowCount} rows${err}`;
|
|
5542
|
+
});
|
|
5543
|
+
await ctx.reply(lines.join("\n"), { parse_mode: "HTML" });
|
|
5544
|
+
} catch (err) {
|
|
5545
|
+
await ctx.reply(formatError(err), { parse_mode: "HTML" });
|
|
5546
|
+
}
|
|
5547
|
+
});
|
|
5548
|
+
bot.command("describe", async (ctx) => {
|
|
5549
|
+
const name = ctx.match.trim();
|
|
5550
|
+
if (!name) {
|
|
5551
|
+
await ctx.reply("Usage: <code>/describe <source-name></code>", { parse_mode: "HTML" });
|
|
5552
|
+
return;
|
|
5553
|
+
}
|
|
5554
|
+
try {
|
|
5555
|
+
const desc = await provider.describeSource(name, 5);
|
|
5556
|
+
if (!desc) {
|
|
5557
|
+
await ctx.reply(`Source <code>${escapeHtml(name)}</code> not found.`, { parse_mode: "HTML" });
|
|
5558
|
+
return;
|
|
5559
|
+
}
|
|
5560
|
+
const fields = desc.fields.length ? desc.fields.map((f) => `<code>${escapeHtml(f)}</code>`).join(", ") : "<i>(none inferred)</i>";
|
|
5561
|
+
const sample = formatResult(desc.sample);
|
|
5562
|
+
await ctx.reply(
|
|
5563
|
+
`<b>${escapeHtml(name)}</b> \u2014 ${desc.status.rowCount.toLocaleString()} rows
|
|
5564
|
+
<b>Fields:</b> ${fields}
|
|
5565
|
+
|
|
5566
|
+
<b>Sample:</b>
|
|
5567
|
+
${sample}`,
|
|
5568
|
+
{ parse_mode: "HTML" }
|
|
5569
|
+
);
|
|
5570
|
+
} catch (err) {
|
|
5571
|
+
await ctx.reply(formatError(err), { parse_mode: "HTML" });
|
|
5572
|
+
}
|
|
5573
|
+
});
|
|
5574
|
+
bot.command("endpoints", async (ctx) => {
|
|
5575
|
+
try {
|
|
5576
|
+
const endpoints = await provider.listEndpoints();
|
|
5577
|
+
if (endpoints.length === 0) {
|
|
5578
|
+
await ctx.reply("<i>No endpoints configured.</i>", { parse_mode: "HTML" });
|
|
5579
|
+
return;
|
|
5580
|
+
}
|
|
5581
|
+
const lines = endpoints.map(
|
|
5582
|
+
(e) => `<b>${escapeHtml(e.path)}</b>
|
|
5583
|
+
<code>${escapeHtml(e.config.query)}</code>`
|
|
5584
|
+
);
|
|
5585
|
+
await ctx.reply(lines.join("\n\n"), { parse_mode: "HTML" });
|
|
5586
|
+
} catch (err) {
|
|
5587
|
+
await ctx.reply(formatError(err), { parse_mode: "HTML" });
|
|
5588
|
+
}
|
|
5589
|
+
});
|
|
5590
|
+
bot.command("call", async (ctx) => {
|
|
5591
|
+
const path = ctx.match.trim();
|
|
5592
|
+
if (!path) {
|
|
5593
|
+
await ctx.reply("Usage: <code>/call <endpoint-path></code>", { parse_mode: "HTML" });
|
|
5594
|
+
return;
|
|
5595
|
+
}
|
|
5596
|
+
try {
|
|
5597
|
+
const result = await provider.callEndpoint(path);
|
|
5598
|
+
await ctx.reply(formatResult(result), { parse_mode: "HTML" });
|
|
5599
|
+
} catch (err) {
|
|
5600
|
+
await ctx.reply(formatError(err), { parse_mode: "HTML" });
|
|
5601
|
+
}
|
|
5602
|
+
});
|
|
5603
|
+
bot.command("query", async (ctx) => {
|
|
5604
|
+
const expr = ctx.match.trim();
|
|
5605
|
+
if (!expr) {
|
|
5606
|
+
await ctx.reply("Usage: <code>/query <pipequery expression></code>", { parse_mode: "HTML" });
|
|
5607
|
+
return;
|
|
5608
|
+
}
|
|
5609
|
+
try {
|
|
5610
|
+
const result = await provider.runQuery(expr);
|
|
5611
|
+
await ctx.reply(formatResult(result), { parse_mode: "HTML" });
|
|
5612
|
+
} catch (err) {
|
|
5613
|
+
await ctx.reply(formatError(err), { parse_mode: "HTML" });
|
|
5614
|
+
}
|
|
5615
|
+
});
|
|
5616
|
+
bot.on("message:text", async (ctx) => {
|
|
5617
|
+
const text = ctx.message.text.trim();
|
|
5618
|
+
if (!text || text.startsWith("/")) return;
|
|
5619
|
+
if (!nl) {
|
|
5620
|
+
await ctx.reply(
|
|
5621
|
+
"I don't recognise that. Use <code>/help</code> to see the available commands. Natural-language queries are disabled \u2014 start the bot with <code>--anthropic-key</code> to enable them.",
|
|
5622
|
+
{ parse_mode: "HTML" }
|
|
5623
|
+
);
|
|
5624
|
+
return;
|
|
5625
|
+
}
|
|
5626
|
+
try {
|
|
5627
|
+
await ctx.replyWithChatAction("typing");
|
|
5628
|
+
const { expression, explanation } = await nl.translate(text);
|
|
5629
|
+
if (!expression) {
|
|
5630
|
+
const reason = explanation || "I couldn't translate that into a pipequery expression.";
|
|
5631
|
+
await ctx.reply(`\u{1F914} ${escapeHtml(reason)}`, { parse_mode: "HTML" });
|
|
5632
|
+
return;
|
|
5633
|
+
}
|
|
5634
|
+
const result = await provider.runQuery(expression);
|
|
5635
|
+
const header = `<i>${escapeHtml(explanation || "Translated query")}</i>
|
|
5636
|
+
<code>${escapeHtml(expression)}</code>
|
|
5637
|
+
|
|
5638
|
+
`;
|
|
5639
|
+
await ctx.reply(header + formatResult(result), { parse_mode: "HTML" });
|
|
5640
|
+
} catch (err) {
|
|
5641
|
+
await ctx.reply(formatError(err), { parse_mode: "HTML" });
|
|
5642
|
+
}
|
|
5643
|
+
});
|
|
5644
|
+
bot.catch((err) => {
|
|
5645
|
+
if (err.error instanceof GrammyError) {
|
|
5646
|
+
process.stderr.write(`[pipequery-telegram] grammy error: ${err.error.description}
|
|
5647
|
+
`);
|
|
5648
|
+
} else if (err.error instanceof HttpError) {
|
|
5649
|
+
process.stderr.write(`[pipequery-telegram] http error: ${err.error.message}
|
|
5650
|
+
`);
|
|
5651
|
+
} else {
|
|
5652
|
+
process.stderr.write(`[pipequery-telegram] unhandled: ${err.error}
|
|
5653
|
+
`);
|
|
5654
|
+
}
|
|
5655
|
+
});
|
|
5656
|
+
return bot;
|
|
5657
|
+
}
|
|
5658
|
+
function normalizeAllowList(entries2) {
|
|
5659
|
+
const set = /* @__PURE__ */ new Set();
|
|
5660
|
+
for (const raw of entries2) {
|
|
5661
|
+
const trimmed = raw.trim().replace(/^@/, "").toLowerCase();
|
|
5662
|
+
if (trimmed) set.add(trimmed);
|
|
5663
|
+
}
|
|
5664
|
+
return set;
|
|
5665
|
+
}
|
|
5666
|
+
function isAllowed(ctx, allowList) {
|
|
5667
|
+
const from = ctx.from;
|
|
5668
|
+
if (!from) return false;
|
|
5669
|
+
if (allowList.has(String(from.id))) return true;
|
|
5670
|
+
if (from.username && allowList.has(from.username.toLowerCase())) return true;
|
|
5671
|
+
return false;
|
|
5672
|
+
}
|
|
5673
|
+
var seenUnauth = /* @__PURE__ */ new Set();
|
|
5674
|
+
function trackUnauthSeen(id, username) {
|
|
5675
|
+
if (seenUnauth.has(id)) return;
|
|
5676
|
+
seenUnauth.add(id);
|
|
5677
|
+
process.stderr.write(
|
|
5678
|
+
`[pipequery-telegram] WARNING: bot has no allowlist; user "${username ?? id}" can query freely. Restart with --allow-user @yourname to lock it down.
|
|
5679
|
+
`
|
|
5680
|
+
);
|
|
5681
|
+
}
|
|
5682
|
+
|
|
5683
|
+
// src/telegram/nl.ts
|
|
5684
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
5685
|
+
var MODEL = "claude-haiku-4-5";
|
|
5686
|
+
var MAX_TOKENS = 1024;
|
|
5687
|
+
var SCHEMA_TTL_MS = 6e4;
|
|
5688
|
+
function createNLTranslator(provider, opts) {
|
|
5689
|
+
const client = opts.client ?? new Anthropic({ apiKey: opts.apiKey });
|
|
5690
|
+
const model = opts.model ?? MODEL;
|
|
5691
|
+
let cachedSchema = null;
|
|
5692
|
+
async function getSchemaPreamble() {
|
|
5693
|
+
const now = Date.now();
|
|
5694
|
+
if (cachedSchema && now - cachedSchema.builtAt < SCHEMA_TTL_MS) {
|
|
5695
|
+
return cachedSchema.text;
|
|
5696
|
+
}
|
|
5697
|
+
const text = await buildSchemaPreamble(provider);
|
|
5698
|
+
cachedSchema = { text, builtAt: now };
|
|
5699
|
+
return text;
|
|
5700
|
+
}
|
|
5701
|
+
async function translate(question) {
|
|
5702
|
+
const schema = await getSchemaPreamble();
|
|
5703
|
+
const response = await client.messages.create({
|
|
5704
|
+
model,
|
|
5705
|
+
max_tokens: MAX_TOKENS,
|
|
5706
|
+
system: [
|
|
5707
|
+
// Cache breakpoint #1 — the always-stable DSL grammar + instructions.
|
|
5708
|
+
// This is the largest block and the one that benefits most from being
|
|
5709
|
+
// cached across every translation request the bot ever serves.
|
|
5710
|
+
{
|
|
5711
|
+
type: "text",
|
|
5712
|
+
text: SYSTEM_PROMPT,
|
|
5713
|
+
cache_control: { type: "ephemeral" }
|
|
5714
|
+
},
|
|
5715
|
+
// Cache breakpoint #2 — the per-tenant source schema. Stable until
|
|
5716
|
+
// the yaml changes (or SCHEMA_TTL_MS elapses).
|
|
5717
|
+
{
|
|
5718
|
+
type: "text",
|
|
5719
|
+
text: schema,
|
|
5720
|
+
cache_control: { type: "ephemeral" }
|
|
5721
|
+
}
|
|
5722
|
+
],
|
|
5723
|
+
messages: [
|
|
5724
|
+
{
|
|
5725
|
+
role: "user",
|
|
5726
|
+
content: question
|
|
5727
|
+
}
|
|
5728
|
+
]
|
|
5729
|
+
});
|
|
5730
|
+
const text = extractText(response);
|
|
5731
|
+
return parseTranslation(text);
|
|
5732
|
+
}
|
|
5733
|
+
return {
|
|
5734
|
+
translate,
|
|
5735
|
+
invalidateSchema: () => {
|
|
5736
|
+
cachedSchema = null;
|
|
5737
|
+
}
|
|
5738
|
+
};
|
|
5739
|
+
}
|
|
5740
|
+
async function buildSchemaPreamble(provider) {
|
|
5741
|
+
const sources = await provider.listSources();
|
|
5742
|
+
const endpoints = await provider.listEndpoints();
|
|
5743
|
+
const sourceLines = [];
|
|
5744
|
+
for (const s of sources) {
|
|
5745
|
+
let fields = [];
|
|
5746
|
+
try {
|
|
5747
|
+
const desc = await provider.describeSource(s.name, 3);
|
|
5748
|
+
if (desc) fields = desc.fields;
|
|
5749
|
+
} catch {
|
|
5750
|
+
}
|
|
5751
|
+
const fieldStr = fields.length ? ` \u2014 fields: ${fields.join(", ")}` : "";
|
|
5752
|
+
sourceLines.push(`- ${s.name} (${s.status.rowCount} rows)${fieldStr}`);
|
|
5753
|
+
}
|
|
5754
|
+
const endpointLines = endpoints.map((e) => `- ${e.path} \u2192 ${e.config.query}`);
|
|
5755
|
+
return [
|
|
5756
|
+
"## Available data sources",
|
|
5757
|
+
sourceLines.length ? sourceLines.join("\n") : "(no sources configured)",
|
|
5758
|
+
"",
|
|
5759
|
+
"## Pre-defined endpoints",
|
|
5760
|
+
endpointLines.length ? endpointLines.join("\n") : "(no endpoints configured)"
|
|
5761
|
+
].join("\n");
|
|
5762
|
+
}
|
|
5763
|
+
function extractText(response) {
|
|
5764
|
+
const parts = [];
|
|
5765
|
+
for (const block of response.content) {
|
|
5766
|
+
if (block.type === "text") parts.push(block.text);
|
|
5767
|
+
}
|
|
5768
|
+
return parts.join("").trim();
|
|
5769
|
+
}
|
|
5770
|
+
function parseTranslation(raw) {
|
|
5771
|
+
const cleaned = raw.replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```\s*$/i, "").trim();
|
|
5772
|
+
const start = cleaned.indexOf("{");
|
|
5773
|
+
const end = cleaned.lastIndexOf("}");
|
|
5774
|
+
if (start === -1 || end === -1 || end < start) {
|
|
5775
|
+
throw new Error(`Translator returned non-JSON: ${truncate(raw, 200)}`);
|
|
5776
|
+
}
|
|
5777
|
+
const slice = cleaned.slice(start, end + 1);
|
|
5778
|
+
let parsed;
|
|
5779
|
+
try {
|
|
5780
|
+
parsed = JSON.parse(slice);
|
|
5781
|
+
} catch (err) {
|
|
5782
|
+
throw new Error(`Translator returned invalid JSON: ${truncate(raw, 200)}`);
|
|
5783
|
+
}
|
|
5784
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.expression !== "string") {
|
|
5785
|
+
throw new Error(`Translator response missing "expression" field: ${truncate(raw, 200)}`);
|
|
5786
|
+
}
|
|
5787
|
+
const obj = parsed;
|
|
5788
|
+
return {
|
|
5789
|
+
expression: obj.expression.trim(),
|
|
5790
|
+
explanation: typeof obj.explanation === "string" ? obj.explanation.trim() : ""
|
|
5791
|
+
};
|
|
5792
|
+
}
|
|
5793
|
+
function truncate(s, n) {
|
|
5794
|
+
return s.length > n ? `${s.slice(0, n)}\u2026` : s;
|
|
5795
|
+
}
|
|
5796
|
+
var SYSTEM_PROMPT = `You are a translator that converts natural-language questions into PipeQuery expressions.
|
|
5797
|
+
|
|
5798
|
+
PipeQuery is a small, pipe-style query DSL. Every expression starts with a source name (or a literal array) and pipes into one or more operations using \`|\`. PipeQuery is read-only by construction \u2014 there is no DDL, DML, INSERT, UPDATE, DELETE, or DROP. You will never produce SQL.
|
|
5799
|
+
|
|
5800
|
+
# Output contract
|
|
5801
|
+
|
|
5802
|
+
You MUST respond with a SINGLE JSON object and nothing else. No prose, no markdown, no code fences.
|
|
5803
|
+
|
|
5804
|
+
The object has exactly two fields:
|
|
5805
|
+
- "expression": a string containing the PipeQuery expression to execute.
|
|
5806
|
+
- "explanation": a one-sentence plain-English description of what the expression does.
|
|
5807
|
+
|
|
5808
|
+
Example:
|
|
5809
|
+
{"expression": "orders | where(status == \\"paid\\") | sort(total desc) | first(5)", "explanation": "Top 5 paid orders by total."}
|
|
5810
|
+
|
|
5811
|
+
If the user's question cannot be answered with the available sources, return:
|
|
5812
|
+
{"expression": "", "explanation": "<brief reason why no expression was produced>"}
|
|
5813
|
+
|
|
5814
|
+
# Grammar
|
|
5815
|
+
|
|
5816
|
+
Pipeline:
|
|
5817
|
+
<source_name> | <operation> | <operation> | ...
|
|
5818
|
+
|
|
5819
|
+
Sources are referenced by their bare name (no quotes). The available source names are listed in the schema preamble that follows this prompt.
|
|
5820
|
+
|
|
5821
|
+
# Operations
|
|
5822
|
+
|
|
5823
|
+
## where(<expression>)
|
|
5824
|
+
Filter rows. The expression is evaluated per row and must return a boolean.
|
|
5825
|
+
- Comparison operators: == != > >= < <=
|
|
5826
|
+
- Logical operators: && || !
|
|
5827
|
+
- Arithmetic: + - * / %
|
|
5828
|
+
- Field reference: bare identifier (e.g., \`price\`, \`status\`)
|
|
5829
|
+
- Strings: double-quoted ("paid")
|
|
5830
|
+
- Numbers: 1, 1.5, -42
|
|
5831
|
+
- Booleans: true, false
|
|
5832
|
+
- Null: null
|
|
5833
|
+
|
|
5834
|
+
Examples:
|
|
5835
|
+
events | where(severity == "high")
|
|
5836
|
+
trades | where(price > 100 && symbol == "BTC")
|
|
5837
|
+
users | where(!banned)
|
|
5838
|
+
|
|
5839
|
+
## sort(<field> asc|desc, ...)
|
|
5840
|
+
Sort by one or more fields. Direction is optional, defaults to asc.
|
|
5841
|
+
orders | sort(total desc)
|
|
5842
|
+
orders | sort(country asc, total desc)
|
|
5843
|
+
|
|
5844
|
+
## first(N) / last(N)
|
|
5845
|
+
Take the first / last N rows. N must be a positive integer literal.
|
|
5846
|
+
trades | sort(ts desc) | first(10)
|
|
5847
|
+
|
|
5848
|
+
## distinct / distinct(field, ...)
|
|
5849
|
+
Deduplicate rows. With no args, dedupes on the full row; with args, dedupes on the listed fields.
|
|
5850
|
+
users | distinct(email)
|
|
5851
|
+
|
|
5852
|
+
## select(<expression>, ...)
|
|
5853
|
+
Project fields. Each expression can be a bare field, a computed expression, or an alias (\`expr as name\`).
|
|
5854
|
+
orders | select(id, total, total * 0.1 as tax)
|
|
5855
|
+
|
|
5856
|
+
## groupBy(<field>, ...) and rollup
|
|
5857
|
+
Aggregate. \`groupBy\` keeps groups; \`rollup\` collapses each group to one row with computed aggregates.
|
|
5858
|
+
|
|
5859
|
+
orders | rollup(country, sum(total) as revenue, count() as n)
|
|
5860
|
+
trades | groupBy(symbol) | rollup(avg(price) as avg)
|
|
5861
|
+
|
|
5862
|
+
Aggregate functions: sum, avg, min, max, count, median, stddev, var, percentile, distinct_count, first_value, last_value, vwap, wavg, pct, sharpe, calmar, sortino, info_ratio, drawdown, sum_abs, abs_sum.
|
|
5863
|
+
|
|
5864
|
+
\`count()\` takes no field. \`percentile(field, p)\` and \`vwap(value, weight)\` take two args.
|
|
5865
|
+
|
|
5866
|
+
## flatten / flatten(field)
|
|
5867
|
+
Unnest array values. With \`flatten(orders)\` each element of \`row.orders\` becomes its own row.
|
|
5868
|
+
|
|
5869
|
+
## map(<expression>, ...)
|
|
5870
|
+
Transform each row into a new shape (similar to \`select\` but always emits a new object).
|
|
5871
|
+
|
|
5872
|
+
## reduce(initial, accumulator)
|
|
5873
|
+
Fold rows into a single value. Rare; prefer rollup.
|
|
5874
|
+
|
|
5875
|
+
## join(<source>, <condition>)
|
|
5876
|
+
Inner join with another source on a boolean condition. Use \`left\` and \`right\` to refer to fields.
|
|
5877
|
+
orders | join(customers, left.customer_id == right.id)
|
|
5878
|
+
|
|
5879
|
+
## pivot(<field>, <agg>, ...)
|
|
5880
|
+
Pivot rows into columns by a key field.
|
|
5881
|
+
|
|
5882
|
+
# Conventions and best practices
|
|
5883
|
+
|
|
5884
|
+
1. Prefer \`rollup\` over \`groupBy\` unless the caller wants the per-group rows kept.
|
|
5885
|
+
2. Always cap result size with \`first(N)\` for "top N" or "show me" questions; default to 10 if the user doesn't specify.
|
|
5886
|
+
3. Use the EXACT field names from the schema preamble. Do not invent fields.
|
|
5887
|
+
4. If two sources need to be joined and there's no obvious join key in the schema, prefer to ask for clarification by emitting the empty-expression form rather than guessing.
|
|
5888
|
+
5. Quote string literals with double quotes; escape internal double quotes with \\".
|
|
5889
|
+
6. Do not produce SQL, JavaScript, or English in the expression field \u2014 only PipeQuery.
|
|
5890
|
+
7. Numbers are bare; no thousands separators, no quotes.
|
|
5891
|
+
8. Comparisons against null use \`== null\` / \`!= null\`.
|
|
5892
|
+
|
|
5893
|
+
# Worked examples
|
|
5894
|
+
|
|
5895
|
+
Question: "What are the top 5 most expensive orders that were paid?"
|
|
5896
|
+
{"expression": "orders | where(status == \\"paid\\") | sort(total desc) | first(5)", "explanation": "Top 5 paid orders by total."}
|
|
5897
|
+
|
|
5898
|
+
Question: "Average BTC price right now"
|
|
5899
|
+
{"expression": "trades | where(symbol == \\"BTC\\") | rollup(avg(price) as avg_price)", "explanation": "Average price across all BTC rows."}
|
|
5900
|
+
|
|
5901
|
+
Question: "Revenue by country"
|
|
5902
|
+
{"expression": "orders | rollup(country, sum(total) as revenue) | sort(revenue desc)", "explanation": "Sum of order totals grouped by country, highest first."}
|
|
5903
|
+
|
|
5904
|
+
Question: "How many distinct users placed an order?"
|
|
5905
|
+
{"expression": "orders | rollup(distinct_count(user_id) as users)", "explanation": "Count of unique user IDs in orders."}
|
|
5906
|
+
|
|
5907
|
+
Question: "Show high-severity events from the last hour"
|
|
5908
|
+
(only answerable if the schema has a timestamp field; otherwise return empty.)
|
|
5909
|
+
{"expression": "events | where(severity == \\"high\\") | sort(ts desc) | first(50)", "explanation": "Most recent high-severity events, capped at 50."}
|
|
5910
|
+
|
|
5911
|
+
Question: "Drop the orders table"
|
|
5912
|
+
{"expression": "", "explanation": "PipeQuery is read-only and cannot drop tables."}
|
|
5913
|
+
|
|
5914
|
+
Question: "How many rows in foo?"
|
|
5915
|
+
(only answerable if a source named \`foo\` exists; otherwise return empty.)
|
|
5916
|
+
{"expression": "foo | rollup(count() as n)", "explanation": "Row count of foo."}
|
|
5917
|
+
|
|
5918
|
+
# Reminder
|
|
5919
|
+
|
|
5920
|
+
- Output exactly ONE JSON object.
|
|
5921
|
+
- The "expression" field must be valid PipeQuery referencing only sources from the schema preamble.
|
|
5922
|
+
- Use double quotes for strings, escape inner quotes as \\".
|
|
5923
|
+
- Default result cap is first(10) for unbounded "show me" questions unless an aggregation makes the result naturally small.
|
|
5924
|
+
- Never produce SQL or destructive operations.
|
|
5925
|
+
`;
|
|
5926
|
+
|
|
5927
|
+
// src/commands/telegram.ts
|
|
5928
|
+
async function telegramServeCommand(opts) {
|
|
5929
|
+
const token = opts.botToken ?? process.env.PIPEQUERY_TG_BOT_TOKEN;
|
|
5930
|
+
if (!token) {
|
|
5931
|
+
log.error(
|
|
5932
|
+
"Bot token required. Pass --bot-token <token> or set PIPEQUERY_TG_BOT_TOKEN."
|
|
5933
|
+
);
|
|
5934
|
+
process.exit(1);
|
|
5935
|
+
}
|
|
5936
|
+
const anthropicKey = opts.anthropicKey ?? process.env.ANTHROPIC_API_KEY;
|
|
5937
|
+
const provider = await buildProvider2(opts.attach);
|
|
5938
|
+
const nl = anthropicKey ? createNLTranslator(provider, { apiKey: anthropicKey }) : void 0;
|
|
5939
|
+
const bot = buildBot(token, provider, {
|
|
5940
|
+
allowUsers: opts.allowUser ?? [],
|
|
5941
|
+
nl
|
|
5942
|
+
});
|
|
5943
|
+
const me = await bot.api.getMe();
|
|
5944
|
+
log.success(`Telegram bot listening as @${me.username} (id ${me.id})`);
|
|
5945
|
+
if (nl) {
|
|
5946
|
+
log.info("Natural-language translation enabled (claude-haiku-4-5).");
|
|
5947
|
+
} else {
|
|
5948
|
+
log.dim("Natural-language translation disabled. Pass --anthropic-key or set ANTHROPIC_API_KEY to enable.");
|
|
5949
|
+
}
|
|
5950
|
+
if (!opts.allowUser || opts.allowUser.length === 0) {
|
|
5951
|
+
log.warn("No --allow-user set: anyone with the bot username can query. Add allowlist for production.");
|
|
5952
|
+
}
|
|
5953
|
+
log.info("Press Ctrl+C to stop.");
|
|
5954
|
+
const shutdown = async () => {
|
|
5955
|
+
log.info("Stopping bot...");
|
|
5956
|
+
await bot.stop();
|
|
5957
|
+
await provider.dispose();
|
|
5958
|
+
process.exit(0);
|
|
5959
|
+
};
|
|
5960
|
+
process.on("SIGINT", () => void shutdown());
|
|
5961
|
+
process.on("SIGTERM", () => void shutdown());
|
|
5962
|
+
await bot.start();
|
|
5963
|
+
}
|
|
5964
|
+
async function buildProvider2(attachUrl) {
|
|
5965
|
+
if (attachUrl) {
|
|
5966
|
+
return new AttachedProvider(attachUrl);
|
|
5967
|
+
}
|
|
5968
|
+
const cwd = process.cwd();
|
|
5969
|
+
const { config } = await loadConfig(cwd);
|
|
5970
|
+
return new LocalProvider(config, cwd);
|
|
5971
|
+
}
|
|
5972
|
+
|
|
5973
|
+
// src/commands/watch.ts
|
|
5974
|
+
import chalk7 from "chalk";
|
|
5975
|
+
async function watchListCommand() {
|
|
5976
|
+
const cwd = process.cwd();
|
|
5977
|
+
const { config } = await loadConfig(cwd);
|
|
5978
|
+
const watches = config.watches ?? {};
|
|
5979
|
+
const entries2 = Object.entries(watches);
|
|
5980
|
+
if (entries2.length === 0) {
|
|
5981
|
+
log.dim("No watches configured. Add one with: pq watch add <name>");
|
|
5982
|
+
return;
|
|
5983
|
+
}
|
|
5984
|
+
console.log(chalk7.bold("Watches:\n"));
|
|
5985
|
+
for (const [name, w] of entries2) {
|
|
5986
|
+
const interval = w.interval ?? "60s";
|
|
5987
|
+
const fireWhen = w.fireWhen ?? "when_non_empty";
|
|
5988
|
+
const channels = Object.keys(w.notify).join(", ") || "<none>";
|
|
5989
|
+
console.log(` ${chalk7.bold(name)} ${chalk7.dim(`every ${interval} \u2022 ${fireWhen}`)}`);
|
|
5990
|
+
console.log(` ${chalk7.dim("query: ")}${w.query}`);
|
|
5991
|
+
console.log(` ${chalk7.dim("notify: ")}${channels}`);
|
|
5992
|
+
}
|
|
5993
|
+
}
|
|
5994
|
+
async function watchAddCommand(name, opts) {
|
|
5995
|
+
const cwd = process.cwd();
|
|
5996
|
+
const { config, path: configPath } = await loadConfig(cwd);
|
|
5997
|
+
if (!opts.telegramChatId) {
|
|
5998
|
+
throw new Error("--telegram-chat-id is required (only Telegram notifier supported in v1)");
|
|
5999
|
+
}
|
|
6000
|
+
const fireWhen = opts.fireWhen ?? "when_non_empty";
|
|
6001
|
+
if (!["when_non_empty", "when_empty", "on_change"].includes(fireWhen)) {
|
|
6002
|
+
throw new Error(`--fire-when must be one of: when_non_empty, when_empty, on_change`);
|
|
6003
|
+
}
|
|
6004
|
+
const watch3 = {
|
|
6005
|
+
query: opts.query,
|
|
6006
|
+
...opts.interval ? { interval: opts.interval } : {},
|
|
6007
|
+
fireWhen,
|
|
6008
|
+
notify: {
|
|
6009
|
+
telegram: {
|
|
6010
|
+
chatId: parseChatId(opts.telegramChatId),
|
|
6011
|
+
...opts.telegramBotToken ? { botToken: opts.telegramBotToken } : {},
|
|
6012
|
+
...opts.telegramMessage ? { message: opts.telegramMessage } : {}
|
|
6013
|
+
}
|
|
6014
|
+
}
|
|
6015
|
+
};
|
|
6016
|
+
config.watches = config.watches ?? {};
|
|
6017
|
+
config.watches[name] = watch3;
|
|
6018
|
+
await saveConfig(config, configPath);
|
|
6019
|
+
log.success(`Watch "${name}" added. Restart \`pq serve\` to activate.`);
|
|
6020
|
+
}
|
|
6021
|
+
async function watchRemoveCommand(name) {
|
|
6022
|
+
const cwd = process.cwd();
|
|
6023
|
+
const { config, path: configPath } = await loadConfig(cwd);
|
|
6024
|
+
if (!config.watches?.[name]) {
|
|
6025
|
+
log.error(`Watch "${name}" not found`);
|
|
6026
|
+
return;
|
|
6027
|
+
}
|
|
6028
|
+
delete config.watches[name];
|
|
6029
|
+
await saveConfig(config, configPath);
|
|
6030
|
+
log.success(`Watch "${name}" removed. Restart \`pq serve\` to apply.`);
|
|
6031
|
+
}
|
|
6032
|
+
function parseChatId(input) {
|
|
6033
|
+
if (/^-?\d+$/.test(input)) {
|
|
6034
|
+
const n = Number(input);
|
|
6035
|
+
if (Number.isFinite(n) && Number.isSafeInteger(n)) return n;
|
|
6036
|
+
}
|
|
6037
|
+
return input;
|
|
6038
|
+
}
|
|
6039
|
+
|
|
5173
6040
|
// src/commands/completion.ts
|
|
5174
6041
|
var BASH_COMPLETION = `###-begin-pq-completions-###
|
|
5175
6042
|
_pq_completions() {
|
|
@@ -5385,7 +6252,7 @@ async function stopCommand(opts) {
|
|
|
5385
6252
|
// src/commands/repl.ts
|
|
5386
6253
|
import { createInterface } from "readline";
|
|
5387
6254
|
import { spawnSync } from "child_process";
|
|
5388
|
-
import
|
|
6255
|
+
import chalk8 from "chalk";
|
|
5389
6256
|
var COMMANDS = {
|
|
5390
6257
|
help: "Show available commands",
|
|
5391
6258
|
serve: "Start the server (serve -d for daemon, serve --stop to stop)",
|
|
@@ -5403,15 +6270,15 @@ async function startRepl() {
|
|
|
5403
6270
|
let serverUrl = null;
|
|
5404
6271
|
try {
|
|
5405
6272
|
serverUrl = await getServerUrl();
|
|
5406
|
-
log.success(`Connected to server at ${
|
|
6273
|
+
log.success(`Connected to server at ${chalk8.white.bold(serverUrl)}`);
|
|
5407
6274
|
} catch {
|
|
5408
6275
|
log.warn("No running server found. Start one with: pq serve");
|
|
5409
6276
|
}
|
|
5410
|
-
console.log(
|
|
6277
|
+
console.log(chalk8.dim(' Type "help" for available commands, "exit" to quit.\n'));
|
|
5411
6278
|
const rl = createInterface({
|
|
5412
6279
|
input: process.stdin,
|
|
5413
6280
|
output: process.stdout,
|
|
5414
|
-
prompt:
|
|
6281
|
+
prompt: chalk8.hex("#06b6d4").bold(" pq > "),
|
|
5415
6282
|
terminal: true
|
|
5416
6283
|
});
|
|
5417
6284
|
rl.prompt();
|
|
@@ -5428,7 +6295,7 @@ async function startRepl() {
|
|
|
5428
6295
|
case "help":
|
|
5429
6296
|
console.log();
|
|
5430
6297
|
for (const [name, desc] of Object.entries(COMMANDS)) {
|
|
5431
|
-
console.log(` ${
|
|
6298
|
+
console.log(` ${chalk8.hex("#06b6d4")(name.padEnd(12))} ${chalk8.dim(desc)}`);
|
|
5432
6299
|
}
|
|
5433
6300
|
console.log();
|
|
5434
6301
|
break;
|
|
@@ -5475,7 +6342,7 @@ async function startRepl() {
|
|
|
5475
6342
|
});
|
|
5476
6343
|
try {
|
|
5477
6344
|
serverUrl = await getServerUrl();
|
|
5478
|
-
log.success(`Connected to server at ${
|
|
6345
|
+
log.success(`Connected to server at ${chalk8.white.bold(serverUrl)}`);
|
|
5479
6346
|
} catch {
|
|
5480
6347
|
}
|
|
5481
6348
|
rl.resume();
|
|
@@ -5503,7 +6370,7 @@ async function startRepl() {
|
|
|
5503
6370
|
break;
|
|
5504
6371
|
case "exit":
|
|
5505
6372
|
case "quit":
|
|
5506
|
-
console.log(
|
|
6373
|
+
console.log(chalk8.dim(" Goodbye.\n"));
|
|
5507
6374
|
rl.close();
|
|
5508
6375
|
process.exit(0);
|
|
5509
6376
|
break;
|
|
@@ -5547,20 +6414,20 @@ function printTable(rows) {
|
|
|
5547
6414
|
const maxData = rows.reduce((max, row) => Math.max(max, formatCell3(row[col]).length), 0);
|
|
5548
6415
|
return Math.max(col.length, maxData);
|
|
5549
6416
|
});
|
|
5550
|
-
const header = columns.map((col, i) =>
|
|
5551
|
-
const separator = widths.map((w) =>
|
|
6417
|
+
const header = columns.map((col, i) => chalk8.hex("#06b6d4").bold(col.padEnd(widths[i]))).join(" ");
|
|
6418
|
+
const separator = widths.map((w) => chalk8.dim("\u2500".repeat(w))).join(chalk8.dim("\u2500\u2500"));
|
|
5552
6419
|
console.log();
|
|
5553
6420
|
console.log(` ${header}`);
|
|
5554
6421
|
console.log(` ${separator}`);
|
|
5555
6422
|
for (const row of rows) {
|
|
5556
6423
|
const line = columns.map((col, i) => {
|
|
5557
6424
|
const val = formatCell3(row[col]);
|
|
5558
|
-
return typeof row[col] === "number" ?
|
|
6425
|
+
return typeof row[col] === "number" ? chalk8.hex("#f59e0b")(val.padStart(widths[i])) : val.padEnd(widths[i]);
|
|
5559
6426
|
}).join(" ");
|
|
5560
6427
|
console.log(` ${line}`);
|
|
5561
6428
|
}
|
|
5562
6429
|
console.log();
|
|
5563
|
-
console.log(
|
|
6430
|
+
console.log(chalk8.dim(` ${rows.length} row(s)`));
|
|
5564
6431
|
console.log();
|
|
5565
6432
|
}
|
|
5566
6433
|
function formatCell3(value) {
|
|
@@ -5578,12 +6445,13 @@ async function fetchAndPrint(serverUrl, path, key) {
|
|
|
5578
6445
|
}
|
|
5579
6446
|
|
|
5580
6447
|
// src/index.ts
|
|
6448
|
+
var collectRepeated = (value, acc) => [...acc, value];
|
|
5581
6449
|
var program = new Command();
|
|
5582
6450
|
program.name("pq").description("PipeQuery CLI \u2014 data sources, aggregations, APIs, and terminal dashboards").version("0.1.0").addHelpText("beforeAll", () => {
|
|
5583
6451
|
printBanner();
|
|
5584
6452
|
return "";
|
|
5585
6453
|
}).configureHelp({
|
|
5586
|
-
subcommandTerm: (cmd) =>
|
|
6454
|
+
subcommandTerm: (cmd) => chalk9.hex("#06b6d4")(cmd.name()) + (cmd.usage() ? " " + chalk9.dim(cmd.usage()) : "")
|
|
5587
6455
|
});
|
|
5588
6456
|
program.command("init").description("Create a pipequery.yaml config file").action(initCommand);
|
|
5589
6457
|
program.command("serve").description("Start the PipeQuery server").option("-p, --port <port>", "Server port", parseInt).option("-H, --host <host>", "Server host").option("-d, --daemon", "Run as a background daemon").option("--stop", "Stop a running daemon").action(serveCommand);
|
|
@@ -5606,6 +6474,12 @@ remote.command("status").description("Check remote server health").action(remote
|
|
|
5606
6474
|
var mcp = program.command("mcp").description("Model Context Protocol server for AI clients");
|
|
5607
6475
|
mcp.command("serve").description("Start an MCP server exposing pipequery as tools (for Claude, Cursor, Copilot, etc.)").option("--http", "Serve over HTTP/SSE instead of stdio").option("-p, --port <port>", "HTTP port (when --http is set)", parseInt).option("-H, --host <host>", "HTTP host (when --http is set)").option("--attach <url>", "Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally").option("--auth-token <token>", "Require Authorization: Bearer <token> on HTTP requests. Falls back to $PIPEQUERY_MCP_TOKEN. Without either, the endpoint is unauthenticated.").action(mcpServeCommand);
|
|
5608
6476
|
mcp.command("inspect").description("Print the MCP tool schemas as JSON (useful for directory submission / debugging)").option("--attach <url>", "Attach to a running `pq serve` instance at the given URL instead of loading pipequery.yaml locally").action(mcpInspectCommand);
|
|
6477
|
+
var telegram = program.command("telegram").description("Telegram bot transport for pipequery");
|
|
6478
|
+
telegram.command("serve").description("Run a Telegram bot that exposes pipequery commands (/query, /sources, /describe, /endpoints, /call) plus natural-language queries when --anthropic-key is set").option("-t, --bot-token <token>", "Telegram bot token (or set $PIPEQUERY_TG_BOT_TOKEN)").option("--attach <url>", "Attach to a running `pq serve` instance instead of loading pipequery.yaml locally").option("--allow-user <handle>", "Allowlist a user (numeric id or @username); repeatable. With no allowlist, anyone with the bot username can query.", collectRepeated, []).option("--anthropic-key <key>", "Anthropic API key (or set $ANTHROPIC_API_KEY) \u2014 enables natural-language \u2192 pipequery translation on plain-text messages via claude-haiku-4-5.").action(telegramServeCommand);
|
|
6479
|
+
var watch2 = program.command("watch").description("Manage alert watches (query \u2192 notification when condition fires)");
|
|
6480
|
+
watch2.command("list").description("List configured watches").action(watchListCommand);
|
|
6481
|
+
watch2.command("add <name>").description("Add a watch that fires a notification when its query result triggers the configured condition").requiredOption("-q, --query <expression>", "PipeQuery expression to evaluate on each tick").option("-i, --interval <duration>", "Poll interval (e.g., 30s, 5m). Default 60s.").option("-f, --fire-when <mode>", "Fire condition: when_non_empty (default), when_empty, on_change").option("--telegram-chat-id <id>", "Telegram chat / channel / group ID to notify").option("--telegram-bot-token <token>", "Bot token override (defaults to $PIPEQUERY_TG_BOT_TOKEN)").option("--telegram-message <template>", "Optional message template; supports {{ .field }} from the first row, plus {{ .count }}, {{ .watchName }}, {{ .reason }}").action(watchAddCommand);
|
|
6482
|
+
watch2.command("remove <name>").description("Remove a watch").action(watchRemoveCommand);
|
|
5609
6483
|
program.command("stop").description("Stop the running PipeQuery server (daemon or foreground)").option("-f, --force", "Force kill (SIGKILL instead of SIGTERM)").action(stopCommand);
|
|
5610
6484
|
program.command("completion").description("Generate shell completion script").option("-s, --shell <shell>", "Shell type: bash, zsh, fish").action(completionCommand);
|
|
5611
6485
|
if (process.argv.length <= 2) {
|