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 +65 -10
- package/dist/cli.js +84 -36
- package/dist/core/types.js +0 -3
- package/dist/feishu/bot.js +128 -86
- package/dist/opencode/client.js +32 -4
- package/package.json +1 -1
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
203
|
+
### Feishu (Long Connection Mode)
|
|
149
204
|
|
|
150
205
|
```
|
|
151
206
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
152
|
-
│ Feishu │───▶│ Feishu │───▶│
|
|
153
|
-
│ Client │ │ Server │ │
|
|
207
|
+
│ Feishu │───▶│ Feishu │───▶│ WebSocket │
|
|
208
|
+
│ Client │ │ Server │ │ (Long Conn) │
|
|
154
209
|
└─────────────┘ └─────────────┘ └──────┬──────┘
|
|
155
210
|
│
|
|
156
211
|
▼
|
|
157
212
|
┌─────────────┐
|
|
158
213
|
│ Feishu Bot │
|
|
159
|
-
│ (
|
|
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 **
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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('\
|
|
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('\
|
|
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
|
|
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(' │
|
|
169
|
-
console.log(' │
|
|
170
|
-
console.log(' │
|
|
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('
|
|
179
|
-
console.log('
|
|
180
|
-
console.log('
|
|
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
|
|
229
|
+
console.log(' 🔗 Step 5: Configure Event Subscription');
|
|
184
230
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
185
231
|
console.log('');
|
|
186
|
-
console.log('
|
|
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('
|
|
190
|
-
console.log('
|
|
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('
|
|
193
|
-
console.log('
|
|
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
|
|
245
|
+
console.log(' 📤 Step 6: Publish App');
|
|
198
246
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
199
247
|
console.log('');
|
|
200
248
|
console.log(' 1. Go to "版本管理与发布" (Version & Publish)');
|
package/dist/core/types.js
CHANGED
|
@@ -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),
|
package/dist/feishu/bot.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Feishu bot implementation for OpenCode Remote Control
|
|
2
|
-
|
|
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
|
-
//
|
|
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
|
|
180
|
-
|
|
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
|
|
201
|
-
await adapter.deleteMessage(ctx.threadId,
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// Handle
|
|
269
|
-
|
|
270
|
-
|
|
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 (!
|
|
283
|
-
|
|
284
|
-
return;
|
|
302
|
+
if (!data?.message) {
|
|
303
|
+
console.warn('Received message event without message data');
|
|
304
|
+
return { code: 0 };
|
|
285
305
|
}
|
|
286
|
-
const
|
|
287
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
307
|
-
return;
|
|
327
|
+
console.log('⏭️ Skipping empty message');
|
|
328
|
+
return { code: 0 };
|
|
308
329
|
}
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
315
|
-
|
|
339
|
+
console.error('Feishu event handler error:', error);
|
|
340
|
+
return { code: 0 };
|
|
316
341
|
}
|
|
317
|
-
|
|
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
|
}
|
package/dist/opencode/client.js
CHANGED
|
@@ -1,13 +1,40 @@
|
|
|
1
1
|
// OpenCode SDK client for remote control
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
}
|