opencode-remote-control 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # OpenCode Remote Control
2
+
3
+ [中文文档](./README_CN.md)
4
+
5
+ Control OpenCode from anywhere via Telegram.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ # Install globally with npm, pnpm, or bun
11
+ npm install -g opencode-remote-control
12
+ # or
13
+ pnpm install -g opencode-remote-control
14
+ # or
15
+ bun install -g opencode-remote-control
16
+
17
+ # Run (will prompt for token on first run)
18
+ opencode-remote
19
+ ```
20
+
21
+ ### Install from Source
22
+
23
+ ```bash
24
+ git clone https://github.com/ceociocto/opencode-remote-control.git
25
+ cd opencode-remote-control
26
+ bun install
27
+ bun run build
28
+ node dist/cli.js
29
+ ```
30
+
31
+ ## Setup
32
+
33
+ On first run, you'll be prompted for a Telegram bot token:
34
+
35
+ 1. Open Telegram, search **@BotFather**
36
+ 2. Send `/newbot` and follow instructions
37
+ 3. Paste the token when prompted
38
+
39
+ Token is saved to `~/.opencode-remote/.env`
40
+
41
+ ## Commands
42
+
43
+ **CLI:**
44
+ ```
45
+ opencode-remote # Start the bot
46
+ opencode-remote config # Reconfigure token
47
+ opencode-remote help # Show help
48
+ ```
49
+
50
+ **Telegram:**
51
+ | Command | Description |
52
+ |--------|-------------|
53
+ | `/start` | Start the bot |
54
+ | `/help` | Show all commands |
55
+ | `/status` | Check connection status |
56
+ | `/approve` | Approve pending changes |
57
+ | `/reject` | Reject pending changes |
58
+ | `/diff` | View pending diff |
59
+ | `/files` | List changed files |
60
+ | `/reset` | Reset session |
61
+
62
+ ## How It Works
63
+
64
+ ```
65
+ ┌─────────────────┐ ┌──────────────────┐
66
+ │ Telegram App │ │ Telegram Server │
67
+ │ (Mobile) │◀──── Messages ────▶│ (Cloud) │
68
+ └─────────────────┘ └────────┬─────────┘
69
+
70
+ ┌──────── Polling ─────────┘
71
+
72
+ ┌─────────────────────────────────────────────────────────┐
73
+ │ Bot Service (Local Machine) │
74
+ │ ┌─────────────┐ ┌──────────────┐ │
75
+ │ │ Telegram │ │ Session │ │
76
+ │ │ Bot │─────▶│ Manager │ │
77
+ │ └─────────────┘ └──────┬───────┘ │
78
+ │ │ │
79
+ │ ▼ │
80
+ │ ┌──────────────────┐ │
81
+ │ │ OpenCode SDK │ │
82
+ │ └──────────────────┘ │
83
+ └─────────────────────────────────────────────────────────┘
84
+ ```
85
+
86
+ The bot uses **Polling Mode** to fetch messages from Telegram servers, requiring no tunnel or public IP configuration.
87
+
88
+ ## Requirements
89
+
90
+ - Node.js >= 18.0.0
91
+ - [OpenCode](https://github.com/opencode-ai/opencode) installed
92
+ - Telegram account
93
+
94
+ ## Contributing
95
+
96
+ Contributions are welcome! Please feel free to submit a Pull Request.
97
+
98
+ 1. Fork the repository
99
+ 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
100
+ 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
101
+ 4. Push to the branch (`git push origin feature/amazing-feature`)
102
+ 5. Open a Pull Request
103
+
104
+ ## License
105
+
106
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ // OpenCode Remote Control - CLI entry point
3
+ import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
4
+ import { homedir } from 'os';
5
+ import { join } from 'path';
6
+ import { startBot } from './telegram/bot.js';
7
+ const CONFIG_DIR = join(homedir(), '.opencode-remote');
8
+ const CONFIG_FILE = join(CONFIG_DIR, '.env');
9
+ function printBanner() {
10
+ console.log(`
11
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
12
+ OpenCode Remote Control
13
+ Control OpenCode from Telegram
14
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15
+ `);
16
+ }
17
+ function printHelp() {
18
+ console.log(`
19
+ Usage: opencode-remote [command]
20
+
21
+ Commands:
22
+ start Start the bot (default)
23
+ config Configure Telegram bot token
24
+ help Show this help message
25
+
26
+ Examples:
27
+ opencode-remote # Start the bot
28
+ opencode-remote start # Start the bot
29
+ opencode-remote config # Configure token
30
+ `);
31
+ }
32
+ async function promptToken() {
33
+ console.log('\n📝 Setup required: Telegram Bot Token');
34
+ console.log('\nHow to get a token:');
35
+ console.log(' 1. Open Telegram');
36
+ console.log(' 2. Search @BotFather');
37
+ console.log(' 3. Send /newbot and follow instructions');
38
+ console.log(' 4. Copy the token you receive');
39
+ console.log('');
40
+ process.stdout.write('Enter your bot token: ');
41
+ // Read from stdin
42
+ const token = await new Promise((resolve) => {
43
+ process.stdin.setEncoding('utf8');
44
+ process.stdin.once('data', (chunk) => {
45
+ resolve(chunk.toString().trim());
46
+ });
47
+ });
48
+ return token;
49
+ }
50
+ async function getConfig() {
51
+ // Check environment variable first
52
+ if (process.env.TELEGRAM_BOT_TOKEN) {
53
+ return process.env.TELEGRAM_BOT_TOKEN;
54
+ }
55
+ // Check config file
56
+ if (existsSync(CONFIG_FILE)) {
57
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
58
+ const match = content.match(/TELEGRAM_BOT_TOKEN=(.+)/);
59
+ if (match) {
60
+ return match[1].trim();
61
+ }
62
+ }
63
+ // Check local .env
64
+ const localEnv = join(process.cwd(), '.env');
65
+ if (existsSync(localEnv)) {
66
+ const content = readFileSync(localEnv, 'utf-8');
67
+ const match = content.match(/TELEGRAM_BOT_TOKEN=(.+)/);
68
+ if (match) {
69
+ return match[1].trim();
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ async function saveConfig(token) {
75
+ // Create config directory if needed
76
+ if (!existsSync(CONFIG_DIR)) {
77
+ mkdirSync(CONFIG_DIR, { recursive: true });
78
+ }
79
+ writeFileSync(CONFIG_FILE, `TELEGRAM_BOT_TOKEN=${token}\n`);
80
+ console.log(`\n✅ Token saved to ${CONFIG_FILE}`);
81
+ }
82
+ async function runConfig() {
83
+ printBanner();
84
+ const token = await promptToken();
85
+ if (!token || token === 'your_bot_token_here') {
86
+ console.log('\n❌ Invalid token. Please try again.');
87
+ process.exit(1);
88
+ }
89
+ await saveConfig(token);
90
+ console.log('\n🚀 Ready! Run `opencode-remote` to start the bot.');
91
+ }
92
+ async function runStart() {
93
+ printBanner();
94
+ const token = await getConfig();
95
+ if (!token) {
96
+ console.log('⚠️ No bot token configured.\n');
97
+ await runConfig();
98
+ return;
99
+ }
100
+ // Set token in environment
101
+ process.env.TELEGRAM_BOT_TOKEN = token;
102
+ console.log('🚀 Starting bot...\n');
103
+ try {
104
+ await startBot();
105
+ }
106
+ catch (error) {
107
+ console.error('Failed to start:', error);
108
+ process.exit(1);
109
+ }
110
+ }
111
+ // Main CLI
112
+ const args = process.argv.slice(2);
113
+ const command = args[0] || 'start';
114
+ switch (command) {
115
+ case 'start':
116
+ runStart();
117
+ break;
118
+ case 'config':
119
+ runConfig();
120
+ break;
121
+ case 'help':
122
+ case '--help':
123
+ case '-h':
124
+ printBanner();
125
+ printHelp();
126
+ break;
127
+ default:
128
+ console.log(`Unknown command: ${command}`);
129
+ printHelp();
130
+ process.exit(1);
131
+ }
@@ -0,0 +1,95 @@
1
+ // Approval workflow for OpenCode Remote Control
2
+ import { loadConfig } from './types.js';
3
+ const approvalCallbacks = new Map();
4
+ export function createApprovalRequest(session, type, data) {
5
+ const config = loadConfig();
6
+ const now = Date.now();
7
+ const request = {
8
+ id: crypto.randomUUID(),
9
+ type,
10
+ description: data.description,
11
+ files: data.files,
12
+ command: data.command,
13
+ createdAt: now,
14
+ expiresAt: now + config.approvalTimeoutMs,
15
+ };
16
+ // Add to session's pending approvals
17
+ session.pendingApprovals.push(request);
18
+ return request;
19
+ }
20
+ export function getPendingApproval(session, requestId) {
21
+ if (requestId) {
22
+ return session.pendingApprovals.find(r => r.id === requestId);
23
+ }
24
+ return session.pendingApprovals[0]; // Return first pending
25
+ }
26
+ export function resolveApproval(session, requestId, approved) {
27
+ const index = session.pendingApprovals.findIndex(r => r.id === requestId);
28
+ if (index === -1) {
29
+ return { success: false, error: 'Approval request not found' };
30
+ }
31
+ const request = session.pendingApprovals[index];
32
+ // Check if expired
33
+ if (Date.now() > request.expiresAt) {
34
+ session.pendingApprovals.splice(index, 1);
35
+ return { success: false, error: 'Approval request expired', request };
36
+ }
37
+ // Remove from pending
38
+ session.pendingApprovals.splice(index, 1);
39
+ // Resolve the promise if there's a callback
40
+ const callback = approvalCallbacks.get(requestId);
41
+ if (callback) {
42
+ callback.resolve(approved);
43
+ approvalCallbacks.delete(requestId);
44
+ }
45
+ return { success: true, request };
46
+ }
47
+ export function waitForApproval(request) {
48
+ return new Promise((resolve, reject) => {
49
+ approvalCallbacks.set(request.id, { resolve, reject });
50
+ // Auto-reject on timeout
51
+ const timeUntilExpiry = request.expiresAt - Date.now();
52
+ setTimeout(() => {
53
+ const callback = approvalCallbacks.get(request.id);
54
+ if (callback) {
55
+ approvalCallbacks.delete(request.id);
56
+ callback.resolve(false); // Auto-reject
57
+ }
58
+ }, timeUntilExpiry);
59
+ });
60
+ }
61
+ export function cancelAllApprovals(session) {
62
+ for (const request of session.pendingApprovals) {
63
+ const callback = approvalCallbacks.get(request.id);
64
+ if (callback) {
65
+ approvalCallbacks.delete(request.id);
66
+ callback.reject(new Error('Session ended'));
67
+ }
68
+ }
69
+ session.pendingApprovals = [];
70
+ }
71
+ // Format approval request for display
72
+ export function formatApprovalMessage(request) {
73
+ const lines = [];
74
+ if (request.type === 'file_edit') {
75
+ lines.push('📝 Approval needed: Edit files');
76
+ lines.push('');
77
+ lines.push('📄 Changes:');
78
+ if (request.files) {
79
+ for (const file of request.files) {
80
+ lines.push(`• ${file.path} (+${file.additions}, -${file.deletions})`);
81
+ }
82
+ }
83
+ }
84
+ else {
85
+ lines.push('📝 Approval needed: Run command');
86
+ lines.push('');
87
+ lines.push(`🔧 \`${request.command}\``);
88
+ }
89
+ lines.push('');
90
+ lines.push('/approve — allow changes');
91
+ lines.push('/reject — deny changes');
92
+ lines.push('/diff — see what will change first');
93
+ lines.push(`⏱️ Expires in ${Math.round((request.expiresAt - Date.now()) / 60000)} min (auto-reject)`);
94
+ return lines.join('\n');
95
+ }
@@ -0,0 +1,116 @@
1
+ // Shared handler logic for OpenCode Remote Control
2
+ import { getOrCreateSession } from './session.js';
3
+ import { createApprovalRequest, waitForApproval, formatApprovalMessage } from './approval.js';
4
+ import { TEMPLATES, splitMessage } from './notifications.js';
5
+ export function createHandler(deps) {
6
+ return {
7
+ // Handle incoming message from user
8
+ async handleMessage(ctx, text) {
9
+ const session = getOrCreateSession(ctx.threadId, ctx.platform);
10
+ // Check if it's a command
11
+ if (text.startsWith('/')) {
12
+ await this.handleCommand(ctx, text, session);
13
+ return;
14
+ }
15
+ // It's a prompt - send to OpenCode
16
+ await deps.sendTyping(ctx.threadId);
17
+ // TODO: Actually send to OpenCode SDK
18
+ // For now, echo back
19
+ await deps.sendMessage(ctx.threadId, TEMPLATES.thinking());
20
+ // Simulate response
21
+ setTimeout(async () => {
22
+ await deps.sendMessage(ctx.threadId, TEMPLATES.taskCompleted([
23
+ { path: 'src/example.ts', additions: 10, deletions: 2 }
24
+ ]));
25
+ }, 1000);
26
+ },
27
+ // Handle commands
28
+ async handleCommand(ctx, text, session) {
29
+ const parts = text.split(/\s+/);
30
+ const command = parts[0].toLowerCase();
31
+ switch (command) {
32
+ case '/start':
33
+ case '/help':
34
+ await deps.sendMessage(ctx.threadId, TEMPLATES.botStarted());
35
+ break;
36
+ case '/approve':
37
+ await this.handleApprove(ctx, session);
38
+ break;
39
+ case '/reject':
40
+ await this.handleReject(ctx, session);
41
+ break;
42
+ case '/diff':
43
+ await this.handleDiff(ctx, session);
44
+ break;
45
+ case '/files':
46
+ await this.handleFiles(ctx, session);
47
+ break;
48
+ case '/status':
49
+ await deps.sendMessage(ctx.threadId, `✅ Connected\n\n💬 Session: ${session.id.slice(0, 8)}\n⏰ Idle: ${Math.round((Date.now() - session.lastActivity) / 1000)}s`);
50
+ break;
51
+ case '/reset':
52
+ session.pendingApprovals = [];
53
+ session.opencodeSessionId = undefined;
54
+ await deps.sendMessage(ctx.threadId, '🔄 Session reset. Start fresh!');
55
+ break;
56
+ default:
57
+ await deps.sendMessage(ctx.threadId, `${EMOJI.WARNING} Unknown command: ${command}\n\nTry /help`);
58
+ }
59
+ },
60
+ // Handle /approve
61
+ async handleApprove(ctx, session) {
62
+ const pending = session.pendingApprovals[0];
63
+ if (!pending) {
64
+ await deps.sendMessage(ctx.threadId, '🤷 Nothing to approve right now');
65
+ return;
66
+ }
67
+ // Resolve the approval
68
+ // TODO: Actually apply changes via OpenCode SDK
69
+ await deps.sendMessage(ctx.threadId, TEMPLATES.approved());
70
+ },
71
+ // Handle /reject
72
+ async handleReject(ctx, session) {
73
+ const pending = session.pendingApprovals[0];
74
+ if (!pending) {
75
+ await deps.sendMessage(ctx.threadId, '🤷 Nothing to reject right now');
76
+ return;
77
+ }
78
+ session.pendingApprovals.shift();
79
+ await deps.sendMessage(ctx.threadId, TEMPLATES.rejected());
80
+ },
81
+ // Handle /diff
82
+ async handleDiff(ctx, session) {
83
+ const pending = session.pendingApprovals[0];
84
+ if (!pending || !pending.files?.length) {
85
+ await deps.sendMessage(ctx.threadId, '📄 No pending changes to show');
86
+ return;
87
+ }
88
+ // TODO: Get actual diff from OpenCode SDK
89
+ const diffPreview = pending.files.map(f => `--- a/${f.path}\n+++ b/${f.path}\n@@ changes +${f.additions} +${f.deletions} @@`).join('\n');
90
+ const messages = splitMessage(`\`\`\`diff\n${diffPreview}\n\`\`\``);
91
+ for (const msg of messages) {
92
+ await deps.sendMessage(ctx.threadId, msg);
93
+ }
94
+ },
95
+ // Handle /files
96
+ async handleFiles(ctx, session) {
97
+ const pending = session.pendingApprovals[0];
98
+ if (!pending || !pending.files?.length) {
99
+ await deps.sendMessage(ctx.threadId, '📄 No files changed in this session');
100
+ return;
101
+ }
102
+ const fileList = pending.files.map(f => `• ${f.path} (+${f.additions}, -${f.deletions})`).join('\n');
103
+ await deps.sendMessage(ctx.threadId, `📄 Changed files:\n${fileList}`);
104
+ },
105
+ // Request approval from user
106
+ async requestApproval(ctx, session, type, data) {
107
+ const request = createApprovalRequest(session, type, data);
108
+ const message = formatApprovalMessage(request);
109
+ await deps.sendMessage(ctx.threadId, message);
110
+ // Wait for user response
111
+ return waitForApproval(request);
112
+ }
113
+ };
114
+ }
115
+ // Re-export emoji for use in handlers
116
+ import { EMOJI } from './types.js';
@@ -0,0 +1,134 @@
1
+ // Notification formatting for OpenCode Remote Control
2
+ import { EMOJI } from './types.js';
3
+ export function formatNotification(options) {
4
+ const lines = [];
5
+ // Status indicator
6
+ switch (options.type) {
7
+ case 'success':
8
+ lines.push(`${EMOJI.SUCCESS} ${options.title || 'Done'}`);
9
+ break;
10
+ case 'error':
11
+ lines.push(`${EMOJI.ERROR} ${options.title || 'Error'}`);
12
+ break;
13
+ case 'loading':
14
+ lines.push(`${EMOJI.LOADING} ${options.title || 'Thinking...'}`);
15
+ break;
16
+ case 'input_needed':
17
+ lines.push(`${EMOJI.QUESTION} ${options.title || 'Question'}`);
18
+ break;
19
+ case 'expired':
20
+ lines.push(`${EMOJI.EXPIRED} ${options.title || 'Session expired'}`);
21
+ break;
22
+ case 'started':
23
+ lines.push(`${EMOJI.START} ${options.title || 'Ready'}`);
24
+ break;
25
+ }
26
+ // Add blank line if we have more content
27
+ if (options.details || options.files || options.actions) {
28
+ lines.push('');
29
+ }
30
+ // Files changed
31
+ if (options.files && options.files.length > 0) {
32
+ lines.push(`📄 ${options.files.length} files changed:`);
33
+ for (const file of options.files.slice(0, 5)) {
34
+ lines.push(`• ${file.path} (+${file.additions}, -${file.deletions})`);
35
+ }
36
+ if (options.files.length > 5) {
37
+ lines.push(`• ... and ${options.files.length - 5} more`);
38
+ }
39
+ }
40
+ // Details
41
+ if (options.details) {
42
+ lines.push(options.details);
43
+ }
44
+ // Actions
45
+ if (options.actions && options.actions.length > 0) {
46
+ lines.push('');
47
+ lines.push(options.actions.join(' • '));
48
+ }
49
+ return lines.join('\n');
50
+ }
51
+ // Pre-built message templates
52
+ export const TEMPLATES = {
53
+ botStarted: () => formatNotification({
54
+ type: 'started',
55
+ title: 'OpenCode Remote Control ready',
56
+ actions: ['💬 Send a prompt to start', '/help — commands', '/status — connection']
57
+ }),
58
+ sessionExpired: () => formatNotification({
59
+ type: 'expired',
60
+ title: 'Session expired (30 min idle)',
61
+ actions: ['💬 Send new message to start fresh']
62
+ }),
63
+ taskCompleted: (files) => formatNotification({
64
+ type: 'success',
65
+ files,
66
+ actions: ['💬 Reply to continue', '/files — details']
67
+ }),
68
+ taskFailed: (error) => formatNotification({
69
+ type: 'error',
70
+ title: error.slice(0, 50),
71
+ details: 'The task failed. OpenCode is still running.',
72
+ actions: ['💬 Try rephrasing', '/reset — start fresh']
73
+ }),
74
+ needsInput: (question, options) => formatNotification({
75
+ type: 'input_needed',
76
+ title: question,
77
+ details: options ? options.map((o, i) => `${i + 1}. ${o}`).join('\n') : undefined,
78
+ actions: options ? ['Reply with number'] : undefined
79
+ }),
80
+ openCodeOffline: () => formatNotification({
81
+ type: 'error',
82
+ title: 'OpenCode is offline',
83
+ details: 'Cannot connect to OpenCode server.',
84
+ actions: ['🔄 /retry — check again', '/status — diagnostics']
85
+ }),
86
+ thinking: () => formatNotification({
87
+ type: 'loading',
88
+ title: 'Thinking...'
89
+ }),
90
+ approved: () => formatNotification({
91
+ type: 'success',
92
+ title: 'Approved — changes applied'
93
+ }),
94
+ rejected: () => formatNotification({
95
+ type: 'success',
96
+ title: 'Rejected — changes discarded'
97
+ }),
98
+ approvalTimeout: () => formatNotification({
99
+ type: 'error',
100
+ title: 'Approval timed out (5 min)',
101
+ details: 'Changes were automatically rejected.',
102
+ }),
103
+ };
104
+ // Split message for Telegram's 4096 char limit
105
+ export function splitMessage(text, maxLength = 4000) {
106
+ if (text.length <= maxLength) {
107
+ return [text];
108
+ }
109
+ const messages = [];
110
+ let remaining = text;
111
+ while (remaining.length > 0) {
112
+ if (remaining.length <= maxLength) {
113
+ messages.push(remaining);
114
+ break;
115
+ }
116
+ // Find a good break point
117
+ let breakPoint = remaining.lastIndexOf('\n', maxLength);
118
+ if (breakPoint < maxLength * 0.5) {
119
+ breakPoint = remaining.lastIndexOf(' ', maxLength);
120
+ }
121
+ if (breakPoint < maxLength * 0.5) {
122
+ breakPoint = maxLength;
123
+ }
124
+ messages.push(remaining.slice(0, breakPoint));
125
+ remaining = remaining.slice(breakPoint).trim();
126
+ }
127
+ // Add continuation indicator
128
+ if (messages.length > 1) {
129
+ for (let i = 0; i < messages.length - 1; i++) {
130
+ messages[i] += '\n\n... (continued)';
131
+ }
132
+ }
133
+ return messages;
134
+ }
@@ -0,0 +1,61 @@
1
+ // Session management for OpenCode Remote Control
2
+ import { loadConfig } from './types.js';
3
+ const sessions = new Map();
4
+ let cleanupTimer = null;
5
+ export function initSessionManager(config = loadConfig()) {
6
+ // Start cleanup timer
7
+ if (cleanupTimer) {
8
+ clearInterval(cleanupTimer);
9
+ }
10
+ cleanupTimer = setInterval(() => {
11
+ const now = Date.now();
12
+ for (const [threadId, session] of sessions.entries()) {
13
+ if (now - session.lastActivity > config.sessionIdleTimeoutMs) {
14
+ sessions.delete(threadId);
15
+ console.log(`Session expired: ${threadId}`);
16
+ }
17
+ }
18
+ }, config.cleanupIntervalMs);
19
+ console.log(`Session manager initialized (cleanup every ${config.cleanupIntervalMs / 1000}s)`);
20
+ }
21
+ export function getOrCreateSession(threadId, platform) {
22
+ const existing = sessions.get(threadId);
23
+ if (existing) {
24
+ existing.lastActivity = Date.now();
25
+ return existing;
26
+ }
27
+ const newSession = {
28
+ id: crypto.randomUUID(),
29
+ threadId,
30
+ platform,
31
+ createdAt: Date.now(),
32
+ lastActivity: Date.now(),
33
+ pendingApprovals: [],
34
+ };
35
+ sessions.set(threadId, newSession);
36
+ console.log(`Session created: ${threadId}`);
37
+ return newSession;
38
+ }
39
+ export function getSession(threadId) {
40
+ return sessions.get(threadId);
41
+ }
42
+ export function updateSession(threadId, updates) {
43
+ const session = sessions.get(threadId);
44
+ if (!session)
45
+ return undefined;
46
+ Object.assign(session, updates, { lastActivity: Date.now() });
47
+ return session;
48
+ }
49
+ export function deleteSession(threadId) {
50
+ return sessions.delete(threadId);
51
+ }
52
+ export function getAllSessions() {
53
+ return Array.from(sessions.values());
54
+ }
55
+ export function getSessionCount() {
56
+ return sessions.size;
57
+ }
58
+ // Export sessions map for testing
59
+ export function _getSessionsMap() {
60
+ return sessions;
61
+ }
@@ -0,0 +1,25 @@
1
+ // Core types for OpenCode Remote Control
2
+ // Message templates - emoji vocabulary
3
+ export const EMOJI = {
4
+ SUCCESS: '✅',
5
+ ERROR: '❌',
6
+ LOADING: '⏳',
7
+ THINKING: '🤔',
8
+ APPROVAL: '📝',
9
+ FILES: '📄',
10
+ CODE: '🔧',
11
+ START: '🚀',
12
+ EXPIRED: '💤',
13
+ WARNING: '⚠️',
14
+ QUESTION: '💬',
15
+ };
16
+ export function loadConfig() {
17
+ return {
18
+ telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
19
+ opencodeServerUrl: process.env.OPENCODE_SERVER_URL || 'http://localhost:3000',
20
+ tunnelUrl: process.env.TUNNEL_URL || '',
21
+ sessionIdleTimeoutMs: parseInt(process.env.SESSION_IDLE_TIMEOUT_MS || '1800000', 10),
22
+ cleanupIntervalMs: parseInt(process.env.CLEANUP_INTERVAL_MS || '300000', 10),
23
+ approvalTimeoutMs: parseInt(process.env.APPROVAL_TIMEOUT_MS || '300000', 10),
24
+ };
25
+ }
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // OpenCode Remote Control - Main entry point
2
+ import { startBot } from './telegram/bot.js';
3
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
4
+ console.log(' OpenCode Remote Control');
5
+ console.log(' Control OpenCode from Telegram or Feishu');
6
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
7
+ // Start the bot
8
+ startBot().catch((err) => {
9
+ console.error('Failed to start bot:', err);
10
+ process.exit(1);
11
+ });
@@ -0,0 +1,118 @@
1
+ // OpenCode SDK client for remote control
2
+ import { createOpencode } from '@opencode-ai/sdk';
3
+ let opencodeInstance = null;
4
+ export async function initOpenCode() {
5
+ if (opencodeInstance) {
6
+ return opencodeInstance;
7
+ }
8
+ console.log('🚀 Starting OpenCode server...');
9
+ opencodeInstance = await createOpencode({
10
+ port: 0, // Don't start HTTP server
11
+ });
12
+ console.log('✅ OpenCode server ready');
13
+ return opencodeInstance;
14
+ }
15
+ export async function createSession(threadId, title = `Remote control session`) {
16
+ const opencode = await initOpenCode();
17
+ try {
18
+ const createResult = await opencode.client.session.create({
19
+ body: { title },
20
+ });
21
+ if (createResult.error) {
22
+ console.error('Failed to create session:', createResult.error);
23
+ return null;
24
+ }
25
+ const sessionId = createResult.data.id;
26
+ console.log(`✅ Created OpenCode session: ${sessionId}`);
27
+ // Share the session to get a URL
28
+ let shareUrl;
29
+ const shareResult = await opencode.client.session.share({
30
+ path: { id: sessionId }
31
+ });
32
+ if (!shareResult.error && shareResult.data?.share?.url) {
33
+ shareUrl = shareResult.data.share.url;
34
+ console.log(`🔗 Session shared: ${shareUrl}`);
35
+ }
36
+ return {
37
+ sessionId,
38
+ client: opencode.client,
39
+ server: opencode.server,
40
+ shareUrl,
41
+ };
42
+ }
43
+ catch (error) {
44
+ console.error('Error creating session:', error);
45
+ return null;
46
+ }
47
+ }
48
+ export async function sendMessage(session, message) {
49
+ try {
50
+ console.log(`📝 Sending to OpenCode: ${message.slice(0, 50)}...`);
51
+ const result = await session.client.session.prompt({
52
+ path: { id: session.sessionId },
53
+ body: {
54
+ parts: [{ type: 'text', text: message }]
55
+ },
56
+ });
57
+ if (result.error) {
58
+ console.error('Failed to send message:', result.error);
59
+ return `❌ Error: ${result.error.message || 'Failed to send message'}`;
60
+ }
61
+ const response = result.data;
62
+ // Build response text from parts
63
+ const responseText = response.info?.content ||
64
+ response.parts
65
+ ?.filter((p) => p.type === 'text')
66
+ .map((p) => p.text)
67
+ .join('\n') ||
68
+ 'I received your message but didn\'t have a response.';
69
+ console.log(`💬 Response: ${responseText.slice(0, 100)}...`);
70
+ return responseText;
71
+ }
72
+ catch (error) {
73
+ console.error('Error sending message:', error);
74
+ return `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
75
+ }
76
+ }
77
+ export async function getSession(session) {
78
+ try {
79
+ const result = await session.client.session.get({
80
+ path: { id: session.sessionId }
81
+ });
82
+ if (result.error) {
83
+ return null;
84
+ }
85
+ return result.data;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ export async function shareSession(session) {
92
+ try {
93
+ const result = await session.client.session.share({
94
+ path: { id: session.sessionId }
95
+ });
96
+ if (result.error || !result.data?.share?.url) {
97
+ return null;
98
+ }
99
+ return result.data.share.url;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ // Get the global opencode instance
106
+ export function getOpenCode() {
107
+ return opencodeInstance;
108
+ }
109
+ // Check if OpenCode is connected
110
+ export async function checkConnection() {
111
+ try {
112
+ const opencode = await initOpenCode();
113
+ return !!opencode.client;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
@@ -0,0 +1,210 @@
1
+ // Telegram bot implementation for OpenCode Remote Control
2
+ import { Bot } from 'grammy';
3
+ import { loadConfig } from '../core/types.js';
4
+ import { initSessionManager, getOrCreateSession } from '../core/session.js';
5
+ import { splitMessage } from '../core/notifications.js';
6
+ import { initOpenCode, createSession, sendMessage, checkConnection } from '../opencode/client.js';
7
+ const config = loadConfig();
8
+ // Create bot instance
9
+ const bot = new Bot(config.telegramBotToken);
10
+ // Initialize session manager
11
+ initSessionManager(config);
12
+ // Store OpenCode sessions by thread ID
13
+ const openCodeSessions = new Map();
14
+ // Start command
15
+ bot.command('start', async (ctx) => {
16
+ await ctx.reply(`🚀 OpenCode Remote Control ready
17
+
18
+ 💬 Send me a prompt to start coding
19
+ /help — see all commands
20
+ /status — check OpenCode connection`);
21
+ });
22
+ // Help command
23
+ bot.command('help', async (ctx) => {
24
+ await ctx.reply(`📖 Commands
25
+
26
+ /start — Start bot
27
+ /status — Check connection
28
+ /reset — Reset session
29
+ /approve — Approve pending changes
30
+ /reject — Reject pending changes
31
+ /diff — See full diff
32
+ /files — List changed files
33
+
34
+ 💬 Anything else is treated as a prompt for OpenCode!`);
35
+ });
36
+ // Status command
37
+ bot.command('status', async (ctx) => {
38
+ const threadId = getThreadId(ctx);
39
+ const session = getOrCreateSession(threadId, 'telegram');
40
+ const openCodeSession = openCodeSessions.get(threadId);
41
+ // Check OpenCode connection
42
+ const connected = await checkConnection();
43
+ if (!connected) {
44
+ await ctx.reply(`❌ OpenCode is offline
45
+
46
+ Cannot connect to OpenCode server.
47
+
48
+ 🔄 /retry — check again`);
49
+ return;
50
+ }
51
+ const idleSeconds = Math.round((Date.now() - session.lastActivity) / 1000);
52
+ const pendingCount = session.pendingApprovals.length;
53
+ await ctx.reply(`✅ Connected
54
+
55
+ 💬 Session: ${openCodeSession?.sessionId?.slice(0, 8) || 'none'}
56
+ ⏰ Idle: ${idleSeconds}s
57
+ 📝 Pending approvals: ${pendingCount}`);
58
+ });
59
+ // Approve command
60
+ bot.command('approve', async (ctx) => {
61
+ const threadId = getThreadId(ctx);
62
+ const session = getOrCreateSession(threadId, 'telegram');
63
+ if (session.pendingApprovals.length === 0) {
64
+ await ctx.reply('🤷 Nothing to approve right now');
65
+ return;
66
+ }
67
+ // Remove first pending approval
68
+ session.pendingApprovals.shift();
69
+ await ctx.reply('✅ Approved — changes applied');
70
+ });
71
+ // Reject command
72
+ bot.command('reject', async (ctx) => {
73
+ const threadId = getThreadId(ctx);
74
+ const session = getOrCreateSession(threadId, 'telegram');
75
+ if (session.pendingApprovals.length === 0) {
76
+ await ctx.reply('🤷 Nothing to reject right now');
77
+ return;
78
+ }
79
+ session.pendingApprovals.shift();
80
+ await ctx.reply('❌ Rejected — changes discarded');
81
+ });
82
+ // Reset command
83
+ bot.command('reset', async (ctx) => {
84
+ const threadId = getThreadId(ctx);
85
+ const session = getOrCreateSession(threadId, 'telegram');
86
+ session.pendingApprovals = [];
87
+ session.opencodeSessionId = undefined;
88
+ // Clear OpenCode session
89
+ openCodeSessions.delete(threadId);
90
+ await ctx.reply('🔄 Session reset. Start fresh!');
91
+ });
92
+ // Diff command
93
+ bot.command('diff', async (ctx) => {
94
+ const threadId = getThreadId(ctx);
95
+ const session = getOrCreateSession(threadId, 'telegram');
96
+ const pending = session.pendingApprovals[0];
97
+ if (!pending?.files?.length) {
98
+ await ctx.reply('📄 No pending changes to show');
99
+ return;
100
+ }
101
+ // Show file list with changes
102
+ const fileList = pending.files.map(f => `• ${f.path} (+${f.additions}, -${f.deletions})`).join('\n');
103
+ await ctx.reply(`📄 Pending changes:\n\n${fileList}\n\n💬 /approve or /reject`);
104
+ });
105
+ // Files command
106
+ bot.command('files', async (ctx) => {
107
+ const threadId = getThreadId(ctx);
108
+ const session = getOrCreateSession(threadId, 'telegram');
109
+ const pending = session.pendingApprovals[0];
110
+ if (!pending?.files?.length) {
111
+ await ctx.reply('📄 No files changed in this session');
112
+ return;
113
+ }
114
+ const fileList = pending.files.map(f => `• ${f.path} (+${f.additions}, -${f.deletions})`).join('\n');
115
+ await ctx.reply(`📄 Changed files:\n\n${fileList}`);
116
+ });
117
+ // Retry command
118
+ bot.command('retry', async (ctx) => {
119
+ const connected = await checkConnection();
120
+ if (connected) {
121
+ await ctx.reply('✅ OpenCode is now online!');
122
+ }
123
+ else {
124
+ await ctx.reply('❌ Still offline. Is OpenCode running?');
125
+ }
126
+ });
127
+ // Handle all other messages as prompts
128
+ bot.on('message:text', async (ctx) => {
129
+ const text = ctx.message.text;
130
+ // Skip if it's a command (already handled)
131
+ if (text.startsWith('/'))
132
+ return;
133
+ const threadId = getThreadId(ctx);
134
+ // Send typing indicator
135
+ await ctx.api.sendChatAction(ctx.chat.id, 'typing');
136
+ // Get or create session
137
+ const session = getOrCreateSession(threadId, 'telegram');
138
+ // Check OpenCode connection
139
+ const connected = await checkConnection();
140
+ if (!connected) {
141
+ await ctx.reply(`❌ OpenCode is offline
142
+
143
+ Cannot connect to OpenCode server.
144
+
145
+ 🔄 /retry — check again`);
146
+ return;
147
+ }
148
+ // Get or create OpenCode session
149
+ let openCodeSession = openCodeSessions.get(threadId);
150
+ if (!openCodeSession) {
151
+ await ctx.reply('⏳ Creating session...');
152
+ const newSession = await createSession(threadId, `Telegram thread ${threadId}`);
153
+ if (!newSession) {
154
+ await ctx.reply('❌ Failed to create OpenCode session');
155
+ return;
156
+ }
157
+ openCodeSession = newSession;
158
+ openCodeSessions.set(threadId, openCodeSession);
159
+ session.opencodeSessionId = openCodeSession.sessionId;
160
+ // Share the session URL
161
+ if (openCodeSession.shareUrl) {
162
+ await ctx.reply(`🔗 Session: ${openCodeSession.shareUrl}`);
163
+ }
164
+ }
165
+ // Send prompt to OpenCode
166
+ await ctx.reply('⏳ Thinking...');
167
+ try {
168
+ const response = await sendMessage(openCodeSession, text);
169
+ // Split long messages
170
+ const messages = splitMessage(response);
171
+ for (const msg of messages) {
172
+ await ctx.reply(msg);
173
+ }
174
+ }
175
+ catch (error) {
176
+ console.error('Error sending message:', error);
177
+ await ctx.reply(`❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
178
+ }
179
+ });
180
+ // Error handling
181
+ bot.catch((err) => {
182
+ console.error('Bot error:', err);
183
+ });
184
+ // Helper to get thread ID
185
+ function getThreadId(ctx) {
186
+ const chatId = ctx.chat?.id;
187
+ const threadId = ctx.message?.message_thread_id || ctx.message?.message_id;
188
+ return `${chatId}:${threadId}`;
189
+ }
190
+ export { bot };
191
+ // Start bot function
192
+ export async function startBot() {
193
+ if (!config.telegramBotToken) {
194
+ console.error('ERROR: TELEGRAM_BOT_TOKEN not set');
195
+ console.log('Get a token from @BotFather on Telegram');
196
+ process.exit(1);
197
+ }
198
+ // Initialize OpenCode
199
+ console.log('🔧 Initializing OpenCode...');
200
+ try {
201
+ await initOpenCode();
202
+ console.log('✅ OpenCode ready');
203
+ }
204
+ catch (error) {
205
+ console.error('❌ Failed to initialize OpenCode:', error);
206
+ console.log('Make sure OpenCode is running');
207
+ }
208
+ console.log('🚀 Starting Telegram bot...');
209
+ await bot.start();
210
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "opencode-remote-control",
3
+ "version": "0.1.0",
4
+ "description": "Control OpenCode from anywhere via Telegram",
5
+ "type": "module",
6
+ "bin": {
7
+ "opencode-remote": "./dist/cli.js"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "scripts": {
11
+ "build": "tsc -p tsconfig.build.json",
12
+ "dev": "bun run --watch src/index.ts",
13
+ "start": "node dist/index.js",
14
+ "test": "bun test",
15
+ "test:watch": "bun test --watch",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "dependencies": {
23
+ "@opencode-ai/sdk": "^1.2.27",
24
+ "grammy": "^1.30.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/bun": "latest",
28
+ "@types/node": "^25.5.0",
29
+ "typescript": "^5.6.0"
30
+ },
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/ceociocto/opencode-remote-control.git"
37
+ },
38
+ "keywords": [
39
+ "opencode",
40
+ "telegram",
41
+ "remote-control",
42
+ "ai",
43
+ "coding"
44
+ ],
45
+ "author": "",
46
+ "license": "MIT"
47
+ }