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.
@@ -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
+ }
@@ -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(threadId, title = `Remote control session`) {
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
- // Share the session to get a URL
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
- const shareResult = await opencode.client.session.share({
30
- path: { id: sessionId }
31
- });
32
- if (!shareResult.error && shareResult.data?.share?.url) {
33
- shareUrl = shareResult.data.share.url;
34
- console.log(`šŸ”— Session shared: ${shareUrl}`);
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,