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
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>;
}
128 changes: 128 additions & 0 deletions assistant/src/messaging/providers/telegram-bot/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* 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',
Comment on lines +50 to +53
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.

🔴 Telegram provider unusable via messaging tools: withProviderToken throws because no access_token exists

All messaging skill tools (send, reply, auth-test, list, search, read, etc.) call withProviderToken(provider, fn) which delegates to withValidToken(provider.credentialService, fn). For the Telegram provider, credentialService is 'telegram', so withValidToken looks for credential:telegram:access_token at assistant/src/security/token-manager.ts:110. However, Telegram stores its credential as credential:telegram:bot_token (see adapter.ts:47). Since no access_token key exists, withValidToken throws a TokenExpiredError at line 116 before the callback is ever invoked.

Root Cause and Impact

The flow is:

  1. messaging-send.ts:20 calls withProviderToken(provider, async (token) => { ... })
  2. shared.ts:44-45 calls withValidToken(provider.credentialService, fn)withValidToken('telegram', fn)
  3. token-manager.ts:110 does getSecureKey('credential:telegram:access_token') → returns undefined
  4. token-manager.ts:116 throws TokenExpiredError('No access token found for "telegram"')

The Telegram adapter's sendMessage (which correctly uses the gateway bearer token, not an OAuth token) is never reached. This makes the Telegram provider completely non-functional through the standard messaging tool interface, despite being registered and appearing as "connected" via isConnected().

Impact: Every attempt to send a Telegram message via the messaging tools will fail with a misleading "No access token found" error, even when the bot token is properly configured.

Prompt for agents
The core issue is that all messaging tools use withProviderToken() which calls withValidToken(service, fn), and withValidToken looks for credential:{service}:access_token. Since Telegram uses bot_token instead of access_token, this always throws.

There are two approaches to fix this:

1. (Recommended) Update assistant/src/config/bundled-skills/messaging/tools/shared.ts to add a Telegram-aware path in withProviderToken. For example, check if the provider has an isConnected() method (indicating it manages its own auth), and if so, skip the withValidToken call and invoke the callback with an empty/dummy token string:

   In shared.ts around line 41-46, change withProviderToken to:
   export async function withProviderToken<T>(
     provider: MessagingProvider,
     fn: (token: string) => Promise<T>,
   ): Promise<T> {
     if (provider.isConnected) {
       return fn('');
     }
     return withValidToken(provider.credentialService, fn);
   }

2. (Alternative) Store the Telegram bot token under credential:telegram:access_token instead of credential:telegram:bot_token so it works with the existing OAuth flow. But this is semantically misleading and would break the isConnected() check in the adapter (assistant/src/messaging/providers/telegram-bot/adapter.ts:47).
Open in Devin Review

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

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.
*/
isConnected(): boolean {
return getBotToken() !== undefined;
},

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.' },
};
}

const resp = await telegram.getMe(botToken);
if (!resp.ok || !resp.result) {
return {
connected: false,
user: 'unknown',
platform: 'telegram',
metadata: { error: resp.description ?? 'getMe failed' },
};
}
Comment on lines +77 to +85
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.

🟡 testConnection has unreachable error handling because getMe throws on non-ok responses

The testConnection method at line 78 checks if (!resp.ok || !resp.result) to return a graceful { connected: false } response. However, telegram.getMe() in client.ts:35-39 already throws a TelegramApiError when !resp.ok, so the !resp.ok branch in testConnection is unreachable dead code.

Detailed Explanation

In client.ts:29-43, the getMe function does:

if (!resp.ok) {
  throw new TelegramApiError(resp.status, ...);
}
return resp.json();

So when the Telegram API returns an error (e.g., invalid bot token → 401), getMe throws instead of returning a response with ok: false. The adapter's testConnection at line 77-85 expects to handle this case gracefully:

const resp = await telegram.getMe(botToken);
if (!resp.ok || !resp.result) { // ← never reached for HTTP errors
  return { connected: false, ... };
}

Instead, the TelegramApiError propagates up uncaught, and the caller (messaging-auth-test.ts) catches it as a generic error, returning a raw error message instead of the structured ConnectionInfo object.

Impact: When a bot token is invalid, testConnection throws an exception instead of returning a structured { connected: false } response. The !resp.ok check on the Telegram JSON response body (which has its own ok field) is also unreachable since HTTP errors are thrown first.

Suggested change
const resp = await telegram.getMe(botToken);
if (!resp.ok || !resp.result) {
return {
connected: false,
user: 'unknown',
platform: 'telegram',
metadata: { error: resp.description ?? 'getMe failed' },
};
}
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' },
};
}
Open in Devin Review

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


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,
},
};
},

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 };
},
};
104 changes: 104 additions & 0 deletions assistant/src/messaging/providers/telegram-bot/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Low-level Telegram operations.
*
* Outbound message delivery routes through the gateway's /deliver/telegram
* endpoint, which handles bot token management and Telegram API retries.
* Connection verification calls the Telegram Bot API directly with the
* stored bot token.
*/

import type { TelegramGetMeResponse } from './types.js';

const TELEGRAM_API_BASE = 'https://api.telegram.org';
const DELIVERY_TIMEOUT_MS = 30_000;

export class TelegramApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = 'TelegramApiError';
}
}

/**
* Verify a bot token by calling Telegram's getMe API directly.
* Used for testConnection() — the only operation that bypasses the gateway.
*/
export async function getMe(botToken: string): Promise<TelegramGetMeResponse> {
const resp = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/getMe`, {
method: 'POST',
signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
});

if (!resp.ok) {
throw new TelegramApiError(
resp.status,
`Telegram getMe failed with status ${resp.status}`,
);
}

return resp.json() as Promise<TelegramGetMeResponse>;
}

/**
* Send a text message to a Telegram chat via the gateway's deliver endpoint.
*/
export async function sendMessage(
gatewayUrl: string,
bearerToken: string,
chatId: string,
text: string,
): Promise<void> {
await deliverToGateway(gatewayUrl, bearerToken, { chatId, text });
}

/**
* Send a message with attachments to a Telegram chat via the gateway.
*/
export async function sendMessageWithAttachments(
gatewayUrl: string,
bearerToken: string,
chatId: string,
text: string | undefined,
attachmentIds: string[],
): Promise<void> {
await deliverToGateway(gatewayUrl, bearerToken, {
chatId,
text,
attachments: attachmentIds.map((id) => ({ id })),
});
}

/** Payload accepted by the gateway's /deliver/telegram endpoint. */
interface DeliverPayload {
chatId: string;
text?: string;
attachments?: Array<{ id: string }>;
}

async function deliverToGateway(
gatewayUrl: string,
bearerToken: string,
payload: DeliverPayload,
): Promise<void> {
const url = `${gatewayUrl}/deliver/telegram`;
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${bearerToken}`,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
});

if (!resp.ok) {
const body = await resp.text().catch(() => '<unreadable>');
throw new TelegramApiError(
resp.status,
`Gateway /deliver/telegram failed (${resp.status}): ${body}`,
);
}
}
15 changes: 15 additions & 0 deletions assistant/src/messaging/providers/telegram-bot/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/** Telegram Bot API types used by the messaging provider. */

export interface TelegramUser {
id: number;
is_bot: boolean;
first_name: string;
last_name?: string;
username?: string;
}

export interface TelegramGetMeResponse {
ok: boolean;
result?: TelegramUser;
description?: string;
}
1 change: 1 addition & 0 deletions assistant/src/messaging/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function getMessagingProvider(id: string): MessagingProvider {
/** Return all registered providers that have stored credentials. */
export function getConnectedProviders(): MessagingProvider[] {
return Array.from(providers.values()).filter((p) => {
if (p.isConnected) return p.isConnected();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Support tokenless providers before marking them connected

By short-circuiting on p.isConnected(), Telegram is now treated as a connected provider even though the messaging tool pipeline still requires withProviderToken() to load credential:${provider.credentialService}:access_token (in assistant/src/config/bundled-skills/messaging/tools/shared.ts). Telegram setup stores credential:telegram:bot_token instead, so messaging_auth_test, messaging_send, and similar calls fail with No access token found for "telegram"; in a Telegram-only setup this makes the new provider unusable.

Useful? React with 👍 / 👎.

const token = getSecureKey(`credential:${p.credentialService}:access_token`);
return token !== undefined;
});
Expand Down
Loading