Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { chunkReply } from "./replies/chunker";
import { pickAdapter } from "./replies/sender";
import { selectModel } from "./upgrade/modelSelector";
import type { Tier } from "./upgrade/modelSelector";
import { monthIaCostUsd, applyBudgetGuard } from "./budget";
import { monthIaCostUsd, enforceBudgetGuard } from "./budget";
import { CustomerFactsRepo } from "./db/facts";
import { createModel } from "./llm/provider";
import { costOfUsage } from "./pricing";
Expand Down Expand Up @@ -269,16 +269,23 @@ export class SupportAgent extends Agent<Env, SupportAgentState> {
lastSearchKbScore: this.state.lastSearchKbScore,
});

// Budget guard: at/over the monthly AI budget the bot keeps answering but
// only on the cheap tier (never goes silent over money).
if (cfg.monthlyBudgetUsd !== undefined && tier !== "fast") {
// Budget guard: at the monthly AI budget the bot downgrades to the cheap
// tier (never goes silent over money); at 1.5x it pauses itself for good
// (enforceBudgetGuard flips the same bot_paused switch the dashboard
// uses) and notifies the owner — a downgrade alone doesn't cap spend.
if (cfg.monthlyBudgetUsd !== undefined) {
const spent = await monthIaCostUsd(db);
const guard = applyBudgetGuard(tier, spent, cfg.monthlyBudgetUsd);
const guard = await enforceBudgetGuard(this.env, db, tier, spent, cfg.monthlyBudgetUsd);
if (guard.downgraded) {
console.warn(
`[SupportAgent] monthly budget reached ($${spent.toFixed(2)}/$${cfg.monthlyBudgetUsd}) — downgrading to fast tier`,
);
}
if (guard.paused) {
console.error(
`[SupportAgent] monthly budget hard-stop ($${spent.toFixed(2)} >= 1.5x $${cfg.monthlyBudgetUsd}) — bot paused, owner notified`,
);
}
tier = guard.tier;
Comment on lines +284 to 289

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Stop processing after a hard stop.

When guard.paused is true, this only logs and then proceeds to streamText() and sendReply(). Return immediately after logging so the triggering request does not generate another paid response after the budget pause.

Proposed fix
       if (guard.paused) {
         console.error(
           `[SupportAgent] monthly budget hard-stop ($${spent.toFixed(2)} >= 1.5x $${cfg.monthlyBudgetUsd}) — bot paused, owner notified`,
         );
+        return;
       }
       tier = guard.tier;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (guard.paused) {
console.error(
`[SupportAgent] monthly budget hard-stop ($${spent.toFixed(2)} >= 1.5x $${cfg.monthlyBudgetUsd}) — bot paused, owner notified`,
);
}
tier = guard.tier;
if (guard.paused) {
console.error(
`[SupportAgent] monthly budget hard-stop ($${spent.toFixed(2)} >= 1.5x $${cfg.monthlyBudgetUsd}) — bot paused, owner notified`,
);
return;
}
tier = guard.tier;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent.ts` around lines 284 - 289, Update the guard.paused branch in the
agent request flow to return immediately after logging the monthly budget
hard-stop, preventing execution from reaching streamText() or sendReply().
Preserve the existing tier assignment and behavior when the guard is not paused.

}

Expand Down
55 changes: 55 additions & 0 deletions src/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,20 @@
* month-to-date AI spend reaches it, the agent downgrades to the "fast" tier
* (cheap model) instead of going silent — the bot keeps answering, it just
* stops burning money on the smart model.
*
* A downgrade alone doesn't cap spend — "fast" still costs money, and a burst
* of traffic between two spend checks can blow past the budget anyway. Once
* spend reaches HARD_STOP_MULTIPLIER × the budget, enforceBudgetGuard() pauses
* the bot globally (the same bot_paused switch the owner uses from the panel)
* and notifies the owner — reusing notifyOwner(), the same best-effort
* Telegram/WhatsApp/email channel the watchdog and handoff tool already use.
*/
import { Db } from "./db/client";
import { costOfUsage, type ModelId } from "./pricing";
import type { Tier } from "./upgrade/modelSelector";
import type { Env } from "./env";
import { SettingsRepo, SETTING_KEYS } from "./db/settings";
import { notifyOwner } from "./tools/handoffHuman";

/** UTC start of the current month (injectable clock for tests). */
export function monthStartMs(now = Date.now()): number {
Expand Down Expand Up @@ -51,3 +61,48 @@ export function applyBudgetGuard(
}
return { tier, downgraded: false };
}

/** Hard-stop threshold: spend at 1.5x the monthly budget pauses the bot. */
export const HARD_STOP_MULTIPLIER = 1.5;

export interface BudgetGuardResult {
tier: Tier;
downgraded: boolean;
/** true only the turn that actually flips bot_paused (already-paused → false). */
paused: boolean;
}

/**
* Downgrades the tier (applyBudgetGuard) and, once spend reaches
* HARD_STOP_MULTIPLIER × the budget, pauses the bot for good measure — sets
* the same `bot_paused` setting the owner's dashboard toggle uses, so every
* conversation goes silent until the owner reactivates it manually. Idempotent:
* if the bot is already paused, does not re-notify on every message.
*/
export async function enforceBudgetGuard(
env: Env,
db: Db,
tier: Tier,
monthCostUsd: number,
budgetUsd: number | undefined,
): Promise<BudgetGuardResult> {
const guard = applyBudgetGuard(tier, monthCostUsd, budgetUsd);
if (budgetUsd === undefined || budgetUsd <= 0 || monthCostUsd < budgetUsd * HARD_STOP_MULTIPLIER) {
return { ...guard, paused: false };
}

const settings = new SettingsRepo(db);
if ((await settings.get(SETTING_KEYS.botPaused)) === "1") {
return { ...guard, paused: false }; // ya pausado — no volver a notificar
}

await settings.set(SETTING_KEYS.botPaused, "1");
await notifyOwner(env, {
reason: "presupuesto de IA excedido",
summary:
`🚨 El bot se pausó solo: el gasto de IA de este mes ($${monthCostUsd.toFixed(2)}) ` +
`superó 1.5x tu presupuesto ($${budgetUsd}). Reactívalo desde el panel (Agente) cuando quieras.`,
ticketId: "budget-guard",
});
Comment on lines +94 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the global pause claim atomic.

Two concurrent agents can both read an unpaused setting before either upsert completes, so both call notifyOwner(). Replace the separate get()/set() with a conditional insert/update that reports whether this invocation actually changed bot_paused, and notify only the winner.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/budget.ts` around lines 94 - 106, Update the botPaused handling in the
budget guard to atomically claim the pause state using a conditional
insert/update operation that indicates whether this invocation changed
SETTING_KEYS.botPaused. Remove the separate settings.get()/settings.set()
sequence, and call notifyOwner() only when the current invocation wins the
atomic claim; preserve the existing guard return for already-paused cases.

return { ...guard, paused: true };
}
17 changes: 17 additions & 0 deletions src/channels/manychat.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared";
import type { Env } from "../env";
import { tokensMatch } from "../http-auth";

const MANYCHAT_API = "https://api.manychat.com/fb";

/**
* ManyChat no firma sus webhooks salientes (External Request), así que la
* protección es un shared secret que el propio miembro configura como header
* custom (`X-Manychat-Secret`) en el flow de ManyChat, comparado contra
* MANYCHAT_WEBHOOK_SECRET. Fail-closed: sin secret configurado, sin header, o
* si no coincide → false.
*/
export function verifyManychatSecret(
headerValue: string | null | undefined,
env: Env,
): boolean {
const expected = env.MANYCHAT_WEBHOOK_SECRET?.trim();
if (!expected) return false;
return tokensMatch((headerValue ?? "").trim(), expected);
}

// Aligned with a typical ManyChat/n8n production flow:
// - ManyChat posts the subscriber in `id` (NOT `subscriber_id`).
// - The text arrives in `last_input_text`.
Expand Down
18 changes: 18 additions & 0 deletions src/channels/telegram.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared";
import type { Env } from "../env";
import { tokensMatch } from "../http-auth";

const TG_API = "https://api.telegram.org/bot";

/**
* Valida el header `X-Telegram-Bot-Api-Secret-Token` que Telegram manda en
* cada POST cuando el webhook se registró con `secret_token` (ver
* https://core.telegram.org/bots/api#setwebhook). Fail-closed: sin
* TELEGRAM_WEBHOOK_SECRET configurado, o sin header, o si no coincide → false.
* Sin esto, cualquiera que adivine la URL del Worker puede mandar un Update
* falso y el bot lo procesa como si viniera de Telegram.
*/
export function verifyTelegramSecret(
headerValue: string | null | undefined,
env: Env,
): boolean {
const expected = env.TELEGRAM_WEBHOOK_SECRET?.trim();
if (!expected) return false;
return tokensMatch((headerValue ?? "").trim(), expected);
}

interface TgUpdate {
update_id: number;
message?: {
Expand Down
31 changes: 31 additions & 0 deletions src/channels/twilio.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
import type { ChannelAdapter, IncomingMessage, OutgoingReply } from "./shared";
import type { Env } from "../env";
import { tokensMatch } from "../http-auth";

/**
* Valida `X-Twilio-Signature` (HMAC-SHA1 sobre la URL exacta del webhook +
* cada par clave+valor del POST, ordenado alfabéticamente por clave — el
* algoritmo oficial de Twilio, ver
* https://www.twilio.com/docs/usage/security#validating-requests). Usa
* TWILIO_AUTH_TOKEN, que ya es requerido para que el canal envíe respuestas —
* no hace falta un secret nuevo. Fail-closed: sin token, sin firma, o si no
* coincide → false.
*/
export async function verifyTwilioSignature(
url: string,
params: Record<string, string>,
signatureHeader: string | null | undefined,
authToken: string | undefined,
): Promise<boolean> {
if (!authToken || !signatureHeader) return false;
let data = url;
for (const key of Object.keys(params).sort()) data += key + params[key];
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(authToken),
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
const expected = btoa(String.fromCharCode(...new Uint8Array(sigBuf)));
return tokensMatch(expected, signatureHeader.trim());
}

export const twilioAdapter: ChannelAdapter = {
async parseIncoming(request: Request, _env: Env): Promise<IncomingMessage> {
Expand Down
11 changes: 11 additions & 0 deletions src/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,14 @@ CREATE TABLE IF NOT EXISTS template_sends (
UNIQUE (campaign_key, conversation_id)
);
CREATE INDEX IF NOT EXISTS idx_template_sends_time ON template_sends(sent_at);

-- Throttle por IP (defensa en profundidad) para rutas expuestas sin sesión:
-- webhooks y /kb/reindex. Un contador por ventana fija (bucket:ip), reseteado
-- cuando cambia window_start. Ver src/rate-limit.ts. La capa principal es una
-- Cloudflare Rate Limiting Rule (fuera de este repo, dashboard de Cloudflare)
-- -- esto cubre el caso sin esa regla configurada.
CREATE TABLE IF NOT EXISTS rate_limits (
key TEXT PRIMARY KEY,
window_start INTEGER NOT NULL,
count INTEGER NOT NULL
);
11 changes: 11 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,18 @@ export interface Env {
OPENAI_API_KEY?: string; // alternative LLM provider (see LLM_PROVIDER)
RESEND_API_KEY?: string;
TELEGRAM_BOT_TOKEN?: string;
// secret_token puesto al registrar el webhook (setWebhook); valida el
// header X-Telegram-Bot-Api-Secret-Token en cada POST entrante (fail-closed
// si falta — ver src/channels/telegram.ts verifyTelegramSecret).
TELEGRAM_WEBHOOK_SECRET?: string;
MANYCHAT_API_KEY?: string;
// shared secret que el miembro configura como header custom
// (X-Manychat-Secret) en el External Request de su flow de ManyChat; valida
// el origen del webhook (fail-closed si falta — ver
// src/channels/manychat.ts verifyManychatSecret). ManyChat no firma sus
// webhooks nativamente, así que esto es lo único que evita que cualquiera
// mande un POST falso a /webhooks/manychat.
MANYCHAT_WEBHOOK_SECRET?: string;
MANYCHAT_CONTENT_TYPE?: "instagram" | "whatsapp" | "telegram" | "messenger"; // ManyChat channel for sendContent; defaults to "instagram"
TWILIO_ACCOUNT_SID?: string;
TWILIO_AUTH_TOKEN?: string;
Expand Down
67 changes: 59 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Hono } from "hono";
import type { Env } from "./env";
import type { ChannelAdapter } from "./channels/shared";
import { telegramAdapter } from "./channels/telegram";
import { manychatAdapter } from "./channels/manychat";
import { twilioAdapter } from "./channels/twilio";
import { telegramAdapter, verifyTelegramSecret } from "./channels/telegram";
import { manychatAdapter, verifyManychatSecret } from "./channels/manychat";
import { twilioAdapter, verifyTwilioSignature } from "./channels/twilio";
import { parseMetaEvents, verifyMetaSignature } from "./channels/meta";
import { parseWhatsAppEvents, serveWhatsAppMedia } from "./channels/whatsapp";
import { adminApp } from "./admin/routes";
Expand All @@ -16,6 +16,7 @@ import { detectKind } from "./learn/fieldPath";
import { saveCapture, isLearnMode } from "./learn/mapping";
import { tokensMatch } from "./http-auth";
import { apiApp } from "./api";
import { isRateLimited, clientIp } from "./rate-limit";

export { SupportAgent } from "./agent";

Expand Down Expand Up @@ -54,12 +55,55 @@ async function routeToAgent(c: { req: { raw: Request }; env: Env; text: (t: stri
}
}

app.post("/webhooks/telegram", (c) => routeToAgent(c, telegramAdapter));
app.post("/webhooks/manychat", (c) => routeToAgent(c, manychatAdapter));
// WhatsApp (Twilio): rutea el mensaje entrante al bot de clientes (Claude). El
// body se lee UNA vez; ack con TwiML vacío para que Twilio no reenvíe el cuerpo
// como mensaje.
// Throttle por IP (defensa en profundidad, ver src/rate-limit.ts) — corre
// ANTES de verificar firma/secret, así una ráfaga no le cuesta ni un HMAC al
// Worker. La capa principal es una Cloudflare Rate Limiting Rule a nivel de
// cuenta (fuera de este repo).
async function checkRateLimit(c: { req: { raw: Request }; env: Env }, bucket: string): Promise<boolean> {
const db = new Db(c.env.DB);
return isRateLimited(db, bucket, clientIp(c.req.raw));
}

// Telegram firma cada POST con el secret_token puesto al registrar el webhook
// (setWebhook). Sin verificar esto, cualquiera que adivine la URL del Worker
// puede mandar un Update falso — incluso suplantar al dueño, ya que
// OWNER_TELEGRAM_CHAT_ID no es secreto. Fail-closed.
app.post("/webhooks/telegram", async (c) => {
if (await checkRateLimit(c, "webhooks/telegram")) return c.text("too many requests", 429);
if (!verifyTelegramSecret(c.req.header("x-telegram-bot-api-secret-token"), c.env)) {
return c.text("forbidden", 403);
}
return routeToAgent(c, telegramAdapter);
});

// ManyChat no firma sus External Requests: la protección es un shared secret
// que el miembro configura como header custom en su flow. Fail-closed.
app.post("/webhooks/manychat", async (c) => {
if (await checkRateLimit(c, "webhooks/manychat")) return c.text("too many requests", 429);
if (!verifyManychatSecret(c.req.header("x-manychat-secret"), c.env)) {
return c.text("forbidden", 403);
}
return routeToAgent(c, manychatAdapter);
});

// WhatsApp (Twilio): rutea el mensaje entrante al bot de clientes (Claude).
// Valida X-Twilio-Signature (fail-closed) antes de procesar — el body de
// formData() se lee del clon para no consumir el stream que necesita
// twilioAdapter.parseIncoming(). Ack con TwiML vacío para que Twilio no
// reenvíe el cuerpo como mensaje.
app.post("/webhooks/twilio", async (c) => {
if (await checkRateLimit(c, "webhooks/twilio")) return c.text("too many requests", 429);
const form = await c.req.raw.clone().formData();
const params: Record<string, string> = {};
for (const [k, v] of form.entries()) params[k] = String(v);
const valid = await verifyTwilioSignature(
c.req.url,
params,
c.req.header("x-twilio-signature"),
c.env.TWILIO_AUTH_TOKEN,
);
if (!valid) return c.text("bad signature", 403);

let msg;
try {
msg = await twilioAdapter.parseIncoming(c.req.raw, c.env);
Expand Down Expand Up @@ -205,6 +249,13 @@ app.route("/api", apiApp);
// KB_REINDEX_TOKEN secret via the X-Reindex-Token header. Trigger after deploy:
// curl -X POST https://<worker>/kb/reindex -H "X-Reindex-Token: <token>"
app.post("/kb/reindex", async (c) => {
// Fail-closed por token, pero sin límite de intentos: un throttle más
// estricto (5/min) que el de los webhooks frena la fuerza bruta sobre el
// token mismo.
const db = new Db(c.env.DB);
if (await isRateLimited(db, "kb/reindex", clientIp(c.req.raw), { max: 5 })) {
return c.json({ ok: false, error: "too many requests" }, 429);
}
const provided = c.req.header("X-Reindex-Token") ?? "";
const expected = c.env.KB_REINDEX_TOKEN ?? "";
if (!expected || !tokensMatch(provided, expected)) {
Expand Down
56 changes: 56 additions & 0 deletions src/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Throttle simple por IP — defensa en profundidad para rutas expuestas sin
// sesión (webhooks, /kb/reindex) mientras se propaga la protección principal:
// una Cloudflare Rate Limiting Rule a nivel de cuenta (fuera de este repo, se
// configura en el dashboard de Cloudflare o en `wrangler.toml` con `[[rules]]`
// de tipo `ratelimit` — ver checklist de deploy). Esta capa cubre el caso en
// que esa regla no está configurada, y no depende de ningún binding nuevo:
// reusa el D1 (`DB`) que el bot ya tiene.
//
// Ventana fija (no sliding window): cada `bucket:ip` cuenta cuántos requests
// cayeron en el intervalo de windowMs actual; al cruzar a la siguiente
// ventana el contador se resetea solo. Suficiente para frenar ráfagas e
// intentos de fuerza bruta — no pretende ser exacto al request.
import { Db } from "./db/client";

const DEFAULT_WINDOW_MS = 60_000;
const DEFAULT_MAX = 20;

export interface RateLimitOptions {
windowMs?: number;
max?: number;
}

/** true si `ip` ya superó el tope de requests en la ventana actual para `bucket`. */
export async function isRateLimited(
db: Db,
bucket: string,
ip: string,
opts: RateLimitOptions = {},
now = Date.now(),
): Promise<boolean> {
const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
const max = opts.max ?? DEFAULT_MAX;
const key = `${bucket}:${ip}`;
const windowStart = Math.floor(now / windowMs) * windowMs;

// Upsert atómico: misma ventana → incrementa; ventana nueva → resetea a 1.
await db.run(
`INSERT INTO rate_limits (key, window_start, count)
VALUES (?, ?, 1)
ON CONFLICT(key) DO UPDATE SET
count = CASE WHEN rate_limits.window_start = excluded.window_start THEN rate_limits.count + 1 ELSE 1 END,
window_start = excluded.window_start`,
[key, windowStart],
);

const row = await db.first<{ count: number }>(
`SELECT count FROM rate_limits WHERE key = ?`,
[key],
);
Comment on lines +37 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Add bounded retention for expired rate-limit keys.

Rows are reset but never deleted. Requests from rotating IPs create permanent bucket:ip records, allowing unbounded D1 growth and eventual failures for all guarded routes. Add expiry cleanup (preferably scheduled/batched) and index window_start for that cleanup.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rate-limit.ts` around lines 37 - 49, Update the rate-limit persistence
flow around the INSERT/SELECT operations to remove expired rows using a bounded,
preferably scheduled or batched cleanup rather than retaining reset keys
indefinitely. Add an index on rate_limits.window_start and ensure cleanup
targets only expired windows while preserving active rate-limit rows and
existing counting behavior.

return (row?.count ?? 0) > max;
}

/** IP del cliente tal como la ve Cloudflare — no falsificable por el request entrante. */
export function clientIp(req: Request): string {
return req.headers.get("cf-connecting-ip") ?? "unknown";
}
Loading