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
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ describe('Invariant 2: no generic plaintext secret read API', () => {
'calls/twilio-provider.ts', // call infrastructure credential lookup
'runtime/http-server.ts', // HTTP server credential lookup
'daemon/handlers/twitter-auth.ts', // Twitter OAuth token storage
'messaging/providers/telegram-bot/adapter.ts', // Telegram bot token lookup for connectivity check
]);

const thisDir = dirname(fileURLToPath(import.meta.url));
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
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,16 @@ export function resolveProvider(platformInput?: string): MessagingProvider {

/**
* Execute a callback with a valid OAuth token for the given provider.
* Providers that manage their own auth (e.g. Telegram with a bot token)
* expose isConnected() and don't need an OAuth access_token lookup.
*/
export async function withProviderToken<T>(
provider: MessagingProvider,
fn: (token: string) => Promise<T>,
): Promise<T> {
if (provider.isConnected?.()) {
return fn('');
}
return withValidToken(provider.credentialService, fn);
}

Expand Down
67 changes: 47 additions & 20 deletions assistant/src/config/vellum-skills/telegram-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,26 +43,13 @@ export default () => ({ secret: randomUUID() });

Save this value for the next steps.

### Step 3: Register the Webhook
### Step 3: Webhook Registration (Automatic)

Use `evaluate_typescript_code` to register the webhook with Telegram:
Manual webhook registration is no longer required. The gateway automatically reconciles the Telegram webhook on startup and whenever credentials change. It compares the current webhook URL against `${INGRESS_PUBLIC_BASE_URL}/webhooks/telegram` and updates it if needed, including the webhook secret and allowed updates.

```typescript
export default async (input: { token: string; url: string; secret: string }) => {
const res = await fetch(`https://api.telegram.org/bot${input.token}/setWebhook`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: input.url,
secret_token: input.secret,
allowed_updates: ['message', 'edited_message'],
}),
});
return res.json();
};
```
If the ingress URL or webhook secret changes (e.g., tunnel restart, secret rotation), the gateway will detect the drift and re-register the webhook automatically.

Verify the response has `ok: true`.
You can skip directly to storing credentials.

### Step 4: Register Bot Commands

Expand Down Expand Up @@ -91,12 +78,52 @@ 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 registered at the provided URL
- 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. 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:

The gateway automatically detects credentials from the vault and will begin accepting Telegram webhooks shortly. No manual environment variable configuration is needed.
| 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 |
2 changes: 2 additions & 0 deletions assistant/src/daemon/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { slackProvider as slackWatcherProvider } from '../watcher/providers/slac
import { registerMessagingProvider } from '../messaging/registry.js';
import { slackProvider as slackMessagingProvider } from '../messaging/providers/slack/adapter.js';
import { gmailMessagingProvider } from '../messaging/providers/gmail/adapter.js';
import { telegramBotMessagingProvider } from '../messaging/providers/telegram-bot/adapter.js';
import { browserManager } from '../tools/browser/browser-manager.js';
import { RuntimeHttpServer } from '../runtime/http-server.js';
import { getHookManager } from '../hooks/manager.js';
Expand Down Expand Up @@ -384,6 +385,7 @@ export async function runDaemon(): Promise<void> {
// Register messaging providers
registerMessagingProvider(slackMessagingProvider);
registerMessagingProvider(gmailMessagingProvider);
registerMessagingProvider(telegramBotMessagingProvider);

const scheduler = startScheduler(
async (conversationId, message) => {
Expand Down
9 changes: 9 additions & 0 deletions assistant/src/messaging/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ export interface MessagingProvider {
getThreadReplies?(token: string, conversationId: string, threadId: string, options?: HistoryOptions): Promise<Message[]>;
markRead?(token: string, conversationId: string, messageId?: string): Promise<void>;

/**
* Override the default credential check used by getConnectedProviders().
* When present, the registry calls this instead of looking for
* credential:${credentialService}:access_token. Useful for providers
* that don't use OAuth (e.g. Telegram bot tokens stored under a
* non-standard key).
*/
isConnected?(): boolean;

/** Platform-specific capabilities for tool routing (e.g. 'reactions', 'threads', 'labels'). */
capabilities: Set<string>;
}
141 changes: 141 additions & 0 deletions assistant/src/messaging/providers/telegram-bot/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Telegram Bot messaging provider adapter.
*
* Enables proactive outbound messaging to Telegram chats via the gateway's
* /deliver/telegram endpoint. Unlike Slack/Gmail which use direct API calls
* with OAuth tokens, Telegram delivery is proxied through the gateway which
* owns the bot token and handles Telegram API retries.
*
* The `token` parameter in MessagingProvider methods is unused for Telegram
* because delivery is authenticated via the gateway's bearer token, not
* a per-user OAuth token.
*/

import type { MessagingProvider } from '../../provider.js';
import type {
Conversation,
Message,
SearchResult,
SendResult,
ConnectionInfo,
ListOptions,
HistoryOptions,
SearchOptions,
SendOptions,
} from '../../provider-types.js';
import { getSecureKey } from '../../../security/secure-keys.js';
import { readHttpToken } from '../../../util/platform.js';
import * as telegram from './client.js';

/** Resolve the local gateway base URL from GATEWAY_PORT (default 7830). */
function getGatewayUrl(): string {
const port = Number(process.env.GATEWAY_PORT) || 7830;
return `http://127.0.0.1:${port}`;
}

/** Read the runtime HTTP bearer token used to authenticate with the gateway. */
function getBearerToken(): string {
const token = readHttpToken();
if (!token) {
throw new Error('No runtime HTTP bearer token available — is the daemon running?');
}
return token;
}

/** Read the Telegram bot token from the credential vault. */
function getBotToken(): string | undefined {
return getSecureKey('credential:telegram:bot_token');
}

export const telegramBotMessagingProvider: MessagingProvider = {
id: 'telegram',
displayName: 'Telegram',
credentialService: 'telegram',
capabilities: new Set(['send']),

/**
* Custom connectivity check. The standard registry check looks for
* credential:telegram:access_token, but the Telegram bot token is
* stored as credential:telegram:bot_token. This method lets the
* registry detect that Telegram credentials exist.
*
* Both bot_token and webhook_secret are required — the gateway's
* /deliver/telegram endpoint rejects requests without the webhook
* secret, so partial credentials would cause every send to fail.
*/
isConnected(): boolean {
return getBotToken() !== undefined && !!getSecureKey('credential:telegram:webhook_secret');
},

async testConnection(_token: string): Promise<ConnectionInfo> {
const botToken = getBotToken();
if (!botToken) {
return {
connected: false,
user: 'unknown',
platform: 'telegram',
metadata: { error: 'No bot token found. Run the telegram-setup skill.' },
};
}

try {
const resp = await telegram.getMe(botToken);
if (!resp.ok || !resp.result) {
return {
connected: false,
user: 'unknown',
platform: 'telegram',
metadata: { error: resp.description ?? 'getMe failed' },
};
}

return {
connected: true,
user: resp.result.username ?? resp.result.first_name,
platform: 'telegram',
metadata: {
botId: resp.result.id,
botUsername: resp.result.username,
botName: resp.result.first_name,
},
};
} catch (e) {
return {
connected: false,
user: 'unknown',
platform: 'telegram',
metadata: { error: e instanceof Error ? e.message : 'getMe failed' },
};
}
},

async sendMessage(_token: string, conversationId: string, text: string, _options?: SendOptions): Promise<SendResult> {
const gatewayUrl = getGatewayUrl();
const bearerToken = getBearerToken();

await telegram.sendMessage(gatewayUrl, bearerToken, conversationId, text);

return {
id: `tg-${Date.now()}`,
timestamp: Date.now(),
conversationId,
};
},

// Telegram Bot API does not support listing conversations. Bots only
// interact with chats where users have initiated contact or the bot
// has been added to a group.
async listConversations(_token: string, _options?: ListOptions): Promise<Conversation[]> {
return [];
},

// Telegram Bot API does not provide message history retrieval.
async getHistory(_token: string, _conversationId: string, _options?: HistoryOptions): Promise<Message[]> {
return [];
},

// Telegram Bot API does not support message search.
async search(_token: string, _query: string, _options?: SearchOptions): Promise<SearchResult> {
return { total: 0, messages: [], hasMore: false };
},
};
Loading
Loading