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 +106 -0
- package/dist/cli.js +131 -0
- package/dist/core/approval.js +95 -0
- package/dist/core/handler-common.js +116 -0
- package/dist/core/notifications.js +134 -0
- package/dist/core/session.js +61 -0
- package/dist/core/types.js +25 -0
- package/dist/index.js +11 -0
- package/dist/opencode/client.js +118 -0
- package/dist/telegram/bot.js +210 -0
- package/package.json +47 -0
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
|
+
}
|