opencode-remote-control 0.2.4 → 0.2.7

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
@@ -1,5 +1,7 @@
1
1
  # OpenCode Remote Control
2
2
 
3
+ > **Lightning-fast AI coding assistant powered by OpenCode — no Claude Code subscription required!**
4
+
3
5
  <p align="center">
4
6
  <a href="https://github.com/ceociocto/opencode-remote-control/actions/workflows/publish.yml?query=branch%3Amain"><img src="https://img.shields.io/github/actions/workflow/status/ceociocto/opencode-remote-control/publish.yml?branch=main&style=for-the-badge" alt="CI status"></a>
5
7
  <a href="https://www.npmjs.com/package/opencode-remote-control"><img src="https://img.shields.io/npm/v/opencode-remote-control?style=for-the-badge" alt="npm version"></a>
@@ -11,19 +13,45 @@
11
13
  <a href="./README_CN.md">中文文档</a>
12
14
  </p>
13
15
 
14
- <p align="center">
15
- Control OpenCode from anywhere via Telegram or Feishu.
16
- </p>
16
+ Control OpenCode from anywhere via Telegram or Feishu.
17
+
18
+ ## What's New in v0.2
19
+
20
+ - **Feishu Channel** — Now supports Feishu/Lark bot with WebSocket long connection (no tunnel required!)
21
+ - **Faster Responses** — Optimized timeout handling for long AI responses
22
+ - **Better CLI** — Improved configuration experience with existing value display
23
+
24
+ ## Requirements
25
+
26
+ - Node.js >= 18.0.0
27
+ - [OpenCode](https://github.com/opencode-ai/opencode) installed and accessible in PATH
28
+ - Telegram account (for Telegram bot)
29
+ - Feishu account (for Feishu bot)
30
+ - **No** ngrok or cloudflared required (Feishu uses WebSocket long connection)
31
+
32
+ ### Verify OpenCode Installation
33
+
34
+ Before starting, make sure OpenCode is installed and accessible:
35
+
36
+ ```bash
37
+ opencode --version
38
+ ```
39
+
40
+ If you see `command not found`, install OpenCode first:
41
+
42
+ ```bash
43
+ npm install -g @opencode-ai/opencode
44
+ ```
17
45
 
18
46
  ## Installation
19
47
 
20
48
  ```bash
21
49
  # Install globally with npm, pnpm, or bun
22
- npm install -g opencode-remote-control
50
+ npm install -g opencode-remote-control@latest
23
51
  # or
24
- pnpm install -g opencode-remote-control
52
+ pnpm install -g opencode-remote-control@latest
25
53
  # or
26
- bun install -g opencode-remote-control
54
+ bun install -g opencode-remote-control@latest
27
55
  ```
28
56
 
29
57
  ## Configuration
@@ -46,7 +74,66 @@ Token is saved to `~/.opencode-remote/.env`
46
74
 
47
75
  ### Feishu Setup
48
76
 
49
- For detailed Feishu setup instructions, see [Feishu Setup Guide](./docs/FEISHU_SETUP_EN.md) or [飞书配置指南](./docs/FEISHU_SETUP.md).
77
+ Feishu uses **WebSocket Long Connection Mode** - no public IP or tunnel required!
78
+
79
+ #### Step 1: Create Feishu App
80
+
81
+ 1. Visit [Feishu Open Platform](https://open.feishu.cn/) and login
82
+ 2. Go to "Developer Console" → "Create Enterprise App"
83
+ 3. Get **App ID** and **App Secret** from "Credentials & Basic Info"
84
+
85
+ #### Step 2: Configure Permissions
86
+
87
+ Go to "Permission Management" → "API Permissions" and enable:
88
+
89
+ | Permission | ID |
90
+ |------------|---|
91
+ | Get and send messages | `im:message` |
92
+ | Send messages as bot | `im:message:send_as_bot` |
93
+ | Receive bot messages | `im:message:receive_as_bot` |
94
+
95
+ #### Step 3: Enable Bot
96
+
97
+ 1. Go to "App Capabilities" → "Bot"
98
+ 2. Enable "Enable Bot"
99
+ 3. Enable "Bot can proactively send messages to users"
100
+ 4. Enable "Users can have private chats with bot"
101
+
102
+ #### Step 4: Configure Event Subscription (Critical!)
103
+
104
+ 1. Go to "Event Subscription" page
105
+ 2. **Subscription Method**: Select "**Use long connection to receive events**"
106
+ > ⚠️ Important: Choose "Use long connection to receive events", NOT "Send events to developer server"
107
+ 3. Click "Add Event" and select:
108
+ - `im.message.receive_v1` - Receive messages
109
+ 4. Save configuration
110
+
111
+ #### Step 5: Run Config Command
112
+
113
+ ```bash
114
+ opencode-remote config
115
+ ```
116
+
117
+ Select "Feishu" and enter your App ID and App Secret.
118
+
119
+ #### Step 6: Publish App
120
+
121
+ 1. Go to "Version Management & Publishing"
122
+ 2. Create version → Request publishing → Publish
123
+
124
+ ---
125
+
126
+ For detailed setup instructions, see [Feishu Setup Guide](./docs/FEISHU_SETUP_EN.md) or [飞书配置指南](./docs/FEISHU_SETUP.md).
127
+
128
+ ## Start Service
129
+
130
+ Once configured, start the bot service:
131
+
132
+ ```bash
133
+ opencode-remote
134
+ ```
135
+
136
+ That's it! You can now send messages to your Telegram bot or Feishu bot to control OpenCode remotely.
50
137
 
51
138
  ## Usage
52
139
 
@@ -113,18 +200,18 @@ Both Telegram and Feishu support the same commands:
113
200
 
114
201
  The Telegram bot uses **Polling Mode** to fetch messages from Telegram servers, requiring no tunnel or public IP configuration.
115
202
 
116
- ### Feishu (Webhook Mode)
203
+ ### Feishu (Long Connection Mode)
117
204
 
118
205
  ```
119
206
  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
120
- │ Feishu │───▶│ Feishu │───▶│ Webhook
121
- │ Client │ │ Server │ │ (ngrok)
207
+ │ Feishu │───▶│ Feishu │───▶│ WebSocket
208
+ │ Client │ │ Server │ │ (Long Conn)
122
209
  └─────────────┘ └─────────────┘ └──────┬──────┘
123
210
 
124
211
 
125
212
  ┌─────────────┐
126
213
  │ Feishu Bot │
127
- │ (port 3001)│
214
+ │ (Local)
128
215
  └──────┬──────┘
129
216
 
130
217
 
@@ -134,15 +221,7 @@ The Telegram bot uses **Polling Mode** to fetch messages from Telegram servers,
134
221
  └─────────────┘
135
222
  ```
136
223
 
137
- The Feishu bot uses **Webhook Mode** and requires a tunnel (ngrok/cloudflared) to receive messages.
138
-
139
- ## Requirements
140
-
141
- - Node.js >= 18.0.0
142
- - [OpenCode](https://github.com/opencode-ai/opencode) installed
143
- - Telegram account (for Telegram bot)
144
- - Feishu account (for Feishu bot)
145
- - ngrok or cloudflared (for Feishu webhook)
224
+ The Feishu bot uses **WebSocket Long Connection Mode** - no public IP or tunnel (ngrok/cloudflared) required!
146
225
 
147
226
  ## Contributing
148
227
 
package/dist/cli.js CHANGED
@@ -103,7 +103,24 @@ async function promptToken() {
103
103
  return token;
104
104
  }
105
105
  async function promptFeishuConfig() {
106
- console.log('\n📝 Step 1: Create Feishu App');
106
+ // Read existing config to show as defaults
107
+ let existingAppId = '';
108
+ let existingAppSecret = '';
109
+ if (existsSync(CONFIG_FILE)) {
110
+ const content = readFileSync(CONFIG_FILE, 'utf-8');
111
+ const appIdMatch = content.match(/FEISHU_APP_ID=(.+)/);
112
+ if (appIdMatch)
113
+ existingAppId = appIdMatch[1].trim();
114
+ const appSecretMatch = content.match(/FEISHU_APP_SECRET=(.+)/);
115
+ if (appSecretMatch)
116
+ existingAppSecret = appSecretMatch[1].trim();
117
+ }
118
+ console.log('');
119
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
120
+ console.log(' 📁 Config file: ' + CONFIG_FILE);
121
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
122
+ console.log('');
123
+ console.log('📝 Step 1: Create Feishu App');
107
124
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
108
125
  console.log('');
109
126
  console.log(' 1. Go to https://open.feishu.cn/app');
@@ -111,8 +128,20 @@ async function promptFeishuConfig() {
111
128
  console.log(' 3. Fill in app name and description');
112
129
  console.log(' 4. Go to "凭证与基础信息" (Credentials) page');
113
130
  console.log('');
114
- const promptInput = async (promptText) => {
115
- process.stdout.write(promptText);
131
+ console.log('⚠️ Important: In "事件订阅" (Event Subscription),');
132
+ console.log(' select "使用长连接接收事件" (Use long connection to receive events)');
133
+ console.log(' This allows the bot to work without ngrok/cloudflared!');
134
+ console.log('');
135
+ const promptInput = async (promptText, defaultValue) => {
136
+ if (defaultValue) {
137
+ const masked = defaultValue.length > 8
138
+ ? defaultValue.slice(0, 4) + '****' + defaultValue.slice(-4)
139
+ : '****';
140
+ process.stdout.write(`${promptText} [current: ${masked}]: `);
141
+ }
142
+ else {
143
+ process.stdout.write(promptText);
144
+ }
116
145
  return new Promise((resolve) => {
117
146
  process.stdin.resume();
118
147
  process.stdin.setEncoding('utf8');
@@ -128,18 +157,20 @@ async function promptFeishuConfig() {
128
157
  process.once('SIGINT', onSigint);
129
158
  process.stdin.once('data', (chunk) => {
130
159
  cleanup();
131
- resolve(chunk.toString().trim());
160
+ const input = chunk.toString().trim();
161
+ // If user presses enter without input, use default value
162
+ resolve(input || defaultValue);
132
163
  });
133
164
  });
134
165
  };
135
- const appId = await promptInput('Enter your App ID: ');
166
+ const appId = await promptInput('Enter your App ID', existingAppId);
136
167
  if (!appId) {
137
- console.log('\nCancelled');
168
+ console.log('\n❌ App ID is required. Press Ctrl+C to cancel.');
138
169
  process.exit(0);
139
170
  }
140
- const appSecret = await promptInput('Enter your App Secret: ');
171
+ const appSecret = await promptInput('Enter your App Secret', existingAppSecret);
141
172
  if (!appSecret) {
142
- console.log('\nCancelled');
173
+ console.log('\n❌ App Secret is required. Press Ctrl+C to cancel.');
143
174
  process.exit(0);
144
175
  }
145
176
  return { appId, appSecret };
@@ -150,51 +181,68 @@ function showFeishuSetupGuide() {
150
181
  console.log(' 📋 Step 2: Configure App Permissions');
151
182
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
152
183
  console.log('');
153
- console.log(' Go to "权限管理" (Permission Management) page');
154
- console.log(' Search and enable these permissions:');
184
+ console.log(' Go to: 权限管理 API权限');
155
185
  console.log('');
156
- console.log(' ┌────────────────────────────────────────────────────┐');
157
- console.log(' │ Permission │ Scope │');
158
- console.log(' ├────────────────────────────────────────────────────┤');
159
- console.log(' │ im:message 获取与发送消息 │');
160
- console.log(' │ im:message:send_as_bot 以应用身份发消息 │');
161
- console.log(' │ im:message:receive_as_bot 接收机器人消息 │');
162
- console.log(' └────────────────────────────────────────────────────┘');
163
- console.log('');
164
- console.log(' 💡 TIP: Copy the JSON below and use "批量添加" feature:');
186
+ console.log(' Click "批量添加" (Batch Add) and paste this JSON:');
165
187
  console.log('');
166
188
  console.log(' ┌────────────────────────────────────────────────────┐');
167
- console.log(' │ [');
168
- console.log(' │ "im:message",');
169
- console.log(' │ "im:message:send_as_bot",');
170
- console.log(' │ "im:message:receive_as_bot"');
171
- console.log(' │ ]');
189
+ console.log(' │');
190
+ console.log(' │ { │');
191
+ console.log(' │ "im:message",');
192
+ console.log(' │ "im:message:send_as_bot", │');
193
+ console.log(' │ "im:message:receive_as_bot" │');
194
+ console.log(' │ } │');
195
+ console.log(' │ │');
172
196
  console.log(' └────────────────────────────────────────────────────┘');
173
197
  console.log('');
198
+ console.log(' 📋 Or add manually:');
199
+ console.log(' • im:message - 获取与发送消息');
200
+ console.log(' • im:message:send_as_bot - 以应用身份发消息');
201
+ console.log(' • im:message:receive_as_bot - 接收机器人消息');
202
+ console.log('');
174
203
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
175
204
  console.log(' 🤖 Step 3: Enable Robot Capability');
176
205
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
177
206
  console.log('');
178
- console.log(' 1. Go to "应用能力" (App Capabilities) "机器人" (Robot)');
179
- console.log(' 2. Click "启用机器人" (Enable Robot)');
180
- console.log(' 3. Set robot name and description');
207
+ console.log(' Go to: 应用能力 → 机器人');
208
+ console.log('');
209
+ console.log(' Enable these options:');
210
+ console.log(' ☑️ 启用机器人');
211
+ console.log(' ☑️ 机器人可主动发送消息给用户');
212
+ console.log(' ☑️ 用户可与机器人进行单聊');
213
+ console.log('');
214
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
215
+ console.log(' 🚀 Step 4: Start the Bot FIRST! (CRITICAL)');
216
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
217
+ console.log('');
218
+ console.log(' ⚠️ You MUST start the bot BEFORE configuring event subscription!');
219
+ console.log('');
220
+ console.log(' Run in another terminal:');
221
+ console.log('');
222
+ console.log(' opencode-remote feishu');
223
+ console.log('');
224
+ console.log(' Wait for: "ws client ready" (WebSocket connected)');
225
+ console.log('');
226
+ console.log(' ✨ Long Connection Mode = NO ngrok/cloudflared needed!');
181
227
  console.log('');
182
228
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
183
- console.log(' 🔗 Step 4: Configure Event Subscription');
229
+ console.log(' 🔗 Step 5: Configure Event Subscription');
184
230
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
185
231
  console.log('');
186
- console.log(' 1. Start the bot locally:');
187
- console.log(' $ opencode-remote feishu');
232
+ console.log(' (Make sure the bot is running! If not, start it first)');
188
233
  console.log('');
189
- console.log(' 2. Expose webhook with ngrok/cloudflared:');
190
- console.log(' $ ngrok http 3001');
234
+ console.log(' 1. Go to "事件订阅" (Event Subscription) page');
235
+ console.log(' 2. 订阅方式: 选择 "使用长连接接收事件"');
236
+ console.log(' (NOT "将事件发送至开发者服务器"!)');
237
+ console.log(' 3. Click "添加事件" and select:');
238
+ console.log(' - im.message.receive_v1 (接收消息)');
239
+ console.log(' 4. Save configuration');
191
240
  console.log('');
192
- console.log(' 3. Go to "事件订阅" (Event Subscription) page');
193
- console.log(' 4. Set Request URL: https://your-ngrok-url/feishu/webhook');
194
- console.log(' 5. Add event: im.message.receive_v1');
241
+ console.log(' If you see "未检测到应用连接信息":');
242
+ console.log(' → The bot is not running. Start it first!');
195
243
  console.log('');
196
244
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
197
- console.log(' 📤 Step 5: Publish App');
245
+ console.log(' 📤 Step 6: Publish App');
198
246
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
199
247
  console.log('');
200
248
  console.log(' 1. Go to "版本管理与发布" (Version & Publish)');
@@ -18,9 +18,6 @@ export function loadConfig() {
18
18
  telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || undefined,
19
19
  feishuAppId: process.env.FEISHU_APP_ID || undefined,
20
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),
24
21
  opencodeServerUrl: process.env.OPENCODE_SERVER_URL || 'http://localhost:3000',
25
22
  tunnelUrl: process.env.TUNNEL_URL || '',
26
23
  sessionIdleTimeoutMs: parseInt(process.env.SESSION_IDLE_TIMEOUT_MS || '1800000', 10),
@@ -1,11 +1,12 @@
1
1
  // Feishu bot implementation for OpenCode Remote Control
2
- import express from 'express';
2
+ // Uses WebSocket long connection mode - no tunnel/ngrok required!
3
3
  import * as lark from '@larksuiteoapi/node-sdk';
4
4
  import { initSessionManager, getOrCreateSession } from '../core/session.js';
5
5
  import { splitMessage } from '../core/notifications.js';
6
6
  import { EMOJI } from '../core/types.js';
7
7
  import { initOpenCode, createSession, sendMessage, checkConnection } from '../opencode/client.js';
8
8
  let feishuClient = null;
9
+ let wsClient = null;
9
10
  let config = null;
10
11
  let openCodeSessions = null;
11
12
  // Map Feishu event to shared MessageContext
@@ -41,8 +42,24 @@ function createFeishuAdapter(client) {
41
42
  }
42
43
  },
43
44
  async sendTypingIndicator(threadId) {
44
- // Feishu doesn't have typing indicator
45
- // Could optionally send a "thinking..." message, but we'll skip for now
45
+ // Feishu doesn't have native typing indicator API
46
+ // We send a "thinking" message that will be deleted later
47
+ const chatId = threadId.replace('feishu:', '');
48
+ try {
49
+ const result = await client.im.message.create({
50
+ params: { receive_id_type: 'chat_id' },
51
+ data: {
52
+ receive_id: chatId,
53
+ msg_type: 'text',
54
+ content: JSON.stringify({ text: '⏳ 思考中...' }),
55
+ },
56
+ });
57
+ return result.data?.message_id || '';
58
+ }
59
+ catch (error) {
60
+ console.error('Failed to send typing indicator:', error);
61
+ return '';
62
+ }
46
63
  },
47
64
  async deleteMessage(threadId, messageId) {
48
65
  if (!messageId)
@@ -176,13 +193,18 @@ Cannot connect to OpenCode server.
176
193
  🔄 /retry — check again`);
177
194
  return;
178
195
  }
179
- // Send typing indicator (Feishu style)
180
- const typingMsg = await adapter.reply(ctx.threadId, '⏳');
196
+ // Send typing indicator
197
+ console.log('⏳ Sending typing indicator...');
198
+ const typingMsgId = await adapter.sendTypingIndicator(ctx.threadId);
181
199
  // Get or create OpenCode session
182
200
  let openCodeSession = openCodeSessions?.get(ctx.threadId);
183
201
  if (!openCodeSession) {
184
202
  const newSession = await createSession(ctx.threadId, `Feishu chat ${ctx.threadId}`);
185
203
  if (!newSession) {
204
+ // Delete typing indicator before error message
205
+ if (typingMsgId && adapter.deleteMessage) {
206
+ await adapter.deleteMessage(ctx.threadId, typingMsgId);
207
+ }
186
208
  await adapter.reply(ctx.threadId, '❌ Failed to create OpenCode session');
187
209
  return;
188
210
  }
@@ -195,10 +217,12 @@ Cannot connect to OpenCode server.
195
217
  }
196
218
  }
197
219
  try {
220
+ console.log('🤖 Sending to OpenCode...');
198
221
  const response = await sendMessage(openCodeSession, text);
222
+ console.log('✅ Got response from OpenCode');
199
223
  // Delete typing indicator
200
- if (adapter.deleteMessage && typingMsg) {
201
- await adapter.deleteMessage(ctx.threadId, typingMsg);
224
+ if (typingMsgId && adapter.deleteMessage) {
225
+ await adapter.deleteMessage(ctx.threadId, typingMsgId);
202
226
  }
203
227
  // Split long messages
204
228
  const messages = splitMessage(response);
@@ -208,6 +232,10 @@ Cannot connect to OpenCode server.
208
232
  }
209
233
  catch (error) {
210
234
  console.error('Error sending message:', error);
235
+ // Delete typing indicator on error too
236
+ if (typingMsgId && adapter.deleteMessage) {
237
+ await adapter.deleteMessage(ctx.threadId, typingMsgId);
238
+ }
211
239
  await adapter.reply(ctx.threadId, `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
212
240
  }
213
241
  }
@@ -228,13 +256,14 @@ function checkRateLimit(chatId) {
228
256
  entry.count++;
229
257
  return true;
230
258
  }
231
- // Start Feishu bot
259
+ // Start Feishu bot using WebSocket long connection mode
260
+ // No tunnel/ngrok required - just needs internet access!
232
261
  export async function startFeishuBot(botConfig) {
233
262
  config = botConfig;
234
263
  if (!config.feishuAppId || !config.feishuAppSecret) {
235
- throw new Error('Feishu credentials not configured');
264
+ throw new Error('Feishu credentials not configured. Run: opencode-remote config');
236
265
  }
237
- // Initialize Feishu client
266
+ // Initialize Feishu client for sending messages
238
267
  feishuClient = new lark.Client({
239
268
  appId: config.feishuAppId,
240
269
  appSecret: config.feishuAppSecret,
@@ -255,103 +284,116 @@ export async function startFeishuBot(botConfig) {
255
284
  console.error('❌ Failed to initialize OpenCode:', error);
256
285
  console.log('Make sure OpenCode is running');
257
286
  }
258
- // Create adapter
287
+ // Create adapter for sending messages
259
288
  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') {
289
+ // Create WebSocket client for long connection
290
+ wsClient = new lark.WSClient({
291
+ appId: config.feishuAppId,
292
+ appSecret: config.feishuAppSecret,
293
+ // For international Lark, add: domain: lark.Domain.Lark
294
+ });
295
+ // Create event dispatcher for handling incoming messages
296
+ const eventDispatcher = new lark.EventDispatcher({}).register({
297
+ // Handle incoming messages
298
+ 'im.message.receive_v1': async (data) => {
299
+ console.log('📩 Received message event:', JSON.stringify(data, null, 2));
280
300
  try {
281
301
  // Validate event data
282
- if (!event.event?.message) {
283
- res.status(400).json({ code: -1, msg: 'Missing message data' });
284
- return;
302
+ if (!data?.message) {
303
+ console.warn('Received message event without message data');
304
+ return { code: 0 };
285
305
  }
286
- const ctx = feishuEventToContext(event.event);
287
- const chatId = event.event.message.chat_id;
306
+ const chatId = data.message.chat_id;
307
+ console.log(`💬 Message from chat: ${chatId}`);
288
308
  // Rate limiting
289
309
  if (!checkRateLimit(chatId)) {
290
310
  console.warn(`Rate limit exceeded for chat: ${chatId}`);
291
- res.status(429).json({ code: -1, msg: 'Rate limit exceeded' });
292
- return;
311
+ return { code: 0 };
293
312
  }
294
313
  // Parse message content
295
314
  let text = '';
296
315
  try {
297
- const content = JSON.parse(event.event.message.content);
316
+ const content = JSON.parse(data.message.content);
298
317
  text = content.text || '';
318
+ console.log(`📝 Message text: ${text}`);
299
319
  }
300
320
  catch {
301
321
  // If not JSON, try to use raw content
302
- text = event.event.message.content || '';
322
+ text = data.message.content || '';
323
+ console.log(`📝 Raw message content: ${text}`);
303
324
  }
304
325
  // Skip empty messages
305
326
  if (!text.trim()) {
306
- res.json({ code: 0 });
307
- return;
327
+ console.log('⏭️ Skipping empty message');
328
+ return { code: 0 };
308
329
  }
309
- // Handle the message
310
- await handleMessage(adapter, ctx, text);
311
- res.json({ code: 0 });
330
+ // Create message context
331
+ const ctx = feishuEventToContext(data);
332
+ // Handle the message (async, don't wait)
333
+ handleMessage(adapter, ctx, text).catch(error => {
334
+ console.error('Error handling Feishu message:', error);
335
+ });
336
+ return { code: 0 };
312
337
  }
313
338
  catch (error) {
314
- console.error('Feishu webhook error:', error);
315
- res.status(500).json({ code: -1, msg: 'Internal error' });
339
+ console.error('Feishu event handler error:', error);
340
+ return { code: 0 };
316
341
  }
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
- });
342
+ },
356
343
  });
344
+ // Start WebSocket long connection
345
+ console.log('🔗 Starting Feishu WebSocket long connection...');
346
+ console.log('');
347
+ console.log('✨ Long connection mode - NO tunnel/ngrok required!');
348
+ console.log(' Just make sure your computer can access the internet.');
349
+ console.log('');
350
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
351
+ console.log(' 📋 Configuration Checklist');
352
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
353
+ console.log('');
354
+ console.log(' Step 1: Add Permissions (权限管理 → API权限)');
355
+ console.log(' ────────────────────────────────────────');
356
+ console.log(' Click "批量添加" (Batch Add) and paste this JSON:');
357
+ console.log('');
358
+ console.log(' ┌────────────────────────────────────────────────────┐');
359
+ console.log(' │ { │');
360
+ console.log(' │ "im:message", │');
361
+ console.log(' │ "im:message:send_as_bot", │');
362
+ console.log(' │ "im:message:receive_as_bot" │');
363
+ console.log(' │ } │');
364
+ console.log(' └────────────────────────────────────────────────────┘');
365
+ console.log('');
366
+ console.log(' Step 2: Enable Robot (应用能力 → 机器人)');
367
+ console.log(' ────────────────────────────────────────');
368
+ console.log(' - Enable "启用机器人"');
369
+ console.log(' - Enable "机器人可主动发送消息给用户"');
370
+ console.log(' - Enable "用户可与机器人进行单聊"');
371
+ console.log('');
372
+ console.log(' Step 3: Event Subscription (事件订阅)');
373
+ console.log(' ────────────────────────────────────────');
374
+ console.log(' ⚠️ MUST start this bot BEFORE saving event config!');
375
+ console.log(' - Select "使用长连接接收事件"');
376
+ console.log(' - Add event: im.message.receive_v1');
377
+ console.log(' - Then click Save');
378
+ console.log('');
379
+ console.log(' Step 4: Publish App (版本管理与发布)');
380
+ console.log(' ────────────────────────────────────────');
381
+ console.log(' - Create version → Request publishing → Publish');
382
+ console.log('');
383
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
384
+ console.log(' 🔍 Debug: Send a message to your bot in Feishu!');
385
+ console.log(' You should see: 📩 Received message event');
386
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
387
+ console.log('');
388
+ // The start() method will block the main thread
389
+ // Handle graceful shutdown
390
+ const shutdown = () => {
391
+ console.log('\n🛑 Shutting down Feishu bot...');
392
+ // WSClient doesn't have a stop method, just let the process exit
393
+ process.exit(0);
394
+ };
395
+ process.once('SIGINT', shutdown);
396
+ process.once('SIGTERM', shutdown);
397
+ // Start the WebSocket client - this will block until process is killed
398
+ await wsClient.start({ eventDispatcher });
357
399
  }
@@ -1,15 +1,118 @@
1
1
  // OpenCode SDK client for remote control
2
+ import { createRequire } from 'node:module';
3
+ import { platform } from 'node:os';
2
4
  import { createOpencode } from '@opencode-ai/sdk';
5
+ const require = createRequire(import.meta.url);
6
+ const childProcess = require('node:child_process');
7
+ // Patch undici's default HeadersTimeout (30s) to 30 minutes for long AI responses.
8
+ // undici is Node.js's default fetch implementation and enforces headersTimeout on
9
+ // every request. Without this, streaming/processing large AI responses timeout.
10
+ let fetchPatched = false;
11
+ function patchFetchForUnlimitedTimeout() {
12
+ if (fetchPatched || typeof globalThis.fetch !== 'function')
13
+ return;
14
+ fetchPatched = true;
15
+ const originalFetch = globalThis.fetch;
16
+ // @ts-ignore - internal API
17
+ const originalDispatcher = originalFetch[Symbol.for('undici.globalDispatcher.1')];
18
+ if (originalDispatcher) {
19
+ // @ts-ignore
20
+ originalDispatcher.headersTimeout = 30 * 60 * 1000;
21
+ // @ts-ignore
22
+ originalDispatcher.bodyTimeout = 30 * 60 * 1000;
23
+ }
24
+ globalThis.fetch = function (input, init) {
25
+ if (init?.signal) {
26
+ const { signal: _signal, ...rest } = init;
27
+ return originalFetch(input, rest);
28
+ }
29
+ return originalFetch(input, init);
30
+ };
31
+ }
32
+ // Windows compatibility: patch child_process.spawn to use shell for 'opencode' command
33
+ // This is needed because Windows requires shell: true to execute .cmd files
34
+ if (platform() === 'win32') {
35
+ const originalSpawn = childProcess.spawn;
36
+ // @ts-ignore - monkey patching for Windows compatibility
37
+ childProcess.spawn = function (command, args, options = {}) {
38
+ if (command === 'opencode' && !options.shell) {
39
+ options.shell = true;
40
+ }
41
+ return originalSpawn(command, args, options);
42
+ };
43
+ }
44
+ /**
45
+ * Verify that OpenCode is installed and accessible
46
+ * This helps catch issues early before the SDK tries to spawn the process
47
+ */
48
+ export async function verifyOpenCodeInstalled() {
49
+ return new Promise((resolve) => {
50
+ const isWindows = platform() === 'win32';
51
+ const command = isWindows ? 'where' : 'which';
52
+ const proc = childProcess.spawn(command, ['opencode'], { shell: isWindows });
53
+ let output = '';
54
+ let errorOutput = '';
55
+ proc.stdout?.on('data', (chunk) => {
56
+ output += chunk.toString();
57
+ });
58
+ proc.stderr?.on('data', (chunk) => {
59
+ errorOutput += chunk.toString();
60
+ });
61
+ proc.on('close', (code) => {
62
+ if (code === 0 && output.trim()) {
63
+ resolve({ ok: true });
64
+ }
65
+ else {
66
+ resolve({
67
+ ok: false,
68
+ error: `OpenCode not found in PATH. Please install it first:\n npm install -g @opencode-ai/opencode\n\nThen verify with:\n opencode --version`
69
+ });
70
+ }
71
+ });
72
+ proc.on('error', (err) => {
73
+ resolve({
74
+ ok: false,
75
+ error: `Failed to check OpenCode installation: ${err.message}\n\nPlease ensure OpenCode is installed:\n npm install -g @opencode-ai/opencode`
76
+ });
77
+ });
78
+ });
79
+ }
3
80
  let opencodeInstance = null;
81
+ let verificationDone = false;
4
82
  export async function initOpenCode() {
83
+ patchFetchForUnlimitedTimeout();
5
84
  if (opencodeInstance) {
6
85
  return opencodeInstance;
7
86
  }
87
+ // Verify OpenCode is installed (only once)
88
+ if (!verificationDone) {
89
+ verificationDone = true;
90
+ console.log('🔧 Verifying OpenCode installation...');
91
+ const verification = await verifyOpenCodeInstalled();
92
+ if (!verification.ok) {
93
+ console.error('\n❌ ' + verification.error);
94
+ throw new Error('OpenCode not found. Please install it first: npm install -g @opencode-ai/opencode');
95
+ }
96
+ console.log('✅ OpenCode found');
97
+ }
8
98
  console.log('🚀 Starting OpenCode server...');
9
- opencodeInstance = await createOpencode({
10
- port: 0, // Don't start HTTP server
11
- });
12
- console.log('✅ OpenCode server ready');
99
+ try {
100
+ opencodeInstance = await createOpencode({
101
+ port: 0, // Don't start HTTP server
102
+ });
103
+ console.log('✅ OpenCode server ready');
104
+ }
105
+ catch (error) {
106
+ const isWindows = platform() === 'win32';
107
+ if (isWindows) {
108
+ console.error('\n❌ Failed to start OpenCode server.');
109
+ console.error('This may be a Windows compatibility issue.');
110
+ console.error('Please ensure OpenCode is installed correctly:');
111
+ console.error(' 1. Run: npm install -g @opencode-ai/opencode');
112
+ console.error(' 2. Verify: opencode --version');
113
+ }
114
+ throw error;
115
+ }
13
116
  return opencodeInstance;
14
117
  }
15
118
  export async function createSession(_threadId, title = `Remote control session`) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-remote-control",
3
- "version": "0.2.4",
3
+ "version": "0.2.7",
4
4
  "description": "Control OpenCode from anywhere via Telegram or Feishu",
5
5
  "type": "module",
6
6
  "bin": {