opencode-remote-control 0.2.5 → 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,9 +13,13 @@
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
17
23
 
18
24
  ## Requirements
19
25
 
@@ -21,7 +27,7 @@
21
27
  - [OpenCode](https://github.com/opencode-ai/opencode) installed and accessible in PATH
22
28
  - Telegram account (for Telegram bot)
23
29
  - Feishu account (for Feishu bot)
24
- - ngrok or cloudflared (for Feishu webhook)
30
+ - **No** ngrok or cloudflared required (Feishu uses WebSocket long connection)
25
31
 
26
32
  ### Verify OpenCode Installation
27
33
 
@@ -68,7 +74,56 @@ Token is saved to `~/.opencode-remote/.env`
68
74
 
69
75
  ### Feishu Setup
70
76
 
71
- 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).
72
127
 
73
128
  ## Start Service
74
129
 
@@ -145,18 +200,18 @@ Both Telegram and Feishu support the same commands:
145
200
 
146
201
  The Telegram bot uses **Polling Mode** to fetch messages from Telegram servers, requiring no tunnel or public IP configuration.
147
202
 
148
- ### Feishu (Webhook Mode)
203
+ ### Feishu (Long Connection Mode)
149
204
 
150
205
  ```
151
206
  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
152
- │ Feishu │───▶│ Feishu │───▶│ Webhook
153
- │ Client │ │ Server │ │ (ngrok)
207
+ │ Feishu │───▶│ Feishu │───▶│ WebSocket
208
+ │ Client │ │ Server │ │ (Long Conn)
154
209
  └─────────────┘ └─────────────┘ └──────┬──────┘
155
210
 
156
211
 
157
212
  ┌─────────────┐
158
213
  │ Feishu Bot │
159
- │ (port 3001)│
214
+ │ (Local)
160
215
  └──────┬──────┘
161
216
 
162
217
 
@@ -166,7 +221,7 @@ The Telegram bot uses **Polling Mode** to fetch messages from Telegram servers,
166
221
  └─────────────┘
167
222
  ```
168
223
 
169
- The Feishu bot uses **Webhook Mode** and requires a tunnel (ngrok/cloudflared) to receive messages.
224
+ The Feishu bot uses **WebSocket Long Connection Mode** - no public IP or tunnel (ngrok/cloudflared) required!
170
225
 
171
226
  ## Contributing
172
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,13 +1,40 @@
1
1
  // OpenCode SDK client for remote control
2
- import { spawn } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
3
  import { platform } from 'node:os';
4
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
+ }
5
32
  // Windows compatibility: patch child_process.spawn to use shell for 'opencode' command
6
33
  // This is needed because Windows requires shell: true to execute .cmd files
7
34
  if (platform() === 'win32') {
8
- const originalSpawn = spawn;
35
+ const originalSpawn = childProcess.spawn;
9
36
  // @ts-ignore - monkey patching for Windows compatibility
10
- require('node:child_process').spawn = function (command, args, options = {}) {
37
+ childProcess.spawn = function (command, args, options = {}) {
11
38
  if (command === 'opencode' && !options.shell) {
12
39
  options.shell = true;
13
40
  }
@@ -22,7 +49,7 @@ export async function verifyOpenCodeInstalled() {
22
49
  return new Promise((resolve) => {
23
50
  const isWindows = platform() === 'win32';
24
51
  const command = isWindows ? 'where' : 'which';
25
- const proc = spawn(command, ['opencode'], { shell: isWindows });
52
+ const proc = childProcess.spawn(command, ['opencode'], { shell: isWindows });
26
53
  let output = '';
27
54
  let errorOutput = '';
28
55
  proc.stdout?.on('data', (chunk) => {
@@ -53,6 +80,7 @@ export async function verifyOpenCodeInstalled() {
53
80
  let opencodeInstance = null;
54
81
  let verificationDone = false;
55
82
  export async function initOpenCode() {
83
+ patchFetchForUnlimitedTimeout();
56
84
  if (opencodeInstance) {
57
85
  return opencodeInstance;
58
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-remote-control",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Control OpenCode from anywhere via Telegram or Feishu",
5
5
  "type": "module",
6
6
  "bin": {