diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d0acb02..c287188 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -150,6 +150,9 @@ export const OWNER_ONLY_TOOLS: ReadonlySet = new Set([ // Discord channel management 'discord_pair', 'discord_unpair', + // Telegram channel management + 'telegram_pair', + 'telegram_unpair', ]); /** diff --git a/plugins/channel/plugin-channel-telegram/CHANGELOG.md b/plugins/channel/plugin-channel-telegram/CHANGELOG.md new file mode 100644 index 0000000..ed3d9d4 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this package are documented in this file. + +## [2.0.0] - 2026-02-23 + +### Added + +- Initial Telegram channel plugin scaffold +- `telegram_pair` and `telegram_unpair` tools +- Pairing and metadata tests diff --git a/plugins/channel/plugin-channel-telegram/README.md b/plugins/channel/plugin-channel-telegram/README.md new file mode 100644 index 0000000..3ca9182 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/README.md @@ -0,0 +1,28 @@ +# @tinyclaw/plugin-channel-telegram + +Telegram channel plugin for Tiny Claw. + +## Setup + +1. Open Telegram and talk to [@BotFather](https://t.me/BotFather) +2. Run `/newbot` and follow the prompts +3. Copy the generated bot token +4. Run Tiny Claw and use `telegram_pair` with the token +5. Run `tinyclaw_restart` to apply changes + +## Current Scope (V1) + +- Pair and unpair Telegram channel +- Plugin lifecycle scaffolding +- Runtime transport is implemented in follow-up milestones + +## Pairing Tools + +| Tool | Description | +|------|-------------| +| `telegram_pair` | Store bot token and enable plugin | +| `telegram_unpair` | Disable plugin (token kept in secrets) | + +## License + +GPLv3 diff --git a/plugins/channel/plugin-channel-telegram/package.json b/plugins/channel/plugin-channel-telegram/package.json new file mode 100644 index 0000000..11d118c --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/package.json @@ -0,0 +1,38 @@ +{ + "name": "@tinyclaw/plugin-channel-telegram", + "version": "2.0.0", + "description": "Telegram channel plugin for Tiny Claw", + "license": "GPL-3.0", + "author": "Waren Gonzaga", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/warengonzaga/tinyclaw.git", + "directory": "plugins/channel/plugin-channel-telegram" + }, + "homepage": "https://github.com/warengonzaga/tinyclaw/tree/main/plugins/channel/plugin-channel-telegram#readme", + "bugs": { + "url": "https://github.com/warengonzaga/tinyclaw/issues" + }, + "keywords": [ + "tinyclaw", + "plugin", + "channel", + "telegram" + ], + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "@tinyclaw/logger": "workspace:*", + "@tinyclaw/types": "workspace:*" + } +} diff --git a/plugins/channel/plugin-channel-telegram/src/index.ts b/plugins/channel/plugin-channel-telegram/src/index.ts new file mode 100644 index 0000000..e7a121f --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/src/index.ts @@ -0,0 +1,60 @@ +/** + * Telegram Channel Plugin + * + * Minimal v1 scaffold. + * - Provides pairing tools + * - Exposes channel metadata and lifecycle hooks + * - Runtime message transport is implemented in Milestone 4+ + */ + +import { logger } from '@tinyclaw/logger'; +import type { + ChannelPlugin, + PluginRuntimeContext, + Tool, + SecretsManagerInterface, + ConfigManagerInterface, +} from '@tinyclaw/types'; +import { + createTelegramPairingTools, + TELEGRAM_ENABLED_CONFIG_KEY, + TELEGRAM_TOKEN_SECRET_KEY, +} from './pairing.js'; + +const telegramPlugin: ChannelPlugin = { + id: '@tinyclaw/plugin-channel-telegram', + name: 'Telegram', + description: 'Connect Tiny Claw to a Telegram bot', + type: 'channel', + version: '0.1.0', + channelPrefix: 'telegram', + + getPairingTools( + secrets: SecretsManagerInterface, + configManager: ConfigManagerInterface, + ): Tool[] { + return createTelegramPairingTools(secrets, configManager); + }, + + async start(context: PluginRuntimeContext): Promise { + const isEnabled = context.configManager.get(TELEGRAM_ENABLED_CONFIG_KEY); + if (!isEnabled) { + logger.info('Telegram plugin: not enabled — run pairing to enable'); + return; + } + + const token = await context.secrets.retrieve(TELEGRAM_TOKEN_SECRET_KEY); + if (!token) { + logger.warn('Telegram plugin: enabled but no token found — re-pair to fix'); + return; + } + + logger.info('Telegram plugin scaffold started (runtime transport pending next milestone)'); + }, + + async stop(): Promise { + logger.info('Telegram plugin stopped'); + }, +}; + +export default telegramPlugin; diff --git a/plugins/channel/plugin-channel-telegram/src/pairing.ts b/plugins/channel/plugin-channel-telegram/src/pairing.ts new file mode 100644 index 0000000..956309b --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/src/pairing.ts @@ -0,0 +1,103 @@ +/** + * Telegram Pairing Tools + * + * Two tools that implement the Telegram bot pairing flow: + * + * 1. telegram_pair — Store the bot token and enable the plugin + * 2. telegram_unpair — Remove from enabled plugins and disable + * + * These tools are injected into the agent's tool list at boot so the agent + * can invoke them conversationally when a user asks to connect Telegram. + */ + +import type { Tool, SecretsManagerInterface, ConfigManagerInterface } from '@tinyclaw/types'; +import { buildChannelKeyName } from '@tinyclaw/types'; + +/** Secret key for the Telegram bot token. */ +export const TELEGRAM_TOKEN_SECRET_KEY = buildChannelKeyName('telegram'); +/** Config key for the enabled flag. */ +export const TELEGRAM_ENABLED_CONFIG_KEY = 'channels.telegram.enabled'; +/** The plugin's package ID. */ +export const TELEGRAM_PLUGIN_ID = '@tinyclaw/plugin-channel-telegram'; + +export function createTelegramPairingTools( + secrets: SecretsManagerInterface, + configManager: ConfigManagerInterface, +): Tool[] { + return [ + { + name: 'telegram_pair', + description: + 'Pair Tiny Claw with a Telegram bot. ' + + 'Stores the bot token securely and enables the Telegram channel plugin. ' + + 'After pairing, call tinyclaw_restart to connect the bot. ' + + 'To get a token, use Telegram BotFather and create a new bot.', + parameters: { + type: 'object', + properties: { + token: { + type: 'string', + description: 'Telegram bot token', + }, + }, + required: ['token'], + }, + async execute(args: Record): Promise { + const token = args.token as string; + if (!token || token.trim() === '') { + return 'Error: token must be a non-empty string.'; + } + + try { + await secrets.store(TELEGRAM_TOKEN_SECRET_KEY, token.trim()); + + configManager.set(TELEGRAM_ENABLED_CONFIG_KEY, true); + configManager.set('channels.telegram.tokenRef', TELEGRAM_TOKEN_SECRET_KEY); + + const current = configManager.get('plugins.enabled') ?? []; + if (!current.includes(TELEGRAM_PLUGIN_ID)) { + configManager.set('plugins.enabled', [...current, TELEGRAM_PLUGIN_ID]); + } + + return ( + 'Telegram bot paired successfully! ' + + 'Token stored securely and plugin enabled. ' + + 'Use the tinyclaw_restart tool now to connect the bot.' + ); + } catch (err) { + return `Error pairing Telegram: ${(err as Error).message}`; + } + }, + }, + { + name: 'telegram_unpair', + description: + 'Disconnect the Telegram bot and disable the Telegram channel plugin. ' + + 'The bot token is kept in secrets for safety. Call tinyclaw_restart after.', + parameters: { + type: 'object', + properties: {}, + required: [], + }, + async execute(): Promise { + try { + configManager.set(TELEGRAM_ENABLED_CONFIG_KEY, false); + + const current = configManager.get('plugins.enabled') ?? []; + configManager.set( + 'plugins.enabled', + current.filter((id) => id !== TELEGRAM_PLUGIN_ID), + ); + + return ( + 'Telegram plugin disabled. ' + + 'Use the tinyclaw_restart tool now to apply the changes. ' + + 'The bot token is still stored in secrets — use list_secrets to manage it.' + ); + } catch (err) { + return `Error unpairing Telegram: ${(err as Error).message}`; + } + }, + }, + ]; +} diff --git a/plugins/channel/plugin-channel-telegram/tests/index.test.ts b/plugins/channel/plugin-channel-telegram/tests/index.test.ts new file mode 100644 index 0000000..1802731 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/tests/index.test.ts @@ -0,0 +1,71 @@ +/** + * Tests for the Telegram channel plugin entry point. + */ + +import { describe, test, expect } from 'bun:test'; +import telegramPlugin from '../src/index.js'; + +describe('telegramPlugin metadata', () => { + test('has the correct id', () => { + expect(telegramPlugin.id).toBe('@tinyclaw/plugin-channel-telegram'); + }); + + test('has a human-readable name', () => { + expect(telegramPlugin.name).toBe('Telegram'); + }); + + test('type is channel', () => { + expect(telegramPlugin.type).toBe('channel'); + }); + + test('has a version string', () => { + expect(telegramPlugin.version).toBeDefined(); + expect(typeof telegramPlugin.version).toBe('string'); + }); + + test('has a description', () => { + expect(telegramPlugin.description).toBeDefined(); + expect(telegramPlugin.description.length).toBeGreaterThan(0); + }); + + test('has telegram channel prefix for outbound routing', () => { + expect(telegramPlugin.channelPrefix).toBe('telegram'); + }); +}); + +describe('getPairingTools', () => { + test('returns tools when called with mock managers', () => { + const mockSecrets = { + store: async () => {}, + check: async () => false, + retrieve: async () => null, + list: async () => [], + resolveProviderKey: async () => null, + close: async () => {}, + }; + const mockConfig = { + get: () => undefined, + has: () => false, + set: () => {}, + delete: () => {}, + reset: () => {}, + clear: () => {}, + store: {}, + size: 0, + path: ':memory:', + onDidChange: () => () => {}, + onDidAnyChange: () => () => {}, + close: () => {}, + }; + + const tools = telegramPlugin.getPairingTools!(mockSecrets as any, mockConfig as any); + expect(tools).toHaveLength(2); + expect(tools.map((t) => t.name)).toEqual(['telegram_pair', 'telegram_unpair']); + }); +}); + +describe('stop', () => { + test('does not throw when called without start', async () => { + await expect(telegramPlugin.stop()).resolves.toBeUndefined(); + }); +}); diff --git a/plugins/channel/plugin-channel-telegram/tests/pairing.test.ts b/plugins/channel/plugin-channel-telegram/tests/pairing.test.ts new file mode 100644 index 0000000..63522e7 --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/tests/pairing.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for Telegram pairing tools (telegram_pair / telegram_unpair). + */ + +import { describe, test, expect, beforeEach } from 'bun:test'; +import type { Tool, SecretsManagerInterface, ConfigManagerInterface } from '@tinyclaw/types'; +import { + createTelegramPairingTools, + TELEGRAM_TOKEN_SECRET_KEY, + TELEGRAM_ENABLED_CONFIG_KEY, + TELEGRAM_PLUGIN_ID, +} from '../src/pairing.js'; + +function createMockSecrets(): SecretsManagerInterface & { stored: Map } { + const stored = new Map(); + return { + stored, + async store(key: string, value: string) { + stored.set(key, value); + }, + async check(key: string) { + return stored.has(key); + }, + async retrieve(key: string) { + return stored.get(key) ?? null; + }, + async list(_pattern?: string) { + return Array.from(stored.keys()); + }, + async resolveProviderKey(_provider: string) { + return null; + }, + async close() {}, + }; +} + +function createMockConfig(): ConfigManagerInterface & { data: Record } { + const data: Record = {}; + return { + data, + get(key: string, defaultValue?: V): V | undefined { + return (data[key] as V) ?? defaultValue; + }, + has(key: string) { + return key in data; + }, + set(keyOrObj: string | Record, value?: unknown) { + if (typeof keyOrObj === 'string') { + data[keyOrObj] = value; + } else { + Object.assign(data, keyOrObj); + } + }, + delete(key: string) { + delete data[key]; + }, + reset() {}, + clear() { + for (const k of Object.keys(data)) delete data[k]; + }, + get store() { + return { ...data }; + }, + get size() { + return Object.keys(data).length; + }, + get path() { + return ':memory:'; + }, + onDidChange() { + return () => {}; + }, + onDidAnyChange() { + return () => {}; + }, + close() {}, + }; +} + +function findTool(tools: Tool[], name: string): Tool { + const tool = tools.find((t) => t.name === name); + if (!tool) throw new Error(`Tool "${name}" not found`); + return tool; +} + +describe('createTelegramPairingTools', () => { + let secrets: ReturnType; + let config: ReturnType; + let tools: Tool[]; + + beforeEach(() => { + secrets = createMockSecrets(); + config = createMockConfig(); + tools = createTelegramPairingTools(secrets, config); + }); + + test('returns two tools', () => { + expect(tools).toHaveLength(2); + expect(tools.map((t) => t.name)).toEqual(['telegram_pair', 'telegram_unpair']); + }); + + describe('telegram_pair', () => { + test('stores token in secrets and enables config', async () => { + const tool = findTool(tools, 'telegram_pair'); + const result = await tool.execute({ token: 'my-bot-token' }); + + expect(secrets.stored.get(TELEGRAM_TOKEN_SECRET_KEY)).toBe('my-bot-token'); + expect(config.data[TELEGRAM_ENABLED_CONFIG_KEY]).toBe(true); + expect(config.data['channels.telegram.tokenRef']).toBe(TELEGRAM_TOKEN_SECRET_KEY); + + const enabled = config.data['plugins.enabled'] as string[]; + expect(enabled).toContain(TELEGRAM_PLUGIN_ID); + + expect(result).toContain('paired successfully'); + }); + + test('trims whitespace from token', async () => { + const tool = findTool(tools, 'telegram_pair'); + await tool.execute({ token: ' spaced-token ' }); + + expect(secrets.stored.get(TELEGRAM_TOKEN_SECRET_KEY)).toBe('spaced-token'); + }); + + test('rejects empty token', async () => { + const tool = findTool(tools, 'telegram_pair'); + const result = await tool.execute({ token: '' }); + + expect(result).toContain('Error'); + expect(secrets.stored.size).toBe(0); + }); + + test('rejects whitespace-only token', async () => { + const tool = findTool(tools, 'telegram_pair'); + const result = await tool.execute({ token: ' ' }); + + expect(result).toContain('Error'); + expect(secrets.stored.size).toBe(0); + }); + + test('deduplicates plugin in enabled list', async () => { + config.data['plugins.enabled'] = [TELEGRAM_PLUGIN_ID]; + + const tool = findTool(tools, 'telegram_pair'); + await tool.execute({ token: 'token-123' }); + + const enabled = config.data['plugins.enabled'] as string[]; + const count = enabled.filter((id) => id === TELEGRAM_PLUGIN_ID).length; + expect(count).toBe(1); + }); + + test('preserves other plugins in enabled list', async () => { + config.data['plugins.enabled'] = ['@tinyclaw/plugin-other']; + + const tool = findTool(tools, 'telegram_pair'); + await tool.execute({ token: 'token-123' }); + + const enabled = config.data['plugins.enabled'] as string[]; + expect(enabled).toContain('@tinyclaw/plugin-other'); + expect(enabled).toContain(TELEGRAM_PLUGIN_ID); + }); + + test('handles secrets.store failure gracefully', async () => { + secrets.store = async () => { + throw new Error('disk full'); + }; + + const tool = findTool(tools, 'telegram_pair'); + const result = await tool.execute({ token: 'token-123' }); + + expect(result).toContain('Error pairing Telegram'); + expect(result).toContain('disk full'); + }); + }); + + describe('telegram_unpair', () => { + test('disables the plugin in config', async () => { + config.data[TELEGRAM_ENABLED_CONFIG_KEY] = true; + config.data['plugins.enabled'] = [TELEGRAM_PLUGIN_ID]; + + const tool = findTool(tools, 'telegram_unpair'); + const result = await tool.execute({}); + + expect(config.data[TELEGRAM_ENABLED_CONFIG_KEY]).toBe(false); + expect(result).toContain('disabled'); + }); + + test('removes plugin from enabled list', async () => { + config.data['plugins.enabled'] = ['@tinyclaw/other', TELEGRAM_PLUGIN_ID]; + + const tool = findTool(tools, 'telegram_unpair'); + await tool.execute({}); + + const enabled = config.data['plugins.enabled'] as string[]; + expect(enabled).not.toContain(TELEGRAM_PLUGIN_ID); + expect(enabled).toContain('@tinyclaw/other'); + }); + + test('handles empty plugins.enabled list', async () => { + const tool = findTool(tools, 'telegram_unpair'); + const result = await tool.execute({}); + + expect(config.data[TELEGRAM_ENABLED_CONFIG_KEY]).toBe(false); + expect(result).toContain('disabled'); + }); + + test('keeps token in secrets (does not delete)', async () => { + secrets.stored.set(TELEGRAM_TOKEN_SECRET_KEY, 'old-token'); + config.data['plugins.enabled'] = [TELEGRAM_PLUGIN_ID]; + + const tool = findTool(tools, 'telegram_unpair'); + await tool.execute({}); + + expect(secrets.stored.has(TELEGRAM_TOKEN_SECRET_KEY)).toBe(true); + }); + }); +}); + +describe('exported constants', () => { + test('TELEGRAM_TOKEN_SECRET_KEY follows channel naming convention', () => { + expect(TELEGRAM_TOKEN_SECRET_KEY).toBe('channel.telegram.token'); + }); + + test('TELEGRAM_ENABLED_CONFIG_KEY matches config schema', () => { + expect(TELEGRAM_ENABLED_CONFIG_KEY).toBe('channels.telegram.enabled'); + }); + + test('TELEGRAM_PLUGIN_ID is the npm package name', () => { + expect(TELEGRAM_PLUGIN_ID).toBe('@tinyclaw/plugin-channel-telegram'); + }); +}); diff --git a/plugins/channel/plugin-channel-telegram/tsconfig.json b/plugins/channel/plugin-channel-telegram/tsconfig.json new file mode 100644 index 0000000..c8c92cb --- /dev/null +++ b/plugins/channel/plugin-channel-telegram/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +}