mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-08 03:07:53 +00:00
feat: add Telegram Bridge MCP Server for remote control of Roo Code
This PR implements the foundation for Issue #11146 - Native Telegram Bot Integration for Remote Control. Changes: - Add ApproveAsk and DenyAsk commands to IPC types - Add IPC command handlers in extension for approve/deny - Create @roo-code/telegram-bridge MCP server package - Add Telegram bot integration with approve/deny buttons - Add tests for new IPC commands The Telegram Bridge MCP Server allows users to: - Receive Roo Code messages on Telegram in real-time - Approve/reject tool operations via inline buttons - Send new instructions from Telegram - Monitor task status remotely
This commit is contained in:
parent
ede1d29299
commit
c7d31ae5d0
10 changed files with 1041 additions and 1 deletions
|
|
@ -108,6 +108,20 @@ export class IpcClient extends EventEmitter<IpcClientEvents> {
|
|||
})
|
||||
}
|
||||
|
||||
public approveAsk(text?: string, images?: string[]) {
|
||||
this.sendCommand({
|
||||
commandName: TaskCommandName.ApproveAsk,
|
||||
data: { text, images },
|
||||
})
|
||||
}
|
||||
|
||||
public denyAsk(text?: string, images?: string[]) {
|
||||
this.sendCommand({
|
||||
commandName: TaskCommandName.DenyAsk,
|
||||
data: { text, images },
|
||||
})
|
||||
}
|
||||
|
||||
public sendMessage(message: IpcMessage) {
|
||||
ipc.of[this._id]?.emit("message", message)
|
||||
}
|
||||
|
|
|
|||
151
packages/telegram-bridge/README.md
Normal file
151
packages/telegram-bridge/README.md
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
# @roo-code/telegram-bridge
|
||||
|
||||
Telegram bridge for remote control of Roo Code. This MCP server enables you to interact with Roo Code via Telegram, allowing you to:
|
||||
|
||||
- **Receive messages**: Get real-time updates from Roo Code on your Telegram
|
||||
- **Approve/Reject operations**: Use inline buttons to approve or deny tool operations
|
||||
- **Send instructions**: Forward messages to Roo Code as new instructions
|
||||
- **Monitor task status**: Track task progress remotely
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Create a Telegram Bot**:
|
||||
- Open Telegram and search for [@BotFather](https://t.me/BotFather)
|
||||
- Send `/newbot` and follow the prompts
|
||||
- Save the bot token you receive
|
||||
|
||||
2. **Get your Chat ID**:
|
||||
- Start a conversation with your new bot
|
||||
- Send any message to the bot
|
||||
- Visit `https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates`
|
||||
- Find your `chat.id` in the response
|
||||
|
||||
## Installation
|
||||
|
||||
### As an MCP Server
|
||||
|
||||
Add the following to your MCP settings file (`~/.roo-code/settings/mcp_settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"telegram-bridge": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/roo-code/packages/telegram-bridge/build/index.js"],
|
||||
"env": {
|
||||
"TELEGRAM_BOT_TOKEN": "your-bot-token",
|
||||
"TELEGRAM_CHAT_ID": "your-chat-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
cd packages/telegram-bridge
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Once the MCP server is running, you can use the following tools:
|
||||
|
||||
### start_bridge
|
||||
|
||||
Start the Telegram bridge with your credentials.
|
||||
|
||||
```json
|
||||
{
|
||||
"botToken": "your-bot-token",
|
||||
"chatId": "your-chat-id",
|
||||
"socketPath": "/path/to/roo-code.sock",
|
||||
"enableTts": false
|
||||
}
|
||||
```
|
||||
|
||||
### stop_bridge
|
||||
|
||||
Stop the Telegram bridge.
|
||||
|
||||
### bridge_status
|
||||
|
||||
Get the current status of the bridge.
|
||||
|
||||
### send_telegram_message
|
||||
|
||||
Send a custom message to Telegram.
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Hello from Roo Code!"
|
||||
}
|
||||
```
|
||||
|
||||
## Telegram Commands
|
||||
|
||||
Once connected, you can use these commands in Telegram:
|
||||
|
||||
- `/start` or `/help` - Show help message
|
||||
- `/status` - Show connection status
|
||||
- `/cancel` - Cancel the current task
|
||||
|
||||
Any other message will be forwarded to Roo Code as a new instruction.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The bridge connects to Roo Code via IPC (Inter-Process Communication)
|
||||
2. It listens for task events and messages from Roo Code
|
||||
3. Messages are formatted and sent to your Telegram chat
|
||||
4. When approval is needed, inline buttons are shown
|
||||
5. Button clicks and messages are sent back to Roo Code
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ IPC ┌──────────────────┐ Telegram API ┌──────────┐
|
||||
│ Roo Code │◄────────────►│ Telegram Bridge │◄──────────────────►│ Telegram │
|
||||
│ Extension │ │ (MCP Server) │ │ App │
|
||||
└─────────────┘ └──────────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Required |
|
||||
|----------|-------------|----------|
|
||||
| `TELEGRAM_BOT_TOKEN` | Your Telegram bot token | Yes |
|
||||
| `TELEGRAM_CHAT_ID` | Your Telegram chat ID | Yes |
|
||||
| `ROO_CODE_SOCKET_PATH` | Custom IPC socket path | No |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Keep your bot token secret - never commit it to version control
|
||||
- The bridge only responds to messages from the configured chat ID
|
||||
- Consider using a private chat or group for better security
|
||||
- All communication between the bridge and Roo Code happens locally via IPC
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bridge not connecting
|
||||
|
||||
1. Ensure Roo Code is running with IPC enabled
|
||||
2. Check the socket path is correct
|
||||
3. Verify your bot token is valid
|
||||
|
||||
### Not receiving messages
|
||||
|
||||
1. Ensure you've started a conversation with the bot
|
||||
2. Check the chat ID is correct
|
||||
3. Look for errors in the MCP server logs
|
||||
|
||||
### Buttons not working
|
||||
|
||||
1. Make sure the bridge is still connected
|
||||
2. Check for any error messages in Telegram
|
||||
3. Try stopping and restarting the bridge
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
38
packages/telegram-bridge/package.json
Normal file
38
packages/telegram-bridge/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "@roo-code/telegram-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Telegram bridge for remote control of Roo Code via IPC",
|
||||
"type": "module",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node build/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"clean": "rm -rf build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@roo-code/ipc": "workspace:*",
|
||||
"@roo-code/types": "workspace:*",
|
||||
"node-telegram-bot-api": "^0.66.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/node-telegram-bot-api": "^0.64.7",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "~5.8.0-dev.20250121"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "~5.8.0-dev.20250121"
|
||||
},
|
||||
"keywords": [
|
||||
"roo-code",
|
||||
"telegram",
|
||||
"mcp",
|
||||
"remote-control"
|
||||
],
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
190
packages/telegram-bridge/src/index.ts
Normal file
190
packages/telegram-bridge/src/index.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Telegram Bridge MCP Server for Roo Code
|
||||
*
|
||||
* This server provides remote control of Roo Code via Telegram.
|
||||
* It connects to Roo Code via IPC and forwards messages to Telegram,
|
||||
* allowing users to approve/reject operations and send new instructions.
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { z } from "zod"
|
||||
|
||||
import { TelegramBridge } from "./telegram-bridge.js"
|
||||
|
||||
// Environment variables
|
||||
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN
|
||||
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID
|
||||
const ROO_CODE_SOCKET_PATH = process.env.ROO_CODE_SOCKET_PATH
|
||||
|
||||
// Create the MCP server
|
||||
const server = new McpServer({
|
||||
name: "telegram-bridge",
|
||||
version: "0.1.0",
|
||||
})
|
||||
|
||||
// Telegram bridge instance (created when start_bridge tool is called)
|
||||
let bridge: TelegramBridge | null = null
|
||||
|
||||
// Tool: Start the Telegram bridge
|
||||
server.tool(
|
||||
"start_bridge",
|
||||
{
|
||||
botToken: z.string().describe("Telegram bot token from @BotFather"),
|
||||
chatId: z.string().describe("Telegram chat ID to send messages to"),
|
||||
socketPath: z.string().optional().describe("Roo Code IPC socket path (optional, uses default if not provided)"),
|
||||
enableTts: z.boolean().optional().describe("Enable text-to-speech for messages (optional)"),
|
||||
},
|
||||
async ({ botToken, chatId, socketPath, enableTts }) => {
|
||||
try {
|
||||
if (bridge) {
|
||||
await bridge.stop()
|
||||
}
|
||||
|
||||
bridge = new TelegramBridge({
|
||||
botToken: botToken || TELEGRAM_BOT_TOKEN || "",
|
||||
chatId: chatId || TELEGRAM_CHAT_ID || "",
|
||||
socketPath: socketPath || ROO_CODE_SOCKET_PATH,
|
||||
enableTts: enableTts ?? false,
|
||||
})
|
||||
|
||||
await bridge.start()
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Telegram bridge started successfully. You can now receive Roo Code messages on Telegram.",
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to start Telegram bridge: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Tool: Stop the Telegram bridge
|
||||
server.tool("stop_bridge", {}, async () => {
|
||||
try {
|
||||
if (bridge) {
|
||||
await bridge.stop()
|
||||
bridge = null
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Telegram bridge stopped.",
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to stop Telegram bridge: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Tool: Get bridge status
|
||||
server.tool("bridge_status", {}, async () => {
|
||||
if (!bridge) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify({ running: false }, null, 2),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const status = bridge.getStatus()
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(status, null, 2),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// Tool: Send a message to Telegram
|
||||
server.tool(
|
||||
"send_telegram_message",
|
||||
{
|
||||
message: z.string().describe("Message to send to Telegram"),
|
||||
},
|
||||
async ({ message }) => {
|
||||
if (!bridge) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Telegram bridge is not running. Use start_bridge first.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await bridge.sendMessage(message)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Message sent to Telegram.",
|
||||
},
|
||||
],
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Failed to send message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Start the MCP server
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
console.error("Telegram Bridge MCP server running on stdio")
|
||||
|
||||
// Handle process signals for graceful shutdown
|
||||
process.on("SIGINT", async () => {
|
||||
if (bridge) {
|
||||
await bridge.stop()
|
||||
}
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
if (bridge) {
|
||||
await bridge.stop()
|
||||
}
|
||||
process.exit(0)
|
||||
})
|
||||
144
packages/telegram-bridge/src/message-formatter.ts
Normal file
144
packages/telegram-bridge/src/message-formatter.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Message formatting utilities for Telegram
|
||||
*/
|
||||
|
||||
import type { ClineMessage } from "@roo-code/types"
|
||||
|
||||
// Telegram message length limit
|
||||
const MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
// Map of ask types to human-readable descriptions
|
||||
const ASK_TYPE_LABELS: Record<string, string> = {
|
||||
followup: "❓ Question",
|
||||
command: "⚡ Command Execution",
|
||||
command_output: "📄 Command Output",
|
||||
completion_result: "✅ Task Complete",
|
||||
tool: "🔧 Tool Operation",
|
||||
api_req_failed: "⚠️ API Request Failed",
|
||||
resume_task: "▶️ Resume Task",
|
||||
resume_completed_task: "🔄 Resume Completed Task",
|
||||
mistake_limit_reached: "🚫 Mistake Limit Reached",
|
||||
browser_action_launch: "🌐 Browser Action",
|
||||
use_mcp_server: "🔌 MCP Server",
|
||||
auto_approval_max_req_reached: "⏸️ Auto-approval Limit",
|
||||
}
|
||||
|
||||
// Map of say types to human-readable descriptions
|
||||
const SAY_TYPE_LABELS: Record<string, string> = {
|
||||
user_feedback: "💬 User Feedback",
|
||||
user_feedback_diff: "📝 Diff Feedback",
|
||||
api_req_started: "🔄 Processing...",
|
||||
api_req_finished: "✅ Processing Complete",
|
||||
api_req_retried: "🔄 Retrying...",
|
||||
api_req_failed: "❌ Request Failed",
|
||||
text: "💬",
|
||||
reasoning: "🧠 Reasoning",
|
||||
command: "⚡ Command",
|
||||
command_output: "📄 Output",
|
||||
completion_result: "✅ Complete",
|
||||
tool: "🔧 Tool",
|
||||
shell_integration_warning: "⚠️ Shell Warning",
|
||||
browser_action: "🌐 Browser",
|
||||
browser_action_result: "🌐 Browser Result",
|
||||
mcp_server_request_started: "🔌 MCP Request",
|
||||
mcp_server_response: "🔌 MCP Response",
|
||||
condense_context: "📦 Context Condensed",
|
||||
checkpoint_saved: "💾 Checkpoint Saved",
|
||||
roo_message: "🤖 Roo",
|
||||
error: "❌ Error",
|
||||
diff: "📝 Diff",
|
||||
clineignore_error: "⚠️ Ignore Error",
|
||||
sliding_window_truncation: "✂️ Truncated",
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a ClineMessage for display in Telegram
|
||||
*/
|
||||
export function formatMessageForTelegram(message: ClineMessage): string {
|
||||
const parts: string[] = []
|
||||
|
||||
// Add type indicator
|
||||
if (message.type === "ask" && message.ask) {
|
||||
const label = ASK_TYPE_LABELS[message.ask] || `❓ ${message.ask}`
|
||||
parts.push(`<b>${label}</b>`)
|
||||
|
||||
// Add approval request indicator
|
||||
if (isApprovalRequired(message.ask)) {
|
||||
parts.push("\n<i>Approval required</i>")
|
||||
}
|
||||
} else if (message.type === "say" && message.say) {
|
||||
const label = SAY_TYPE_LABELS[message.say] || message.say
|
||||
if (label !== "💬") {
|
||||
parts.push(`<b>${label}</b>`)
|
||||
}
|
||||
}
|
||||
|
||||
// Add message text
|
||||
if (message.text) {
|
||||
const formattedText = escapeHtml(message.text)
|
||||
parts.push(formattedText)
|
||||
}
|
||||
|
||||
// Add reasoning if present
|
||||
if (message.reasoning) {
|
||||
parts.push(`\n<i>Reasoning: ${escapeHtml(truncateMessage(message.reasoning, 500))}</i>`)
|
||||
}
|
||||
|
||||
return truncateMessage(parts.join("\n"), MAX_MESSAGE_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an ask type requires user approval
|
||||
*/
|
||||
function isApprovalRequired(askType: string): boolean {
|
||||
const approvalAsks = ["command", "tool", "browser_action_launch", "use_mcp_server", "followup"]
|
||||
return approvalAsks.includes(askType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters for Telegram
|
||||
*/
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a message to a maximum length, adding ellipsis if needed
|
||||
*/
|
||||
export function truncateMessage(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text
|
||||
}
|
||||
return text.substring(0, maxLength - 3) + "..."
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a code block for Telegram
|
||||
*/
|
||||
export function formatCodeBlock(code: string, language?: string): string {
|
||||
const escapedCode = escapeHtml(code)
|
||||
if (language) {
|
||||
return `<pre><code class="language-${language}">${escapedCode}</code></pre>`
|
||||
}
|
||||
return `<pre>${escapedCode}</pre>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a diff for Telegram display
|
||||
*/
|
||||
export function formatDiff(diff: string): string {
|
||||
const lines = diff.split("\n")
|
||||
const formattedLines = lines.map((line) => {
|
||||
if (line.startsWith("+")) {
|
||||
return `<code>+ ${escapeHtml(line.substring(1))}</code>`
|
||||
} else if (line.startsWith("-")) {
|
||||
return `<code>- ${escapeHtml(line.substring(1))}</code>`
|
||||
}
|
||||
return escapeHtml(line)
|
||||
})
|
||||
return formattedLines.join("\n")
|
||||
}
|
||||
384
packages/telegram-bridge/src/telegram-bridge.ts
Normal file
384
packages/telegram-bridge/src/telegram-bridge.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
/**
|
||||
* TelegramBridge - Handles communication between Roo Code and Telegram
|
||||
*
|
||||
* This class connects to Roo Code via IPC and manages a Telegram bot
|
||||
* to forward messages and handle user interactions.
|
||||
*/
|
||||
|
||||
import TelegramBot from "node-telegram-bot-api"
|
||||
import { IpcClient } from "@roo-code/ipc"
|
||||
import { IpcMessageType, RooCodeEventName, TaskCommandName } from "@roo-code/types"
|
||||
import type { ClineMessage, TaskEvent } from "@roo-code/types"
|
||||
|
||||
import { formatMessageForTelegram, truncateMessage } from "./message-formatter.js"
|
||||
|
||||
export interface TelegramBridgeOptions {
|
||||
botToken: string
|
||||
chatId: string
|
||||
socketPath?: string
|
||||
enableTts?: boolean
|
||||
}
|
||||
|
||||
export interface BridgeStatus {
|
||||
running: boolean
|
||||
connected: boolean
|
||||
botUsername?: string
|
||||
chatId: string
|
||||
messagesSent: number
|
||||
messagesReceived: number
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
// Callback data format: action:taskId
|
||||
const CALLBACK_APPROVE = "approve"
|
||||
const CALLBACK_DENY = "deny"
|
||||
|
||||
export class TelegramBridge {
|
||||
private readonly options: TelegramBridgeOptions
|
||||
private bot: TelegramBot | null = null
|
||||
private ipcClient: IpcClient | null = null
|
||||
private running = false
|
||||
private botUsername?: string
|
||||
private messagesSent = 0
|
||||
private messagesReceived = 0
|
||||
private lastError?: string
|
||||
private pendingApprovals: Map<string, { messageId: number; taskId: string }> = new Map()
|
||||
|
||||
constructor(options: TelegramBridgeOptions) {
|
||||
if (!options.botToken) {
|
||||
throw new Error("Telegram bot token is required")
|
||||
}
|
||||
if (!options.chatId) {
|
||||
throw new Error("Telegram chat ID is required")
|
||||
}
|
||||
this.options = options
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize Telegram bot
|
||||
this.bot = new TelegramBot(this.options.botToken, { polling: true })
|
||||
|
||||
// Get bot info
|
||||
const me = await this.bot.getMe()
|
||||
this.botUsername = me.username
|
||||
|
||||
// Set up bot event handlers
|
||||
this.setupBotHandlers()
|
||||
|
||||
// Connect to Roo Code IPC
|
||||
const socketPath = this.options.socketPath || this.getDefaultSocketPath()
|
||||
this.ipcClient = new IpcClient(socketPath, console.error)
|
||||
|
||||
// Set up IPC event handlers
|
||||
this.setupIpcHandlers()
|
||||
|
||||
this.running = true
|
||||
|
||||
// Send startup message
|
||||
await this.sendMessage("🤖 Roo Code Telegram Bridge connected! You can now control Roo Code remotely.")
|
||||
} catch (error) {
|
||||
this.lastError = error instanceof Error ? error.message : String(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.running) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Send shutdown message
|
||||
await this.sendMessage("👋 Roo Code Telegram Bridge disconnected.")
|
||||
} catch {
|
||||
// Ignore errors when sending shutdown message
|
||||
}
|
||||
|
||||
if (this.bot) {
|
||||
this.bot.stopPolling()
|
||||
this.bot = null
|
||||
}
|
||||
|
||||
if (this.ipcClient) {
|
||||
this.ipcClient.disconnect()
|
||||
this.ipcClient = null
|
||||
}
|
||||
|
||||
this.running = false
|
||||
this.pendingApprovals.clear()
|
||||
}
|
||||
|
||||
getStatus(): BridgeStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
connected: this.ipcClient?.isConnected ?? false,
|
||||
botUsername: this.botUsername,
|
||||
chatId: this.options.chatId,
|
||||
messagesSent: this.messagesSent,
|
||||
messagesReceived: this.messagesReceived,
|
||||
lastError: this.lastError,
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(text: string, options?: TelegramBot.SendMessageOptions): Promise<TelegramBot.Message | null> {
|
||||
if (!this.bot || !this.running) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await this.bot.sendMessage(this.options.chatId, text, {
|
||||
parse_mode: "HTML",
|
||||
...options,
|
||||
})
|
||||
this.messagesSent++
|
||||
return message
|
||||
} catch (error) {
|
||||
this.lastError = error instanceof Error ? error.message : String(error)
|
||||
console.error("Failed to send Telegram message:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultSocketPath(): string {
|
||||
const platform = process.platform
|
||||
if (platform === "win32") {
|
||||
return "\\\\.\\pipe\\roo-code-ipc"
|
||||
}
|
||||
// macOS and Linux
|
||||
const tmpDir = process.env.TMPDIR || process.env.TMP || "/tmp"
|
||||
return `${tmpDir}/roo-code-ipc.sock`
|
||||
}
|
||||
|
||||
private setupBotHandlers(): void {
|
||||
if (!this.bot) return
|
||||
|
||||
// Handle incoming messages
|
||||
this.bot.on("message", async (msg) => {
|
||||
if (msg.chat.id.toString() !== this.options.chatId) {
|
||||
// Ignore messages from other chats
|
||||
return
|
||||
}
|
||||
|
||||
this.messagesReceived++
|
||||
|
||||
const text = msg.text?.trim()
|
||||
if (!text) return
|
||||
|
||||
// Handle commands
|
||||
if (text.startsWith("/")) {
|
||||
await this.handleCommand(text)
|
||||
return
|
||||
}
|
||||
|
||||
// Forward message to Roo Code as a new instruction
|
||||
if (this.ipcClient?.isReady) {
|
||||
this.ipcClient.sendTaskMessage(text)
|
||||
await this.sendMessage("📤 Message sent to Roo Code")
|
||||
} else {
|
||||
await this.sendMessage("❌ Not connected to Roo Code")
|
||||
}
|
||||
})
|
||||
|
||||
// Handle callback queries (button clicks)
|
||||
this.bot.on("callback_query", async (query) => {
|
||||
if (!query.data || !query.message) return
|
||||
|
||||
const [action, approvalId] = query.data.split(":")
|
||||
|
||||
if (action === CALLBACK_APPROVE) {
|
||||
await this.handleApproval(approvalId, true, query.message.message_id)
|
||||
await this.bot?.answerCallbackQuery(query.id, { text: "✅ Approved" })
|
||||
} else if (action === CALLBACK_DENY) {
|
||||
await this.handleApproval(approvalId, false, query.message.message_id)
|
||||
await this.bot?.answerCallbackQuery(query.id, { text: "❌ Denied" })
|
||||
}
|
||||
})
|
||||
|
||||
// Handle polling errors
|
||||
this.bot.on("polling_error", (error) => {
|
||||
this.lastError = error.message
|
||||
console.error("Telegram polling error:", error)
|
||||
})
|
||||
}
|
||||
|
||||
private async handleCommand(text: string): Promise<void> {
|
||||
const [command, ...args] = text.split(" ")
|
||||
|
||||
switch (command.toLowerCase()) {
|
||||
case "/start":
|
||||
case "/help":
|
||||
await this.sendMessage(
|
||||
"🤖 <b>Roo Code Telegram Bridge</b>\n\n" +
|
||||
"<b>Commands:</b>\n" +
|
||||
"/status - Show connection status\n" +
|
||||
"/cancel - Cancel current task\n" +
|
||||
"/help - Show this help message\n\n" +
|
||||
"<b>Usage:</b>\n" +
|
||||
"• Send any message to forward it as an instruction to Roo Code\n" +
|
||||
"• Use the Approve/Deny buttons when prompted for tool operations",
|
||||
)
|
||||
break
|
||||
|
||||
case "/status":
|
||||
const status = this.getStatus()
|
||||
await this.sendMessage(
|
||||
`📊 <b>Bridge Status</b>\n\n` +
|
||||
`Running: ${status.running ? "✅" : "❌"}\n` +
|
||||
`Connected: ${status.connected ? "✅" : "❌"}\n` +
|
||||
`Messages Sent: ${status.messagesSent}\n` +
|
||||
`Messages Received: ${status.messagesReceived}` +
|
||||
(status.lastError ? `\nLast Error: ${status.lastError}` : ""),
|
||||
)
|
||||
break
|
||||
|
||||
case "/cancel":
|
||||
if (this.ipcClient?.isReady) {
|
||||
// Send cancel command
|
||||
this.ipcClient.sendCommand({
|
||||
commandName: TaskCommandName.CancelTask,
|
||||
data: "", // Current task
|
||||
})
|
||||
await this.sendMessage("⏹️ Cancel request sent")
|
||||
} else {
|
||||
await this.sendMessage("❌ Not connected to Roo Code")
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
await this.sendMessage(`❓ Unknown command: ${command}\nUse /help for available commands.`)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApproval(approvalId: string, approved: boolean, messageId: number): Promise<void> {
|
||||
const approval = this.pendingApprovals.get(approvalId)
|
||||
if (!approval) {
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from pending
|
||||
this.pendingApprovals.delete(approvalId)
|
||||
|
||||
// Send approval/denial to Roo Code
|
||||
if (this.ipcClient?.isReady) {
|
||||
if (approved) {
|
||||
this.ipcClient.approveAsk()
|
||||
} else {
|
||||
this.ipcClient.denyAsk()
|
||||
}
|
||||
}
|
||||
|
||||
// Update the message to show the decision
|
||||
const statusText = approved ? "✅ APPROVED" : "❌ DENIED"
|
||||
try {
|
||||
await this.bot?.editMessageReplyMarkup(
|
||||
{ inline_keyboard: [] },
|
||||
{
|
||||
chat_id: this.options.chatId,
|
||||
message_id: messageId,
|
||||
},
|
||||
)
|
||||
await this.bot?.editMessageText(`${statusText}`, {
|
||||
chat_id: this.options.chatId,
|
||||
message_id: messageId,
|
||||
parse_mode: "HTML",
|
||||
})
|
||||
} catch (error) {
|
||||
// Ignore edit errors
|
||||
}
|
||||
}
|
||||
|
||||
private setupIpcHandlers(): void {
|
||||
if (!this.ipcClient) return
|
||||
|
||||
this.ipcClient.on(IpcMessageType.Connect, () => {
|
||||
console.log("Connected to Roo Code IPC")
|
||||
})
|
||||
|
||||
this.ipcClient.on(IpcMessageType.Disconnect, () => {
|
||||
console.log("Disconnected from Roo Code IPC")
|
||||
this.sendMessage("⚠️ Lost connection to Roo Code")
|
||||
})
|
||||
|
||||
this.ipcClient.on(IpcMessageType.Ack, (data) => {
|
||||
console.log("IPC handshake complete:", data.clientId)
|
||||
this.sendMessage("✅ Connected to Roo Code")
|
||||
})
|
||||
|
||||
this.ipcClient.on(IpcMessageType.TaskEvent, async (event) => {
|
||||
await this.handleTaskEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
private async handleTaskEvent(event: TaskEvent): Promise<void> {
|
||||
switch (event.eventName) {
|
||||
case RooCodeEventName.TaskStarted:
|
||||
await this.sendMessage("🚀 <b>Task Started</b>")
|
||||
break
|
||||
|
||||
case RooCodeEventName.TaskCompleted:
|
||||
await this.sendMessage("✅ <b>Task Completed</b>")
|
||||
break
|
||||
|
||||
case RooCodeEventName.TaskAborted:
|
||||
await this.sendMessage("⏹️ <b>Task Aborted</b>")
|
||||
break
|
||||
|
||||
case RooCodeEventName.TaskInteractive:
|
||||
// Task needs user interaction - this is handled by the Message event
|
||||
break
|
||||
|
||||
case RooCodeEventName.Message:
|
||||
const [messageData] = event.payload as [{ action: "created" | "updated"; message: ClineMessage }]
|
||||
await this.handleClineMessage(messageData)
|
||||
break
|
||||
|
||||
case RooCodeEventName.TaskModeSwitched:
|
||||
const [taskId, mode] = event.payload as [string, string]
|
||||
await this.sendMessage(`🔄 Mode switched to: <b>${mode}</b>`)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private async handleClineMessage(messageData: { action: string; message: ClineMessage }): Promise<void> {
|
||||
const { action, message } = messageData
|
||||
|
||||
// Only handle new messages
|
||||
if (action !== "created") {
|
||||
return
|
||||
}
|
||||
|
||||
// Format the message for Telegram
|
||||
const formattedMessage = formatMessageForTelegram(message)
|
||||
|
||||
if (message.type === "ask" && message.ask) {
|
||||
// This is an approval request
|
||||
const approvalId = `${Date.now()}-${Math.random().toString(36).substring(7)}`
|
||||
|
||||
const sentMessage = await this.sendMessage(formattedMessage, {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: "✅ Approve", callback_data: `${CALLBACK_APPROVE}:${approvalId}` },
|
||||
{ text: "❌ Deny", callback_data: `${CALLBACK_DENY}:${approvalId}` },
|
||||
],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (sentMessage) {
|
||||
this.pendingApprovals.set(approvalId, {
|
||||
messageId: sentMessage.message_id,
|
||||
taskId: String(message.ts),
|
||||
})
|
||||
}
|
||||
} else if (message.type === "say" && message.text) {
|
||||
// This is an informational message
|
||||
await this.sendMessage(formattedMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/telegram-bridge/tsconfig.json
Normal file
11
packages/telegram-bridge/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "@roo-code/config-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
|
|
@ -7,7 +7,15 @@ describe("IPC Types", () => {
|
|||
})
|
||||
|
||||
it("should have all expected task commands", () => {
|
||||
const expectedCommands = ["StartNewTask", "CancelTask", "CloseTask", "ResumeTask"]
|
||||
const expectedCommands = [
|
||||
"StartNewTask",
|
||||
"CancelTask",
|
||||
"CloseTask",
|
||||
"ResumeTask",
|
||||
"SendMessage",
|
||||
"ApproveAsk",
|
||||
"DenyAsk",
|
||||
]
|
||||
const actualCommands = Object.values(TaskCommandName)
|
||||
|
||||
expectedCommands.forEach((command) => {
|
||||
|
|
@ -15,6 +23,14 @@ describe("IPC Types", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("should include ApproveAsk command", () => {
|
||||
expect(TaskCommandName.ApproveAsk).toBe("ApproveAsk")
|
||||
})
|
||||
|
||||
it("should include DenyAsk command", () => {
|
||||
expect(TaskCommandName.DenyAsk).toBe("DenyAsk")
|
||||
})
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should handle ResumeTask command gracefully when task not found", () => {
|
||||
// This test verifies the schema validation - the actual error handling
|
||||
|
|
@ -70,5 +86,55 @@ describe("IPC Types", () => {
|
|||
const result = taskCommandSchema.safeParse(invalidCommand)
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should validate ApproveAsk command with optional text and images", () => {
|
||||
const approveCommand = {
|
||||
commandName: TaskCommandName.ApproveAsk,
|
||||
data: { text: "Approved with comment", images: [] },
|
||||
}
|
||||
|
||||
const result = taskCommandSchema.safeParse(approveCommand)
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
if (result.success) {
|
||||
expect(result.data.commandName).toBe("ApproveAsk")
|
||||
expect(result.data.data.text).toBe("Approved with comment")
|
||||
}
|
||||
})
|
||||
|
||||
it("should validate ApproveAsk command with empty data", () => {
|
||||
const approveCommand = {
|
||||
commandName: TaskCommandName.ApproveAsk,
|
||||
data: {},
|
||||
}
|
||||
|
||||
const result = taskCommandSchema.safeParse(approveCommand)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should validate DenyAsk command with optional text and images", () => {
|
||||
const denyCommand = {
|
||||
commandName: TaskCommandName.DenyAsk,
|
||||
data: { text: "Denied with reason", images: [] },
|
||||
}
|
||||
|
||||
const result = taskCommandSchema.safeParse(denyCommand)
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
if (result.success) {
|
||||
expect(result.data.commandName).toBe("DenyAsk")
|
||||
expect(result.data.data.text).toBe("Denied with reason")
|
||||
}
|
||||
})
|
||||
|
||||
it("should validate DenyAsk command with empty data", () => {
|
||||
const denyCommand = {
|
||||
commandName: TaskCommandName.DenyAsk,
|
||||
data: {},
|
||||
}
|
||||
|
||||
const result = taskCommandSchema.safeParse(denyCommand)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export enum TaskCommandName {
|
|||
CloseTask = "CloseTask",
|
||||
ResumeTask = "ResumeTask",
|
||||
SendMessage = "SendMessage",
|
||||
ApproveAsk = "ApproveAsk",
|
||||
DenyAsk = "DenyAsk",
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,6 +83,20 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [
|
|||
images: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.ApproveAsk),
|
||||
data: z.object({
|
||||
text: z.string().optional(),
|
||||
images: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
commandName: z.literal(TaskCommandName.DenyAsk),
|
||||
data: z.object({
|
||||
text: z.string().optional(),
|
||||
images: z.array(z.string()).optional(),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
export type TaskCommand = z.infer<typeof taskCommandSchema>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,14 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
|
|||
this.log(`[API] SendMessage -> ${data.text}`)
|
||||
await this.sendMessage(data.text, data.images)
|
||||
break
|
||||
case TaskCommandName.ApproveAsk:
|
||||
this.log(`[API] ApproveAsk -> ${data.text}`)
|
||||
await this.approveCurrentAsk(data.text, data.images)
|
||||
break
|
||||
case TaskCommandName.DenyAsk:
|
||||
this.log(`[API] DenyAsk -> ${data.text}`)
|
||||
await this.denyCurrentAsk(data.text, data.images)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -194,6 +202,24 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
|
|||
await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images })
|
||||
}
|
||||
|
||||
public async approveCurrentAsk(text?: string, images?: string[]) {
|
||||
const currentTask = this.sidebarProvider.getCurrentTask()
|
||||
if (currentTask) {
|
||||
currentTask.approveAsk({ text, images })
|
||||
} else {
|
||||
this.log("[API] ApproveAsk failed: no current task")
|
||||
}
|
||||
}
|
||||
|
||||
public async denyCurrentAsk(text?: string, images?: string[]) {
|
||||
const currentTask = this.sidebarProvider.getCurrentTask()
|
||||
if (currentTask) {
|
||||
currentTask.denyAsk({ text, images })
|
||||
} else {
|
||||
this.log("[API] DenyAsk failed: no current task")
|
||||
}
|
||||
}
|
||||
|
||||
public async pressPrimaryButton() {
|
||||
await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" })
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue