opencode-remote-control 0.1.3 → 0.2.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 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
- ## Setup
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 # Start the bot
43
- opencode-remote config # Reconfigure token
44
- opencode-remote help # Show help
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
- ## Telegram Commands
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,45 @@ function printHelp() {
19
20
  Usage: opencode-remote [command]
20
21
 
21
22
  Commands:
22
- start Start the bot (default)
23
- config Configure Telegram bot token
24
- help Show this help message
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 # Start the bot
28
- opencode-remote start # Start the bot
29
- opencode-remote config # Configure token
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
+ process.stdin.once('data', (chunk) => {
49
+ resolve(chunk.toString().trim());
50
+ });
51
+ });
52
+ if (choice === '1' || choice.toLowerCase() === 'telegram') {
53
+ return 'telegram';
54
+ }
55
+ else if (choice === '2' || choice.toLowerCase() === 'feishu') {
56
+ return 'feishu';
57
+ }
58
+ // Default to telegram if invalid input
59
+ console.log('Invalid choice, defaulting to Telegram');
60
+ return 'telegram';
61
+ }
32
62
  async function promptToken() {
33
63
  console.log('\n📝 Setup required: Telegram Bot Token');
34
64
  console.log('\nHow to get a token:');
@@ -47,62 +77,281 @@ async function promptToken() {
47
77
  });
48
78
  return token;
49
79
  }
80
+ async function promptFeishuConfig() {
81
+ console.log('\n📝 Step 1: Create Feishu App');
82
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
83
+ console.log('');
84
+ console.log(' 1. Go to https://open.feishu.cn/app');
85
+ console.log(' 2. Click "创建企业自建应用" (Create enterprise app)');
86
+ console.log(' 3. Fill in app name and description');
87
+ console.log(' 4. Go to "凭证与基础信息" (Credentials) page');
88
+ console.log('');
89
+ process.stdout.write('Enter your App ID: ');
90
+ const appId = await new Promise((resolve) => {
91
+ process.stdin.setEncoding('utf8');
92
+ process.stdin.once('data', (chunk) => {
93
+ resolve(chunk.toString().trim());
94
+ });
95
+ });
96
+ process.stdout.write('Enter your App Secret: ');
97
+ const appSecret = await new Promise((resolve) => {
98
+ process.stdin.setEncoding('utf8');
99
+ process.stdin.once('data', (chunk) => {
100
+ resolve(chunk.toString().trim());
101
+ });
102
+ });
103
+ return { appId, appSecret };
104
+ }
105
+ function showFeishuSetupGuide() {
106
+ console.log('');
107
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
108
+ console.log(' 📋 Step 2: Configure App Permissions');
109
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
110
+ console.log('');
111
+ console.log(' Go to "权限管理" (Permission Management) page');
112
+ console.log(' Search and enable these permissions:');
113
+ console.log('');
114
+ console.log(' ┌────────────────────────────────────────────────────┐');
115
+ console.log(' │ Permission │ Scope │');
116
+ console.log(' ├────────────────────────────────────────────────────┤');
117
+ console.log(' │ im:message 获取与发送消息 │');
118
+ console.log(' │ im:message:send_as_bot 以应用身份发消息 │');
119
+ console.log(' │ im:message:receive_as_bot 接收机器人消息 │');
120
+ console.log(' └────────────────────────────────────────────────────┘');
121
+ console.log('');
122
+ console.log(' 💡 TIP: Copy the JSON below and use "批量添加" feature:');
123
+ console.log('');
124
+ console.log(' ┌────────────────────────────────────────────────────┐');
125
+ console.log(' │ [');
126
+ console.log(' │ "im:message",');
127
+ console.log(' │ "im:message:send_as_bot",');
128
+ console.log(' │ "im:message:receive_as_bot"');
129
+ console.log(' │ ]');
130
+ console.log(' └────────────────────────────────────────────────────┘');
131
+ console.log('');
132
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
133
+ console.log(' 🤖 Step 3: Enable Robot Capability');
134
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
135
+ console.log('');
136
+ console.log(' 1. Go to "应用能力" (App Capabilities) → "机器人" (Robot)');
137
+ console.log(' 2. Click "启用机器人" (Enable Robot)');
138
+ console.log(' 3. Set robot name and description');
139
+ console.log('');
140
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
141
+ console.log(' 🔗 Step 4: Configure Event Subscription');
142
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
143
+ console.log('');
144
+ console.log(' 1. Start the bot locally:');
145
+ console.log(' $ opencode-remote feishu');
146
+ console.log('');
147
+ console.log(' 2. Expose webhook with ngrok/cloudflared:');
148
+ console.log(' $ ngrok http 3001');
149
+ console.log('');
150
+ console.log(' 3. Go to "事件订阅" (Event Subscription) page');
151
+ console.log(' 4. Set Request URL: https://your-ngrok-url/feishu/webhook');
152
+ console.log(' 5. Add event: im.message.receive_v1');
153
+ console.log('');
154
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
155
+ console.log(' 📤 Step 5: Publish App');
156
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
157
+ console.log('');
158
+ console.log(' 1. Go to "版本管理与发布" (Version & Publish)');
159
+ console.log(' 2. Click "创建版本" (Create Version)');
160
+ console.log(' 3. Fill in version info and submit for review');
161
+ console.log(' 4. After approval, click "发布" (Publish)');
162
+ console.log(' 5. Search your bot in Feishu and start chatting!');
163
+ console.log('');
164
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
165
+ }
50
166
  async function getConfig() {
51
- // Check environment variable first (must be non-empty)
52
- const envToken = process.env.TELEGRAM_BOT_TOKEN;
53
- if (envToken && envToken.trim()) {
54
- return envToken.trim();
55
- }
167
+ const config = {
168
+ opencodeServerUrl: process.env.OPENCODE_SERVER_URL || 'http://localhost:3000',
169
+ tunnelUrl: process.env.TUNNEL_URL || '',
170
+ sessionIdleTimeoutMs: parseInt(process.env.SESSION_IDLE_TIMEOUT_MS || '1800000', 10),
171
+ cleanupIntervalMs: parseInt(process.env.CLEANUP_INTERVAL_MS || '300000', 10),
172
+ approvalTimeoutMs: parseInt(process.env.APPROVAL_TIMEOUT_MS || '300000', 10),
173
+ };
56
174
  // Check config file
57
175
  if (existsSync(CONFIG_FILE)) {
58
176
  const content = readFileSync(CONFIG_FILE, 'utf-8');
59
- const match = content.match(/TELEGRAM_BOT_TOKEN=(.+)/);
60
- if (match) {
61
- const token = match[1].trim();
177
+ // Parse Telegram token
178
+ const telegramMatch = content.match(/TELEGRAM_BOT_TOKEN=(.+)/);
179
+ if (telegramMatch) {
180
+ const token = telegramMatch[1].trim();
62
181
  if (token && token !== 'your_bot_token_here') {
63
- return token;
182
+ config.telegramBotToken = token;
64
183
  }
65
184
  }
185
+ // Parse Feishu config
186
+ const feishuAppIdMatch = content.match(/FEISHU_APP_ID=(.+)/);
187
+ if (feishuAppIdMatch) {
188
+ config.feishuAppId = feishuAppIdMatch[1].trim();
189
+ }
190
+ const feishuSecretMatch = content.match(/FEISHU_APP_SECRET=(.+)/);
191
+ if (feishuSecretMatch) {
192
+ config.feishuAppSecret = feishuSecretMatch[1].trim();
193
+ }
194
+ }
195
+ // Check environment variables
196
+ if (process.env.TELEGRAM_BOT_TOKEN?.trim()) {
197
+ config.telegramBotToken = process.env.TELEGRAM_BOT_TOKEN.trim();
198
+ }
199
+ if (process.env.FEISHU_APP_ID?.trim()) {
200
+ config.feishuAppId = process.env.FEISHU_APP_ID.trim();
201
+ }
202
+ if (process.env.FEISHU_APP_SECRET?.trim()) {
203
+ config.feishuAppSecret = process.env.FEISHU_APP_SECRET.trim();
66
204
  }
67
205
  // Check local .env
68
206
  const localEnv = join(process.cwd(), '.env');
69
207
  if (existsSync(localEnv)) {
70
208
  const content = readFileSync(localEnv, 'utf-8');
71
- const match = content.match(/TELEGRAM_BOT_TOKEN=(.+)/);
72
- if (match) {
73
- const token = match[1].trim();
74
- if (token && token !== 'your_bot_token_here') {
75
- return token;
209
+ const telegramMatch = content.match(/TELEGRAM_BOT_TOKEN=(.+)/);
210
+ if (telegramMatch) {
211
+ const token = telegramMatch[1].trim();
212
+ if (token && token !== 'your_bot_token_here' && !config.telegramBotToken) {
213
+ config.telegramBotToken = token;
76
214
  }
77
215
  }
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
+ }
78
224
  }
79
- return null;
225
+ return config;
80
226
  }
81
227
  async function saveConfig(token) {
82
228
  // Create config directory if needed
83
229
  if (!existsSync(CONFIG_DIR)) {
84
230
  mkdirSync(CONFIG_DIR, { recursive: true });
85
231
  }
86
- writeFileSync(CONFIG_FILE, `TELEGRAM_BOT_TOKEN=${token}\n`);
232
+ // Read existing config
233
+ let existing = '';
234
+ if (existsSync(CONFIG_FILE)) {
235
+ existing = readFileSync(CONFIG_FILE, 'utf-8');
236
+ }
237
+ // Add or update Telegram token
238
+ const lines = existing.split('\n').filter(line => !line.startsWith('TELEGRAM_BOT_TOKEN='));
239
+ lines.push(`TELEGRAM_BOT_TOKEN=${token}`);
240
+ writeFileSync(CONFIG_FILE, lines.join('\n'));
87
241
  console.log(`\n✅ Token saved to ${CONFIG_FILE}`);
88
242
  }
243
+ async function saveFeishuConfig(appId, appSecret) {
244
+ // Create config directory if needed
245
+ if (!existsSync(CONFIG_DIR)) {
246
+ mkdirSync(CONFIG_DIR, { recursive: true });
247
+ }
248
+ // Read existing config
249
+ let existing = '';
250
+ if (existsSync(CONFIG_FILE)) {
251
+ existing = readFileSync(CONFIG_FILE, 'utf-8');
252
+ }
253
+ // Filter out old Feishu config
254
+ const lines = existing.split('\n').filter(line => !line.startsWith('FEISHU_APP_ID=') &&
255
+ !line.startsWith('FEISHU_APP_SECRET='));
256
+ // Add new Feishu config
257
+ lines.push(`FEISHU_APP_ID=${appId}`);
258
+ lines.push(`FEISHU_APP_SECRET=${appSecret}`);
259
+ writeFileSync(CONFIG_FILE, lines.join('\n'));
260
+ console.log(`\n✅ Feishu config saved to ${CONFIG_FILE}`);
261
+ // Show setup guide
262
+ showFeishuSetupGuide();
263
+ }
89
264
  async function runConfig() {
90
265
  printBanner();
91
- const token = await promptToken();
92
- if (!token || token === 'your_bot_token_here') {
93
- console.log('\n❌ Invalid token. Please try again.');
266
+ // Let user select channel
267
+ const channel = await promptChannel();
268
+ if (channel === 'telegram') {
269
+ const token = await promptToken();
270
+ if (!token || token === 'your_bot_token_here') {
271
+ console.log('\n❌ Invalid token. Please try again.');
272
+ process.exit(1);
273
+ }
274
+ await saveConfig(token);
275
+ console.log('\n🚀 Ready! Run `opencode-remote` to start the bot.');
276
+ }
277
+ else {
278
+ const { appId, appSecret } = await promptFeishuConfig();
279
+ if (!appId || !appSecret) {
280
+ console.log('\n❌ Invalid credentials. Please try again.');
281
+ process.exit(1);
282
+ }
283
+ await saveFeishuConfig(appId, appSecret);
284
+ console.log('\n🚀 Ready! Run `opencode-remote feishu` to start the Feishu bot.');
285
+ }
286
+ process.exit(0);
287
+ }
288
+ async function runConfigFeishu() {
289
+ printBanner();
290
+ const { appId, appSecret } = await promptFeishuConfig();
291
+ if (!appId || !appSecret) {
292
+ console.log('\n❌ Invalid credentials. Please try again.');
94
293
  process.exit(1);
95
294
  }
96
- await saveConfig(token);
295
+ await saveFeishuConfig(appId, appSecret);
97
296
  console.log('\n🚀 Ready! Run `opencode-remote` to start the bot.');
98
297
  process.exit(0);
99
298
  }
299
+ function hasTelegramConfig(config) {
300
+ return !!(config.telegramBotToken?.trim());
301
+ }
302
+ function hasFeishuConfig(config) {
303
+ return !!(config.feishuAppId?.trim() &&
304
+ config.feishuAppSecret?.trim());
305
+ }
100
306
  async function runStart() {
101
- const token = await getConfig();
102
- // Set token in environment for startBot to use
103
- if (token) {
104
- process.env.TELEGRAM_BOT_TOKEN = token;
307
+ const config = await getConfig();
308
+ printBanner();
309
+ // Check what's configured
310
+ const hasTelegram = hasTelegramConfig(config);
311
+ const hasFeishu = hasFeishuConfig(config);
312
+ if (!hasTelegram && !hasFeishu) {
313
+ console.log('❌ No bots configured!');
314
+ console.log('\nRun one of:');
315
+ console.log(' opencode-remote config # Configure Telegram');
316
+ console.log(' opencode-remote config-feishu # Configure Feishu');
317
+ process.exit(1);
318
+ }
319
+ // Start bots
320
+ const promises = [];
321
+ if (hasTelegram) {
322
+ console.log('🤖 Starting Telegram bot...');
323
+ process.env.TELEGRAM_BOT_TOKEN = config.telegramBotToken;
324
+ promises.push(startBot().catch((err) => {
325
+ console.error('Telegram bot failed:', err);
326
+ return { status: 'rejected', reason: err };
327
+ }));
328
+ }
329
+ if (hasFeishu) {
330
+ console.log('🤖 Starting Feishu bot...');
331
+ promises.push(startFeishuBot(config).catch((err) => {
332
+ console.error('Feishu bot failed:', err);
333
+ return { status: 'rejected', reason: err };
334
+ }));
105
335
  }
336
+ // Wait for all bots
337
+ const results = await Promise.allSettled(promises);
338
+ const failed = results.filter((r) => r.status === 'rejected');
339
+ if (failed.length > 0) {
340
+ console.log(`\n⚠️ ${failed.length} bot(s) failed to start`);
341
+ process.exit(1);
342
+ }
343
+ console.log('\n✅ All bots started!');
344
+ }
345
+ async function runTelegramOnly() {
346
+ const config = await getConfig();
347
+ if (!hasTelegramConfig(config)) {
348
+ console.log('❌ Telegram bot not configured!');
349
+ console.log('\nRun: opencode-remote config');
350
+ process.exit(1);
351
+ }
352
+ printBanner();
353
+ console.log('🤖 Starting Telegram bot...');
354
+ process.env.TELEGRAM_BOT_TOKEN = config.telegramBotToken;
106
355
  try {
107
356
  await startBot();
108
357
  }
@@ -111,6 +360,23 @@ async function runStart() {
111
360
  process.exit(1);
112
361
  }
113
362
  }
363
+ async function runFeishuOnly() {
364
+ const config = await getConfig();
365
+ if (!hasFeishuConfig(config)) {
366
+ console.log('❌ Feishu bot not configured!');
367
+ console.log('\nRun: opencode-remote config-feishu');
368
+ process.exit(1);
369
+ }
370
+ printBanner();
371
+ console.log('🤖 Starting Feishu bot...');
372
+ try {
373
+ await startFeishuBot(config);
374
+ }
375
+ catch (error) {
376
+ console.error('Failed to start:', error);
377
+ process.exit(1);
378
+ }
379
+ }
114
380
  // Main CLI
115
381
  const args = process.argv.slice(2);
116
382
  const command = args[0] || 'start';
@@ -118,9 +384,18 @@ switch (command) {
118
384
  case 'start':
119
385
  runStart();
120
386
  break;
387
+ case 'telegram':
388
+ runTelegramOnly();
389
+ break;
390
+ case 'feishu':
391
+ runFeishuOnly();
392
+ break;
121
393
  case 'config':
122
394
  runConfig();
123
395
  break;
396
+ case 'config-feishu':
397
+ runConfigFeishu();
398
+ break;
124
399
  case 'help':
125
400
  case '--help':
126
401
  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.sendTyping(ctx.threadId);
16
+ await deps.sendTypingIndicator(ctx.threadId);
17
17
  // TODO: Actually send to OpenCode SDK
18
18
  // For now, echo back
19
- await deps.sendMessage(ctx.threadId, TEMPLATES.thinking());
19
+ await deps.reply(ctx.threadId, TEMPLATES.thinking());
20
20
  // Simulate response
21
21
  setTimeout(async () => {
22
- await deps.sendMessage(ctx.threadId, TEMPLATES.taskCompleted([
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.sendMessage(ctx.threadId, TEMPLATES.botStarted());
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.sendMessage(ctx.threadId, `✅ Connected\n\n💬 Session: ${session.id.slice(0, 8)}\n⏰ Idle: ${Math.round((Date.now() - session.lastActivity) / 1000)}s`);
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.sendMessage(ctx.threadId, '🔄 Session reset. Start fresh!');
54
+ await deps.reply(ctx.threadId, '🔄 Session reset. Start fresh!');
55
55
  break;
56
56
  default:
57
- await deps.sendMessage(ctx.threadId, `${EMOJI.WARNING} Unknown command: ${command}\n\nTry /help`);
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.sendMessage(ctx.threadId, '🤷 Nothing to approve right now');
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.sendMessage(ctx.threadId, TEMPLATES.approved());
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.sendMessage(ctx.threadId, '🤷 Nothing to reject right now');
75
+ await deps.reply(ctx.threadId, '🤷 Nothing to reject right now');
76
76
  return;
77
77
  }
78
78
  session.pendingApprovals.shift();
79
- await deps.sendMessage(ctx.threadId, TEMPLATES.rejected());
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.sendMessage(ctx.threadId, '📄 No pending changes to show');
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.sendMessage(ctx.threadId, msg);
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.sendMessage(ctx.threadId, '📄 No files changed in this session');
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.sendMessage(ctx.threadId, `📄 Changed files:\n${fileList}`);
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.sendMessage(ctx.threadId, message);
109
+ await deps.reply(ctx.threadId, message);
110
110
  // Wait for user response
111
111
  return waitForApproval(request);
112
112
  }
@@ -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,350 @@
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.on('SIGINT', () => {
343
+ console.log('\n🛑 Shutting down Feishu bot...');
344
+ server.close(() => {
345
+ console.log('Feishu bot stopped');
346
+ resolve();
347
+ });
348
+ });
349
+ });
350
+ }
@@ -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(threadId, title = `Remote control session`) {
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
- // Share the session to get a URL
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
- 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}`);
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,
@@ -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
- await ctx.reply('⏳ Creating session...');
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
- // Send prompt to OpenCode
171
- await ctx.reply('⏳ Thinking...');
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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-remote-control",
3
- "version": "0.1.3",
4
- "description": "Control OpenCode from anywhere via Telegram",
3
+ "version": "0.2.0",
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"