Skip to content
Merged
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
14 changes: 12 additions & 2 deletions assistant/src/config/vellum-skills/telegram-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,22 @@ Use `credential_store` twice to securely save the credentials:
2. **Store the webhook secret:**
- action: `store`, service: `telegram`, field: `webhook_secret`, value: the generated secret

### Step 6: Report Success
### Step 6: Validate Routing Configuration

Verify that the gateway routing is configured to deliver inbound messages to the assistant:

- In **single-assistant mode** (the default local deployment), routing is automatically configured. The CLI sets `GATEWAY_UNMAPPED_POLICY=default` and `GATEWAY_DEFAULT_ASSISTANT_ID` to the current assistant's ID when starting the gateway, so no manual routing configuration is needed.
- In **multi-assistant mode**, the operator must set `GATEWAY_ASSISTANT_ROUTING_JSON` to map specific chat IDs or user IDs to assistant IDs, or configure a default assistant via `GATEWAY_DEFAULT_ASSISTANT_ID` with `GATEWAY_UNMAPPED_POLICY=default`.

If routing is misconfigured, inbound Telegram messages will be rejected and the gateway will send a visible notice to the chat explaining the issue (rate-limited to once per 5 minutes per chat).

### Step 7: Report Success

Summarize what was done:
- Bot verified: @username (ID: nnn)
- Webhook registration: handled automatically by the gateway
- Bot commands registered: /new
- Credentials stored securely in the vault
- Routing configuration validated

The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. No manual environment variable configuration or webhook registration is needed. If the ingress URL or secret changes later, the gateway will automatically re-register the webhook.
The gateway automatically detects credentials from the vault, reconciles the Telegram webhook registration, and begins accepting Telegram webhooks shortly. In single-assistant mode, routing is automatically configured — no manual environment variable configuration or webhook registration is needed. If the ingress URL or secret changes later, the gateway will automatically re-register the webhook.
12 changes: 11 additions & 1 deletion cli/src/lib/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { createRequire } from "module";
import { homedir } from "os";
import { dirname, join } from "path";

import { GATEWAY_PORT } from "../lib/constants";
import { loadLatestAssistant } from "./assistant-config.js";
import { GATEWAY_PORT } from "./constants.js";

const _require = createRequire(import.meta.url);

Expand Down Expand Up @@ -286,11 +287,20 @@ export async function startGateway(): Promise<string> {

console.log("🌐 Starting gateway...");
const gatewayDir = resolveGatewayDir();
// Auto-configure routing for single-assistant local deployments so that
// inbound Telegram messages are forwarded without manual env var setup.
const defaultAssistantId =
process.env.GATEWAY_DEFAULT_ASSISTANT_ID
|| loadLatestAssistant()?.assistantId
|| "default";

const gatewayEnv: Record<string, string> = {
...process.env as Record<string, string>,
GATEWAY_RUNTIME_PROXY_ENABLED: "true",
GATEWAY_RUNTIME_PROXY_REQUIRE_AUTH: "false",
RUNTIME_HTTP_PORT: process.env.RUNTIME_HTTP_PORT || "7821",
GATEWAY_UNMAPPED_POLICY: process.env.GATEWAY_UNMAPPED_POLICY || "default",
GATEWAY_DEFAULT_ASSISTANT_ID: defaultAssistantId,
};
const workspaceIngressPublicBaseUrl = readWorkspaceIngressPublicBaseUrl();
const ingressPublicBaseUrl =
Expand Down
34 changes: 33 additions & 1 deletion gateway/src/http/routes/telegram-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ export function buildTelegramTransportMetadata(): { hints: string[]; uxBrief: st
};
}

// Rate limiter for routing rejection notices — at most one reply per chat
// within the cooldown window to avoid spamming the user.
const REJECTION_NOTICE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
const rejectionNoticeTimestamps = new Map<string, number>();

function shouldSendRejectionNotice(chatId: string): boolean {
const now = Date.now();
const lastSent = rejectionNoticeTimestamps.get(chatId);
if (lastSent !== undefined && now - lastSent < REJECTION_NOTICE_COOLDOWN_MS) {
return false;
}
rejectionNoticeTimestamps.set(chatId, now);
Comment on lines +36 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound rejection notice cache growth

rejectionNoticeTimestamps is updated for every new chat ID but never evicted, so a long-lived gateway handling many distinct Telegram chats will retain stale IDs forever and grow memory usage over time. This is especially likely for public bots/groups where unique chat IDs can accumulate continuously; adding TTL-based cleanup or a max-size eviction policy would prevent unbounded growth.

Useful? React with 👍 / 👎.

return true;
Comment on lines +32 to +41
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Unbounded memory growth in rejectionNoticeTimestamps map — no eviction of expired entries

The rejectionNoticeTimestamps module-level Map<string, number> grows without bound. Every unique chat ID that triggers a routing rejection adds an entry, but entries are never removed — even after the 5-minute cooldown expires.

Root Cause and Impact

Unlike the DedupCache (at gateway/src/dedup-cache.ts:18-23) which has a maxSize cap and TTL-based eviction, the rejectionNoticeTimestamps map has no eviction mechanism. The shouldSendRejectionNotice function only reads and writes entries but never deletes stale ones:

const rejectionNoticeTimestamps = new Map<string, number>();

function shouldSendRejectionNotice(chatId: string): boolean {
  const now = Date.now();
  const lastSent = rejectionNoticeTimestamps.get(chatId);
  if (lastSent !== undefined && now - lastSent < REJECTION_NOTICE_COOLDOWN_MS) {
    return false;
  }
  rejectionNoticeTimestamps.set(chatId, now);
  return true;
}

In a scenario where the gateway is misconfigured (routing rejects all messages) and the bot is added to many Telegram groups or receives messages from many unique users, each distinct chatId permanently consumes memory. Over a long-running gateway lifetime, this leads to unbounded memory growth proportional to the total number of unique rejected chat IDs ever seen.

Impact: Slow memory leak in long-running gateway processes. Severity depends on traffic volume — a bot in many groups with misconfigured routing could accumulate thousands of entries that are never cleaned up.

Prompt for agents
In gateway/src/http/routes/telegram-webhook.ts, the rejectionNoticeTimestamps Map (line 32) grows without bound. Add periodic eviction of entries older than REJECTION_NOTICE_COOLDOWN_MS. Two approaches:

1. (Simple) In shouldSendRejectionNotice (lines 34-41), after the cooldown check, periodically sweep the map to delete entries where (now - timestamp) >= REJECTION_NOTICE_COOLDOWN_MS. For example, run the sweep every N calls or every M minutes using a counter or last-sweep timestamp.

2. (Better) Add a maxSize cap similar to DedupCache. When the map exceeds the cap, evict all expired entries. If still over cap, delete the oldest entries. This bounds memory usage regardless of traffic patterns.

Either approach prevents unbounded memory growth from accumulating stale chat ID entries.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

export function createTelegramWebhookHandler(config: GatewayConfig) {
const dedupCache = new DedupCache();

Expand Down Expand Up @@ -207,7 +222,24 @@ export function createTelegramWebhookHandler(config: GatewayConfig) {
replyCallbackUrl: `http://127.0.0.1:${config.port}/deliver/telegram`,
});

if (!result.forwarded && !result.rejected) {
if (result.rejected) {
log.warn(
{ chatId, reason: result.rejectionReason },
"Routing rejected inbound Telegram message",
);
if (shouldSendRejectionNotice(chatId)) {
sendTelegramReply(
config,
chatId,
"\u26a0\ufe0f This message could not be routed to an assistant. Please check your gateway routing configuration.",
).catch((err) => {
log.error({ err, chatId }, "Failed to send routing rejection notice");
});
}
return respond({ ok: true });
}

if (!result.forwarded) {
log.error({ updateId: payload.update_id }, "Failed to forward inbound event");
if (updateId !== undefined) dedupCache.unreserve(updateId);
return Response.json({ error: "Internal error" }, { status: 500 });
Expand Down
Loading