@gridlock/adapter-telegram
v0.2.2
Published
Reference Telegram bot adapter for the @gridlock/channel-contract surface (#1609). Implements ChannelAdapter end-to-end against the Telegram Bot API — zero external runtime dependencies (uses fetch). Ships a record/replay stub for hermetic CI use.
Readme
@gridlock/adapter-telegram
Reference Telegram bot adapter for the @gridlock/channel-contract
surface (#1609, MS-33). Implements ChannelAdapter end-to-end against
the Telegram Bot HTTP API with zero runtime deps beyond
@gridlock/channel-contract — uses the platform fetch.
What's in here
| Symbol | Role |
|---|---|
| TelegramAdapter | Production adapter. Talks to api.telegram.org. |
| TelegramAdapterStub | Record/replay double for hermetic CI. |
| parseTelegramUpdate | Inbound parser: Telegram Update → PlayerAction. |
| TELEGRAM_TRANSPORT | The transport literal 'telegram'. |
Production wiring
import { TelegramAdapter, parseTelegramUpdate } from '@gridlock/adapter-telegram';
import type { ChannelDispatcher } from '@gridlock/channel-contract';
const adapter = new TelegramAdapter({
botToken: process.env.TELEGRAM_BOT_TOKEN!,
// optional log hook
log: (entry) => console.log('telegram:', entry),
});
// Outbound: register the adapter on the dispatcher.
dispatcher.registerAdapter(adapter);
// Inbound: parse webhook bodies into PlayerAction and feed to your handler.
app.post('/webhook/telegram', async (req, res) => {
const update = req.body as TelegramUpdate;
const result = parseTelegramUpdate(update, {
resolvePlayerId: (telegramUserId) => playerStore.byTelegramId(telegramUserId),
resolveTargetNpcId: (chatId) => npcStore.byTelegramChatId(chatId),
});
if (result.kind === 'ok') {
await dialogueLayer.handle(result.action);
}
res.status(200).send('ok');
});Player-profile shape
TelegramAdapter reads recipient.providerIdentity.telegramChatId (string
or number). Hosts populate this when the player links their Telegram
account. Missing chat IDs surface as a DeliveryResult with
error.code: 'INVALID_RECIPIENT' — the adapter never throws on a
missing identity.
Error mapping
| Telegram response | DeliveryResult.error.code |
|---|---|
| HTTP 429 (rate-limit) | RATE_LIMITED |
| Forbidden: bot was blocked by the user | PLAYER_BLOCKED |
| chat not found (400/403) | UNKNOWN_RECIPIENT |
| message is too long (400) | CONTENT_TOO_LONG |
| HTTP 5xx | PROVIDER_INTERNAL |
| Fetch failure (network) | NETWORK_ERROR |
| Other | TELEGRAM_ERROR (description preserved) |
| kind: 'template' (WhatsApp-only) | UNSUPPORTED_CONTENT |
| Missing telegramChatId | INVALID_RECIPIENT |
The adapter never throws on transport failures — exceptions are reserved
for programmer errors (missing botToken, no fetch available).
Testing
The package ships with the full @gridlock/channel-contract/acceptance
suite wired to both the production adapter (against an injected fake
fetch) and the stub:
pnpm --filter @gridlock/adapter-telegram test # 27 / 27 pass
pnpm --filter @gridlock/adapter-telegram typecheck # tsc --noEmitLive-bot test recipe (manual)
CI cannot test against a real Telegram bot without a token. To validate locally:
- Talk to @BotFather and create a test bot. Save the token.
- Send
/startto your test bot from your personal Telegram account so the bot has a chat to send to. Note your Telegram user ID (visible via @userinfobot). - Run:
import { TelegramAdapter } from '@gridlock/adapter-telegram'; const adapter = new TelegramAdapter({ botToken: process.env.TELEGRAM_BOT_TOKEN! }); await adapter.send( { type: 'dialogue.response', recipientPlayerId: 'p1', sourceNpcId: 'alice', content: { kind: 'text', body: 'Hello from gridlock' }, timestamp: new Date() }, { playerId: 'p1', transport: 'telegram', verified: true, verifiedAt: new Date(), optIn: { narrativeMessages: true, systemNotifications: true }, providerIdentity: { telegramChatId: YOUR_CHAT_ID } }, { npcId: 'alice', worldId: 'w', transport: 'telegram', channelTier: 'contact', persona: { displayName: 'Alice' }, status: 'active', provisionedAt: new Date(), providerIdentity: {} }, ); - Confirm the message arrives in your Telegram.
Independence from @tic/*
The package has zero @tic/* imports — verified by inspection. Only
dependencies is @gridlock/channel-contract; only devDependencies
are @types/node, tsx, typescript.
