opencode-remote-control 0.1.3 → 0.2.1
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 +54 -8
- package/dist/cli.js +353 -31
- package/dist/core/handler-common.js +16 -16
- package/dist/core/types.js +6 -1
- package/dist/feishu/bot.js +357 -0
- package/dist/opencode/client.js +11 -8
- package/dist/telegram/bot.js +18 -4
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
15
|
-
Control OpenCode from anywhere via Telegram.
|
|
15
|
+
Control OpenCode from anywhere via Telegram or Feishu.
|
|
16
16
|
</p>
|
|
17
17
|
|
|
18
18
|
## Installation
|
|
@@ -26,7 +26,9 @@ pnpm install -g opencode-remote-control
|
|
|
26
26
|
bun install -g opencode-remote-control
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
##
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
### Telegram Setup
|
|
30
32
|
|
|
31
33
|
On first run, you'll be prompted for a Telegram bot token:
|
|
32
34
|
|
|
@@ -36,12 +38,26 @@ On first run, you'll be prompted for a Telegram bot token:
|
|
|
36
38
|
|
|
37
39
|
Token is saved to `~/.opencode-remote/.env`
|
|
38
40
|
|
|
41
|
+
### Feishu Setup
|
|
42
|
+
|
|
43
|
+
Run the config command for Feishu:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
opencode-remote config-feishu
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Follow the interactive guide to configure your Feishu bot. For detailed setup instructions, see [Feishu Setup Guide](./docs/FEISHU_SETUP_EN.md) or [飞书配置指南](./docs/FEISHU_SETUP.md).
|
|
50
|
+
|
|
39
51
|
## Usage
|
|
40
52
|
|
|
41
53
|
```bash
|
|
42
|
-
opencode-remote
|
|
43
|
-
opencode-remote
|
|
44
|
-
opencode-remote
|
|
54
|
+
opencode-remote # Start all configured bots
|
|
55
|
+
opencode-remote start # Start all configured bots
|
|
56
|
+
opencode-remote telegram # Start Telegram bot only
|
|
57
|
+
opencode-remote feishu # Start Feishu bot only
|
|
58
|
+
opencode-remote config # Configure a channel (interactive)
|
|
59
|
+
opencode-remote config-feishu # Configure Feishu directly
|
|
60
|
+
opencode-remote help # Show help
|
|
45
61
|
```
|
|
46
62
|
|
|
47
63
|
## Install from Source
|
|
@@ -54,7 +70,9 @@ bun run build
|
|
|
54
70
|
node dist/cli.js
|
|
55
71
|
```
|
|
56
72
|
|
|
57
|
-
##
|
|
73
|
+
## Bot Commands
|
|
74
|
+
|
|
75
|
+
Both Telegram and Feishu support the same commands:
|
|
58
76
|
|
|
59
77
|
| Command | Description |
|
|
60
78
|
|--------|-------------|
|
|
@@ -66,9 +84,12 @@ node dist/cli.js
|
|
|
66
84
|
| `/diff` | View pending diff |
|
|
67
85
|
| `/files` | List changed files |
|
|
68
86
|
| `/reset` | Reset session |
|
|
87
|
+
| `/retry` | Retry connection |
|
|
69
88
|
|
|
70
89
|
## How It Works
|
|
71
90
|
|
|
91
|
+
### Telegram (Polling Mode)
|
|
92
|
+
|
|
72
93
|
```
|
|
73
94
|
┌─────────────────┐ ┌──────────────────┐
|
|
74
95
|
│ Telegram App │ │ Telegram Server │
|
|
@@ -91,13 +112,38 @@ node dist/cli.js
|
|
|
91
112
|
└─────────────────────────────────────────────────────────┘
|
|
92
113
|
```
|
|
93
114
|
|
|
94
|
-
The bot uses **Polling Mode** to fetch messages from Telegram servers, requiring no tunnel or public IP configuration.
|
|
115
|
+
The Telegram bot uses **Polling Mode** to fetch messages from Telegram servers, requiring no tunnel or public IP configuration.
|
|
116
|
+
|
|
117
|
+
### Feishu (Webhook Mode)
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
121
|
+
│ Feishu │───▶│ Feishu │───▶│ Webhook │
|
|
122
|
+
│ Client │ │ Server │ │ (ngrok) │
|
|
123
|
+
└─────────────┘ └─────────────┘ └──────┬──────┘
|
|
124
|
+
│
|
|
125
|
+
▼
|
|
126
|
+
┌─────────────┐
|
|
127
|
+
│ Feishu Bot │
|
|
128
|
+
│ (port 3001)│
|
|
129
|
+
└──────┬──────┘
|
|
130
|
+
│
|
|
131
|
+
▼
|
|
132
|
+
┌─────────────┐
|
|
133
|
+
│ OpenCode │
|
|
134
|
+
│ SDK │
|
|
135
|
+
└─────────────┘
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The Feishu bot uses **Webhook Mode** and requires a tunnel (ngrok/cloudflared) to receive messages.
|
|
95
139
|
|
|
96
140
|
## Requirements
|
|
97
141
|
|
|
98
142
|
- Node.js >= 18.0.0
|
|
99
143
|
- [OpenCode](https://github.com/opencode-ai/opencode) installed
|
|
100
|
-
- Telegram account
|
|
144
|
+
- Telegram account (for Telegram bot)
|
|
145
|
+
- Feishu account (for Feishu bot)
|
|
146
|
+
- ngrok or cloudflared (for Feishu webhook)
|
|
101
147
|
|
|
102
148
|
## Contributing
|
|
103
149
|
|
package/dist/cli.js
CHANGED
|
@@ -4,13 +4,14 @@ import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
|
|
|
4
4
|
import { homedir } from 'os';
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import { startBot } from './telegram/bot.js';
|
|
7
|
+
import { startFeishuBot } from './feishu/bot.js';
|
|
7
8
|
const CONFIG_DIR = join(homedir(), '.opencode-remote');
|
|
8
9
|
const CONFIG_FILE = join(CONFIG_DIR, '.env');
|
|
9
10
|
function printBanner() {
|
|
10
11
|
console.log(`
|
|
11
12
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
12
13
|
OpenCode Remote Control
|
|
13
|
-
Control OpenCode from Telegram
|
|
14
|
+
Control OpenCode from Telegram or Feishu
|
|
14
15
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
15
16
|
`);
|
|
16
17
|
}
|
|
@@ -19,16 +20,56 @@ function printHelp() {
|
|
|
19
20
|
Usage: opencode-remote [command]
|
|
20
21
|
|
|
21
22
|
Commands:
|
|
22
|
-
start
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
start Start all configured bots (default)
|
|
24
|
+
telegram Start Telegram bot only
|
|
25
|
+
feishu Start Feishu bot only
|
|
26
|
+
config Configure a channel (interactive selection)
|
|
27
|
+
config-feishu Configure Feishu bot directly
|
|
28
|
+
help Show this help message
|
|
25
29
|
|
|
26
30
|
Examples:
|
|
27
|
-
opencode-remote
|
|
28
|
-
opencode-remote start
|
|
29
|
-
opencode-remote
|
|
31
|
+
opencode-remote # Start all bots
|
|
32
|
+
opencode-remote start # Start all bots
|
|
33
|
+
opencode-remote telegram # Start Telegram only
|
|
34
|
+
opencode-remote feishu # Start Feishu only
|
|
35
|
+
opencode-remote config # Interactive channel selection
|
|
36
|
+
opencode-remote config-feishu # Configure Feishu directly
|
|
30
37
|
`);
|
|
31
38
|
}
|
|
39
|
+
async function promptChannel() {
|
|
40
|
+
console.log('\n📝 Select a channel to configure:');
|
|
41
|
+
console.log('');
|
|
42
|
+
console.log(' 1. Telegram');
|
|
43
|
+
console.log(' 2. Feishu (飞书)');
|
|
44
|
+
console.log('');
|
|
45
|
+
process.stdout.write('Enter your choice (1 or 2): ');
|
|
46
|
+
const choice = await new Promise((resolve) => {
|
|
47
|
+
process.stdin.setEncoding('utf8');
|
|
48
|
+
const cleanup = () => {
|
|
49
|
+
process.stdin.pause();
|
|
50
|
+
process.removeListener('SIGINT', onSigint);
|
|
51
|
+
};
|
|
52
|
+
const onSigint = () => {
|
|
53
|
+
cleanup();
|
|
54
|
+
console.log('\nCancelled');
|
|
55
|
+
process.exit(0);
|
|
56
|
+
};
|
|
57
|
+
process.once('SIGINT', onSigint);
|
|
58
|
+
process.stdin.once('data', (chunk) => {
|
|
59
|
+
cleanup();
|
|
60
|
+
resolve(chunk.toString().trim());
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
if (choice === '1' || choice.toLowerCase() === 'telegram') {
|
|
64
|
+
return 'telegram';
|
|
65
|
+
}
|
|
66
|
+
else if (choice === '2' || choice.toLowerCase() === 'feishu') {
|
|
67
|
+
return 'feishu';
|
|
68
|
+
}
|
|
69
|
+
// Default to telegram if invalid input
|
|
70
|
+
console.log('Invalid choice, defaulting to Telegram');
|
|
71
|
+
return 'telegram';
|
|
72
|
+
}
|
|
32
73
|
async function promptToken() {
|
|
33
74
|
console.log('\n📝 Setup required: Telegram Bot Token');
|
|
34
75
|
console.log('\nHow to get a token:');
|
|
@@ -41,68 +82,323 @@ async function promptToken() {
|
|
|
41
82
|
// Read from stdin
|
|
42
83
|
const token = await new Promise((resolve) => {
|
|
43
84
|
process.stdin.setEncoding('utf8');
|
|
85
|
+
const cleanup = () => {
|
|
86
|
+
process.stdin.pause();
|
|
87
|
+
process.removeListener('SIGINT', onSigint);
|
|
88
|
+
};
|
|
89
|
+
const onSigint = () => {
|
|
90
|
+
cleanup();
|
|
91
|
+
console.log('\nCancelled');
|
|
92
|
+
process.exit(0);
|
|
93
|
+
};
|
|
94
|
+
process.once('SIGINT', onSigint);
|
|
44
95
|
process.stdin.once('data', (chunk) => {
|
|
96
|
+
cleanup();
|
|
45
97
|
resolve(chunk.toString().trim());
|
|
46
98
|
});
|
|
47
99
|
});
|
|
48
100
|
return token;
|
|
49
101
|
}
|
|
102
|
+
async function promptFeishuConfig() {
|
|
103
|
+
console.log('\n📝 Step 1: Create Feishu App');
|
|
104
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
105
|
+
console.log('');
|
|
106
|
+
console.log(' 1. Go to https://open.feishu.cn/app');
|
|
107
|
+
console.log(' 2. Click "创建企业自建应用" (Create enterprise app)');
|
|
108
|
+
console.log(' 3. Fill in app name and description');
|
|
109
|
+
console.log(' 4. Go to "凭证与基础信息" (Credentials) page');
|
|
110
|
+
console.log('');
|
|
111
|
+
const promptInput = async (promptText) => {
|
|
112
|
+
process.stdout.write(promptText);
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
process.stdin.setEncoding('utf8');
|
|
115
|
+
const cleanup = () => {
|
|
116
|
+
process.stdin.pause();
|
|
117
|
+
process.removeListener('SIGINT', onSigint);
|
|
118
|
+
};
|
|
119
|
+
const onSigint = () => {
|
|
120
|
+
cleanup();
|
|
121
|
+
console.log('\nCancelled');
|
|
122
|
+
process.exit(0);
|
|
123
|
+
};
|
|
124
|
+
process.once('SIGINT', onSigint);
|
|
125
|
+
process.stdin.once('data', (chunk) => {
|
|
126
|
+
cleanup();
|
|
127
|
+
resolve(chunk.toString().trim());
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
const appId = await promptInput('Enter your App ID: ');
|
|
132
|
+
const appSecret = await promptInput('Enter your App Secret: ');
|
|
133
|
+
return { appId, appSecret };
|
|
134
|
+
}
|
|
135
|
+
function showFeishuSetupGuide() {
|
|
136
|
+
console.log('');
|
|
137
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
138
|
+
console.log(' 📋 Step 2: Configure App Permissions');
|
|
139
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
140
|
+
console.log('');
|
|
141
|
+
console.log(' Go to "权限管理" (Permission Management) page');
|
|
142
|
+
console.log(' Search and enable these permissions:');
|
|
143
|
+
console.log('');
|
|
144
|
+
console.log(' ┌────────────────────────────────────────────────────┐');
|
|
145
|
+
console.log(' │ Permission │ Scope │');
|
|
146
|
+
console.log(' ├────────────────────────────────────────────────────┤');
|
|
147
|
+
console.log(' │ im:message 获取与发送消息 │');
|
|
148
|
+
console.log(' │ im:message:send_as_bot 以应用身份发消息 │');
|
|
149
|
+
console.log(' │ im:message:receive_as_bot 接收机器人消息 │');
|
|
150
|
+
console.log(' └────────────────────────────────────────────────────┘');
|
|
151
|
+
console.log('');
|
|
152
|
+
console.log(' 💡 TIP: Copy the JSON below and use "批量添加" feature:');
|
|
153
|
+
console.log('');
|
|
154
|
+
console.log(' ┌────────────────────────────────────────────────────┐');
|
|
155
|
+
console.log(' │ [');
|
|
156
|
+
console.log(' │ "im:message",');
|
|
157
|
+
console.log(' │ "im:message:send_as_bot",');
|
|
158
|
+
console.log(' │ "im:message:receive_as_bot"');
|
|
159
|
+
console.log(' │ ]');
|
|
160
|
+
console.log(' └────────────────────────────────────────────────────┘');
|
|
161
|
+
console.log('');
|
|
162
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
163
|
+
console.log(' 🤖 Step 3: Enable Robot Capability');
|
|
164
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
165
|
+
console.log('');
|
|
166
|
+
console.log(' 1. Go to "应用能力" (App Capabilities) → "机器人" (Robot)');
|
|
167
|
+
console.log(' 2. Click "启用机器人" (Enable Robot)');
|
|
168
|
+
console.log(' 3. Set robot name and description');
|
|
169
|
+
console.log('');
|
|
170
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
171
|
+
console.log(' 🔗 Step 4: Configure Event Subscription');
|
|
172
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
173
|
+
console.log('');
|
|
174
|
+
console.log(' 1. Start the bot locally:');
|
|
175
|
+
console.log(' $ opencode-remote feishu');
|
|
176
|
+
console.log('');
|
|
177
|
+
console.log(' 2. Expose webhook with ngrok/cloudflared:');
|
|
178
|
+
console.log(' $ ngrok http 3001');
|
|
179
|
+
console.log('');
|
|
180
|
+
console.log(' 3. Go to "事件订阅" (Event Subscription) page');
|
|
181
|
+
console.log(' 4. Set Request URL: https://your-ngrok-url/feishu/webhook');
|
|
182
|
+
console.log(' 5. Add event: im.message.receive_v1');
|
|
183
|
+
console.log('');
|
|
184
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
185
|
+
console.log(' 📤 Step 5: Publish App');
|
|
186
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
187
|
+
console.log('');
|
|
188
|
+
console.log(' 1. Go to "版本管理与发布" (Version & Publish)');
|
|
189
|
+
console.log(' 2. Click "创建版本" (Create Version)');
|
|
190
|
+
console.log(' 3. Fill in version info and submit for review');
|
|
191
|
+
console.log(' 4. After approval, click "发布" (Publish)');
|
|
192
|
+
console.log(' 5. Search your bot in Feishu and start chatting!');
|
|
193
|
+
console.log('');
|
|
194
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
195
|
+
}
|
|
50
196
|
async function getConfig() {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
197
|
+
const config = {
|
|
198
|
+
opencodeServerUrl: process.env.OPENCODE_SERVER_URL || 'http://localhost:3000',
|
|
199
|
+
tunnelUrl: process.env.TUNNEL_URL || '',
|
|
200
|
+
sessionIdleTimeoutMs: parseInt(process.env.SESSION_IDLE_TIMEOUT_MS || '1800000', 10),
|
|
201
|
+
cleanupIntervalMs: parseInt(process.env.CLEANUP_INTERVAL_MS || '300000', 10),
|
|
202
|
+
approvalTimeoutMs: parseInt(process.env.APPROVAL_TIMEOUT_MS || '300000', 10),
|
|
203
|
+
};
|
|
56
204
|
// Check config file
|
|
57
205
|
if (existsSync(CONFIG_FILE)) {
|
|
58
206
|
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
207
|
+
// Parse Telegram token
|
|
208
|
+
const telegramMatch = content.match(/TELEGRAM_BOT_TOKEN=(.+)/);
|
|
209
|
+
if (telegramMatch) {
|
|
210
|
+
const token = telegramMatch[1].trim();
|
|
62
211
|
if (token && token !== 'your_bot_token_here') {
|
|
63
|
-
|
|
212
|
+
config.telegramBotToken = token;
|
|
64
213
|
}
|
|
65
214
|
}
|
|
215
|
+
// Parse Feishu config
|
|
216
|
+
const feishuAppIdMatch = content.match(/FEISHU_APP_ID=(.+)/);
|
|
217
|
+
if (feishuAppIdMatch) {
|
|
218
|
+
config.feishuAppId = feishuAppIdMatch[1].trim();
|
|
219
|
+
}
|
|
220
|
+
const feishuSecretMatch = content.match(/FEISHU_APP_SECRET=(.+)/);
|
|
221
|
+
if (feishuSecretMatch) {
|
|
222
|
+
config.feishuAppSecret = feishuSecretMatch[1].trim();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Check environment variables
|
|
226
|
+
if (process.env.TELEGRAM_BOT_TOKEN?.trim()) {
|
|
227
|
+
config.telegramBotToken = process.env.TELEGRAM_BOT_TOKEN.trim();
|
|
228
|
+
}
|
|
229
|
+
if (process.env.FEISHU_APP_ID?.trim()) {
|
|
230
|
+
config.feishuAppId = process.env.FEISHU_APP_ID.trim();
|
|
231
|
+
}
|
|
232
|
+
if (process.env.FEISHU_APP_SECRET?.trim()) {
|
|
233
|
+
config.feishuAppSecret = process.env.FEISHU_APP_SECRET.trim();
|
|
66
234
|
}
|
|
67
235
|
// Check local .env
|
|
68
236
|
const localEnv = join(process.cwd(), '.env');
|
|
69
237
|
if (existsSync(localEnv)) {
|
|
70
238
|
const content = readFileSync(localEnv, 'utf-8');
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
const token =
|
|
74
|
-
if (token && token !== 'your_bot_token_here') {
|
|
75
|
-
|
|
239
|
+
const telegramMatch = content.match(/TELEGRAM_BOT_TOKEN=(.+)/);
|
|
240
|
+
if (telegramMatch) {
|
|
241
|
+
const token = telegramMatch[1].trim();
|
|
242
|
+
if (token && token !== 'your_bot_token_here' && !config.telegramBotToken) {
|
|
243
|
+
config.telegramBotToken = token;
|
|
76
244
|
}
|
|
77
245
|
}
|
|
246
|
+
const feishuAppIdMatch = content.match(/FEISHU_APP_ID=(.+)/);
|
|
247
|
+
if (feishuAppIdMatch) {
|
|
248
|
+
config.feishuAppId = feishuAppIdMatch[1].trim();
|
|
249
|
+
}
|
|
250
|
+
const feishuSecretMatch = content.match(/FEISHU_APP_SECRET=(.+)/);
|
|
251
|
+
if (feishuSecretMatch) {
|
|
252
|
+
config.feishuAppSecret = feishuSecretMatch[1].trim();
|
|
253
|
+
}
|
|
78
254
|
}
|
|
79
|
-
return
|
|
255
|
+
return config;
|
|
80
256
|
}
|
|
81
257
|
async function saveConfig(token) {
|
|
82
258
|
// Create config directory if needed
|
|
83
259
|
if (!existsSync(CONFIG_DIR)) {
|
|
84
260
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
85
261
|
}
|
|
86
|
-
|
|
262
|
+
// Read existing config
|
|
263
|
+
let existing = '';
|
|
264
|
+
if (existsSync(CONFIG_FILE)) {
|
|
265
|
+
existing = readFileSync(CONFIG_FILE, 'utf-8');
|
|
266
|
+
}
|
|
267
|
+
// Add or update Telegram token
|
|
268
|
+
const lines = existing.split('\n').filter(line => !line.startsWith('TELEGRAM_BOT_TOKEN='));
|
|
269
|
+
lines.push(`TELEGRAM_BOT_TOKEN=${token}`);
|
|
270
|
+
writeFileSync(CONFIG_FILE, lines.join('\n'));
|
|
87
271
|
console.log(`\n✅ Token saved to ${CONFIG_FILE}`);
|
|
88
272
|
}
|
|
273
|
+
async function saveFeishuConfig(appId, appSecret) {
|
|
274
|
+
// Create config directory if needed
|
|
275
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
276
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
277
|
+
}
|
|
278
|
+
// Read existing config
|
|
279
|
+
let existing = '';
|
|
280
|
+
if (existsSync(CONFIG_FILE)) {
|
|
281
|
+
existing = readFileSync(CONFIG_FILE, 'utf-8');
|
|
282
|
+
}
|
|
283
|
+
// Filter out old Feishu config
|
|
284
|
+
const lines = existing.split('\n').filter(line => !line.startsWith('FEISHU_APP_ID=') &&
|
|
285
|
+
!line.startsWith('FEISHU_APP_SECRET='));
|
|
286
|
+
// Add new Feishu config
|
|
287
|
+
lines.push(`FEISHU_APP_ID=${appId}`);
|
|
288
|
+
lines.push(`FEISHU_APP_SECRET=${appSecret}`);
|
|
289
|
+
writeFileSync(CONFIG_FILE, lines.join('\n'));
|
|
290
|
+
console.log(`\n✅ Feishu config saved to ${CONFIG_FILE}`);
|
|
291
|
+
// Show setup guide
|
|
292
|
+
showFeishuSetupGuide();
|
|
293
|
+
}
|
|
89
294
|
async function runConfig() {
|
|
90
295
|
printBanner();
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
296
|
+
// Let user select channel
|
|
297
|
+
const channel = await promptChannel();
|
|
298
|
+
if (channel === 'telegram') {
|
|
299
|
+
const token = await promptToken();
|
|
300
|
+
if (!token || token === 'your_bot_token_here') {
|
|
301
|
+
console.log('\n❌ Invalid token. Please try again.');
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
await saveConfig(token);
|
|
305
|
+
console.log('\n🚀 Ready! Run `opencode-remote` to start the bot.');
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
const { appId, appSecret } = await promptFeishuConfig();
|
|
309
|
+
if (!appId || !appSecret) {
|
|
310
|
+
console.log('\n❌ Invalid credentials. Please try again.');
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
await saveFeishuConfig(appId, appSecret);
|
|
314
|
+
console.log('\n🚀 Ready! Run `opencode-remote feishu` to start the Feishu bot.');
|
|
315
|
+
}
|
|
316
|
+
process.exit(0);
|
|
317
|
+
}
|
|
318
|
+
async function runConfigFeishu() {
|
|
319
|
+
printBanner();
|
|
320
|
+
const { appId, appSecret } = await promptFeishuConfig();
|
|
321
|
+
if (!appId || !appSecret) {
|
|
322
|
+
console.log('\n❌ Invalid credentials. Please try again.');
|
|
94
323
|
process.exit(1);
|
|
95
324
|
}
|
|
96
|
-
await
|
|
325
|
+
await saveFeishuConfig(appId, appSecret);
|
|
97
326
|
console.log('\n🚀 Ready! Run `opencode-remote` to start the bot.');
|
|
98
327
|
process.exit(0);
|
|
99
328
|
}
|
|
329
|
+
function hasTelegramConfig(config) {
|
|
330
|
+
return !!(config.telegramBotToken?.trim());
|
|
331
|
+
}
|
|
332
|
+
function hasFeishuConfig(config) {
|
|
333
|
+
return !!(config.feishuAppId?.trim() &&
|
|
334
|
+
config.feishuAppSecret?.trim());
|
|
335
|
+
}
|
|
100
336
|
async function runStart() {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
337
|
+
const config = await getConfig();
|
|
338
|
+
printBanner();
|
|
339
|
+
// Check what's configured
|
|
340
|
+
const hasTelegram = hasTelegramConfig(config);
|
|
341
|
+
const hasFeishu = hasFeishuConfig(config);
|
|
342
|
+
if (!hasTelegram && !hasFeishu) {
|
|
343
|
+
console.log('❌ No bots configured!');
|
|
344
|
+
console.log('\nRun one of:');
|
|
345
|
+
console.log(' opencode-remote config # Configure Telegram');
|
|
346
|
+
console.log(' opencode-remote config-feishu # Configure Feishu');
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
// Track shutdown state
|
|
350
|
+
let isShuttingDown = false;
|
|
351
|
+
// Handle graceful shutdown at CLI level
|
|
352
|
+
const handleShutdown = () => {
|
|
353
|
+
if (isShuttingDown)
|
|
354
|
+
return;
|
|
355
|
+
isShuttingDown = true;
|
|
356
|
+
console.log('\n🛑 Shutting down...');
|
|
357
|
+
// The individual bots will handle their own cleanup via their SIGINT handlers
|
|
358
|
+
// We just need to ensure the process exits
|
|
359
|
+
setTimeout(() => {
|
|
360
|
+
console.log('Goodbye!');
|
|
361
|
+
process.exit(0);
|
|
362
|
+
}, 1000);
|
|
363
|
+
};
|
|
364
|
+
process.once('SIGINT', handleShutdown);
|
|
365
|
+
process.once('SIGTERM', handleShutdown);
|
|
366
|
+
// Start bots
|
|
367
|
+
const promises = [];
|
|
368
|
+
if (hasTelegram) {
|
|
369
|
+
console.log('🤖 Starting Telegram bot...');
|
|
370
|
+
process.env.TELEGRAM_BOT_TOKEN = config.telegramBotToken;
|
|
371
|
+
promises.push(startBot().catch((err) => {
|
|
372
|
+
console.error('Telegram bot failed:', err);
|
|
373
|
+
return { status: 'rejected', reason: err };
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
if (hasFeishu) {
|
|
377
|
+
console.log('🤖 Starting Feishu bot...');
|
|
378
|
+
promises.push(startFeishuBot(config).catch((err) => {
|
|
379
|
+
console.error('Feishu bot failed:', err);
|
|
380
|
+
return { status: 'rejected', reason: err };
|
|
381
|
+
}));
|
|
105
382
|
}
|
|
383
|
+
// Wait for all bots
|
|
384
|
+
const results = await Promise.allSettled(promises);
|
|
385
|
+
const failed = results.filter((r) => r.status === 'rejected');
|
|
386
|
+
if (failed.length > 0) {
|
|
387
|
+
console.log(`\n⚠️ ${failed.length} bot(s) failed to start`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
console.log('\n✅ All bots started!');
|
|
391
|
+
}
|
|
392
|
+
async function runTelegramOnly() {
|
|
393
|
+
const config = await getConfig();
|
|
394
|
+
if (!hasTelegramConfig(config)) {
|
|
395
|
+
console.log('❌ Telegram bot not configured!');
|
|
396
|
+
console.log('\nRun: opencode-remote config');
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
printBanner();
|
|
400
|
+
console.log('🤖 Starting Telegram bot...');
|
|
401
|
+
process.env.TELEGRAM_BOT_TOKEN = config.telegramBotToken;
|
|
106
402
|
try {
|
|
107
403
|
await startBot();
|
|
108
404
|
}
|
|
@@ -111,6 +407,23 @@ async function runStart() {
|
|
|
111
407
|
process.exit(1);
|
|
112
408
|
}
|
|
113
409
|
}
|
|
410
|
+
async function runFeishuOnly() {
|
|
411
|
+
const config = await getConfig();
|
|
412
|
+
if (!hasFeishuConfig(config)) {
|
|
413
|
+
console.log('❌ Feishu bot not configured!');
|
|
414
|
+
console.log('\nRun: opencode-remote config-feishu');
|
|
415
|
+
process.exit(1);
|
|
416
|
+
}
|
|
417
|
+
printBanner();
|
|
418
|
+
console.log('🤖 Starting Feishu bot...');
|
|
419
|
+
try {
|
|
420
|
+
await startFeishuBot(config);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
console.error('Failed to start:', error);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
114
427
|
// Main CLI
|
|
115
428
|
const args = process.argv.slice(2);
|
|
116
429
|
const command = args[0] || 'start';
|
|
@@ -118,9 +431,18 @@ switch (command) {
|
|
|
118
431
|
case 'start':
|
|
119
432
|
runStart();
|
|
120
433
|
break;
|
|
434
|
+
case 'telegram':
|
|
435
|
+
runTelegramOnly();
|
|
436
|
+
break;
|
|
437
|
+
case 'feishu':
|
|
438
|
+
runFeishuOnly();
|
|
439
|
+
break;
|
|
121
440
|
case 'config':
|
|
122
441
|
runConfig();
|
|
123
442
|
break;
|
|
443
|
+
case 'config-feishu':
|
|
444
|
+
runConfigFeishu();
|
|
445
|
+
break;
|
|
124
446
|
case 'help':
|
|
125
447
|
case '--help':
|
|
126
448
|
case '-h':
|
|
@@ -13,13 +13,13 @@ export function createHandler(deps) {
|
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
15
|
// It's a prompt - send to OpenCode
|
|
16
|
-
await deps.
|
|
16
|
+
await deps.sendTypingIndicator(ctx.threadId);
|
|
17
17
|
// TODO: Actually send to OpenCode SDK
|
|
18
18
|
// For now, echo back
|
|
19
|
-
await deps.
|
|
19
|
+
await deps.reply(ctx.threadId, TEMPLATES.thinking());
|
|
20
20
|
// Simulate response
|
|
21
21
|
setTimeout(async () => {
|
|
22
|
-
await deps.
|
|
22
|
+
await deps.reply(ctx.threadId, TEMPLATES.taskCompleted([
|
|
23
23
|
{ path: 'src/example.ts', additions: 10, deletions: 2 }
|
|
24
24
|
]));
|
|
25
25
|
}, 1000);
|
|
@@ -31,7 +31,7 @@ export function createHandler(deps) {
|
|
|
31
31
|
switch (command) {
|
|
32
32
|
case '/start':
|
|
33
33
|
case '/help':
|
|
34
|
-
await deps.
|
|
34
|
+
await deps.reply(ctx.threadId, TEMPLATES.botStarted());
|
|
35
35
|
break;
|
|
36
36
|
case '/approve':
|
|
37
37
|
await this.handleApprove(ctx, session);
|
|
@@ -46,67 +46,67 @@ export function createHandler(deps) {
|
|
|
46
46
|
await this.handleFiles(ctx, session);
|
|
47
47
|
break;
|
|
48
48
|
case '/status':
|
|
49
|
-
await deps.
|
|
49
|
+
await deps.reply(ctx.threadId, `✅ Connected\n\n💬 Session: ${session.id.slice(0, 8)}\n⏰ Idle: ${Math.round((Date.now() - session.lastActivity) / 1000)}s`);
|
|
50
50
|
break;
|
|
51
51
|
case '/reset':
|
|
52
52
|
session.pendingApprovals = [];
|
|
53
53
|
session.opencodeSessionId = undefined;
|
|
54
|
-
await deps.
|
|
54
|
+
await deps.reply(ctx.threadId, '🔄 Session reset. Start fresh!');
|
|
55
55
|
break;
|
|
56
56
|
default:
|
|
57
|
-
await deps.
|
|
57
|
+
await deps.reply(ctx.threadId, `${EMOJI.WARNING} Unknown command: ${command}\n\nTry /help`);
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
60
|
// Handle /approve
|
|
61
61
|
async handleApprove(ctx, session) {
|
|
62
62
|
const pending = session.pendingApprovals[0];
|
|
63
63
|
if (!pending) {
|
|
64
|
-
await deps.
|
|
64
|
+
await deps.reply(ctx.threadId, '🤷 Nothing to approve right now');
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
67
|
// Resolve the approval
|
|
68
68
|
// TODO: Actually apply changes via OpenCode SDK
|
|
69
|
-
await deps.
|
|
69
|
+
await deps.reply(ctx.threadId, TEMPLATES.approved());
|
|
70
70
|
},
|
|
71
71
|
// Handle /reject
|
|
72
72
|
async handleReject(ctx, session) {
|
|
73
73
|
const pending = session.pendingApprovals[0];
|
|
74
74
|
if (!pending) {
|
|
75
|
-
await deps.
|
|
75
|
+
await deps.reply(ctx.threadId, '🤷 Nothing to reject right now');
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
78
|
session.pendingApprovals.shift();
|
|
79
|
-
await deps.
|
|
79
|
+
await deps.reply(ctx.threadId, TEMPLATES.rejected());
|
|
80
80
|
},
|
|
81
81
|
// Handle /diff
|
|
82
82
|
async handleDiff(ctx, session) {
|
|
83
83
|
const pending = session.pendingApprovals[0];
|
|
84
84
|
if (!pending || !pending.files?.length) {
|
|
85
|
-
await deps.
|
|
85
|
+
await deps.reply(ctx.threadId, '📄 No pending changes to show');
|
|
86
86
|
return;
|
|
87
87
|
}
|
|
88
88
|
// TODO: Get actual diff from OpenCode SDK
|
|
89
89
|
const diffPreview = pending.files.map(f => `--- a/${f.path}\n+++ b/${f.path}\n@@ changes +${f.additions} +${f.deletions} @@`).join('\n');
|
|
90
90
|
const messages = splitMessage(`\`\`\`diff\n${diffPreview}\n\`\`\``);
|
|
91
91
|
for (const msg of messages) {
|
|
92
|
-
await deps.
|
|
92
|
+
await deps.reply(ctx.threadId, msg);
|
|
93
93
|
}
|
|
94
94
|
},
|
|
95
95
|
// Handle /files
|
|
96
96
|
async handleFiles(ctx, session) {
|
|
97
97
|
const pending = session.pendingApprovals[0];
|
|
98
98
|
if (!pending || !pending.files?.length) {
|
|
99
|
-
await deps.
|
|
99
|
+
await deps.reply(ctx.threadId, '📄 No files changed in this session');
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
102
|
const fileList = pending.files.map(f => `• ${f.path} (+${f.additions}, -${f.deletions})`).join('\n');
|
|
103
|
-
await deps.
|
|
103
|
+
await deps.reply(ctx.threadId, `📄 Changed files:\n${fileList}`);
|
|
104
104
|
},
|
|
105
105
|
// Request approval from user
|
|
106
106
|
async requestApproval(ctx, session, type, data) {
|
|
107
107
|
const request = createApprovalRequest(session, type, data);
|
|
108
108
|
const message = formatApprovalMessage(request);
|
|
109
|
-
await deps.
|
|
109
|
+
await deps.reply(ctx.threadId, message);
|
|
110
110
|
// Wait for user response
|
|
111
111
|
return waitForApproval(request);
|
|
112
112
|
}
|
package/dist/core/types.js
CHANGED
|
@@ -15,7 +15,12 @@ export const EMOJI = {
|
|
|
15
15
|
};
|
|
16
16
|
export function loadConfig() {
|
|
17
17
|
return {
|
|
18
|
-
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN ||
|
|
18
|
+
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || undefined,
|
|
19
|
+
feishuAppId: process.env.FEISHU_APP_ID || undefined,
|
|
20
|
+
feishuAppSecret: process.env.FEISHU_APP_SECRET || undefined,
|
|
21
|
+
feishuEncryptKey: process.env.FEISHU_ENCRYPT_KEY,
|
|
22
|
+
feishuVerificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
|
|
23
|
+
feishuWebhookPort: parseInt(process.env.FEISHU_WEBHOOK_PORT || '3001', 10),
|
|
19
24
|
opencodeServerUrl: process.env.OPENCODE_SERVER_URL || 'http://localhost:3000',
|
|
20
25
|
tunnelUrl: process.env.TUNNEL_URL || '',
|
|
21
26
|
sessionIdleTimeoutMs: parseInt(process.env.SESSION_IDLE_TIMEOUT_MS || '1800000', 10),
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Feishu bot implementation for OpenCode Remote Control
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import * as lark from '@larksuiteoapi/node-sdk';
|
|
4
|
+
import { initSessionManager, getOrCreateSession } from '../core/session.js';
|
|
5
|
+
import { splitMessage } from '../core/notifications.js';
|
|
6
|
+
import { EMOJI } from '../core/types.js';
|
|
7
|
+
import { initOpenCode, createSession, sendMessage, checkConnection } from '../opencode/client.js';
|
|
8
|
+
let feishuClient = null;
|
|
9
|
+
let config = null;
|
|
10
|
+
let openCodeSessions = null;
|
|
11
|
+
// Map Feishu event to shared MessageContext
|
|
12
|
+
// Prefix threadId with 'feishu:' to avoid collision with Telegram sessions
|
|
13
|
+
function feishuEventToContext(event) {
|
|
14
|
+
return {
|
|
15
|
+
platform: 'feishu',
|
|
16
|
+
threadId: `feishu:${event.message.chat_id}`,
|
|
17
|
+
userId: event.sender?.sender_id?.user_id || 'unknown',
|
|
18
|
+
messageId: event.message.message_id,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// BotAdapter implementation for Feishu
|
|
22
|
+
function createFeishuAdapter(client) {
|
|
23
|
+
return {
|
|
24
|
+
async reply(threadId, text) {
|
|
25
|
+
// Extract chat_id from threadId (format: feishu:chat_id)
|
|
26
|
+
const chatId = threadId.replace('feishu:', '');
|
|
27
|
+
try {
|
|
28
|
+
const result = await client.im.message.create({
|
|
29
|
+
params: { receive_id_type: 'chat_id' },
|
|
30
|
+
data: {
|
|
31
|
+
receive_id: chatId,
|
|
32
|
+
msg_type: 'text',
|
|
33
|
+
content: JSON.stringify({ text }),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return result.data?.message_id || '';
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error('Failed to send Feishu message:', error);
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
async sendTypingIndicator(threadId) {
|
|
44
|
+
// Feishu doesn't have typing indicator
|
|
45
|
+
// Could optionally send a "thinking..." message, but we'll skip for now
|
|
46
|
+
},
|
|
47
|
+
async deleteMessage(threadId, messageId) {
|
|
48
|
+
if (!messageId)
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
await client.im.message.delete({
|
|
52
|
+
path: { message_id: messageId },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// Ignore delete errors - not critical
|
|
57
|
+
console.warn('Failed to delete Feishu message:', error);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Command handler (mirrors Telegram bot structure)
|
|
63
|
+
async function handleCommand(adapter, ctx, text) {
|
|
64
|
+
const session = getOrCreateSession(ctx.threadId, 'feishu');
|
|
65
|
+
const parts = text.split(/\s+/);
|
|
66
|
+
const command = parts[0].toLowerCase();
|
|
67
|
+
switch (command) {
|
|
68
|
+
case '/start':
|
|
69
|
+
case '/help':
|
|
70
|
+
await adapter.reply(ctx.threadId, `🚀 OpenCode Remote Control ready
|
|
71
|
+
|
|
72
|
+
💬 Send me a prompt to start coding
|
|
73
|
+
/help — see all commands
|
|
74
|
+
/status — check OpenCode connection
|
|
75
|
+
|
|
76
|
+
Commands:
|
|
77
|
+
/start — Start bot
|
|
78
|
+
/status — Check connection
|
|
79
|
+
/reset — Reset session
|
|
80
|
+
/approve — Approve pending changes
|
|
81
|
+
/reject — Reject pending changes
|
|
82
|
+
/diff — See full diff
|
|
83
|
+
/files — List changed files
|
|
84
|
+
/retry — Retry connection
|
|
85
|
+
|
|
86
|
+
💬 Anything else is treated as a prompt for OpenCode!`);
|
|
87
|
+
break;
|
|
88
|
+
case '/approve': {
|
|
89
|
+
const pending = session.pendingApprovals[0];
|
|
90
|
+
if (!pending) {
|
|
91
|
+
await adapter.reply(ctx.threadId, '🤷 Nothing to approve right now');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// TODO: Actually apply changes via OpenCode SDK
|
|
95
|
+
await adapter.reply(ctx.threadId, '✅ Approved — changes applied');
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case '/reject': {
|
|
99
|
+
const pending = session.pendingApprovals[0];
|
|
100
|
+
if (!pending) {
|
|
101
|
+
await adapter.reply(ctx.threadId, '🤷 Nothing to reject right now');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
session.pendingApprovals.shift();
|
|
105
|
+
await adapter.reply(ctx.threadId, '❌ Rejected — changes discarded');
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case '/diff': {
|
|
109
|
+
const pending = session.pendingApprovals[0];
|
|
110
|
+
if (!pending || !pending.files?.length) {
|
|
111
|
+
await adapter.reply(ctx.threadId, '📄 No pending changes to show');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// TODO: Get actual diff from OpenCode SDK
|
|
115
|
+
const diffPreview = pending.files.map(f => `--- a/${f.path}\n+++ b/${f.path}\n@@ changes +${f.additions} -${f.deletions} @@`).join('\n');
|
|
116
|
+
const messages = splitMessage(`\`\`\`diff\n${diffPreview}\n\`\`\``);
|
|
117
|
+
for (const msg of messages) {
|
|
118
|
+
await adapter.reply(ctx.threadId, msg);
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case '/files': {
|
|
123
|
+
const pending = session.pendingApprovals[0];
|
|
124
|
+
if (!pending || !pending.files?.length) {
|
|
125
|
+
await adapter.reply(ctx.threadId, '📄 No files changed in this session');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const fileList = pending.files.map(f => `• ${f.path} (+${f.additions}, -${f.deletions})`).join('\n');
|
|
129
|
+
await adapter.reply(ctx.threadId, `📄 Changed files:\n${fileList}`);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case '/status': {
|
|
133
|
+
const openCodeConnected = await checkConnection();
|
|
134
|
+
const openCodeSession = openCodeSessions?.get(ctx.threadId);
|
|
135
|
+
const idleSeconds = Math.round((Date.now() - session.lastActivity) / 1000);
|
|
136
|
+
const pendingCount = session.pendingApprovals.length;
|
|
137
|
+
await adapter.reply(ctx.threadId, `${openCodeConnected ? '✅' : '❌'} Connected\n\n💬 Session: ${openCodeSession?.sessionId?.slice(0, 8) || 'none'}\n⏰ Idle: ${idleSeconds}s\n📝 Pending approvals: ${pendingCount}`);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case '/reset':
|
|
141
|
+
session.pendingApprovals = [];
|
|
142
|
+
session.opencodeSessionId = undefined;
|
|
143
|
+
// Clear OpenCode session
|
|
144
|
+
openCodeSessions?.delete(ctx.threadId);
|
|
145
|
+
await adapter.reply(ctx.threadId, '🔄 Session reset. Start fresh!');
|
|
146
|
+
break;
|
|
147
|
+
case '/retry': {
|
|
148
|
+
const connected = await checkConnection();
|
|
149
|
+
if (connected) {
|
|
150
|
+
await adapter.reply(ctx.threadId, '✅ OpenCode is now online!');
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
await adapter.reply(ctx.threadId, '❌ Still offline. Is OpenCode running?');
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
await adapter.reply(ctx.threadId, `${EMOJI.WARNING} Unknown command: ${command}\n\nTry /help`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Handle incoming message from Feishu
|
|
162
|
+
async function handleMessage(adapter, ctx, text) {
|
|
163
|
+
const session = getOrCreateSession(ctx.threadId, 'feishu');
|
|
164
|
+
// Check if it's a command
|
|
165
|
+
if (text.startsWith('/')) {
|
|
166
|
+
await handleCommand(adapter, ctx, text);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Check OpenCode connection
|
|
170
|
+
const connected = await checkConnection();
|
|
171
|
+
if (!connected) {
|
|
172
|
+
await adapter.reply(ctx.threadId, `❌ OpenCode is offline
|
|
173
|
+
|
|
174
|
+
Cannot connect to OpenCode server.
|
|
175
|
+
|
|
176
|
+
🔄 /retry — check again`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Send typing indicator (Feishu style)
|
|
180
|
+
const typingMsg = await adapter.reply(ctx.threadId, '⏳');
|
|
181
|
+
// Get or create OpenCode session
|
|
182
|
+
let openCodeSession = openCodeSessions?.get(ctx.threadId);
|
|
183
|
+
if (!openCodeSession) {
|
|
184
|
+
const newSession = await createSession(ctx.threadId, `Feishu chat ${ctx.threadId}`);
|
|
185
|
+
if (!newSession) {
|
|
186
|
+
await adapter.reply(ctx.threadId, '❌ Failed to create OpenCode session');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
openCodeSession = newSession;
|
|
190
|
+
openCodeSessions.set(ctx.threadId, openCodeSession);
|
|
191
|
+
session.opencodeSessionId = openCodeSession.sessionId;
|
|
192
|
+
// Share the session URL (only if sharing is enabled)
|
|
193
|
+
if (openCodeSession.shareUrl) {
|
|
194
|
+
await adapter.reply(ctx.threadId, `🔗 Session: ${openCodeSession.shareUrl}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const response = await sendMessage(openCodeSession, text);
|
|
199
|
+
// Delete typing indicator
|
|
200
|
+
if (adapter.deleteMessage && typingMsg) {
|
|
201
|
+
await adapter.deleteMessage(ctx.threadId, typingMsg);
|
|
202
|
+
}
|
|
203
|
+
// Split long messages
|
|
204
|
+
const messages = splitMessage(response);
|
|
205
|
+
for (const msg of messages) {
|
|
206
|
+
await adapter.reply(ctx.threadId, msg);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
console.error('Error sending message:', error);
|
|
211
|
+
await adapter.reply(ctx.threadId, `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Rate limiting (in-memory, per-chat)
|
|
215
|
+
const rateLimitMap = new Map();
|
|
216
|
+
const RATE_LIMIT = 100; // messages per minute per chat
|
|
217
|
+
const RATE_WINDOW = 60000; // 1 minute
|
|
218
|
+
function checkRateLimit(chatId) {
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const entry = rateLimitMap.get(chatId);
|
|
221
|
+
if (!entry || now > entry.resetTime) {
|
|
222
|
+
rateLimitMap.set(chatId, { count: 1, resetTime: now + RATE_WINDOW });
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
if (entry.count >= RATE_LIMIT) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
entry.count++;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
// Start Feishu bot
|
|
232
|
+
export async function startFeishuBot(botConfig) {
|
|
233
|
+
config = botConfig;
|
|
234
|
+
if (!config.feishuAppId || !config.feishuAppSecret) {
|
|
235
|
+
throw new Error('Feishu credentials not configured');
|
|
236
|
+
}
|
|
237
|
+
// Initialize Feishu client
|
|
238
|
+
feishuClient = new lark.Client({
|
|
239
|
+
appId: config.feishuAppId,
|
|
240
|
+
appSecret: config.feishuAppSecret,
|
|
241
|
+
// Uses feishu.cn domain by default (for China users)
|
|
242
|
+
// For international Lark, add: domain: lark.Domain.Lark
|
|
243
|
+
});
|
|
244
|
+
// Initialize session manager
|
|
245
|
+
initSessionManager(config);
|
|
246
|
+
// Initialize OpenCode sessions map
|
|
247
|
+
openCodeSessions = new Map();
|
|
248
|
+
// Initialize OpenCode
|
|
249
|
+
console.log('🔧 Initializing OpenCode...');
|
|
250
|
+
try {
|
|
251
|
+
await initOpenCode();
|
|
252
|
+
console.log('✅ OpenCode ready');
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
console.error('❌ Failed to initialize OpenCode:', error);
|
|
256
|
+
console.log('Make sure OpenCode is running');
|
|
257
|
+
}
|
|
258
|
+
// Create adapter
|
|
259
|
+
const adapter = createFeishuAdapter(feishuClient);
|
|
260
|
+
// Create Express server for webhook
|
|
261
|
+
const app = express();
|
|
262
|
+
app.use(express.json());
|
|
263
|
+
const webhookPath = '/feishu/webhook';
|
|
264
|
+
const port = config.feishuWebhookPort || 3001;
|
|
265
|
+
// Webhook endpoint for Feishu events
|
|
266
|
+
app.post(webhookPath, async (req, res) => {
|
|
267
|
+
const event = req.body;
|
|
268
|
+
// Handle URL verification challenge (required for Feishu bot setup)
|
|
269
|
+
if (event.type === 'url_verification') {
|
|
270
|
+
res.json({ challenge: event.challenge });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Validate event structure
|
|
274
|
+
if (!event.header?.event_type) {
|
|
275
|
+
res.status(400).json({ code: -1, msg: 'Invalid event structure' });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Handle message events
|
|
279
|
+
if (event.header.event_type === 'im.message.receive_v1') {
|
|
280
|
+
try {
|
|
281
|
+
// Validate event data
|
|
282
|
+
if (!event.event?.message) {
|
|
283
|
+
res.status(400).json({ code: -1, msg: 'Missing message data' });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const ctx = feishuEventToContext(event.event);
|
|
287
|
+
const chatId = event.event.message.chat_id;
|
|
288
|
+
// Rate limiting
|
|
289
|
+
if (!checkRateLimit(chatId)) {
|
|
290
|
+
console.warn(`Rate limit exceeded for chat: ${chatId}`);
|
|
291
|
+
res.status(429).json({ code: -1, msg: 'Rate limit exceeded' });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Parse message content
|
|
295
|
+
let text = '';
|
|
296
|
+
try {
|
|
297
|
+
const content = JSON.parse(event.event.message.content);
|
|
298
|
+
text = content.text || '';
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// If not JSON, try to use raw content
|
|
302
|
+
text = event.event.message.content || '';
|
|
303
|
+
}
|
|
304
|
+
// Skip empty messages
|
|
305
|
+
if (!text.trim()) {
|
|
306
|
+
res.json({ code: 0 });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Handle the message
|
|
310
|
+
await handleMessage(adapter, ctx, text);
|
|
311
|
+
res.json({ code: 0 });
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
console.error('Feishu webhook error:', error);
|
|
315
|
+
res.status(500).json({ code: -1, msg: 'Internal error' });
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// Unknown event type - acknowledge but ignore
|
|
320
|
+
console.log(`Received Feishu event: ${event.header.event_type}`);
|
|
321
|
+
res.json({ code: 0 });
|
|
322
|
+
});
|
|
323
|
+
// Health check endpoint
|
|
324
|
+
app.get('/health', (_req, res) => {
|
|
325
|
+
res.json({ status: 'ok', platform: 'feishu' });
|
|
326
|
+
});
|
|
327
|
+
// Start server
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
const server = app.listen(port, () => {
|
|
330
|
+
console.log(`🚀 Feishu webhook listening on port ${port}`);
|
|
331
|
+
console.log(`📡 Webhook URL: http://localhost:${port}${webhookPath}`);
|
|
332
|
+
console.log('\n📝 Setup instructions:');
|
|
333
|
+
console.log(' 1. Use ngrok or cloudflared to expose this endpoint:');
|
|
334
|
+
console.log(' ngrok http ' + port);
|
|
335
|
+
console.log(' 2. Configure the webhook URL in Feishu admin console');
|
|
336
|
+
console.log(' 3. Subscribe to "im.message.receive_v1" event');
|
|
337
|
+
});
|
|
338
|
+
server.on('error', (err) => {
|
|
339
|
+
reject(err);
|
|
340
|
+
});
|
|
341
|
+
// Handle graceful shutdown
|
|
342
|
+
process.once('SIGINT', () => {
|
|
343
|
+
console.log('\n🛑 Shutting down Feishu bot...');
|
|
344
|
+
server.close(() => {
|
|
345
|
+
console.log('Feishu bot stopped');
|
|
346
|
+
resolve();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
process.once('SIGTERM', () => {
|
|
350
|
+
console.log('\n🛑 Shutting down Feishu bot...');
|
|
351
|
+
server.close(() => {
|
|
352
|
+
console.log('Feishu bot stopped');
|
|
353
|
+
resolve();
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
}
|
package/dist/opencode/client.js
CHANGED
|
@@ -12,7 +12,7 @@ export async function initOpenCode() {
|
|
|
12
12
|
console.log('✅ OpenCode server ready');
|
|
13
13
|
return opencodeInstance;
|
|
14
14
|
}
|
|
15
|
-
export async function createSession(
|
|
15
|
+
export async function createSession(_threadId, title = `Remote control session`) {
|
|
16
16
|
const opencode = await initOpenCode();
|
|
17
17
|
try {
|
|
18
18
|
const createResult = await opencode.client.session.create({
|
|
@@ -24,14 +24,17 @@ export async function createSession(threadId, title = `Remote control session`)
|
|
|
24
24
|
}
|
|
25
25
|
const sessionId = createResult.data.id;
|
|
26
26
|
console.log(`✅ Created OpenCode session: ${sessionId}`);
|
|
27
|
-
//
|
|
27
|
+
// Note: Sharing is disabled by default for privacy
|
|
28
|
+
// Set SHARE_SESSIONS=true in .env to enable public sharing
|
|
28
29
|
let shareUrl;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
if (process.env.SHARE_SESSIONS === 'true') {
|
|
31
|
+
const shareResult = await opencode.client.session.share({
|
|
32
|
+
path: { id: sessionId }
|
|
33
|
+
});
|
|
34
|
+
if (!shareResult.error && shareResult.data?.share?.url) {
|
|
35
|
+
shareUrl = shareResult.data.share.url;
|
|
36
|
+
console.log(`🔗 Session shared: ${shareUrl}`);
|
|
37
|
+
}
|
|
35
38
|
}
|
|
36
39
|
return {
|
|
37
40
|
sessionId,
|
package/dist/telegram/bot.js
CHANGED
|
@@ -153,7 +153,7 @@ Cannot connect to OpenCode server.
|
|
|
153
153
|
// Get or create OpenCode session
|
|
154
154
|
let openCodeSession = openCodeSessions.get(threadId);
|
|
155
155
|
if (!openCodeSession) {
|
|
156
|
-
|
|
156
|
+
// Keep typing indicator while creating session
|
|
157
157
|
const newSession = await createSession(threadId, `Telegram thread ${threadId}`);
|
|
158
158
|
if (!newSession) {
|
|
159
159
|
await ctx.reply('❌ Failed to create OpenCode session');
|
|
@@ -162,13 +162,13 @@ Cannot connect to OpenCode server.
|
|
|
162
162
|
openCodeSession = newSession;
|
|
163
163
|
openCodeSessions.set(threadId, openCodeSession);
|
|
164
164
|
session.opencodeSessionId = openCodeSession.sessionId;
|
|
165
|
-
// Share the session URL
|
|
165
|
+
// Share the session URL (only if sharing is enabled)
|
|
166
166
|
if (openCodeSession.shareUrl) {
|
|
167
167
|
await ctx.reply(`🔗 Session: ${openCodeSession.shareUrl}`);
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
-
//
|
|
171
|
-
await ctx.
|
|
170
|
+
// Refresh typing indicator before sending prompt
|
|
171
|
+
await ctx.api.sendChatAction(ctx.chat.id, 'typing');
|
|
172
172
|
try {
|
|
173
173
|
const response = await sendMessage(openCodeSession, text);
|
|
174
174
|
// Split long messages
|
|
@@ -236,5 +236,19 @@ export async function startBot() {
|
|
|
236
236
|
console.log('Make sure OpenCode is running');
|
|
237
237
|
}
|
|
238
238
|
console.log('🚀 Starting Telegram bot...');
|
|
239
|
+
// Handle graceful shutdown
|
|
240
|
+
const shutdown = async () => {
|
|
241
|
+
console.log('\n🛑 Shutting down Telegram bot...');
|
|
242
|
+
if (bot) {
|
|
243
|
+
await bot.stop();
|
|
244
|
+
}
|
|
245
|
+
console.log('Telegram bot stopped');
|
|
246
|
+
};
|
|
247
|
+
process.once('SIGINT', async () => {
|
|
248
|
+
await shutdown();
|
|
249
|
+
});
|
|
250
|
+
process.once('SIGTERM', async () => {
|
|
251
|
+
await shutdown();
|
|
252
|
+
});
|
|
239
253
|
await bot.start();
|
|
240
254
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-remote-control",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Control OpenCode from anywhere via Telegram",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Control OpenCode from anywhere via Telegram or Feishu",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"opencode-remote": "./dist/cli.js"
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
"README.md"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
+
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
23
24
|
"@opencode-ai/sdk": "^1.2.27",
|
|
25
|
+
"@types/express": "^5.0.6",
|
|
26
|
+
"express": "^5.2.1",
|
|
24
27
|
"grammy": "^1.30.0"
|
|
25
28
|
},
|
|
26
29
|
"devDependencies": {
|
|
@@ -38,6 +41,8 @@
|
|
|
38
41
|
"keywords": [
|
|
39
42
|
"opencode",
|
|
40
43
|
"telegram",
|
|
44
|
+
"feishu",
|
|
45
|
+
"lark",
|
|
41
46
|
"remote-control",
|
|
42
47
|
"ai",
|
|
43
48
|
"coding"
|