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
37 changes: 37 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3432,6 +3432,43 @@ All public-facing URLs are constructed by `assistant/src/inbound/public-ingress-
| `getOAuthCallbackUrl()` | `${base}/webhooks/oauth/callback` |
| `getTelegramWebhookUrl()` | `${base}/webhooks/telegram` |

### Telegram Messaging Flow

Telegram messages follow three paths through the system:

```
Inbound (user → assistant):
Telegram → Gateway POST /webhooks/telegram → verify secret → normalize → route
→ Runtime POST /v1/assistants/:id/channels/inbound

Outbound reply (assistant → user, triggered by inbound):
Runtime callback → Gateway POST /deliver/telegram (bearer auth) → Telegram sendMessage/sendPhoto/sendDocument

Outbound proactive (assistant → user, initiated by messaging provider):
Runtime messaging provider → Gateway POST /deliver/telegram (bearer auth) → Telegram sendMessage
```

The `/deliver/telegram` endpoint requires bearer auth unconditionally (fail-closed). If no bearer token is configured and the dev-only bypass flag (`GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS`) is not set, the endpoint returns 503 rather than allowing unauthenticated access.

### Webhook Reconciliation

On startup, the gateway automatically reconciles the Telegram webhook registration:

1. Reads `INGRESS_PUBLIC_BASE_URL` and Telegram credentials (bot token, webhook secret)
2. Calls `getWebhookInfo` to check the current registration
3. Compares the URL, secret, and allowed updates against the expected values
4. If any differ, calls `setWebhook` to update the registration

This also runs when the credential watcher detects changes to Telegram credentials. Manual webhook registration is no longer required.

### Routing Auto-Configuration

In single-assistant mode (the default local deployment), routing is automatically configured by the CLI:
- `GATEWAY_UNMAPPED_POLICY=default` is set so all inbound messages are forwarded
- `GATEWAY_DEFAULT_ASSISTANT_ID` is set to the current assistant's ID

In multi-assistant mode, the operator must configure `GATEWAY_ASSISTANT_ROUTING_JSON` to map specific chat/user IDs to assistant IDs.

---

## Outgoing AI Phone Calls — Twilio ConversationRelay
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,11 @@ If a proxied command receives a 401 or 403 despite having the correct credential

Vellum integrates with third-party services via OAuth2. Each integration is exposed as a bundled skill with its own set of tools.

### Messaging (Gmail, Slack)
### Messaging (Gmail, Slack, Telegram)

The unified messaging layer provides platform-agnostic tools (`messaging_send`, `messaging_read`, `messaging_search`, etc.) that delegate to provider adapters. Gmail and Slack each implement the `MessagingProvider` interface. Platform-specific tools (e.g. `gmail_archive`, `slack_add_reaction`) extend beyond the generic interface where needed.
The unified messaging layer provides platform-agnostic tools (`messaging_send`, `messaging_read`, `messaging_search`, etc.) that delegate to provider adapters. Gmail and Slack each implement the `MessagingProvider` interface. Telegram is also supported as a messaging provider, though with limited capabilities compared to Slack and Gmail: bots can send messages to known chat IDs but cannot list conversations, retrieve message history, or search messages (Bot API limitations). Bots can only message users or groups that have previously interacted with the bot. Platform-specific tools (e.g. `gmail_archive`, `slack_add_reaction`) extend beyond the generic interface where needed.

Connect via the Settings UI or `integration_connect` IPC message. OAuth2 tokens are stored in the credential vault — the LLM never sees raw tokens.
Connect Gmail and Slack via the Settings UI or `integration_connect` IPC message. OAuth2 tokens are stored in the credential vault — the LLM never sees raw tokens. Telegram uses a bot token (not OAuth) — see the `telegram-setup` skill for setup instructions.

### Twitter (X)

Expand Down
17 changes: 16 additions & 1 deletion assistant/src/config/bundled-skills/messaging/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ The telegram-setup skill handles: verifying the bot token from @BotFather, gener

## Capabilities

### Universal (all platforms)
### Universal (Slack, Gmail)
- **Auth Test**: Verify connection and show account info
- **List Conversations**: Show channels, inboxes, DMs with unread counts
- **Read Messages**: Read message history from a conversation
Expand All @@ -56,6 +56,21 @@ The telegram-setup skill handles: verifying the bot token from @BotFather, gener
- **Reply**: Reply in a thread (medium risk)
- **Mark Read**: Mark conversation as read

### Telegram
Telegram is supported as a messaging provider with limited capabilities compared to Slack and Gmail due to Bot API constraints:

- **Send**: Send a message to a known chat ID (high risk — requires user approval)
- **Auth Test**: Verify bot token and show bot info

**Not available** (Bot API limitations):
- List conversations — the Bot API does not expose a method to enumerate chats a bot belongs to
- Read message history — bots cannot retrieve past messages from a chat
- Search messages — no search API is available for bots

**Bot-account limits:**
- The bot can only message users or groups that have previously interacted with it (sent `/start` or been added to a group). Bots cannot initiate conversations with arbitrary phone numbers.
- Future support for MTProto user-account sessions may lift some of these restrictions.

### Slack-specific
- **Add Reaction**: Add an emoji reaction to a message
- **Leave Channel**: Leave a Slack channel
Expand Down
30 changes: 30 additions & 0 deletions assistant/src/config/vellum-skills/telegram-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,33 @@ Summarize what was done:
- Routing configuration validated

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.

## Bot-Account Limitations

Telegram bot accounts have inherent limitations imposed by the Bot API:

- **No arbitrary messaging**: Bots cannot initiate conversations with users who have not first interacted with the bot (sent `/start` or added it to a group). Messaging arbitrary phone numbers is not possible.
- **No conversation listing**: The Bot API does not expose a method to enumerate the chats a bot belongs to.
- **No message history retrieval**: Bots cannot fetch past messages from a chat.
- **No message search**: No search API is available for bots.

These limitations apply to all Telegram bots regardless of configuration. Future support for MTProto user-account sessions may lift some of these restrictions.

## Automated vs Manual Steps

The following steps are now **automated** by the gateway and CLI:

| Step | Status | Details |
|------|--------|---------|
| Webhook registration | Automated | The gateway reconciles the webhook URL on startup and when credentials change |
| Routing configuration | Automated (single-assistant) | The CLI sets `GATEWAY_UNMAPPED_POLICY=default` and `GATEWAY_DEFAULT_ASSISTANT_ID` automatically |
| Credential detection | Automated | The gateway watches the credential vault for changes |

The following steps still require **manual** action:

| Step | Details |
|------|---------|
| Bot token from @BotFather | User must create a bot and provide the token |
| Bot command registration | Registered via the setup skill (Step 4 above) |
| Credential storage | Stored via the setup skill (Step 5 above) |
| Multi-assistant routing | Requires manual `GATEWAY_ASSISTANT_ROUTING_JSON` configuration |
18 changes: 17 additions & 1 deletion gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ bun run dev
| `GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES` | No | `1048576` | Max inbound webhook payload size (rejects with 413) |
| `GATEWAY_MAX_ATTACHMENT_BYTES` | No | `20971520` | Max single attachment size (oversized are skipped) |
| `GATEWAY_MAX_ATTACHMENT_CONCURRENCY` | No | `3` | Max concurrent attachment download/upload operations |
| `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` | No | `false` | Dev-only: skip bearer auth on `/deliver/telegram` when no token is configured |

## Routing

Expand All @@ -67,13 +68,28 @@ v1 uses deterministic settings-based routing (no database):

## Setting up the Telegram webhook

After deploying the gateway, register the webhook with Telegram using the `setWebhook` API method. Pass:
Webhook registration is now handled automatically by the gateway. On startup, the gateway reconciles the Telegram webhook by comparing the current registration against `${INGRESS_PUBLIC_BASE_URL}/webhooks/telegram`. If the URL, secret, or allowed updates differ, the gateway re-registers the webhook automatically. This also runs whenever credentials change (e.g., tunnel restart, secret rotation).

For manual setup (or reference), register the webhook with Telegram using the `setWebhook` API method. Pass:
- `url` — your gateway URL, e.g. `https://your-host/webhooks/telegram`
- The verify value matching your `TELEGRAM_WEBHOOK_SECRET` env var
- `allowed_updates` — `["message", "edited_message"]`

See the [Telegram Bot API docs](https://core.telegram.org/bots/api#setwebhook) for the full API reference.

## Telegram Deliver Endpoint Security

The `/deliver/telegram` endpoint requires bearer auth by default (fail-closed). The security behavior is:

| Condition | Result |
|-----------|--------|
| Bearer token configured + valid `Authorization` header | Request allowed |
| Bearer token configured + missing/invalid `Authorization` header | 401 Unauthorized |
| No bearer token configured + `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS=true` | Request allowed (dev-only) |
| No bearer token configured + bypass not set | 503 Service Not Configured |

This ensures that misconfiguration cannot expose an unauthenticated public message-send surface. In production, always configure `RUNTIME_PROXY_BEARER_TOKEN`. The `GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS` flag is intended for local development only.

## Public Ingress Routes

The gateway serves as the single public ingress point for all external callbacks. The following routes are handled directly by the gateway before any proxy forwarding:
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function withEnv(overrides: Record<string, string | undefined>, fn: () => void)
"GATEWAY_MAX_WEBHOOK_PAYLOAD_BYTES",
"GATEWAY_MAX_ATTACHMENT_BYTES",
"GATEWAY_MAX_ATTACHMENT_CONCURRENCY",
"GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS",
"VELLUM_HTTP_TOKEN_PATH",
];

Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/load-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/oauth-callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const makeConfig = (overrides: Partial<GatewayConfig> = {}): GatewayConfig => ({
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/resolve-assistant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/runtime-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const makeConfig = (overrides: Partial<GatewayConfig> = {}): GatewayConfig => ({
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/runtime-proxy-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/runtime-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down
42 changes: 38 additions & 4 deletions gateway/src/__tests__/telegram-deliver-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down Expand Up @@ -78,7 +79,7 @@ describe("/deliver/telegram attachment delivery without assistantId", () => {
}) as any;

const handler = createTelegramDeliverHandler(
makeConfig({ runtimeProxyBearerToken: undefined }),
makeConfig({ runtimeProxyBearerToken: undefined, telegramDeliverAuthBypass: true }),
);
const req = new Request("http://localhost:7830/deliver/telegram", {
method: "POST",
Expand Down Expand Up @@ -131,7 +132,7 @@ describe("/deliver/telegram attachment delivery without assistantId", () => {
}) as any;

const handler = createTelegramDeliverHandler(
makeConfig({ runtimeProxyBearerToken: undefined }),
makeConfig({ runtimeProxyBearerToken: undefined, telegramDeliverAuthBypass: true }),
);
const req = new Request("http://localhost:7830/deliver/telegram", {
method: "POST",
Expand Down Expand Up @@ -222,8 +223,7 @@ describe("/deliver/telegram bearer auth enforcement", () => {
expect(body.ok).toBe(true);
});

test("allows unauthenticated access when no token is configured", async () => {
mockTelegramApi();
test("returns 503 when no token is configured and bypass is not set", async () => {
const handler = createTelegramDeliverHandler(
makeConfig({ runtimeProxyBearerToken: undefined }),
);
Expand All @@ -234,11 +234,45 @@ describe("/deliver/telegram bearer auth enforcement", () => {
});
const res = await handler(req);

expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toBe("Service not configured: bearer token required");
});

test("allows unauthenticated access when bypass flag is set and no token configured", async () => {
mockTelegramApi();
const handler = createTelegramDeliverHandler(
makeConfig({ runtimeProxyBearerToken: undefined, telegramDeliverAuthBypass: true }),
);
const req = new Request("http://localhost:7830/deliver/telegram", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ chatId: "123", text: "hello" }),
});
const res = await handler(req);

expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
});

test("bypass flag is ignored when a bearer token is configured (auth still required)", async () => {
const handler = createTelegramDeliverHandler(
makeConfig({ telegramDeliverAuthBypass: true }),
);
const req = new Request("http://localhost:7830/deliver/telegram", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ chatId: "123", text: "hello" }),
});
const res = await handler(req);

// Token is configured, so missing Authorization header is still rejected
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});

test("still rejects non-POST methods before auth check", async () => {
const handler = createTelegramDeliverHandler(makeConfig());
const req = new Request("http://localhost:7830/deliver/telegram", {
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/telegram-send-attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const makeConfig = (overrides: Partial<GatewayConfig> = {}): GatewayConfig => ({
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/telegram-webhook-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 0,
telegramTimeoutMs: 15000,
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/twilio-relay-websocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const makeConfig = (overrides: Partial<GatewayConfig> = {}): GatewayConfig => ({
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down
1 change: 1 addition & 0 deletions gateway/src/__tests__/twilio-webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const makeConfig = (overrides: Partial<GatewayConfig> = {}): GatewayConfig => ({
runtimeTimeoutMs: 30000,
runtimeMaxRetries: 2,
runtimeInitialBackoffMs: 500,
telegramDeliverAuthBypass: false,
telegramInitialBackoffMs: 1000,
telegramMaxRetries: 3,
telegramTimeoutMs: 15000,
Expand Down
19 changes: 19 additions & 0 deletions gateway/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export type GatewayConfig = {
shutdownDrainMs: number;
telegramApiBaseUrl: string;
telegramBotToken: string | undefined;
/**
* When true, the /deliver/telegram endpoint allows unauthenticated access
* even when no bearer token is configured. Intended for local development only.
*/
telegramDeliverAuthBypass: boolean;
telegramInitialBackoffMs: number;
telegramMaxRetries: number;
telegramTimeoutMs: number;
Expand Down Expand Up @@ -173,6 +178,18 @@ export function loadConfig(): GatewayConfig {
throw new Error("GATEWAY_RUNTIME_INITIAL_BACKOFF_MS must be a positive number");
}

const telegramDeliverAuthBypassRaw = process.env.GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS;
if (
telegramDeliverAuthBypassRaw !== undefined &&
telegramDeliverAuthBypassRaw !== "true" &&
telegramDeliverAuthBypassRaw !== "false"
) {
throw new Error(
`GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS must be "true" or "false", got "${telegramDeliverAuthBypassRaw}"`,
);
}
const telegramDeliverAuthBypass = telegramDeliverAuthBypassRaw === "true";

const telegramTimeoutMs = Number(process.env.GATEWAY_TELEGRAM_TIMEOUT_MS || "15000");
if (!Number.isFinite(telegramTimeoutMs) || telegramTimeoutMs <= 0) {
throw new Error("GATEWAY_TELEGRAM_TIMEOUT_MS must be a positive number");
Expand Down Expand Up @@ -240,6 +257,7 @@ export function loadConfig(): GatewayConfig {
port,
runtimeProxyEnabled,
runtimeProxyRequireAuth,
telegramDeliverAuthBypass,
hasTwilioAuthToken: !!twilioAuthToken,
publicUrl,
},
Expand All @@ -265,6 +283,7 @@ export function loadConfig(): GatewayConfig {
shutdownDrainMs,
telegramApiBaseUrl,
telegramBotToken,
telegramDeliverAuthBypass,
telegramInitialBackoffMs,
telegramMaxRetries,
telegramTimeoutMs,
Expand Down
Loading
Loading