feat(bridge): add alerts, context, spawn, suggestions, health, feedback, knowledge, chat-mirror, telegram-webhook, angel/positions routes + wire in index.ts

This commit is contained in:
Manohar 2026-06-05 19:49:23 +00:00
parent 2c0bf6c999
commit e00c6db544
11 changed files with 877 additions and 0 deletions

View file

@ -63,6 +63,11 @@ import express from "express";
import cors from "cors";
import { authMiddleware } from "./auth.js";
import statusRouter from "./routes/status.js";
import healthRouter from "./routes/health.js";
import suggestionsRouter from "./routes/suggestions.js";
import alertsRouter from "./routes/alerts.js";
import spawnRouter from "./routes/spawn.js";
import contextRouter from "./routes/context.js";
import logsRouter from "./routes/logs.js";
import execRouter from "./routes/exec.js";
import configRouter from "./routes/config.js";
@ -120,6 +125,13 @@ app.get("/health", (_req, res) => {
// Tiger endpoints — all scoped under /tiger
app.use("/tiger/status", statusRouter);
app.use("/tiger/health", healthRouter);
app.use("/tiger/suggestions", suggestionsRouter);
app.use("/tiger/alerts", alertsRouter);
app.use("/tiger/spawn", spawnRouter);
app.use("/tiger/context", contextRouter);
app.use("/tiger/knowledge", (await import("./routes/knowledge.js")).default);
app.use("/tiger/feedback", (await import("./routes/feedback.js")).default);
app.use("/tiger/logs", logsRouter); // SSE stream
app.use("/tiger/exec", execRouter);
app.use("/tiger/config", configRouter);
@ -141,6 +153,9 @@ app.use("/tiger/deploy-dashboard", deployRouter);
app.use("/tiger/route-task", routeTaskRouter);
app.use("/tiger/keys", keysRouter);
app.use("/tiger/chat", (await import("./routes/chat.js")).default);
app.use("/tiger/chat/mirror", (await import("./routes/chat-mirror.js")).default);
app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default);
app.use("/angel", (await import("./routes/angel/positions.js")).default);
// Gateway proxy — forwards to gateway inside Tiger container
// This is needed because the dashboard runs in Dokploy which can't reach the container directly

View file

@ -0,0 +1,88 @@
/**
* alerts.ts GET /tiger/alerts
*
* Returns active alerts and allows configuring proactive notifications.
*
* GET /tiger/alerts get active alerts
* GET /tiger/alerts?check=true run check and return fresh alerts
*
* Response:
* { ok: true, alerts: [{ type, message, priority, timestamp }] }
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
interface Alert {
type: string
message: string
priority: "high" | "medium" | "low"
timestamp: string
}
async function checkSystemAlerts(): Promise<Alert[]> {
const alerts: Alert[] = []
const now = new Date().toISOString()
try {
// Check memory usage
const memResult = await execInSandbox("cat /proc/meminfo | grep MemAvailable")
if (memResult.stdout.includes("MemAvailable")) {
const match = memResult.stdout.match(/(\d+)/)
if (match) {
const availableMB = parseInt(match[1]) / 1024
if (availableMB < 500) {
alerts.push({
type: "memory",
message: `Low memory: ${Math.round(availableMB)}MB available`,
priority: "high",
timestamp: now
})
}
}
}
// Check disk usage
const diskResult = await execInSandbox("df -h / | tail -1")
if (diskResult.stdout.includes("%")) {
const match = diskResult.stdout.match(/(\d+)%/)
if (match) {
const usage = parseInt(match[1])
if (usage > 85) {
alerts.push({
type: "disk",
message: `High disk usage: ${usage}%`,
priority: usage > 95 ? "high" : "medium",
timestamp: now
})
}
}
}
} catch (e) {
// System check failed
}
return alerts
}
router.get("/", async (req: Request, res: Response) => {
const doCheck = req.query.check === "true"
let alerts: Alert[] = []
if (doCheck) {
alerts = await checkSystemAlerts()
}
res.json({
ok: true,
alerts,
lastCheck: new Date().toISOString(),
count: alerts.length
})
})
export default router

View file

@ -0,0 +1,138 @@
/**
* angel-positions.ts Fetch open positions from Angel ONE Smart API
*
* Endpoints:
* - POST /login Get JWT token
* - GET /portfolio Fetch positions
*/
import { Router, Request, Response } from "express";
import axios from "axios";
import * as OTPAuth from "otplib";
const router = Router();
const API_BASE = process.env.DATA_SOURCE === "live"
? "https://smartapi.angelone.in"
: "https://smapi.angelone.in";
const API_KEY = process.env.ANGEL_ONE_API_KEY || "";
const CLIENT_ID = process.env.ANGEL_ONE_CLIENT_ID || "";
const PASSWORD = process.env.ANGEL_ONE_PASSWORD || "";
const TOTP_SECRET = process.env.ANGEL_ONE_TOTP_SECRET || "";
// In-memory token cache (simple, valid for ~1 day)
let cachedToken: string | null = null;
let tokenExpiry: number = 0;
// Generate TOTP from secret
function generateTOTP(secret: string): string {
try {
const totp = new OTPAuth.TOTP({
issuer: "AngelOne",
label: "SmartAPI",
algorithm: "sha1",
digits: 6,
period: 30,
secret: secret
});
return totp.generate() as string;
} catch (e) {
console.error("[angel] TOTP generation failed:", e);
return "";
}
}
// Login to Angel ONE and get JWT token
async function getToken(): Promise<string | null> {
// Check cache
if (cachedToken && Date.now() < tokenExpiry) {
return cachedToken;
}
try {
const totp = generateTOTP(TOTP_SECRET);
if (!totp) {
throw new Error("TOTP generation failed");
}
const payload = {
clientcode: CLIENT_ID,
password: PASSWORD,
totp: totp,
apiKey: API_KEY
};
const response = await axios.post(`${API_BASE}/login`, payload, {
headers: { "Content-Type": "application/json" }
});
if (response.data?.status?.success) {
cachedToken = response.data.data.jwtToken;
// Token valid for ~24 hours, cache for 23 hours
tokenExpiry = Date.now() + (23 * 60 * 60 * 1000);
return cachedToken;
} else {
console.error("[angel] Login failed:", response.data);
return null;
}
} catch (err: any) {
console.error("[angel] Login error:", err.message);
return null;
}
}
// Fetch open positions
async function getPositions(): Promise<any[]> {
const token = await getToken();
if (!token) {
throw new Error("Failed to get Angel ONE token");
}
try {
const response = await axios.get(`${API_BASE}/portfolio`, {
headers: {
"Authorization": token,
"Content-Type": "application/json"
}
});
if (response.data?.status?.success) {
return response.data.data || [];
} else {
return [];
}
} catch (err: any) {
console.error("[angel] Portfolio fetch error:", err.message);
return [];
}
}
// GET /angel/positions — Get current positions
router.get("/positions", async (req: Request, res: Response) => {
try {
const positions = await getPositions();
res.json({ ok: true, count: positions.length, positions });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET /angel/positions/short — Short summary (symbol + P&L only)
router.get("/positions/short", async (req: Request, res: Response) => {
try {
const positions = await getPositions();
const summary = positions.map((p: any) => ({
symbol: p.tradingSymbol || p.symbol || p.tsym,
pnl: p.pnl || p.realizedPnL || p.unrealizedPnL || 0,
quantity: p.quantity || p.qty || 0,
ltp: p.ltp || p.avgPrice || 0
}));
res.json({ ok: true, summary });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;
export { getPositions, getToken };

View file

@ -0,0 +1,68 @@
/**
* chat-mirror.ts Mirror Telegram messages to shared SQLite
*
* This is a simple endpoint that can be called to mirror messages
* from any channel (Telegram, WhatsApp, etc.) into the chat history.
*
* POST /tiger/chat/mirror
* Body: {
* role: "user" | "agent",
* content: "message text",
* source: "telegram" | "whatsapp" | "web",
* sessionId?: "agent:main:main"
* }
*
* Response: { ok: true, id: number }
*/
import { Router, Request, Response } from "express";
import db from "../db.js";
const router = Router();
const DEFAULT_SESSION_ID = "agent:main:main";
const insertMessage = db.prepare(`
INSERT INTO chat_messages (session_id, role, content, meta)
VALUES (?, ?, ?, ?)
`);
// POST /tiger/chat/mirror — store a message from any source
router.post("/", async (req: Request, res: Response) => {
const { role, content, source, sessionId } = req.body;
if (!role || !content) {
return res.status(400).json({
ok: false,
error: "role and content are required"
});
}
if (role !== "user" && role !== "agent" && role !== "system") {
return res.status(400).json({
ok: false,
error: "role must be 'user', 'agent', or 'system'"
});
}
const sid = sessionId || DEFAULT_SESSION_ID;
const meta = JSON.stringify({
source: source || "unknown",
mirrored: true,
timestamp: new Date().toISOString()
});
try {
const info = insertMessage.run(sid, role, content, meta);
res.json({
ok: true,
id: info.lastInsertRowid,
sessionId: sid,
source: source || "unknown"
});
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;

View file

@ -0,0 +1,83 @@
/**
* context.ts GET/POST /tiger/context
*
* Session context storage - remembers context across messages.
* This enables T012: Context Injection.
*
* GET /tiger/context?sessionId=X get context for session
* POST /tiger/context set context { sessionId, key, value }
* DELETE /tiger/context?sessionId=X clear context
*
* Response:
* { ok: true, context: { ... }, message }
*/
import { Router, Request, Response } from "express";
import db from "../db.js";
const router = Router();
// Table for session context
const getContext = db.prepare(`
SELECT key, value FROM session_context WHERE session_id = ?
`);
const setContext = db.prepare(`
INSERT OR REPLACE INTO session_context (session_id, key, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
`);
const clearContext = db.prepare(`
DELETE FROM session_context WHERE session_id = ?
`);
const DEFAULT_SESSION = "agent:main:main";
// GET context
router.get("/", async (req: Request, res: Response) => {
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION;
try {
const rows = getContext.all(sessionId) as any[];
const context: Record<string, string> = {};
for (const row of rows) {
context[row.key] = row.value;
}
res.json({ ok: true, sessionId, context });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// POST set context
router.post("/", async (req: Request, res: Response) => {
const { sessionId, key, value } = req.body;
const sid = sessionId || DEFAULT_SESSION;
if (!key) {
return res.status(400).json({ ok: false, error: "key is required" });
}
if (!value) {
return res.status(400).json({ ok: false, error: "value is required" });
}
try {
setContext.run(sid, key, value);
res.json({ ok: true, sessionId: sid, key, value, message: "Context saved" });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// DELETE clear context
router.delete("/", async (req: Request, res: Response) => {
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION;
try {
clearContext.run(sessionId);
res.json({ ok: true, message: "Context cleared", sessionId });
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
export default router

View file

@ -0,0 +1,78 @@
/**
* feedback.ts Continuous Learning
* Simple feedback storage
*/
import { Router, Request, Response } from "express";
import { randomUUID } from "crypto";
import db from "../db.js";
const router = Router();
// Ensure tables exist
const initTables = () => {
try {
db.exec(`
CREATE TABLE IF NOT EXISTS feedback_log (
id TEXT PRIMARY KEY,
context TEXT NOT NULL,
user_feedback TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
`);
} catch (e) { /* tables may exist */ }
};
initTables();
// Log feedback
router.post("/", async (req: Request, res: Response) => {
const { context, feedback } = req.body;
if (!context || !feedback) {
return res.status(400).json({ error: "context and feedback required" });
}
const id = randomUUID();
try {
db.prepare("INSERT INTO feedback_log (id, context, user_feedback) VALUES (?, ?, ?)")
.run(id, context, feedback);
// Simple pattern detection
if (feedback.toLowerCase().includes("short") || feedback.toLowerCase().includes("brief")) {
db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('reply_length', 'brief')").run();
}
res.json({ ok: true, id });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Get preferences
router.get("/prefer", async (req: Request, res: Response) => {
try {
const prefs = db.prepare("SELECT * FROM user_preferences").all();
res.json({ preferences: prefs });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Store preference
router.post("/prefer", async (req: Request, res: Response) => {
const { key, value } = req.body;
if (!key || value === undefined) {
return res.status(400).json({ error: "key and value required" });
}
try {
db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES (?, ?)").run(key, String(value));
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;

View file

@ -0,0 +1,75 @@
/**
* health.ts GET /tiger/health
*
* Self-healing health checks. Returns system status and can trigger
* auto-restart if critical services are down.
*
* GET /tiger/health?check=true run full check and restart if needed
* GET /tiger/health just return status
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
// Service check definitions
const services = [
{ name: "gateway", port: 18789, path: "/health" },
{ name: "bridge", port: 3456, path: "/tiger/status" },
]
async function checkService(name: string, port: number, path: string): Promise<{
name: string
status: "ok" | "error"
response?: string
}> {
try {
const portMap: Record<number, string> = {
18789: "http://127.0.0.1",
3456: "http://127.0.0.1",
}
const baseUrl = portMap[port] || `http://127.0.0.1:${port}`
const res = await fetch(`${baseUrl}${path}`, {
signal: AbortSignal.timeout(5000),
})
if (res.ok) {
return { name, status: "ok" }
}
return { name, status: "error", response: `HTTP ${res.status}` }
} catch (err: any) {
return { name, status: "error", response: err.message }
}
}
router.get("/", async (_req: Request, res: Response) => {
const shouldRestart = _req.query.check === "true"
const results = await Promise.all(
services.map((s) => checkService(s.name, s.port, s.path))
)
const allHealthy = results.every((r) => r.status === "ok")
const response = {
ok: true,
timestamp: new Date().toISOString(),
status: allHealthy ? "healthy" : "degraded",
services: results,
}
// Auto-restart if requested and services are down
if (shouldRestart && !allHealthy) {
const failed = results.filter((r) => r.status === "error").map((r) => r.name)
console.log(`[health] Services down: ${failed.join(", ")}. Triggering restart.`)
// Trigger restart but don't wait
execInSandbox("docker restart tiger-openclaw 2>/dev/null || true")
.then(() => console.log("[health] Restart triggered"))
.catch(() => console.log("[health] Restart failed"))
}
res.json(response)
})
export default router

View file

@ -0,0 +1,126 @@
/**
* knowledge.ts Knowledge Graph endpoints
* Uses raw SQL execution to create tables if not exist
*/
import { Router, Request, Response } from "express";
import { randomUUID } from "crypto";
import db from "../db.js";
const router = Router();
// Ensure tables exist
const initTables = () => {
try {
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_nodes (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS knowledge_edges (
id TEXT PRIMARY KEY,
from_node TEXT NOT NULL,
to_node TEXT NOT NULL,
relationship TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
`);
} catch (e) { /* tables may exist */ }
};
initTables();
// List all nodes
router.get("/", async (req: Request, res: Response) => {
const { q, limit = 50 } = req.query;
try {
let sql = "SELECT * FROM knowledge_nodes";
const params: string[] = [];
if (q) {
sql += " WHERE name LIKE ? OR description LIKE ?";
params.push(`%${q}%`, `%${q}%`);
}
sql += " ORDER BY created_at DESC LIMIT ?";
params.push(String(limit));
const nodes = db.prepare(sql).all(...params);
res.json({ nodes });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Get node with connections
router.get("/:id", async (req: Request, res: Response) => {
const { id } = req.params;
try {
const node = db.prepare("SELECT * FROM knowledge_nodes WHERE id = ?").get(id);
if (!node) return res.status(404).json({ error: "Not found" });
// Get all edges for graph
const edges = db.prepare(`
SELECT ke.from_node, ke.to_node, ke.relationship, kn.name as to_name
FROM knowledge_edges ke
JOIN knowledge_nodes kn ON ke.to_node = kn.id
`).all();
// Get all nodes for graph
const allNodes = db.prepare("SELECT * FROM knowledge_nodes").all();
res.json({ node, connections: edges, allNodes });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Create node
router.post("/", async (req: Request, res: Response) => {
const { type, name, description } = req.body;
if (!type || !name) return res.status(400).json({ error: "type, name required" });
const id = randomUUID();
try {
db.prepare("INSERT INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)")
.run(id, type, name, description || "");
res.json({ ok: true, id });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Create connection
router.post("/connect", async (req: Request, res: Response) => {
const { from, to, relationship } = req.body;
if (!from || !to || !relationship) {
return res.status(400).json({ error: "from, to, relationship required" });
}
try {
const id = randomUUID();
db.prepare("INSERT INTO knowledge_edges (id, from_node, to_node, relationship) VALUES (?, ?, ?, ?)")
.run(id, from, to, relationship);
res.json({ ok: true, id });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// Seed initial knowledge
router.post("/seed", async (req: Request, res: Response) => {
const nodes = [
{ t: "person", n: "Manohar", d: "IIT Roorkee, IIM Rohtak, works at Renew Power" },
{ t: "company", n: "Renew Power", d: "India renewable energy, NYSE: RNW" },
{ t: "company", n: "Adani Green", d: "Competitor in renewables" },
{ t: "concept", n: "PE/VC", d: "Career path interest" },
{ t: "concept", n: "Option Trading", d: "Nifty options selling" },
];
try {
for (const x of nodes) {
db.prepare("INSERT OR IGNORE INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)")
.run(randomUUID(), x.t, x.n, x.d);
}
res.json({ ok: true, count: nodes.length });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;

View file

@ -0,0 +1,66 @@
/**
* spawn.ts POST /tiger/spawn
*
* Trigger spawning of sub-agents. This is a placeholder -
* real implementation requires sub-agent permission config.
*
* POST /tiger/spawn
* { agentId: "coder" | "researcher" | "writer" | "pm", task: "..." }
*
* Response:
* { ok: true, sessionId, status: "spawned" | "pending" }
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
const validAgents = ["coder", "researcher", "writer", "pm"];
router.post("/", async (req: Request, res: Response) => {
const { agentId, task } = req.body;
if (!agentId || !validAgents.includes(agentId)) {
return res.status(400).json({
ok: false,
error: `Invalid agent. Use: ${validAgents.join(", ")}`
});
}
if (!task) {
return res.status(400).json({ ok: false, error: "task is required" });
}
try {
// Note: Sub-agent spawning requires config
// This is a placeholder - returns info about what's needed
res.json({
ok: true,
agentId,
task,
status: "pending",
message: "Sub-agent spawning requires config. Set agents.defaults.subagents in openclaw.json"
});
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// GET available agents
router.get("/agents", (_req: Request, res: Response) => {
res.json({
ok: true,
agents: validAgents.map(id => ({
id,
name: id === "coder" ? "Cody" :
id === "researcher" ? "Ethan" :
id === "writer" ? "Cathy" : "Elon",
role: id === "coder" ? "Code" :
id === "researcher" ? "Research" :
id === "writer" ? "Write" : "PM"
}))
});
});
export default router

View file

@ -0,0 +1,66 @@
/**
* suggestions.ts GET /tiger/suggestions
*
* Returns AI-powered suggestions based on current context.
* This is a placeholder - real implementation would use the LLM.
*
* GET /tiger/suggestions
* ?context=current_task,project,dashboard
*
* Response:
* { ok: true, suggestions: [{ text, action, priority }] }
*/
import { Router, Request, Response } from "express";
import { execInSandbox } from "../tiger.js";
const router = Router();
// Default suggestions when no AI
const defaultSuggestions = [
{ text: "Check active tasks", action: "/tasks", priority: "high" },
{ text: "View project status", action: "/projects", priority: "medium" },
{ text: "Check system health", action: "/api/tiger/status", priority: "medium" },
]
router.get("/", async (_req: Request, res: Response) => {
try {
// Get active tasks
const tasksResult = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
// Count in-progress tasks
let hasActiveWork = false
if (tasksResult.stdout.includes("in-progress")) {
hasActiveWork = true
}
const suggestions = []
if (hasActiveWork) {
suggestions.push({
text: "Continue with active task",
action: "/projects",
priority: "high"
})
}
// Adddefaults
suggestions.push(...defaultSuggestions.slice(0, 3))
res.json({
ok: true,
suggestions: suggestions.slice(0, 5),
hasActiveWork
})
} catch (err: any) {
// Fallback to defaults
res.json({
ok: true,
suggestions: defaultSuggestions,
hasActiveWork: false,
error: err.message
})
}
})
export default router

View file

@ -0,0 +1,74 @@
/**
* telegram-webhook.ts Handle Telegram webhooks and mirror to chat history
*
* Receives Telegram message updates and mirrors them to the chat_messages table.
* This enables Telegram WebChat history sync.
*
* POST /tiger/telegram-webhook
* Body: Telegram Update object (https://core.telegram.org/bots/api#update)
* Response: OK
*/
import { Router, Request, Response } from "express";
import db from "../db.js";
const router = Router();
const DEFAULT_SESSION_ID = "agent:main:main";
const insertMessage = db.prepare(`
INSERT INTO chat_messages (session_id, role, content, meta)
VALUES (?, ?, ?, ?)
`);
// POST /tiger/telegram-webhook — receive Telegram updates
router.post("/", async (req: Request, res: Response) => {
try {
const update = req.body;
// Handle message updates
if (update.message) {
const msg = update.message;
const chatId = msg.chat?.id?.toString();
const text = msg.text;
const from = msg.from;
if (text && chatId) {
// Store user message
const meta = JSON.stringify({
source: "telegram",
chatId: chatId,
messageId: msg.message_id,
from: from ? {
id: from.id,
firstName: from.first_name,
lastName: from.last_name,
username: from.username
} : null,
timestamp: new Date().toISOString()
});
insertMessage.run(DEFAULT_SESSION_ID, "user", text, meta);
// If it's a reply (has reply_to_message), store agent response too
if (msg.reply_to_message) {
const replyText = msg.reply_to_message.text;
const replyMeta = JSON.stringify({
source: "telegram",
chatId: chatId,
replyToMessageId: msg.message_id,
timestamp: new Date().toISOString()
});
insertMessage.run(DEFAULT_SESSION_ID, "agent", replyText, replyMeta);
}
}
}
res.json({ ok: true });
} catch (err: any) {
console.error("[telegram-webhook] Error:", err.message);
res.status(500).json({ ok: false, error: err.message });
}
});
export default router;