opencode-remote-control 0.1.0 ā 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -23
- package/dist/cli.js +310 -32
- package/dist/core/handler-common.js +16 -16
- package/dist/core/types.js +6 -1
- package/dist/feishu/bot.js +350 -0
- package/dist/opencode/client.js +11 -8
- package/dist/telegram/bot.js +188 -158
- package/package.json +7 -2
package/dist/core/types.js
CHANGED
|
@@ -15,7 +15,12 @@ export const EMOJI = {
|
|
|
15
15
|
};
|
|
16
16
|
export function loadConfig() {
|
|
17
17
|
return {
|
|
18
|
-
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN ||
|
|
18
|
+
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || undefined,
|
|
19
|
+
feishuAppId: process.env.FEISHU_APP_ID || undefined,
|
|
20
|
+
feishuAppSecret: process.env.FEISHU_APP_SECRET || undefined,
|
|
21
|
+
feishuEncryptKey: process.env.FEISHU_ENCRYPT_KEY,
|
|
22
|
+
feishuVerificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
|
|
23
|
+
feishuWebhookPort: parseInt(process.env.FEISHU_WEBHOOK_PORT || '3001', 10),
|
|
19
24
|
opencodeServerUrl: process.env.OPENCODE_SERVER_URL || 'http://localhost:3000',
|
|
20
25
|
tunnelUrl: process.env.TUNNEL_URL || '',
|
|
21
26
|
sessionIdleTimeoutMs: parseInt(process.env.SESSION_IDLE_TIMEOUT_MS || '1800000', 10),
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// Feishu bot implementation for OpenCode Remote Control
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import * as lark from '@larksuiteoapi/node-sdk';
|
|
4
|
+
import { initSessionManager, getOrCreateSession } from '../core/session.js';
|
|
5
|
+
import { splitMessage } from '../core/notifications.js';
|
|
6
|
+
import { EMOJI } from '../core/types.js';
|
|
7
|
+
import { initOpenCode, createSession, sendMessage, checkConnection } from '../opencode/client.js';
|
|
8
|
+
let feishuClient = null;
|
|
9
|
+
let config = null;
|
|
10
|
+
let openCodeSessions = null;
|
|
11
|
+
// Map Feishu event to shared MessageContext
|
|
12
|
+
// Prefix threadId with 'feishu:' to avoid collision with Telegram sessions
|
|
13
|
+
function feishuEventToContext(event) {
|
|
14
|
+
return {
|
|
15
|
+
platform: 'feishu',
|
|
16
|
+
threadId: `feishu:${event.message.chat_id}`,
|
|
17
|
+
userId: event.sender?.sender_id?.user_id || 'unknown',
|
|
18
|
+
messageId: event.message.message_id,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// BotAdapter implementation for Feishu
|
|
22
|
+
function createFeishuAdapter(client) {
|
|
23
|
+
return {
|
|
24
|
+
async reply(threadId, text) {
|
|
25
|
+
// Extract chat_id from threadId (format: feishu:chat_id)
|
|
26
|
+
const chatId = threadId.replace('feishu:', '');
|
|
27
|
+
try {
|
|
28
|
+
const result = await client.im.message.create({
|
|
29
|
+
params: { receive_id_type: 'chat_id' },
|
|
30
|
+
data: {
|
|
31
|
+
receive_id: chatId,
|
|
32
|
+
msg_type: 'text',
|
|
33
|
+
content: JSON.stringify({ text }),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return result.data?.message_id || '';
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error('Failed to send Feishu message:', error);
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
async sendTypingIndicator(threadId) {
|
|
44
|
+
// Feishu doesn't have typing indicator
|
|
45
|
+
// Could optionally send a "thinking..." message, but we'll skip for now
|
|
46
|
+
},
|
|
47
|
+
async deleteMessage(threadId, messageId) {
|
|
48
|
+
if (!messageId)
|
|
49
|
+
return;
|
|
50
|
+
try {
|
|
51
|
+
await client.im.message.delete({
|
|
52
|
+
path: { message_id: messageId },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// Ignore delete errors - not critical
|
|
57
|
+
console.warn('Failed to delete Feishu message:', error);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Command handler (mirrors Telegram bot structure)
|
|
63
|
+
async function handleCommand(adapter, ctx, text) {
|
|
64
|
+
const session = getOrCreateSession(ctx.threadId, 'feishu');
|
|
65
|
+
const parts = text.split(/\s+/);
|
|
66
|
+
const command = parts[0].toLowerCase();
|
|
67
|
+
switch (command) {
|
|
68
|
+
case '/start':
|
|
69
|
+
case '/help':
|
|
70
|
+
await adapter.reply(ctx.threadId, `š OpenCode Remote Control ready
|
|
71
|
+
|
|
72
|
+
š¬ Send me a prompt to start coding
|
|
73
|
+
/help ā see all commands
|
|
74
|
+
/status ā check OpenCode connection
|
|
75
|
+
|
|
76
|
+
Commands:
|
|
77
|
+
/start ā Start bot
|
|
78
|
+
/status ā Check connection
|
|
79
|
+
/reset ā Reset session
|
|
80
|
+
/approve ā Approve pending changes
|
|
81
|
+
/reject ā Reject pending changes
|
|
82
|
+
/diff ā See full diff
|
|
83
|
+
/files ā List changed files
|
|
84
|
+
/retry ā Retry connection
|
|
85
|
+
|
|
86
|
+
š¬ Anything else is treated as a prompt for OpenCode!`);
|
|
87
|
+
break;
|
|
88
|
+
case '/approve': {
|
|
89
|
+
const pending = session.pendingApprovals[0];
|
|
90
|
+
if (!pending) {
|
|
91
|
+
await adapter.reply(ctx.threadId, '𤷠Nothing to approve right now');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// TODO: Actually apply changes via OpenCode SDK
|
|
95
|
+
await adapter.reply(ctx.threadId, 'ā
Approved ā changes applied');
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case '/reject': {
|
|
99
|
+
const pending = session.pendingApprovals[0];
|
|
100
|
+
if (!pending) {
|
|
101
|
+
await adapter.reply(ctx.threadId, '𤷠Nothing to reject right now');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
session.pendingApprovals.shift();
|
|
105
|
+
await adapter.reply(ctx.threadId, 'ā Rejected ā changes discarded');
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case '/diff': {
|
|
109
|
+
const pending = session.pendingApprovals[0];
|
|
110
|
+
if (!pending || !pending.files?.length) {
|
|
111
|
+
await adapter.reply(ctx.threadId, 'š No pending changes to show');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// TODO: Get actual diff from OpenCode SDK
|
|
115
|
+
const diffPreview = pending.files.map(f => `--- a/${f.path}\n+++ b/${f.path}\n@@ changes +${f.additions} -${f.deletions} @@`).join('\n');
|
|
116
|
+
const messages = splitMessage(`\`\`\`diff\n${diffPreview}\n\`\`\``);
|
|
117
|
+
for (const msg of messages) {
|
|
118
|
+
await adapter.reply(ctx.threadId, msg);
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case '/files': {
|
|
123
|
+
const pending = session.pendingApprovals[0];
|
|
124
|
+
if (!pending || !pending.files?.length) {
|
|
125
|
+
await adapter.reply(ctx.threadId, 'š No files changed in this session');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const fileList = pending.files.map(f => `⢠${f.path} (+${f.additions}, -${f.deletions})`).join('\n');
|
|
129
|
+
await adapter.reply(ctx.threadId, `š Changed files:\n${fileList}`);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case '/status': {
|
|
133
|
+
const openCodeConnected = await checkConnection();
|
|
134
|
+
const openCodeSession = openCodeSessions?.get(ctx.threadId);
|
|
135
|
+
const idleSeconds = Math.round((Date.now() - session.lastActivity) / 1000);
|
|
136
|
+
const pendingCount = session.pendingApprovals.length;
|
|
137
|
+
await adapter.reply(ctx.threadId, `${openCodeConnected ? 'ā
' : 'ā'} Connected\n\nš¬ Session: ${openCodeSession?.sessionId?.slice(0, 8) || 'none'}\nā° Idle: ${idleSeconds}s\nš Pending approvals: ${pendingCount}`);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case '/reset':
|
|
141
|
+
session.pendingApprovals = [];
|
|
142
|
+
session.opencodeSessionId = undefined;
|
|
143
|
+
// Clear OpenCode session
|
|
144
|
+
openCodeSessions?.delete(ctx.threadId);
|
|
145
|
+
await adapter.reply(ctx.threadId, 'š Session reset. Start fresh!');
|
|
146
|
+
break;
|
|
147
|
+
case '/retry': {
|
|
148
|
+
const connected = await checkConnection();
|
|
149
|
+
if (connected) {
|
|
150
|
+
await adapter.reply(ctx.threadId, 'ā
OpenCode is now online!');
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
await adapter.reply(ctx.threadId, 'ā Still offline. Is OpenCode running?');
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
await adapter.reply(ctx.threadId, `${EMOJI.WARNING} Unknown command: ${command}\n\nTry /help`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Handle incoming message from Feishu
|
|
162
|
+
async function handleMessage(adapter, ctx, text) {
|
|
163
|
+
const session = getOrCreateSession(ctx.threadId, 'feishu');
|
|
164
|
+
// Check if it's a command
|
|
165
|
+
if (text.startsWith('/')) {
|
|
166
|
+
await handleCommand(adapter, ctx, text);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Check OpenCode connection
|
|
170
|
+
const connected = await checkConnection();
|
|
171
|
+
if (!connected) {
|
|
172
|
+
await adapter.reply(ctx.threadId, `ā OpenCode is offline
|
|
173
|
+
|
|
174
|
+
Cannot connect to OpenCode server.
|
|
175
|
+
|
|
176
|
+
š /retry ā check again`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// Send typing indicator (Feishu style)
|
|
180
|
+
const typingMsg = await adapter.reply(ctx.threadId, 'ā³');
|
|
181
|
+
// Get or create OpenCode session
|
|
182
|
+
let openCodeSession = openCodeSessions?.get(ctx.threadId);
|
|
183
|
+
if (!openCodeSession) {
|
|
184
|
+
const newSession = await createSession(ctx.threadId, `Feishu chat ${ctx.threadId}`);
|
|
185
|
+
if (!newSession) {
|
|
186
|
+
await adapter.reply(ctx.threadId, 'ā Failed to create OpenCode session');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
openCodeSession = newSession;
|
|
190
|
+
openCodeSessions.set(ctx.threadId, openCodeSession);
|
|
191
|
+
session.opencodeSessionId = openCodeSession.sessionId;
|
|
192
|
+
// Share the session URL (only if sharing is enabled)
|
|
193
|
+
if (openCodeSession.shareUrl) {
|
|
194
|
+
await adapter.reply(ctx.threadId, `š Session: ${openCodeSession.shareUrl}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const response = await sendMessage(openCodeSession, text);
|
|
199
|
+
// Delete typing indicator
|
|
200
|
+
if (adapter.deleteMessage && typingMsg) {
|
|
201
|
+
await adapter.deleteMessage(ctx.threadId, typingMsg);
|
|
202
|
+
}
|
|
203
|
+
// Split long messages
|
|
204
|
+
const messages = splitMessage(response);
|
|
205
|
+
for (const msg of messages) {
|
|
206
|
+
await adapter.reply(ctx.threadId, msg);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
console.error('Error sending message:', error);
|
|
211
|
+
await adapter.reply(ctx.threadId, `ā Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Rate limiting (in-memory, per-chat)
|
|
215
|
+
const rateLimitMap = new Map();
|
|
216
|
+
const RATE_LIMIT = 100; // messages per minute per chat
|
|
217
|
+
const RATE_WINDOW = 60000; // 1 minute
|
|
218
|
+
function checkRateLimit(chatId) {
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const entry = rateLimitMap.get(chatId);
|
|
221
|
+
if (!entry || now > entry.resetTime) {
|
|
222
|
+
rateLimitMap.set(chatId, { count: 1, resetTime: now + RATE_WINDOW });
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
if (entry.count >= RATE_LIMIT) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
entry.count++;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
// Start Feishu bot
|
|
232
|
+
export async function startFeishuBot(botConfig) {
|
|
233
|
+
config = botConfig;
|
|
234
|
+
if (!config.feishuAppId || !config.feishuAppSecret) {
|
|
235
|
+
throw new Error('Feishu credentials not configured');
|
|
236
|
+
}
|
|
237
|
+
// Initialize Feishu client
|
|
238
|
+
feishuClient = new lark.Client({
|
|
239
|
+
appId: config.feishuAppId,
|
|
240
|
+
appSecret: config.feishuAppSecret,
|
|
241
|
+
// Uses feishu.cn domain by default (for China users)
|
|
242
|
+
// For international Lark, add: domain: lark.Domain.Lark
|
|
243
|
+
});
|
|
244
|
+
// Initialize session manager
|
|
245
|
+
initSessionManager(config);
|
|
246
|
+
// Initialize OpenCode sessions map
|
|
247
|
+
openCodeSessions = new Map();
|
|
248
|
+
// Initialize OpenCode
|
|
249
|
+
console.log('š§ Initializing OpenCode...');
|
|
250
|
+
try {
|
|
251
|
+
await initOpenCode();
|
|
252
|
+
console.log('ā
OpenCode ready');
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
console.error('ā Failed to initialize OpenCode:', error);
|
|
256
|
+
console.log('Make sure OpenCode is running');
|
|
257
|
+
}
|
|
258
|
+
// Create adapter
|
|
259
|
+
const adapter = createFeishuAdapter(feishuClient);
|
|
260
|
+
// Create Express server for webhook
|
|
261
|
+
const app = express();
|
|
262
|
+
app.use(express.json());
|
|
263
|
+
const webhookPath = '/feishu/webhook';
|
|
264
|
+
const port = config.feishuWebhookPort || 3001;
|
|
265
|
+
// Webhook endpoint for Feishu events
|
|
266
|
+
app.post(webhookPath, async (req, res) => {
|
|
267
|
+
const event = req.body;
|
|
268
|
+
// Handle URL verification challenge (required for Feishu bot setup)
|
|
269
|
+
if (event.type === 'url_verification') {
|
|
270
|
+
res.json({ challenge: event.challenge });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Validate event structure
|
|
274
|
+
if (!event.header?.event_type) {
|
|
275
|
+
res.status(400).json({ code: -1, msg: 'Invalid event structure' });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Handle message events
|
|
279
|
+
if (event.header.event_type === 'im.message.receive_v1') {
|
|
280
|
+
try {
|
|
281
|
+
// Validate event data
|
|
282
|
+
if (!event.event?.message) {
|
|
283
|
+
res.status(400).json({ code: -1, msg: 'Missing message data' });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const ctx = feishuEventToContext(event.event);
|
|
287
|
+
const chatId = event.event.message.chat_id;
|
|
288
|
+
// Rate limiting
|
|
289
|
+
if (!checkRateLimit(chatId)) {
|
|
290
|
+
console.warn(`Rate limit exceeded for chat: ${chatId}`);
|
|
291
|
+
res.status(429).json({ code: -1, msg: 'Rate limit exceeded' });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Parse message content
|
|
295
|
+
let text = '';
|
|
296
|
+
try {
|
|
297
|
+
const content = JSON.parse(event.event.message.content);
|
|
298
|
+
text = content.text || '';
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// If not JSON, try to use raw content
|
|
302
|
+
text = event.event.message.content || '';
|
|
303
|
+
}
|
|
304
|
+
// Skip empty messages
|
|
305
|
+
if (!text.trim()) {
|
|
306
|
+
res.json({ code: 0 });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Handle the message
|
|
310
|
+
await handleMessage(adapter, ctx, text);
|
|
311
|
+
res.json({ code: 0 });
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
console.error('Feishu webhook error:', error);
|
|
315
|
+
res.status(500).json({ code: -1, msg: 'Internal error' });
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// Unknown event type - acknowledge but ignore
|
|
320
|
+
console.log(`Received Feishu event: ${event.header.event_type}`);
|
|
321
|
+
res.json({ code: 0 });
|
|
322
|
+
});
|
|
323
|
+
// Health check endpoint
|
|
324
|
+
app.get('/health', (_req, res) => {
|
|
325
|
+
res.json({ status: 'ok', platform: 'feishu' });
|
|
326
|
+
});
|
|
327
|
+
// Start server
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
const server = app.listen(port, () => {
|
|
330
|
+
console.log(`š Feishu webhook listening on port ${port}`);
|
|
331
|
+
console.log(`š” Webhook URL: http://localhost:${port}${webhookPath}`);
|
|
332
|
+
console.log('\nš Setup instructions:');
|
|
333
|
+
console.log(' 1. Use ngrok or cloudflared to expose this endpoint:');
|
|
334
|
+
console.log(' ngrok http ' + port);
|
|
335
|
+
console.log(' 2. Configure the webhook URL in Feishu admin console');
|
|
336
|
+
console.log(' 3. Subscribe to "im.message.receive_v1" event');
|
|
337
|
+
});
|
|
338
|
+
server.on('error', (err) => {
|
|
339
|
+
reject(err);
|
|
340
|
+
});
|
|
341
|
+
// Handle graceful shutdown
|
|
342
|
+
process.on('SIGINT', () => {
|
|
343
|
+
console.log('\nš Shutting down Feishu bot...');
|
|
344
|
+
server.close(() => {
|
|
345
|
+
console.log('Feishu bot stopped');
|
|
346
|
+
resolve();
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
package/dist/opencode/client.js
CHANGED
|
@@ -12,7 +12,7 @@ export async function initOpenCode() {
|
|
|
12
12
|
console.log('ā
OpenCode server ready');
|
|
13
13
|
return opencodeInstance;
|
|
14
14
|
}
|
|
15
|
-
export async function createSession(
|
|
15
|
+
export async function createSession(_threadId, title = `Remote control session`) {
|
|
16
16
|
const opencode = await initOpenCode();
|
|
17
17
|
try {
|
|
18
18
|
const createResult = await opencode.client.session.create({
|
|
@@ -24,14 +24,17 @@ export async function createSession(threadId, title = `Remote control session`)
|
|
|
24
24
|
}
|
|
25
25
|
const sessionId = createResult.data.id;
|
|
26
26
|
console.log(`ā
Created OpenCode session: ${sessionId}`);
|
|
27
|
-
//
|
|
27
|
+
// Note: Sharing is disabled by default for privacy
|
|
28
|
+
// Set SHARE_SESSIONS=true in .env to enable public sharing
|
|
28
29
|
let shareUrl;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
if (process.env.SHARE_SESSIONS === 'true') {
|
|
31
|
+
const shareResult = await opencode.client.session.share({
|
|
32
|
+
path: { id: sessionId }
|
|
33
|
+
});
|
|
34
|
+
if (!shareResult.error && shareResult.data?.share?.url) {
|
|
35
|
+
shareUrl = shareResult.data.share.url;
|
|
36
|
+
console.log(`š Session shared: ${shareUrl}`);
|
|
37
|
+
}
|
|
35
38
|
}
|
|
36
39
|
return {
|
|
37
40
|
sessionId,
|