opencode-remote-control 0.2.5 → 0.2.8

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)');
@@ -0,0 +1,95 @@
1
+ // Authorization management for OpenCode Remote Control
2
+ // First user to send /start becomes the owner automatically
3
+ const authState = {
4
+ telegramOwner: null,
5
+ feishuOwner: null,
6
+ };
7
+ // Auth file path for persistence
8
+ import { homedir } from 'os';
9
+ import { join } from 'path';
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
11
+ const AUTH_DIR = join(homedir(), '.opencode-remote');
12
+ const AUTH_FILE = join(AUTH_DIR, 'auth.json');
13
+ function ensureAuthDir() {
14
+ if (!existsSync(AUTH_DIR)) {
15
+ mkdirSync(AUTH_DIR, { recursive: true });
16
+ }
17
+ }
18
+ function loadAuth() {
19
+ try {
20
+ if (existsSync(AUTH_FILE)) {
21
+ const data = JSON.parse(readFileSync(AUTH_FILE, 'utf-8'));
22
+ authState.telegramOwner = data.telegramOwner || null;
23
+ authState.feishuOwner = data.feishuOwner || null;
24
+ }
25
+ }
26
+ catch (error) {
27
+ console.warn('Failed to load auth state, starting fresh:', error);
28
+ }
29
+ }
30
+ function saveAuth() {
31
+ try {
32
+ ensureAuthDir();
33
+ writeFileSync(AUTH_FILE, JSON.stringify(authState, null, 2));
34
+ }
35
+ catch (error) {
36
+ console.error('Failed to save auth state:', error);
37
+ }
38
+ }
39
+ // Initialize on module load
40
+ loadAuth();
41
+ export function isAuthorized(platform, userId) {
42
+ if (platform === 'telegram') {
43
+ return authState.telegramOwner === userId;
44
+ }
45
+ else {
46
+ return authState.feishuOwner === userId;
47
+ }
48
+ }
49
+ export function hasOwner(platform) {
50
+ if (platform === 'telegram') {
51
+ return authState.telegramOwner !== null;
52
+ }
53
+ else {
54
+ return authState.feishuOwner !== null;
55
+ }
56
+ }
57
+ export function claimOwnership(platform, userId) {
58
+ if (platform === 'telegram') {
59
+ if (authState.telegramOwner) {
60
+ if (authState.telegramOwner === userId) {
61
+ return { success: true, message: 'already_owner' };
62
+ }
63
+ return { success: false, message: 'already_claimed' };
64
+ }
65
+ authState.telegramOwner = userId;
66
+ saveAuth();
67
+ return { success: true, message: 'claimed' };
68
+ }
69
+ else {
70
+ if (authState.feishuOwner) {
71
+ if (authState.feishuOwner === userId) {
72
+ return { success: true, message: 'already_owner' };
73
+ }
74
+ return { success: false, message: 'already_claimed' };
75
+ }
76
+ authState.feishuOwner = userId;
77
+ saveAuth();
78
+ return { success: true, message: 'claimed' };
79
+ }
80
+ }
81
+ export function getOwner(platform) {
82
+ if (platform === 'telegram') {
83
+ return authState.telegramOwner;
84
+ }
85
+ else {
86
+ return authState.feishuOwner;
87
+ }
88
+ }
89
+ // For debugging/display
90
+ export function getAuthStatus() {
91
+ return {
92
+ telegram: authState.telegramOwner !== null,
93
+ feishu: authState.feishuOwner !== null,
94
+ };
95
+ }
@@ -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,13 @@
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
+ import { isAuthorized, hasOwner, claimOwnership, getAuthStatus } from '../core/auth.js';
8
9
  let feishuClient = null;
10
+ let wsClient = null;
9
11
  let config = null;
10
12
  let openCodeSessions = null;
11
13
  // Map Feishu event to shared MessageContext
@@ -41,8 +43,24 @@ function createFeishuAdapter(client) {
41
43
  }
42
44
  },
43
45
  async sendTypingIndicator(threadId) {
44
- // Feishu doesn't have typing indicator
45
- // Could optionally send a "thinking..." message, but we'll skip for now
46
+ // Feishu doesn't have native typing indicator API
47
+ // We send a "thinking" message that will be deleted later
48
+ const chatId = threadId.replace('feishu:', '');
49
+ try {
50
+ const result = await client.im.message.create({
51
+ params: { receive_id_type: 'chat_id' },
52
+ data: {
53
+ receive_id: chatId,
54
+ msg_type: 'text',
55
+ content: JSON.stringify({ text: '⏳ 思考中...' }),
56
+ },
57
+ });
58
+ return result.data?.message_id || '';
59
+ }
60
+ catch (error) {
61
+ console.error('Failed to send typing indicator:', error);
62
+ return '';
63
+ }
46
64
  },
47
65
  async deleteMessage(threadId, messageId) {
48
66
  if (!messageId)
@@ -65,9 +83,33 @@ async function handleCommand(adapter, ctx, text) {
65
83
  const parts = text.split(/\s+/);
66
84
  const command = parts[0].toLowerCase();
67
85
  switch (command) {
68
- case '/start':
69
- case '/help':
70
- await adapter.reply(ctx.threadId, `🚀 OpenCode Remote Control ready
86
+ case '/start': {
87
+ const result = claimOwnership('feishu', ctx.userId);
88
+ if (result.success) {
89
+ if (result.message === 'claimed') {
90
+ await adapter.reply(ctx.threadId, `🔐 **Security Setup Complete!**
91
+
92
+ ✅ You are now the authorized owner of this bot.
93
+
94
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
95
+ ⚠️ **IMPORTANT SECURITY NOTICE**
96
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
97
+
98
+ Only YOU can control OpenCode through this bot.
99
+ Other users will be blocked automatically.
100
+
101
+ Your Feishu ID: \`${ctx.userId}\`
102
+
103
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
104
+
105
+ 🚀 **Ready to use!**
106
+ 💬 Send me a prompt to start coding
107
+ /help — see all commands
108
+ /status — check OpenCode connection`);
109
+ }
110
+ else {
111
+ // Already owner
112
+ await adapter.reply(ctx.threadId, `🚀 OpenCode Remote Control ready
71
113
 
72
114
  💬 Send me a prompt to start coding
73
115
  /help — see all commands
@@ -83,6 +125,31 @@ Commands:
83
125
  /files — List changed files
84
126
  /retry — Retry connection
85
127
 
128
+ 💬 Anything else is treated as a prompt for OpenCode!`);
129
+ }
130
+ }
131
+ else {
132
+ // Already claimed by someone else
133
+ await adapter.reply(ctx.threadId, `🚫 **Access Denied**
134
+
135
+ This bot is already secured by another user.
136
+
137
+ If you are the owner, check your configuration.`);
138
+ }
139
+ break;
140
+ }
141
+ case '/help':
142
+ await adapter.reply(ctx.threadId, `📖 Commands
143
+
144
+ /start — Claim ownership & Start bot
145
+ /status — Check connection
146
+ /reset — Reset session
147
+ /approve — Approve pending changes
148
+ /reject — Reject pending changes
149
+ /diff — See full diff
150
+ /files — List changed files
151
+ /retry — Retry connection
152
+
86
153
  💬 Anything else is treated as a prompt for OpenCode!`);
87
154
  break;
88
155
  case '/approve': {
@@ -163,9 +230,26 @@ async function handleMessage(adapter, ctx, text) {
163
230
  const session = getOrCreateSession(ctx.threadId, 'feishu');
164
231
  // Check if it's a command
165
232
  if (text.startsWith('/')) {
233
+ // Commands are handled by handleCommand which has its own auth logic for /start
166
234
  await handleCommand(adapter, ctx, text);
167
235
  return;
168
236
  }
237
+ // Authorization check for non-command messages
238
+ if (!isAuthorized('feishu', ctx.userId)) {
239
+ if (!hasOwner('feishu')) {
240
+ await adapter.reply(ctx.threadId, `🔐 **Authorization Required**
241
+
242
+ This bot is not yet secured.
243
+
244
+ Please send /start to claim ownership first.`);
245
+ }
246
+ else {
247
+ await adapter.reply(ctx.threadId, `🚫 **Access Denied**
248
+
249
+ You are not authorized to use this bot.`);
250
+ }
251
+ return;
252
+ }
169
253
  // Check OpenCode connection
170
254
  const connected = await checkConnection();
171
255
  if (!connected) {
@@ -176,13 +260,18 @@ Cannot connect to OpenCode server.
176
260
  🔄 /retry — check again`);
177
261
  return;
178
262
  }
179
- // Send typing indicator (Feishu style)
180
- const typingMsg = await adapter.reply(ctx.threadId, '⏳');
263
+ // Send typing indicator
264
+ console.log('⏳ Sending typing indicator...');
265
+ const typingMsgId = await adapter.sendTypingIndicator(ctx.threadId);
181
266
  // Get or create OpenCode session
182
267
  let openCodeSession = openCodeSessions?.get(ctx.threadId);
183
268
  if (!openCodeSession) {
184
269
  const newSession = await createSession(ctx.threadId, `Feishu chat ${ctx.threadId}`);
185
270
  if (!newSession) {
271
+ // Delete typing indicator before error message
272
+ if (typingMsgId && adapter.deleteMessage) {
273
+ await adapter.deleteMessage(ctx.threadId, typingMsgId);
274
+ }
186
275
  await adapter.reply(ctx.threadId, '❌ Failed to create OpenCode session');
187
276
  return;
188
277
  }
@@ -195,10 +284,12 @@ Cannot connect to OpenCode server.
195
284
  }
196
285
  }
197
286
  try {
287
+ console.log('🤖 Sending to OpenCode...');
198
288
  const response = await sendMessage(openCodeSession, text);
289
+ console.log('✅ Got response from OpenCode');
199
290
  // Delete typing indicator
200
- if (adapter.deleteMessage && typingMsg) {
201
- await adapter.deleteMessage(ctx.threadId, typingMsg);
291
+ if (typingMsgId && adapter.deleteMessage) {
292
+ await adapter.deleteMessage(ctx.threadId, typingMsgId);
202
293
  }
203
294
  // Split long messages
204
295
  const messages = splitMessage(response);
@@ -208,6 +299,10 @@ Cannot connect to OpenCode server.
208
299
  }
209
300
  catch (error) {
210
301
  console.error('Error sending message:', error);
302
+ // Delete typing indicator on error too
303
+ if (typingMsgId && adapter.deleteMessage) {
304
+ await adapter.deleteMessage(ctx.threadId, typingMsgId);
305
+ }
211
306
  await adapter.reply(ctx.threadId, `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
212
307
  }
213
308
  }
@@ -228,13 +323,14 @@ function checkRateLimit(chatId) {
228
323
  entry.count++;
229
324
  return true;
230
325
  }
231
- // Start Feishu bot
326
+ // Start Feishu bot using WebSocket long connection mode
327
+ // No tunnel/ngrok required - just needs internet access!
232
328
  export async function startFeishuBot(botConfig) {
233
329
  config = botConfig;
234
330
  if (!config.feishuAppId || !config.feishuAppSecret) {
235
- throw new Error('Feishu credentials not configured');
331
+ throw new Error('Feishu credentials not configured. Run: opencode-remote config');
236
332
  }
237
- // Initialize Feishu client
333
+ // Initialize Feishu client for sending messages
238
334
  feishuClient = new lark.Client({
239
335
  appId: config.feishuAppId,
240
336
  appSecret: config.feishuAppSecret,
@@ -255,103 +351,135 @@ export async function startFeishuBot(botConfig) {
255
351
  console.error('❌ Failed to initialize OpenCode:', error);
256
352
  console.log('Make sure OpenCode is running');
257
353
  }
258
- // Create adapter
354
+ // Create adapter for sending messages
259
355
  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') {
356
+ // Create WebSocket client for long connection
357
+ wsClient = new lark.WSClient({
358
+ appId: config.feishuAppId,
359
+ appSecret: config.feishuAppSecret,
360
+ // For international Lark, add: domain: lark.Domain.Lark
361
+ });
362
+ // Create event dispatcher for handling incoming messages
363
+ const eventDispatcher = new lark.EventDispatcher({}).register({
364
+ // Handle incoming messages
365
+ 'im.message.receive_v1': async (data) => {
366
+ console.log('📩 Received message event:', JSON.stringify(data, null, 2));
280
367
  try {
281
368
  // Validate event data
282
- if (!event.event?.message) {
283
- res.status(400).json({ code: -1, msg: 'Missing message data' });
284
- return;
369
+ if (!data?.message) {
370
+ console.warn('Received message event without message data');
371
+ return { code: 0 };
285
372
  }
286
- const ctx = feishuEventToContext(event.event);
287
- const chatId = event.event.message.chat_id;
373
+ const chatId = data.message.chat_id;
374
+ console.log(`💬 Message from chat: ${chatId}`);
288
375
  // Rate limiting
289
376
  if (!checkRateLimit(chatId)) {
290
377
  console.warn(`Rate limit exceeded for chat: ${chatId}`);
291
- res.status(429).json({ code: -1, msg: 'Rate limit exceeded' });
292
- return;
378
+ return { code: 0 };
293
379
  }
294
380
  // Parse message content
295
381
  let text = '';
296
382
  try {
297
- const content = JSON.parse(event.event.message.content);
383
+ const content = JSON.parse(data.message.content);
298
384
  text = content.text || '';
385
+ console.log(`📝 Message text: ${text}`);
299
386
  }
300
387
  catch {
301
388
  // If not JSON, try to use raw content
302
- text = event.event.message.content || '';
389
+ text = data.message.content || '';
390
+ console.log(`📝 Raw message content: ${text}`);
303
391
  }
304
392
  // Skip empty messages
305
393
  if (!text.trim()) {
306
- res.json({ code: 0 });
307
- return;
394
+ console.log('⏭️ Skipping empty message');
395
+ return { code: 0 };
308
396
  }
309
- // Handle the message
310
- await handleMessage(adapter, ctx, text);
311
- res.json({ code: 0 });
397
+ // Create message context
398
+ const ctx = feishuEventToContext(data);
399
+ // Handle the message (async, don't wait)
400
+ handleMessage(adapter, ctx, text).catch(error => {
401
+ console.error('Error handling Feishu message:', error);
402
+ });
403
+ return { code: 0 };
312
404
  }
313
405
  catch (error) {
314
- console.error('Feishu webhook error:', error);
315
- res.status(500).json({ code: -1, msg: 'Internal error' });
406
+ console.error('Feishu event handler error:', error);
407
+ return { code: 0 };
316
408
  }
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
- });
409
+ },
356
410
  });
411
+ // Start WebSocket long connection
412
+ console.log('🔗 Starting Feishu WebSocket long connection...');
413
+ console.log('');
414
+ console.log('✨ Long connection mode - NO tunnel/ngrok required!');
415
+ console.log(' Just make sure your computer can access the internet.');
416
+ console.log('');
417
+ // Show security status
418
+ const authStatus = getAuthStatus();
419
+ if (!authStatus.feishu) {
420
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
421
+ console.log(' 🔐 SECURITY NOTICE');
422
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
423
+ console.log('');
424
+ console.log(' Bot is NOT yet secured!');
425
+ console.log(' The FIRST user to send /start will become the owner.');
426
+ console.log('');
427
+ console.log(' 👉 Open Feishu and send /start to YOUR bot NOW!');
428
+ console.log('');
429
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
430
+ console.log('');
431
+ }
432
+ else {
433
+ console.log('🔒 Bot is secured (owner authorized)');
434
+ console.log('');
435
+ }
436
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
437
+ console.log(' 📋 Configuration Checklist');
438
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
439
+ console.log('');
440
+ console.log(' Step 1: Add Permissions (权限管理 → API权限)');
441
+ console.log(' ────────────────────────────────────────');
442
+ console.log(' Click "批量添加" (Batch Add) and paste this JSON:');
443
+ console.log('');
444
+ console.log(' ┌────────────────────────────────────────────────────┐');
445
+ console.log(' │ { │');
446
+ console.log(' │ "im:message", │');
447
+ console.log(' │ "im:message:send_as_bot", │');
448
+ console.log(' │ "im:message:receive_as_bot" │');
449
+ console.log(' │ } │');
450
+ console.log(' └────────────────────────────────────────────────────┘');
451
+ console.log('');
452
+ console.log(' Step 2: Enable Robot (应用能力 → 机器人)');
453
+ console.log(' ────────────────────────────────────────');
454
+ console.log(' - Enable "启用机器人"');
455
+ console.log(' - Enable "机器人可主动发送消息给用户"');
456
+ console.log(' - Enable "用户可与机器人进行单聊"');
457
+ console.log('');
458
+ console.log(' Step 3: Event Subscription (事件订阅)');
459
+ console.log(' ────────────────────────────────────────');
460
+ console.log(' ⚠️ MUST start this bot BEFORE saving event config!');
461
+ console.log(' - Select "使用长连接接收事件"');
462
+ console.log(' - Add event: im.message.receive_v1');
463
+ console.log(' - Then click Save');
464
+ console.log('');
465
+ console.log(' Step 4: Publish App (版本管理与发布)');
466
+ console.log(' ────────────────────────────────────────');
467
+ console.log(' - Create version → Request publishing → Publish');
468
+ console.log('');
469
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
470
+ console.log(' 🔍 Debug: Send a message to your bot in Feishu!');
471
+ console.log(' You should see: 📩 Received message event');
472
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
473
+ console.log('');
474
+ // The start() method will block the main thread
475
+ // Handle graceful shutdown
476
+ const shutdown = () => {
477
+ console.log('\n🛑 Shutting down Feishu bot...');
478
+ // WSClient doesn't have a stop method, just let the process exit
479
+ process.exit(0);
480
+ };
481
+ process.once('SIGINT', shutdown);
482
+ process.once('SIGTERM', shutdown);
483
+ // Start the WebSocket client - this will block until process is killed
484
+ await wsClient.start({ eventDispatcher });
357
485
  }
@@ -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
  }
@@ -4,6 +4,7 @@ import { loadConfig } from '../core/types.js';
4
4
  import { initSessionManager, getOrCreateSession } from '../core/session.js';
5
5
  import { splitMessage } from '../core/notifications.js';
6
6
  import { initOpenCode, createSession, sendMessage, checkConnection } from '../opencode/client.js';
7
+ import { isAuthorized, hasOwner, claimOwnership, getAuthStatus } from '../core/auth.js';
7
8
  // Lazy initialization - bot is only created when startBot() is called
8
9
  let config = null;
9
10
  let bot = null;
@@ -18,11 +19,47 @@ function getThreadId(ctx) {
18
19
  function setupBotCommands(bot, openCodeSessions) {
19
20
  // Start command
20
21
  bot.command('start', async (ctx) => {
21
- await ctx.reply(`🚀 OpenCode Remote Control ready
22
+ const userId = String(ctx.from?.id);
23
+ const result = claimOwnership('telegram', userId);
24
+ if (result.success) {
25
+ if (result.message === 'claimed') {
26
+ await ctx.reply(`🔐 **Security Setup Complete!**
27
+
28
+ ✅ You are now the authorized owner of this bot.
29
+
30
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
31
+ ⚠️ **IMPORTANT SECURITY NOTICE**
32
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33
+
34
+ Only YOU can control OpenCode through this bot.
35
+ Other users will be blocked automatically.
36
+
37
+ Your Telegram ID: \`${userId}\`
38
+
39
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40
+
41
+ 🚀 **Ready to use!**
42
+ 💬 Send me a prompt to start coding
43
+ /help — see all commands
44
+ /status — check OpenCode connection`, { parse_mode: 'Markdown' });
45
+ }
46
+ else {
47
+ // Already owner
48
+ await ctx.reply(`🚀 OpenCode Remote Control ready
22
49
 
23
50
  💬 Send me a prompt to start coding
24
51
  /help — see all commands
25
52
  /status — check OpenCode connection`);
53
+ }
54
+ }
55
+ else {
56
+ // Already claimed by someone else
57
+ await ctx.reply(`🚫 **Access Denied**
58
+
59
+ This bot is already secured by another user.
60
+
61
+ If you are the owner, check your configuration.`, { parse_mode: 'Markdown' });
62
+ }
26
63
  });
27
64
  // Help command
28
65
  bot.command('help', async (ctx) => {
@@ -132,9 +169,26 @@ Cannot connect to OpenCode server.
132
169
  // Handle all other messages as prompts
133
170
  bot.on('message:text', async (ctx) => {
134
171
  const text = ctx.message.text;
172
+ const userId = String(ctx.from?.id);
135
173
  // Skip if it's a command (already handled)
136
174
  if (text.startsWith('/'))
137
175
  return;
176
+ // Authorization check
177
+ if (!isAuthorized('telegram', userId)) {
178
+ if (!hasOwner('telegram')) {
179
+ await ctx.reply(`🔐 **Authorization Required**
180
+
181
+ This bot is not yet secured.
182
+
183
+ Please send /start to claim ownership first.`, { parse_mode: 'Markdown' });
184
+ }
185
+ else {
186
+ await ctx.reply(`🚫 **Access Denied**
187
+
188
+ You are not authorized to use this bot.`, { parse_mode: 'Markdown' });
189
+ }
190
+ return;
191
+ }
138
192
  const threadId = getThreadId(ctx);
139
193
  // Send typing indicator
140
194
  await ctx.api.sendChatAction(ctx.chat.id, 'typing');
@@ -236,6 +290,24 @@ export async function startBot() {
236
290
  console.log('Make sure OpenCode is running');
237
291
  }
238
292
  console.log('🚀 Starting Telegram bot...');
293
+ // Show security status
294
+ const authStatus = getAuthStatus();
295
+ if (!authStatus.telegram) {
296
+ console.log('');
297
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
298
+ console.log(' 🔐 SECURITY NOTICE');
299
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
300
+ console.log('');
301
+ console.log(' Bot is NOT yet secured!');
302
+ console.log(' The FIRST user to send /start will become the owner.');
303
+ console.log('');
304
+ console.log(' 👉 Open Telegram and send /start to YOUR bot NOW!');
305
+ console.log('');
306
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
307
+ }
308
+ else {
309
+ console.log('🔒 Bot is secured (owner authorized)');
310
+ }
239
311
  // Handle graceful shutdown
240
312
  const shutdown = async () => {
241
313
  console.log('\n🛑 Shutting down Telegram bot...');
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.8",
4
4
  "description": "Control OpenCode from anywhere via Telegram or Feishu",
5
5
  "type": "module",
6
6
  "bin": {