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