From c1b36083012880ee13cd4b676664973889452a1e Mon Sep 17 00:00:00 2001 From: Felipe-258 Date: Thu, 27 Aug 2026 09:09:07 -0300 Subject: [PATCH 1/3] feat: Implement session management features including modal handling, select menus, and session archiving - Added modal submission handling for creating new sessions with validation. - Implemented string select menu interactions for model and project selection. - Enhanced session channel creation with agent mode and project alias management. - Introduced session archiving and reopening functionality. - Updated data store to manage archived sessions and channel agents. - Created a new hub command to configure project and model settings with interactive buttons. - Added session flow services for managing session channels and sticky messages. - Improved error handling and logging throughout the session management process. --- package-lock.json | 206 +++++++++++++ package.json | 1 + src/__tests__/voiceService.test.ts | 118 ++++---- src/cli.ts | 43 +-- src/commands/diff.ts | 80 ++--- src/commands/hub.ts | 128 ++++++++ src/commands/index.ts | 2 + src/commands/opencode.ts | 123 +++++--- src/commands/voice.ts | 49 +--- src/handlers/buttonHandler.ts | 452 +++++++++++++++++++++++++++-- src/handlers/interactionHandler.ts | 217 +++++++++++++- src/handlers/messageHandler.ts | 12 +- src/services/dataStore.ts | 59 +++- src/services/executionService.ts | 26 +- src/services/sessionFlow.ts | 294 +++++++++++++++++++ src/services/sessionManager.ts | 129 +++++++- src/services/voiceService.ts | 120 +++++--- src/types/index.ts | 28 ++ 18 files changed, 1804 insertions(+), 283 deletions(-) create mode 100644 src/commands/hub.ts create mode 100644 src/services/sessionFlow.ts diff --git a/package-lock.json b/package-lock.json index c0b9195..b24c3f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "commander": "^13.1.0", "discord.js": "^14.25.1", "eventsource": "^4.1.0", + "ffmpeg-static": "^5.3.0", "node-pty": "^1.1.0", "open": "^10.1.0", "opencode-antigravity-auth": "^1.4.6", @@ -69,6 +70,21 @@ "node": ">=12" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@discordjs/builders": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", @@ -1545,6 +1561,18 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -1686,6 +1714,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1713,6 +1747,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1756,6 +1796,21 @@ "node": ">=18" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/config-chain": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", @@ -1797,6 +1852,23 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -1920,6 +1992,15 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2046,6 +2127,22 @@ } } }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2104,6 +2201,40 @@ "node": ">=16.9.0" } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ini": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", @@ -2305,6 +2436,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2406,6 +2543,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2461,6 +2603,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -2514,6 +2665,20 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/registry-auth-token": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.1.1.tgz", @@ -2607,6 +2772,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2662,6 +2847,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -2830,6 +3024,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2882,6 +3082,12 @@ "url": "https://github.com/yeoman/update-notifier?sponsor=1" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 6829ef7..2b0c5f1 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "commander": "^13.1.0", "discord.js": "^14.25.1", "eventsource": "^4.1.0", + "ffmpeg-static": "^5.3.0", "node-pty": "^1.1.0", "open": "^10.1.0", "opencode-antigravity-auth": "^1.4.6", diff --git a/src/__tests__/voiceService.test.ts b/src/__tests__/voiceService.test.ts index 0ee76e0..e316bf4 100644 --- a/src/__tests__/voiceService.test.ts +++ b/src/__tests__/voiceService.test.ts @@ -1,13 +1,28 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import * as configStore from '../services/configStore.js'; - -vi.mock('../services/configStore.js'); +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +vi.mock('node:child_process', () => ({ execFile: vi.fn() })); +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + mkdtempSync: vi.fn(() => '/tmp/opencode-handy-test'), + writeFileSync: vi.fn(), + rmSync: vi.fn(), +})); +vi.mock('node:module', async (importOriginal) => ({ + ...(await importOriginal()), + createRequire: () => () => '/usr/bin/ffmpeg', +})); + +const mockExecFile = vi.mocked(execFile); +const mockExistsSync = vi.mocked(existsSync); // Mock global fetch const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); -// Must import after mocks are set up +vi.stubEnv('HANDY_BIN', '/usr/bin/handy'); + const { isVoiceEnabled, transcribe } = await import('../services/voiceService.js'); describe('voiceService', () => { @@ -20,18 +35,13 @@ describe('voiceService', () => { }); describe('isVoiceEnabled', () => { - it('should return true when API key is configured', () => { - vi.mocked(configStore.getOpenAIApiKey).mockReturnValue('sk-test-key'); + it('should return true when Handy binary exists', () => { + mockExistsSync.mockReturnValue(true); expect(isVoiceEnabled()).toBe(true); }); - it('should return false when API key is not configured', () => { - vi.mocked(configStore.getOpenAIApiKey).mockReturnValue(undefined); - expect(isVoiceEnabled()).toBe(false); - }); - - it('should return false when API key is empty string', () => { - vi.mocked(configStore.getOpenAIApiKey).mockReturnValue(''); + it('should return false when Handy binary is missing', () => { + mockExistsSync.mockReturnValue(false); expect(isVoiceEnabled()).toBe(false); }); }); @@ -47,41 +57,41 @@ describe('voiceService', () => { } as unknown as Response); } - function mockSuccessfulWhisper(text: string) { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(text), - } as unknown as Response); + function mockHandyOutput(json: string) { + mockExecFile.mockImplementation(((file: unknown, _args: unknown, _opts: unknown, cb: unknown) => { + if (file === '/usr/bin/ffmpeg') { + (cb as (err: Error | null, stdout: string, stderr: string) => void)(null, '', ''); + } else { + (cb as (err: Error | null, stdout: string, stderr: string) => void)(null, json, ''); + } + return {} as never; + }) as never); } beforeEach(() => { - vi.mocked(configStore.getOpenAIApiKey).mockReturnValue('sk-test-key'); + mockExistsSync.mockReturnValue(true); }); - it('should transcribe audio successfully', async () => { + it('should transcribe audio successfully with Handy', async () => { mockSuccessfulDownload(); - mockSuccessfulWhisper('Hello world'); + mockHandyOutput('{"text":"Hola mundo","model":"parakeet"}'); const result = await transcribe(fakeAttachmentUrl, 1024); - expect(result).toBe('Hello world'); - // First call: Discord CDN download - expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result).toBe('Hola mundo'); + expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch.mock.calls[0][0]).toBe(fakeAttachmentUrl); - // Second call: Whisper API - expect(mockFetch.mock.calls[1][0]).toBe('https://api.openai.com/v1/audio/transcriptions'); - expect(mockFetch.mock.calls[1][1]).toMatchObject({ - method: 'POST', - headers: { Authorization: 'Bearer sk-test-key' }, - }); + expect(mockExecFile).toHaveBeenCalledTimes(2); + expect(mockExecFile.mock.calls[0][0]).toBe('/usr/bin/ffmpeg'); + expect(mockExecFile.mock.calls[1][0]).toBe('/usr/bin/handy'); }); it('should trim whitespace from transcription result', async () => { mockSuccessfulDownload(); - mockSuccessfulWhisper(' Hello world \n'); + mockHandyOutput('{"text":" Hola mundo \\n"}'); const result = await transcribe(fakeAttachmentUrl); - expect(result).toBe('Hello world'); + expect(result).toBe('Hola mundo'); }); it('should throw when file size exceeds 25MB limit', async () => { @@ -90,9 +100,9 @@ describe('voiceService', () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it('should throw when API key is not configured', async () => { - vi.mocked(configStore.getOpenAIApiKey).mockReturnValue(undefined); - await expect(transcribe(fakeAttachmentUrl)).rejects.toThrow('OpenAI API key is not configured'); + it('should throw when Handy binary is missing', async () => { + mockExistsSync.mockReturnValue(false); + await expect(transcribe(fakeAttachmentUrl)).rejects.toThrow('Handy binary not found'); expect(mockFetch).not.toHaveBeenCalled(); }); @@ -105,36 +115,18 @@ describe('voiceService', () => { await expect(transcribe(fakeAttachmentUrl)).rejects.toThrow('Failed to download audio: HTTP 404'); }); - it('should throw AUTH_FAILURE when Whisper returns 401', async () => { + it('should throw when Handy returns empty transcription', async () => { mockSuccessfulDownload(); - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - text: () => Promise.resolve('Unauthorized'), - } as unknown as Response); + mockHandyOutput('{"text":""}'); - // Suppress expected console.error - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - await expect(transcribe(fakeAttachmentUrl)).rejects.toThrow('AUTH_FAILURE'); - consoleSpy.mockRestore(); + await expect(transcribe(fakeAttachmentUrl)).rejects.toThrow('Handy returned empty transcription'); }); - it('should throw sanitized error for non-401 Whisper failures', async () => { + it('should throw when Handy output has no JSON', async () => { mockSuccessfulDownload(); - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - text: () => Promise.resolve('Internal server error with sensitive details'), - } as unknown as Response); + mockHandyOutput('[INFO] some log line without json'); - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - await expect(transcribe(fakeAttachmentUrl)).rejects.toThrow('Whisper API error (HTTP 500)'); - // Verify full error is logged server-side - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('Whisper API error 500'), - 'Internal server error with sensitive details', - ); - consoleSpy.mockRestore(); + await expect(transcribe(fakeAttachmentUrl)).rejects.toThrow('Handy did not return JSON output'); }); it('should handle fetch abort (timeout) gracefully', async () => { @@ -145,19 +137,19 @@ describe('voiceService', () => { it('should work without fileSize parameter', async () => { mockSuccessfulDownload(); - mockSuccessfulWhisper('No size check'); + mockHandyOutput('{"text":"Sin tamaño"}'); const result = await transcribe(fakeAttachmentUrl); - expect(result).toBe('No size check'); + expect(result).toBe('Sin tamaño'); }); it('should allow file exactly at 25MB limit', async () => { const exactLimit = 25 * 1024 * 1024; mockSuccessfulDownload(); - mockSuccessfulWhisper('Exact limit'); + mockHandyOutput('{"text":"Exacto"}'); const result = await transcribe(fakeAttachmentUrl, exactLimit); - expect(result).toBe('Exact limit'); + expect(result).toBe('Exacto'); }); }); }); diff --git a/src/cli.ts b/src/cli.ts index 28dfbb0..0784841 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,12 +3,13 @@ process.removeAllListeners('warning'); import { Command } from 'commander'; import pc from 'picocolors'; import { createRequire } from 'module'; +import { existsSync } from 'node:fs'; import updateNotifier from 'update-notifier'; import { runSetupWizard } from './setup/wizard.js'; import { deployCommands } from './setup/deploy.js'; import { undeployCommands } from './setup/undeploy.js'; import { startBot } from './bot.js'; -import { hasBotConfig, getConfigDir, getAllowedUserIds, addAllowedUserId, removeAllowedUserId, setAllowedUserIds, getOpenAIApiKey, setOpenAIApiKey, removeOpenAIApiKey } from './services/configStore.js'; +import { hasBotConfig, getConfigDir, getAllowedUserIds, addAllowedUserId, removeAllowedUserId, setAllowedUserIds } from './services/configStore.js'; const require = createRequire(import.meta.url); // In dev mode (src/cli.ts), package.json is one level up @@ -144,43 +145,23 @@ allowCmd console.log(pc.green('✅ Allowlist cleared. All server members can now use the bot.')); }); -const voiceCmd = program.command('voice').description('Manage voice transcription settings'); - -voiceCmd - .command('set ') - .description('Set OpenAI API key for voice transcription') - .action((apiKey: string) => { - if (!apiKey.startsWith('sk-') || apiKey.length < 20) { - console.log(pc.red('❌ Invalid API key format. Must start with "sk-" and be at least 20 characters.')); - process.exit(1); - } - setOpenAIApiKey(apiKey); - console.log(pc.green('✅ OpenAI API key set. Voice transcription is now enabled.')); - }); - -voiceCmd - .command('remove') - .description('Remove OpenAI API key') - .action(() => { - removeOpenAIApiKey(); - console.log(pc.green('✅ OpenAI API key removed. Voice transcription is now disabled.')); - }); +const voiceCmd = program.command('voice').description('Voice transcription (Handy local)'); voiceCmd .command('status') .description('Show voice transcription status') .action(() => { - const envKey = process.env.OPENAI_API_KEY; - const configKey = getOpenAIApiKey(); - if (!configKey) { + const handyBin = process.env.HANDY_BIN || '/usr/bin/handy'; + const installed = existsSync(handyBin); + if (!installed) { console.log(pc.yellow('🎙️ Voice Transcription: Disabled')); - console.log(' No OpenAI API key configured.'); + console.log(` Handy not found at ${handyBin}. Install it with: sudo apt install handy`); } else { - const source = envKey ? 'environment variable' : 'config file'; - const masked = configKey.slice(0, 3) + '...' + configKey.slice(-6); - console.log(pc.green('🎙️ Voice Transcription: Enabled')); - console.log(` Source: ${source}`); - console.log(` API Key: ${masked}`); + console.log(pc.green('🎙️ Voice Transcription: Enabled (Handy local)')); + console.log(` Binary: ${handyBin}`); + if (process.env.HANDY_MODEL) { + console.log(` Model: ${process.env.HANDY_MODEL}`); + } } }); diff --git a/src/commands/diff.ts b/src/commands/diff.ts index 2e7071c..e8e462f 100644 --- a/src/commands/diff.ts +++ b/src/commands/diff.ts @@ -21,6 +21,47 @@ function formatDiff(raw: string): string { return '```diff\n' + truncated + '\n```'; } +export function computeDiff( + projectPath: string, + target: string = 'unstaged', + stat: boolean = false, + base: string = 'main', +): Promise<{ ok: boolean; content?: string; error?: string }> { + return (async () => { + if (target === 'branch' && !GIT_REF_PATTERN.test(base)) { + return { ok: false, error: 'Invalid base branch name.' }; + } + + let gitArgs: string[]; + switch (target) { + case 'staged': + gitArgs = ['diff', '--cached']; + break; + case 'branch': + gitArgs = ['diff', `${base}...HEAD`]; + break; + default: + gitArgs = ['diff']; + } + + if (stat) { + gitArgs.push('--stat'); + } + + try { + const { stdout } = await execFileAsync('git', gitArgs, { cwd: projectPath }); + const output = stdout.trim(); + if (!output) { + const targetLabel = target === 'branch' ? `branch (base: ${base})` : target; + return { ok: true, content: `✅ No ${targetLabel} changes.` }; + } + return { ok: true, content: formatDiff(output) }; + } catch (error) { + return { ok: false, error: (error as Error).message }; + } + })(); +} + export const diff: Command = { data: new SlashCommandBuilder() .setName('diff') @@ -82,42 +123,13 @@ export const diff: Command = { return; } - if (target === 'branch' && !GIT_REF_PATTERN.test(base)) { - await i.reply({ content: '❌ Invalid base branch name.', flags: MessageFlags.Ephemeral }); - return; - } - await i.deferReply(); - try { - let gitArgs: string[]; - switch (target) { - case 'staged': - gitArgs = ['diff', '--cached']; - break; - case 'branch': - gitArgs = ['diff', `${base}...HEAD`]; - break; - default: - gitArgs = ['diff']; - } - - if (stat) { - gitArgs.push('--stat'); - } - - const { stdout } = await execFileAsync('git', gitArgs, { cwd: projectPath }); - const output = stdout.trim(); - - if (!output) { - const targetLabel = target === 'branch' ? `branch (base: ${base})` : target; - await i.editReply(`✅ No ${targetLabel} changes.`); - return; - } - - await i.editReply(formatDiff(output)); - } catch (error) { - await i.editReply(`❌ Failed to get diff: ${(error as Error).message}`); + const result = await computeDiff(projectPath, target, stat, base); + if (result.ok) { + await i.editReply(result.content as string); + } else { + await i.editReply(`❌ Failed to get diff: ${result.error}`); } } }; diff --git a/src/commands/hub.ts b/src/commands/hub.ts new file mode 100644 index 0000000..4dc5fc1 --- /dev/null +++ b/src/commands/hub.ts @@ -0,0 +1,128 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChatInputCommandInteraction, + MessageFlags, + SlashCommandBuilder, + TextChannel, +} from 'discord.js'; +import * as dataStore from '../services/dataStore.js'; +import type { Command } from './index.js'; + +const DEFAULT_MODEL = 'deepseek/deepseek-v4-flash'; + +export function hubButtonRows( + projectAlias: string, + model: string, +): Array> { + const newsession = new ButtonBuilder() + .setCustomId('new-session') + .setLabel('🆕 Nueva sesión') + .setStyle(ButtonStyle.Primary); + + const sessions = new ButtonBuilder() + .setCustomId('sessions-list') + .setLabel('📋 Sesiones') + .setStyle(ButtonStyle.Secondary); + + const modelbtn = new ButtonBuilder() + .setCustomId('model-hub') + .setLabel('🧠 Modelo') + .setStyle(ButtonStyle.Secondary); + + const projectbtn = new ButtonBuilder() + .setCustomId('project-hub') + .setLabel('📂 Proyecto') + .setStyle(ButtonStyle.Secondary); + + const row = new ActionRowBuilder().addComponents( + newsession, + sessions, + modelbtn, + projectbtn, + ); + + return [row]; +} + +export async function postHubMessage( + channel: TextChannel, + projectAlias: string, + model: string, +): Promise { + try { + const pinned = await channel.messages.fetchPinned(); + for (const msg of pinned.values()) { + if (msg.author.id === channel.client.user.id) { + await msg.unpin().catch(() => {}); + } + } + } catch { + // ignore + } + + const message = await channel.send({ + content: + `**Hub de OpenCode**\n` + + `📂 Proyecto: \`${projectAlias}\`\n` + + `🧠 Modelo: \`${model}\`\n\n` + + `🆕 **Nueva sesión** — crea un canal de sesión (nombre, passthrough activo).\n` + + `📋 **Sesiones** — lista activas y archivadas, reabrir sesión.\n` + + `🧠 **Modelo** — cambia el modelo por defecto.\n` + + `📂 **Proyecto** — cambia el proyecto por defecto.`, + components: hubButtonRows(projectAlias, model), + }); + await message.pin().catch(() => {}); +} + +export const hub: Command = { + data: new SlashCommandBuilder() + .setName('hub') + .setDescription('Configurar el hub: botones para lanzar y gestionar sesiones'), + + async execute(interaction: ChatInputCommandInteraction) { + const current = dataStore.getHubConfig(); + let projectAlias = current?.projectAlias; + if (!projectAlias) { + const projects = dataStore.getProjects(); + if (projects.length === 0) { + await interaction.reply({ + content: + '❌ No hay proyectos registrados. Usá `/setpath alias: path:` primero.', + flags: MessageFlags.Ephemeral, + }); + return; + } + projectAlias = projects[0].alias; + } + + const model = current?.model ?? DEFAULT_MODEL; + const agent = current?.agent ?? 'build'; + + dataStore.setHubConfig({ + hubChannelId: interaction.channelId, + categoryId: current?.categoryId ?? '', + projectAlias, + model, + agent, + }); + dataStore.setChannelBinding(interaction.channelId, projectAlias, model); + + const channel = interaction.channel; + if (!channel?.isTextBased() || channel.isDMBased()) { + await interaction.reply({ + content: '❌ Este comando solo funciona en canales de texto del servidor.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.reply({ + content: '✅ Hub configurado en este canal.', + flags: MessageFlags.Ephemeral, + }); + + await postHubMessage(channel as TextChannel, projectAlias, model); + }, +}; diff --git a/src/commands/index.ts b/src/commands/index.ts index 41dd88e..c938f9e 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -14,6 +14,7 @@ import { allow } from './allow.js'; import { diff } from './diff.js'; import { voice } from './voice.js'; import { session } from './session.js'; +import { hub } from './hub.js'; export interface Command { data: SlashCommandBuilder; @@ -37,3 +38,4 @@ commands.set(allow.data.name, allow); commands.set(diff.data.name, diff); commands.set(voice.data.name, voice); commands.set(session.data.name, session); +commands.set(hub.data.name, hub); diff --git a/src/commands/opencode.ts b/src/commands/opencode.ts index 2968de2..6fc526a 100644 --- a/src/commands/opencode.ts +++ b/src/commands/opencode.ts @@ -1,81 +1,114 @@ -import { - SlashCommandBuilder, - ChatInputCommandInteraction, +import { + SlashCommandBuilder, + ChatInputCommandInteraction, MessageFlags, - ThreadChannel + TextChannel, + ThreadChannel, } from 'discord.js'; import * as dataStore from '../services/dataStore.js'; -import { getOrCreateThread } from '../utils/threadHelper.js'; +import * as sessionFlow from '../services/sessionFlow.js'; import type { Command } from './index.js'; import { runPrompt } from '../services/executionService.js'; import { isBusy } from '../services/queueManager.js'; -function getParentChannelId(interaction: ChatInputCommandInteraction): string { - const channel = interaction.channel; - if (channel?.isThread()) { - return (channel as ThreadChannel).parentId ?? interaction.channelId; - } - return interaction.channelId; -} - export const opencode: Command = { data: new SlashCommandBuilder() .setName('opencode') - .setDescription('Send a command to OpenCode') + .setDescription('Enviar un prompt a OpenCode (crea un canal de sesión si no estás en uno)') .addStringOption(option => option.setName('prompt') .setDescription('Prompt to send to OpenCode') .setRequired(true)) as SlashCommandBuilder, - + async execute(interaction: ChatInputCommandInteraction) { const prompt = interaction.options.getString('prompt', true); - const channelId = getParentChannelId(interaction); - const isInThread = interaction.channel?.isThread() ?? false; - - const projectPath = dataStore.getChannelProjectPath(channelId); - if (!projectPath) { - await interaction.reply({ - content: '❌ No project set for this channel. Use `/use ` to set a project.', - flags: MessageFlags.Ephemeral - }); - return; + const channel = interaction.channel; + + let targetChannel: TextChannel | ThreadChannel; + let threadId: string; + let parentChannelId: string; + + if (channel?.isThread()) { + targetChannel = channel; + threadId = channel.id; + parentChannelId = channel.parentId ?? channel.id; + } else if ( + channel && + channel.isTextBased() && + !channel.isDMBased() && + dataStore.isPassthroughEnabled(channel.id) + ) { + targetChannel = channel as TextChannel; + threadId = channel.id; + parentChannelId = channel.id; + } else { + const hub = dataStore.getHubConfig(); + if (!hub || !interaction.guild) { + await interaction.reply({ + content: + '❌ No hay hub configurado. Usá `/hub` en el canal principal, o entrá a un canal de sesión.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply(); + + try { + const created = await sessionFlow.createSessionChannel( + interaction.guild, + prompt.slice(0, 90), + hub.projectAlias, + hub.model, + hub.agent, + interaction.user.id, + ); + targetChannel = created; + threadId = created.id; + parentChannelId = created.id; + } catch (error) { + await interaction.editReply({ + content: `❌ No se pudo crear el canal de sesión: ${(error as Error).message}`, + }); + return; + } } - await interaction.deferReply(); - - let thread; - try { - if (isInThread && interaction.channel?.isThread()) { - thread = interaction.channel; + const projectPath = dataStore.getChannelProjectPath(parentChannelId); + if (!projectPath) { + if (interaction.replied || interaction.deferred) { + await interaction.editReply({ + content: '❌ No hay proyecto asignado a este canal.', + }); } else { - thread = await getOrCreateThread(interaction, prompt); + await interaction.reply({ + content: '❌ No hay proyecto asignado a este canal.', + flags: MessageFlags.Ephemeral, + }); } - } catch { - await interaction.editReply({ - content: '❌ Cannot create thread.', - }); return; } - - const threadId = thread.id; - + + if (!interaction.deferred && !interaction.replied) { + await interaction.deferReply(); + } + if (isBusy(threadId)) { dataStore.addToQueue(threadId, { prompt, userId: interaction.user.id, - timestamp: Date.now() + timestamp: Date.now(), }); await interaction.editReply({ - content: '📥 Prompt added to queue.', + content: `📥 Prompt agregado a la cola de <#${threadId}>.`, }); return; } await interaction.editReply({ - content: `📌 **Prompt**: ${prompt}` + content: `📌 **Prompt**: ${prompt} — sesión <#${threadId}>`, }); - await runPrompt(thread as any, threadId, prompt, channelId); - } + await runPrompt(targetChannel as any, threadId, prompt, parentChannelId); + }, }; - diff --git a/src/commands/voice.ts b/src/commands/voice.ts index c57ab45..224ab1b 100644 --- a/src/commands/voice.ts +++ b/src/commands/voice.ts @@ -1,24 +1,15 @@ -import { - SlashCommandBuilder, - ChatInputCommandInteraction, - MessageFlags +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + MessageFlags } from 'discord.js'; -import { getOpenAIApiKey, removeOpenAIApiKey } from '../services/configStore.js'; import type { Command } from './index.js'; - -function maskApiKey(key: string): string { - if (key.length <= 7) return '***'; - return key.slice(0, 3) + '...' + key.slice(-6); -} +import { isVoiceEnabled, getHandyBin } from '../services/voiceService.js'; export const voice: Command = { data: new SlashCommandBuilder() .setName('voice') - .setDescription('Manage voice message transcription settings') - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Remove OpenAI API key')) + .setDescription('Show voice message transcription status') .addSubcommand(subcommand => subcommand .setName('status') @@ -27,29 +18,19 @@ export const voice: Command = { async execute(interaction: ChatInputCommandInteraction) { const subcommand = interaction.options.getSubcommand(); - if (subcommand === 'remove') { - removeOpenAIApiKey(); - await interaction.reply({ - content: '✅ OpenAI API key removed. Voice transcription disabled.', - flags: MessageFlags.Ephemeral, - }); - } else if (subcommand === 'status') { - const envKey = process.env.OPENAI_API_KEY; - const configKey = getOpenAIApiKey(); - - if (!configKey) { + if (subcommand === 'status') { + if (isVoiceEnabled()) { + const model = process.env.HANDY_MODEL ? `\n Model: \`${process.env.HANDY_MODEL}\`` : ''; await interaction.reply({ - content: '🎙️ Voice Transcription: **Disabled**\n No OpenAI API key configured.\n Use `remote-opencode voice set ` (CLI) or set `OPENAI_API_KEY` env var.', + content: `🎙️ Voice Transcription: **Enabled**\n Engine: **Handy** (local)\n Binary: \`${getHandyBin()}\`${model}\n Mandá un voice message en un canal de sesión y se transcribe automáticamente.`, + flags: MessageFlags.Ephemeral, + }); + } else { + await interaction.reply({ + content: `🎙️ Voice Transcription: **Disabled**\n No se encontró Handy en \`${getHandyBin()}\`.\n Instalalo con \`sudo apt install handy\` y dejalo corriendo.`, flags: MessageFlags.Ephemeral, }); - return; } - - const source = envKey ? 'environment variable' : 'config file'; - await interaction.reply({ - content: `🎙️ Voice Transcription: **Enabled**\n Source: ${source}\n API Key: \`${maskApiKey(configKey)}\``, - flags: MessageFlags.Ephemeral, - }); } } }; diff --git a/src/handlers/buttonHandler.ts b/src/handlers/buttonHandler.ts index d282d2b..a7eaffa 100644 --- a/src/handlers/buttonHandler.ts +++ b/src/handlers/buttonHandler.ts @@ -1,43 +1,295 @@ -import { ButtonInteraction, ThreadChannel, MessageFlags } from 'discord.js'; +import { + ActionRowBuilder, + ButtonInteraction, + EmbedBuilder, + MessageFlags, + StringSelectMenuBuilder, + TextChannel, + TextInputBuilder, + TextInputStyle, + ThreadChannel, +} from 'discord.js'; import * as sessionManager from '../services/sessionManager.js'; import * as serveManager from '../services/serveManager.js'; import * as dataStore from '../services/dataStore.js'; import * as worktreeManager from '../services/worktreeManager.js'; +import * as sessionFlow from '../services/sessionFlow.js'; +import { computeDiff } from '../commands/diff.js'; +import { getCachedModels } from '../commands/model.js'; export async function handleButton(interaction: ButtonInteraction) { const customId = interaction.customId; - - const [action, threadId] = customId.split('_'); - - if (!threadId) { + + if (customId === 'new-session') { + await handleNewSession(interaction); + return; + } + if (customId === 'sessions-list') { + await handleSessionsList(interaction); + return; + } + if (customId === 'model-hub') { + await handleModelSelect(interaction, getHubChannelId(interaction)); + return; + } + if (customId === 'project-hub') { + await handleProjectSelect(interaction); + return; + } + + const [action, channelId] = customId.split('_'); + + if (!channelId) { await interaction.reply({ content: '❌ Invalid button.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); return; } - - if (action === 'interrupt') { - await handleInterrupt(interaction, threadId); - } else if (action === 'delete') { - await handleWorktreeDelete(interaction, threadId); - } else if (action === 'pr') { - await handleWorktreePR(interaction, threadId); - } else { + + switch (action) { + case 'interrupt': + await handleInterrupt(interaction, channelId); + break; + case 'mode': + await handleModeToggle(interaction, channelId); + break; + case 'diff': + await handleDiff(interaction, channelId); + break; + case 'undo': + await handleUndo(interaction, channelId); + break; + case 'archive': + await handleArchive(interaction, channelId); + break; + case 'status': + await handleStatus(interaction, channelId); + break; + case 'compact': + await handleCompact(interaction, channelId); + break; + case 'init': + await handleInit(interaction, channelId); + break; + case 'modelbtn': + await handleModelSelect(interaction, channelId); + break; + case 'delete': + await handleWorktreeDelete(interaction, channelId); + break; + case 'pr': + await handleWorktreePR(interaction, channelId); + break; + default: + await interaction.reply({ + content: '❌ Unknown action.', + flags: MessageFlags.Ephemeral, + }); + } +} + +function getHubChannelId(interaction: ButtonInteraction): string { + const hub = dataStore.getHubConfig(); + return hub?.hubChannelId ?? interaction.channelId; +} + +async function handleNewSession(interaction: ButtonInteraction) { + await interaction.showModal({ + customId: 'new-session-modal', + title: 'Nueva sesión', + components: [ + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('session-name') + .setLabel('Nombre de la sesión') + .setStyle(TextInputStyle.Short) + .setPlaceholder('ej: 24 agosto') + .setRequired(true) + .setMaxLength(90), + ), + ], + }); +} + +async function handleSessionsList(interaction: ButtonInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const hub = dataStore.getHubConfig(); + const threadSessions = dataStore.getAllThreadSessions(); + const mappedBySession = new Map(threadSessions.map((s) => [s.sessionId, s])); + const archived = dataStore.getArchivedSessions(); + const archivedByChannel = new Map(archived.map((a) => [a.channelId, a])); + + let projectPath: string | undefined; + if (hub) { + projectPath = dataStore.getChannelProjectPath(hub.hubChannelId); + } + + let storedSessions: { id: string; title: string; port: number; time?: string; timeUpdated?: string }[] = []; + if (projectPath) { + try { + const port = await serveManager.spawnServe(projectPath, hub?.model); + await serveManager.waitForReady(port, 30000, projectPath, hub?.model); + const list = await sessionManager.listSessions(port); + storedSessions = list.map((s) => ({ ...s, port })); + } catch (error) { + console.error('[sessions] Failed to list opencode sessions:', error); + } + } + + const mappedLines: string[] = []; + const unmapped: { id: string; title: string }[] = []; + + const sortedSessions = [...storedSessions].sort((a, b) => { + const ta = a.timeUpdated || a.time || ''; + const tb = b.timeUpdated || b.time || ''; + if (ta && tb && ta !== tb) return tb.localeCompare(ta); + return a.id.localeCompare(b.id); + }); + + for (const s of sortedSessions) { + const mapping = mappedBySession.get(s.id); + if (mapping) { + const archivedFlag = archivedByChannel.has(mapping.threadId) ? '🔒 ' : ''; + const busy = sessionManager.getSseClient(mapping.threadId)?.isConnected() + ? '🟢' + : '⚪'; + mappedLines.push(`${busy} ${archivedFlag}<#${mapping.threadId}> · ${s.title || s.id.slice(0, 8)}`); + } else { + unmapped.push({ id: s.id, title: s.title || s.id.slice(0, 8) }); + } + } + + const embed = new EmbedBuilder() + .setTitle('📋 Sesiones') + .setColor(0x3498db); + + if (mappedLines.length > 0) { + embed.addFields({ + name: 'En Discord', + value: mappedLines.slice(0, 15).join('\n'), + }); + } + + const unmappedToShow = unmapped.slice(0, 15); + if (unmappedToShow.length > 0) { + embed.addFields({ + name: 'Otras sesiones (de opencode)', + value: unmappedToShow.map((s) => `• ${s.title} (\`${s.id.slice(0, 8)}\`)`).join('\n'), + }); + } + + if (!mappedLines.length && !unmappedToShow.length && archived.length === 0) { + embed.setDescription('No hay sesiones. Tocá **🆕 Nueva sesión** para crear una.'); + } + + const rows: Array> = []; + + if (unmappedToShow.length > 0) { + const select = new StringSelectMenuBuilder() + .setCustomId('attach-session-select') + .setPlaceholder('Resumir sesión en Discord') + .setMaxValues(Math.min(unmappedToShow.length, 5)) + .addOptions( + unmappedToShow.slice(0, 25).map((s) => ({ + label: s.title.slice(0, 25), + description: s.id.slice(0, 8), + value: s.id, + })), + ); + rows.push(new ActionRowBuilder().addComponents(select)); + } + + if (archived.length > 0) { + const select = new StringSelectMenuBuilder() + .setCustomId('reopen-select') + .setPlaceholder('Reabrir sesión archivada') + .setMaxValues(Math.min(archived.length, 5)) + .addOptions( + archived + .slice(-25) + .reverse() + .map((a) => ({ + label: (a.title || a.channelId.slice(0, 8)).slice(0, 25), + description: a.sessionId ? a.sessionId.slice(0, 8) : 'sin sesión', + value: a.channelId, + })), + ); + rows.push(new ActionRowBuilder().addComponents(select)); + } + + await interaction.editReply({ + embeds: [embed], + components: rows, + }); +} + +async function handleModelSelect( + interaction: ButtonInteraction, + channelId: string, +) { + const models = getCachedModels(); + if (models.length === 0) { + await interaction.reply({ + content: '❌ No se pudieron cargar los modelos.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const options = models.slice(0, 25).map((m) => ({ + label: m, + value: m, + })); + + const select = new StringSelectMenuBuilder() + .setCustomId(`model-select_${channelId}`) + .setPlaceholder('Elegí un modelo') + .addOptions(options); + + await interaction.reply({ + content: `🧠 **Modelo** para <#${channelId}>:`, + components: [new ActionRowBuilder().addComponents(select)], + flags: MessageFlags.Ephemeral, + }); +} + +async function handleProjectSelect(interaction: ButtonInteraction) { + const projects = dataStore.getProjects(); + if (projects.length === 0) { await interaction.reply({ - content: '❌ Unknown action.', - flags: MessageFlags.Ephemeral + content: '❌ No hay proyectos registrados. Usá `/setpath`.', + flags: MessageFlags.Ephemeral, }); + return; } + + const select = new StringSelectMenuBuilder() + .setCustomId('project-select') + .setPlaceholder('Elegí un proyecto') + .addOptions( + projects.slice(0, 25).map((p) => ({ + label: p.alias.slice(0, 25), + description: p.path.slice(0, 80), + value: p.alias, + })), + ); + + await interaction.reply({ + content: '📂 **Proyecto** por defecto:', + components: [new ActionRowBuilder().addComponents(select)], + flags: MessageFlags.Ephemeral, + }); } async function handleInterrupt(interaction: ButtonInteraction, threadId: string) { const session = sessionManager.getSessionForThread(threadId); - + if (!session) { await interaction.reply({ content: '⚠️ Session not found.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); return; } @@ -47,19 +299,19 @@ async function handleInterrupt(interaction: ButtonInteraction, threadId: string) const preferredModel = parentChannelId ? dataStore.getChannelModel(parentChannelId) : undefined; const port = serveManager.getPort(session.projectPath, preferredModel); - + if (!port) { await interaction.reply({ content: '⚠️ Server is not running.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); return; } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - + const success = await sessionManager.abortSession(port, session.sessionId); - + if (success) { await interaction.editReply({ content: '⏸️ Interrupt request sent.' }); } else { @@ -67,6 +319,162 @@ async function handleInterrupt(interaction: ButtonInteraction, threadId: string) } } +async function handleModeToggle(interaction: ButtonInteraction, channelId: string) { + const current = dataStore.getChannelAgent(channelId); + const next = current === 'plan' ? 'build' : 'plan'; + dataStore.setChannelAgent(channelId, next); + + const channel = interaction.channel; + if (channel?.isTextBased() && !channel.isDMBased()) { + await sessionFlow.refreshSticky(channel as TextChannel, channelId, next); + } + + await interaction.reply({ + content: next === 'plan' ? '🎯 Modo **Plan** activado (no toca archivos).' : '🔨 Modo **Build** activado.', + flags: MessageFlags.Ephemeral, + }); +} + +async function handleDiff(interaction: ButtonInteraction, channelId: string) { + const projectPath = dataStore.getChannelProjectPath(channelId); + if (!projectPath) { + await interaction.reply({ + content: '❌ No project bound to this channel.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const result = await computeDiff(projectPath, 'unstaged', false, 'main'); + if (result.ok) { + await interaction.editReply(result.content as string); + } else { + await interaction.editReply(`❌ Failed to get diff: ${result.error}`); + } +} + +async function handleUndo(interaction: ButtonInteraction, channelId: string) { + const session = sessionManager.getSessionForThread(channelId); + if (!session) { + await interaction.reply({ + content: '⚠️ No hay sesión en este canal.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const result = await sessionManager.revertLastMessage(session.port, session.sessionId); + if (result.ok) { + await interaction.editReply({ content: '↩️ Último mensaje revertido.' }); + } else { + await interaction.editReply({ + content: `⚠️ No se pudo revertir: ${result.message || 'error desconocido'}`, + }); + } +} + +async function handleArchive(interaction: ButtonInteraction, channelId: string) { + const channel = interaction.channel; + if (!channel?.isTextBased() || channel.isDMBased()) { + await interaction.reply({ + content: '❌ Este botón solo funciona en un canal de sesión.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + await sessionFlow.archiveSessionChannel(channel as TextChannel, channelId); + + await interaction.editReply({ + content: '🗑 Sesión archivada y canal oculto. Para reabrirla: **📋 Sesiones** en el hub.', + }); +} + +async function handleStatus(interaction: ButtonInteraction, channelId: string) { + const projectPath = dataStore.getChannelProjectPath(channelId); + const session = sessionManager.getSessionForThread(channelId); + const model = dataStore.getChannelModel(channelId) || 'default'; + const agent = dataStore.getChannelAgent(channelId); + const busy = sessionManager.getSseClient(channelId)?.isConnected(); + + let branch = 'n/a'; + if (projectPath) { + try { + branch = (await worktreeManager.getCurrentBranch(projectPath)) || 'n/a'; + } catch { + branch = 'n/a'; + } + } + + let sessionTitle = session?.sessionId ?? 'sin sesión'; + if (session) { + try { + const info = await sessionManager.getSessionInfo(session.port, session.sessionId); + if (info?.title) sessionTitle = info.title; + } catch { + // ignore + } + } + + const embed = new EmbedBuilder() + .setTitle('ℹ️ Estado de la sesión') + .addFields( + { name: 'Proyecto', value: projectPath ? `\`${projectPath}\`` : 'n/a' }, + { name: 'Rama', value: `\`${branch}\``, inline: true }, + { name: 'Modelo', value: `\`${model}\``, inline: true }, + { name: 'Modo', value: agent === 'plan' ? '🎯 Plan' : '🔨 Build', inline: true }, + { name: 'Sesión', value: `\`${sessionTitle}\`` }, + { name: 'Estado', value: busy ? '🟢 trabajando' : '⚪ inactivo', inline: true }, + ) + .setColor(busy ? 0x2ecc71 : 0x95a5a6); + + await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); +} + +async function handleCompact(interaction: ButtonInteraction, channelId: string) { + const session = sessionManager.getSessionForThread(channelId); + if (!session) { + await interaction.reply({ + content: '⚠️ No hay sesión en este canal.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const model = dataStore.getChannelModel(channelId); + const ok = await sessionManager.summarizeSession(session.port, session.sessionId, model); + await interaction.editReply({ + content: ok ? '🧹 Resumen del contexto generado.' : '⚠️ No se pudo compactar el contexto.', + }); +} + +async function handleInit(interaction: ButtonInteraction, channelId: string) { + const session = sessionManager.getSessionForThread(channelId); + if (!session) { + await interaction.reply({ + content: '⚠️ No hay sesión en este canal.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const model = dataStore.getChannelModel(channelId); + const ok = await sessionManager.initSession(session.port, session.sessionId, model); + await interaction.editReply({ + content: ok ? '⚡ AGENTS.md generado/actualizado.' : '⚠️ No se pudo inicializar el proyecto.', + }); +} + async function handleWorktreeDelete(interaction: ButtonInteraction, threadId: string) { const mapping = dataStore.getWorktreeMapping(threadId); if (!mapping) { diff --git a/src/handlers/interactionHandler.ts b/src/handlers/interactionHandler.ts index def24d3..27f33ee 100644 --- a/src/handlers/interactionHandler.ts +++ b/src/handlers/interactionHandler.ts @@ -1,7 +1,11 @@ -import { Interaction, MessageFlags } from 'discord.js'; +import { Interaction, MessageFlags, StringSelectMenuInteraction, TextChannel } from 'discord.js'; import { commands } from '../commands/index.js'; import { handleButton } from './buttonHandler.js'; import { isAuthorized } from '../services/configStore.js'; +import * as dataStore from '../services/dataStore.js'; +import * as sessionFlow from '../services/sessionFlow.js'; +import * as serveManager from '../services/serveManager.js'; +import * as sessionManager from '../services/sessionManager.js'; export async function handleInteraction(interaction: Interaction) { if (interaction.isButton()) { @@ -20,6 +24,36 @@ export async function handleInteraction(interaction: Interaction) { return; } + if (interaction.isModalSubmit()) { + if (!isAuthorized(interaction.user.id)) { + await interaction.reply({ + content: '🚫 You are not authorized to use this bot.', + flags: MessageFlags.Ephemeral + }); + return; + } + if (interaction.customId === 'new-session-modal') { + await handleNewSessionModal(interaction); + } + return; + } + + if (interaction.isStringSelectMenu()) { + if (!isAuthorized(interaction.user.id)) { + await interaction.reply({ + content: '🚫 You are not authorized to use this bot.', + flags: MessageFlags.Ephemeral + }); + return; + } + try { + await handleSelectMenu(interaction); + } catch (error) { + console.error('Error handling select menu:', error); + } + return; + } + if (interaction.isAutocomplete()) { const command = commands.get(interaction.commandName); if (command?.autocomplete) { @@ -72,3 +106,184 @@ export async function handleInteraction(interaction: Interaction) { } } } + +async function handleNewSessionModal(interaction: Interaction) { + if (!interaction.isModalSubmit()) return; + const name = interaction.fields.getTextInputValue('session-name').trim(); + if (!name) { + await interaction.reply({ + content: '❌ El nombre no puede estar vacío.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const hub = dataStore.getHubConfig(); + if (!hub) { + await interaction.reply({ + content: '❌ No hay hub configurado. Usá `/hub` primero.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (!interaction.guild) { + await interaction.reply({ + content: '❌ No se pudo determinar el servidor.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + const channel = await sessionFlow.createSessionChannel( + interaction.guild, + name, + hub.projectAlias, + hub.model, + hub.agent, + interaction.user.id, + ); + await interaction.editReply({ + content: `✅ Sesión **${name}** creada: <#${channel.id}>\nEscribí tu prompt en ese canal.`, + }); + } catch (error) { + console.error('Failed to create session channel:', error); + await interaction.editReply({ + content: `❌ No se pudo crear la sesión: ${(error as Error).message}`, + }); + } +} + +async function handleSelectMenu(interaction: StringSelectMenuInteraction) { + const customId = interaction.customId; + + if (customId.startsWith('model-select_')) { + const channelId = customId.replace('model-select_', ''); + const model = interaction.values[0]; + dataStore.setChannelModel(channelId, model); + + const hub = dataStore.getHubConfig(); + if (hub?.hubChannelId === channelId) { + dataStore.setHubConfig({ ...hub, model }); + } + + await interaction.update({ + content: `✅ Modelo para <#${channelId}> → \`${model}\``, + components: [], + }); + return; + } + + if (customId === 'project-select') { + const alias = interaction.values[0]; + const hub = dataStore.getHubConfig(); + if (hub) { + dataStore.setHubConfig({ ...hub, projectAlias: alias }); + dataStore.setChannelBinding(hub.hubChannelId, alias, hub.model); + } + await interaction.update({ + content: `✅ Proyecto por defecto → \`${alias}\``, + components: [], + }); + return; + } + + if (customId === 'reopen-select') { + const hub = dataStore.getHubConfig(); + const reabiertas: string[] = []; + for (const channelId of interaction.values) { + const archived = dataStore.getArchivedSession(channelId); + if (archived && interaction.guild) { + const channel = await sessionFlow.reopenSessionChannel( + interaction.guild, + archived, + interaction.user.id, + ); + if (channel) reabiertas.push(`<#${channel.id}>`); + } + } + + const hubNote = hub ? ` Hub: <#${hub.hubChannelId}>` : ''; + await interaction.update({ + content: + reabiertas.length > 0 + ? `✅ Sesiones reabiertas: ${reabiertas.join(', ')}` + : '⚠️ No se pudo reabrir ninguna sesión.', + components: [], + }); + return; + } + + if (customId === 'attach-session-select') { + await interaction.update({ + content: '🔄 Creando canales de sesión...', + components: [], + }); + + const hub = dataStore.getHubConfig(); + if (!hub) { + await interaction.editReply({ + content: '❌ No hay hub configurado. Usá `/hub` primero.', + }); + return; + } + if (!interaction.guild) { + await interaction.editReply({ + content: '❌ No se pudo determinar el servidor.', + }); + return; + } + + const projectPath = dataStore.getChannelProjectPath(hub.hubChannelId); + if (!projectPath) { + await interaction.editReply({ + content: '❌ No hay proyecto asignado al hub.', + }); + return; + } + + try { + const port = serveManager.getPort(projectPath, hub.model); + if (!port) { + await interaction.editReply({ + content: '❌ No hay servidor opencode activo para el proyecto. Probá de nuevo en un momento.', + }); + return; + } + + const creados: string[] = []; + for (const sessionId of interaction.values) { + const info = await sessionManager.getSessionInfo(port, sessionId); + const title = info?.title || 'sesion'; + const channel = await sessionFlow.createChannelForSession( + interaction.guild, + sessionId, + title, + projectPath, + port, + hub.projectAlias, + hub.model, + hub.agent, + interaction.user.id, + ); + creados.push(`<#${channel.id}>`); + } + + await interaction.editReply({ + content: + creados.length > 0 + ? `✅ Sesiones resumidas en Discord: ${creados.join(', ')}` + : '⚠️ No se pudo resumir ninguna sesión.', + }); + } catch (error) { + console.error('Failed to attach sessions:', error); + await interaction.editReply({ + content: `❌ Error al resumir sesiones: ${(error as Error).message}`, + }); + } + return; + } +} diff --git a/src/handlers/messageHandler.ts b/src/handlers/messageHandler.ts index 7954bf1..b93cebd 100644 --- a/src/handlers/messageHandler.ts +++ b/src/handlers/messageHandler.ts @@ -30,7 +30,11 @@ export async function handleMessageCreate(message: Message): Promise { if (message.system) return; const channel = message.channel; - if (!channel.isThread()) return; + const isThread = typeof channel.isThread === 'function' ? channel.isThread() : false; + const isText = !isThread && typeof (channel as any).isTextBased === 'function' + ? (channel as any).isTextBased() + : 'send' in channel; + if (!isThread && !isText) return; const threadId = channel.id; @@ -38,8 +42,10 @@ export async function handleMessageCreate(message: Message): Promise { if (!isAuthorized(message.author.id)) return; - const parentChannelId = (channel as ThreadChannel).parentId; - if (!parentChannelId) return; + let parentChannelId = channel.id; + if (channel.isThread()) { + parentChannelId = (channel as ThreadChannel).parentId ?? channel.id; + } let prompt = message.content.trim(); diff --git a/src/services/dataStore.ts b/src/services/dataStore.ts index f36bba2..21b334f 100644 --- a/src/services/dataStore.ts +++ b/src/services/dataStore.ts @@ -1,7 +1,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; -import type { DataStore, ProjectConfig, ChannelBinding, ThreadSession, WorktreeMapping, PassthroughThread, QueuedMessage, QueueSettings } from '../types/index.js'; +import type { DataStore, ProjectConfig, ChannelBinding, ThreadSession, WorktreeMapping, PassthroughThread, QueuedMessage, QueueSettings, HubConfig, ArchivedSession, AgentMode } from '../types/index.js'; import { sanitizeModel } from '../utils/stringUtils.js'; @@ -258,6 +258,63 @@ export function getProjectAutoPassthrough(alias: string): boolean { return project?.autoPassthrough ?? false; } +export function getHubConfig(): HubConfig | undefined { + return loadData().hub; +} + +export function setHubConfig(hub: HubConfig): void { + const data = loadData(); + data.hub = hub; + saveData(data); +} + +export function getArchivedSessions(): ArchivedSession[] { + return loadData().archivedSessions ?? []; +} + +export function getArchivedSession(channelId: string): ArchivedSession | undefined { + return loadData().archivedSessions?.find((a) => a.channelId === channelId); +} + +export function setArchivedSession(session: ArchivedSession): void { + const data = loadData(); + if (!data.archivedSessions) data.archivedSessions = []; + data.archivedSessions = data.archivedSessions.filter((a) => a.channelId !== session.channelId); + data.archivedSessions.push(session); + saveData(data); +} + +export function removeArchivedSession(channelId: string): void { + const data = loadData(); + if (!data.archivedSessions) return; + data.archivedSessions = data.archivedSessions.filter((a) => a.channelId !== channelId); + saveData(data); +} + +export function getChannelAgent(channelId: string): AgentMode { + const data = loadData(); + return data.channelAgents?.find((a) => a.channelId === channelId)?.agent ?? 'build'; +} + +export function setChannelAgent(channelId: string, agent: AgentMode): void { + const data = loadData(); + if (!data.channelAgents) data.channelAgents = []; + const existing = data.channelAgents.findIndex((a) => a.channelId === channelId); + if (existing >= 0) { + data.channelAgents[existing].agent = agent; + } else { + data.channelAgents.push({ channelId, agent }); + } + saveData(data); +} + +export function clearChannelAgent(channelId: string): void { + const data = loadData(); + if (!data.channelAgents) return; + data.channelAgents = data.channelAgents.filter((a) => a.channelId !== channelId); + saveData(data); +} + // Queue Management export function getQueue(threadId: string): QueuedMessage[] { const data = loadData(); diff --git a/src/services/executionService.ts b/src/services/executionService.ts index 25d9c41..cbc3f66 100644 --- a/src/services/executionService.ts +++ b/src/services/executionService.ts @@ -14,6 +14,27 @@ import { SSEClient } from './sseClient.js'; import { formatOutput, formatOutputForMobile, buildContextHeader } from '../utils/messageFormatter.js'; import { processNextInQueue } from './queueManager.js'; +async function syncSessionTitle( + channel: TextBasedChannel, + port: number, + sessionId: string, +): Promise { + try { + const info = await sessionManager.getSessionInfo(port, sessionId); + if (!info || !info.title) return; + if (!('setName' in channel)) return; + const current = (channel as any).name; + if (!current) return; + const prefix = current.startsWith('🔒') ? '🔒' : current.startsWith('🤖') ? '🤖' : null; + if (!prefix) return; + if (current === `${prefix} ${info.title}`) return; + const renamed = `${prefix} ${info.title}`.slice(0, 100); + await (channel as any).setName(renamed); + } catch { + // ignore rename failures + } +} + export async function runPrompt( channel: TextBasedChannel, threadId: string, @@ -79,6 +100,7 @@ export async function runPrompt( const effectivePath = worktreeMapping?.worktreePath ?? projectPath; const preferredModel = dataStore.getChannelModel(parentChannelId); + const agent = dataStore.getChannelAgent(threadId); const modelDisplay = preferredModel ? `${preferredModel}` : 'default'; const branchName = worktreeMapping?.branchName ?? await worktreeManager.getCurrentBranch(effectivePath) ?? 'main'; @@ -208,6 +230,8 @@ export async function runPrompt( await safeSend('✅ Done'); } + await syncSessionTitle(channel, port, sessionId); + sseClient.disconnect(); sessionManager.clearSseClient(threadId); @@ -317,7 +341,7 @@ export async function runPrompt( }, 1000); await updateStreamMessage(`${contextHeader}\n📌 **Prompt**: ${prompt}\n\n📝 Sending prompt...`, [buttons]); - await sessionManager.sendPrompt(port, sessionId, prompt, preferredModel); + await sessionManager.sendPrompt(port, sessionId, prompt, preferredModel, agent); promptSent = true; } catch (error) { diff --git a/src/services/sessionFlow.ts b/src/services/sessionFlow.ts new file mode 100644 index 0000000..a9e93bd --- /dev/null +++ b/src/services/sessionFlow.ts @@ -0,0 +1,294 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + CategoryChannel, + ChannelType, + Guild, + TextChannel, +} from 'discord.js'; +import * as dataStore from './dataStore.js'; +import * as serveManager from './serveManager.js'; +import * as sessionManager from './sessionManager.js'; +import type { AgentMode } from '../types/index.js'; + +export const SESSION_PREFIX = '🤖'; +export const ARCHIVE_PREFIX = '🔒'; + +export function sanitizeChannelName(name: string): string { + let clean = name.replace(/["#:~@%*]/g, '').replace(/\s+/g, ' ').trim(); + clean = clean.slice(0, 100); + return clean || 'sesion'; +} + +export function sessionButtonRows( + channelId: string, + agent: AgentMode, +): Array> { + const mode = new ButtonBuilder() + .setCustomId(`mode_${channelId}`) + .setLabel(agent === 'plan' ? '🎯 Plan' : '🔨 Build') + .setStyle(agent === 'plan' ? ButtonStyle.Primary : ButtonStyle.Success); + + const interrupt = new ButtonBuilder() + .setCustomId(`interrupt_${channelId}`) + .setLabel('⏹️ Interrupt') + .setStyle(ButtonStyle.Secondary); + + const diff = new ButtonBuilder() + .setCustomId(`diff_${channelId}`) + .setLabel('📊 Diff') + .setStyle(ButtonStyle.Secondary); + + const model = new ButtonBuilder() + .setCustomId(`modelbtn_${channelId}`) + .setLabel('🧠 Modelo') + .setStyle(ButtonStyle.Secondary); + + const archive = new ButtonBuilder() + .setCustomId(`archive_${channelId}`) + .setLabel('🗑 Archivar') + .setStyle(ButtonStyle.Danger); + + const row1 = new ActionRowBuilder().addComponents( + mode, + interrupt, + diff, + model, + archive, + ); + + const undo = new ButtonBuilder() + .setCustomId(`undo_${channelId}`) + .setLabel('↩️ Undo') + .setStyle(ButtonStyle.Secondary); + + const status = new ButtonBuilder() + .setCustomId(`status_${channelId}`) + .setLabel('ℹ️ Status') + .setStyle(ButtonStyle.Secondary); + + const compact = new ButtonBuilder() + .setCustomId(`compact_${channelId}`) + .setLabel('🧹 Compactar') + .setStyle(ButtonStyle.Secondary); + + const init = new ButtonBuilder() + .setCustomId(`init_${channelId}`) + .setLabel('⚡ Init') + .setStyle(ButtonStyle.Secondary); + + const row2 = new ActionRowBuilder().addComponents( + undo, + status, + compact, + init, + ); + + return [row1, row2]; +} + +export async function postSticky( + channel: TextChannel, + channelId: string, + agent: AgentMode, +): Promise { + const rows = sessionButtonRows(channelId, agent); + const message = await channel.send({ + content: + '**Sesión de OpenCode**\nEscribí tu prompt acá abajo — va directo al agente. Usá los botones para controlar.', + components: rows, + }); + await message.pin().catch(() => {}); +} + +export async function refreshSticky( + channel: TextChannel, + channelId: string, + agent: AgentMode, +): Promise { + try { + const pinned = await channel.messages.fetchPinned(); + const sticky = pinned.find((m) => m.author.id === channel.client.user.id); + if (sticky) { + await sticky.edit({ + components: sessionButtonRows(channelId, agent), + }); + } + } catch { + // ignore + } +} + +async function getOrCreateCategory(guild: Guild): Promise { + const hub = dataStore.getHubConfig(); + if (hub?.categoryId) { + try { + const existing = await guild.channels.fetch(hub.categoryId); + if (existing?.type === ChannelType.GuildCategory) { + return existing as CategoryChannel; + } + } catch { + // fall through and create + } + } + const category = await guild.channels.create({ + name: 'Sesiones', + type: ChannelType.GuildCategory, + }); + if (hub) { + dataStore.setHubConfig({ ...hub, categoryId: category.id }); + } + return category; +} + +export async function createSessionChannel( + guild: Guild, + name: string, + projectAlias: string, + model: string | undefined, + agent: AgentMode, + userId: string, +): Promise { + const category = await getOrCreateCategory(guild); + const channel = await guild.channels.create({ + name: `${SESSION_PREFIX} ${sanitizeChannelName(name)}`, + type: ChannelType.GuildText, + parent: category.id, + }); + + dataStore.setChannelBinding(channel.id, projectAlias, model); + dataStore.setPassthroughMode(channel.id, true, userId); + dataStore.setChannelAgent(channel.id, agent); + + try { + const projectPath = dataStore.getChannelProjectPath(channel.id); + if (projectPath) { + const port = await serveManager.spawnServe(projectPath, model); + await serveManager.waitForReady(port, 30000, projectPath, model); + await sessionManager.ensureSessionForThread(channel.id, projectPath, port, name); + } + } catch (error) { + console.error('[sessionFlow] Failed to create session eagerly:', error); + } + + await postSticky(channel, channel.id, agent); + return channel; +} + +export async function createChannelForSession( + guild: Guild, + sessionId: string, + title: string, + projectPath: string, + port: number, + projectAlias: string, + model: string | undefined, + agent: AgentMode, + userId: string, +): Promise { + const category = await getOrCreateCategory(guild); + const channel = await guild.channels.create({ + name: `${SESSION_PREFIX} ${sanitizeChannelName(title || 'sesion')}`, + type: ChannelType.GuildText, + parent: category.id, + }); + + dataStore.setChannelBinding(channel.id, projectAlias, model); + dataStore.setPassthroughMode(channel.id, true, userId); + dataStore.setChannelAgent(channel.id, agent); + sessionManager.setSessionForThread(channel.id, sessionId, projectPath, port); + + await postSticky(channel, channel.id, agent); + return channel; +} + +export async function archiveSessionChannel( + channel: TextChannel, + channelId: string, +): Promise { + const session = sessionManager.getSessionForThread(channelId); + const model = dataStore.getChannelModel(channelId); + const currentName = channel.name.replace(/^(🤖|🔒)\s*/, ''); + + await channel.setName(`${ARCHIVE_PREFIX} ${sanitizeChannelName(currentName)}`); + try { + await channel.permissionOverwrites.edit(channel.guild.roles.everyone, { + ViewChannel: false, + }); + } catch { + // ignore permission errors + } + + dataStore.removePassthroughMode(channelId); + dataStore.setArchivedSession({ + channelId, + sessionId: session?.sessionId, + projectPath: session?.projectPath, + port: session?.port, + model, + title: currentName, + archivedAt: Date.now(), + }); +} + +export async function reopenSessionChannel( + guild: Guild, + archived: { + channelId: string; + sessionId?: string; + projectPath?: string; + port?: number; + model?: string; + title?: string; + }, + userId: string, +): Promise { + try { + const fetched = await guild.channels.fetch(archived.channelId); + if (!fetched?.isTextBased() || fetched.isDMBased()) return null; + const channel = fetched as TextChannel; + + await channel.permissionOverwrites.edit(guild.roles.everyone, { + ViewChannel: true, + }); + await channel.setName( + `${SESSION_PREFIX} ${sanitizeChannelName(archived.title || 'sesion')}`, + ); + + dataStore.removeArchivedSession(archived.channelId); + dataStore.setPassthroughMode(channel.id, true, userId); + dataStore.setChannelAgent(channel.id, 'build'); + + try { + const projectPath = + archived.projectPath || dataStore.getChannelProjectPath(channel.id); + if (projectPath) { + const port = + archived.port || + (await serveManager.spawnServe(projectPath, archived.model)); + await serveManager.waitForReady(port, 30000, projectPath, archived.model); + const sid = + sessionManager.getSessionForThread(channel.id)?.sessionId ?? + archived.sessionId; + if (sid) { + sessionManager.setSessionForThread(channel.id, sid, projectPath, port); + } + await sessionManager.ensureSessionForThread( + channel.id, + projectPath, + port, + archived.title, + ); + } + } catch (error) { + console.error('[sessionFlow] Failed to resume session on reopen:', error); + } + + await postSticky(channel, channel.id, 'build'); + return channel; + } catch (error) { + console.error('[sessionFlow] Failed to reopen channel:', error); + return null; + } +} diff --git a/src/services/sessionManager.ts b/src/services/sessionManager.ts index 31be14a..2d79cf8 100644 --- a/src/services/sessionManager.ts +++ b/src/services/sessionManager.ts @@ -9,12 +9,13 @@ function jsonHeaders(): Record { return { "Content-Type": "application/json", ...getAuthHeaders() }; } -export async function createSession(port: number): Promise { +export async function createSession(port: number, title?: string): Promise { const url = `http://127.0.0.1:${port}/session`; + const body = title ? { title } : {}; const response = await fetch(url, { method: "POST", headers: jsonHeaders(), - body: "{}", + body: JSON.stringify(body), }); if (!response.ok) { @@ -52,11 +53,13 @@ export async function sendPrompt( sessionId: string, text: string, model?: string, + agent?: string, ): Promise { const url = `http://127.0.0.1:${port}/session/${sessionId}/prompt_async`; const body: { parts: { type: string; text: string }[]; model?: { providerID: string; modelID: string }; + agent?: string; } = { parts: [{ type: "text", text }], }; @@ -69,6 +72,10 @@ export async function sendPrompt( } } + if (agent) { + body.agent = agent; + } + const response = await fetch(url, { method: "POST", headers: jsonHeaders(), @@ -84,6 +91,108 @@ export async function sendPrompt( } } +export async function renameSession( + port: number, + sessionId: string, + title: string, +): Promise { + const url = `http://127.0.0.1:${port}/session/${sessionId}`; + const response = await fetch(url, { + method: "PATCH", + headers: jsonHeaders(), + body: JSON.stringify({ title }), + }); + if (!response.ok) { + assertNotAuthError(response.status, "Failed to rename session"); + return false; + } + return true; +} + +export async function revertLastMessage( + port: number, + sessionId: string, +): Promise<{ ok: boolean; message?: string }> { + const listUrl = `http://127.0.0.1:${port}/session/${sessionId}/message?limit=30`; + const listResponse = await fetch(listUrl, { headers: jsonHeaders() }); + if (!listResponse.ok) { + assertNotAuthError(listResponse.status, "Failed to list messages"); + return { ok: false, message: `list failed: ${listResponse.status}` }; + } + const messages = await listResponse.json(); + if (!Array.isArray(messages) || messages.length === 0) { + return { ok: false, message: "no messages" }; + } + const lastUser = [...messages].reverse().find((m) => m.info?.role === "user"); + if (!lastUser?.info?.id) { + return { ok: false, message: "no user message found" }; + } + const revertUrl = `http://127.0.0.1:${port}/session/${sessionId}/revert`; + const revertResponse = await fetch(revertUrl, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ messageID: lastUser.info.id }), + }); + if (!revertResponse.ok) { + assertNotAuthError(revertResponse.status, "Failed to revert message"); + return { ok: false, message: `revert failed: ${revertResponse.status}` }; + } + return { ok: true }; +} + +function parseModelPair(model?: string): { providerID: string; modelID: string } | null { + if (!model) return null; + return parseModelString(model); +} + +export async function summarizeSession( + port: number, + sessionId: string, + model?: string, +): Promise { + const pair = parseModelPair(model); + const body: Record = {}; + if (pair) { + body.providerID = pair.providerID; + body.modelID = pair.modelID; + } + const url = `http://127.0.0.1:${port}/session/${sessionId}/summarize`; + const response = await fetch(url, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify(body), + }); + if (!response.ok) { + assertNotAuthError(response.status, "Failed to summarize session"); + return false; + } + return true; +} + +export async function initSession( + port: number, + sessionId: string, + model?: string, +): Promise { + const pair = parseModelPair(model); + const body: Record = {}; + if (pair) { + body.providerID = pair.providerID; + body.modelID = pair.modelID; + } + const url = `http://127.0.0.1:${port}/session/${sessionId}/init`; + const response = await fetch(url, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify(body), + }); + if (!response.ok) { + assertNotAuthError(response.status, "Failed to init session"); + return false; + } + return true; +} + export async function validateSession( port: number, sessionId: string, @@ -125,12 +234,19 @@ export async function getSessionInfo( return null; } const data = await response.json(); - return { id: data.id, title: data.title ?? "" }; + return { + id: data.id, + title: data.title ?? "", + time: data.time ?? "", + timeUpdated: data.timeUpdated ?? "", + }; } export interface SessionInfo { id: string; title: string; + time?: string; + timeUpdated?: string; } export async function listSessions(port: number): Promise { @@ -152,9 +268,11 @@ export async function listSessions(port: number): Promise { const data = await response.json(); if (Array.isArray(data)) { - return data.map((s: { id: string; title?: string }) => ({ + return data.map((s: { id: string; title?: string; time?: string; timeUpdated?: string }) => ({ id: s.id, title: s.title ?? "", + time: s.time ?? "", + timeUpdated: s.timeUpdated ?? "", })); } return []; @@ -215,6 +333,7 @@ export async function ensureSessionForThread( threadId: string, projectPath: string, port: number, + title?: string, ): Promise { const existingSession = getSessionForThread(threadId); @@ -231,7 +350,7 @@ export async function ensureSessionForThread( } } - const sessionId = await createSession(port); + const sessionId = await createSession(port, title); setSessionForThread(threadId, sessionId, projectPath, port); return sessionId; } diff --git a/src/services/voiceService.ts b/src/services/voiceService.ts index 9f92e22..21060c4 100644 --- a/src/services/voiceService.ts +++ b/src/services/voiceService.ts @@ -1,12 +1,40 @@ -import { getOpenAIApiKey } from './configStore.js'; +import { execFile, type ExecFileOptions } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRequire } from 'node:module'; -const WHISPER_API_URL = 'https://api.openai.com/v1/audio/transcriptions'; +const require = createRequire(import.meta.url); +const ffmpegPath: string = require('ffmpeg-static'); + +function execFileP( + file: string, + args: readonly string[], + options: ExecFileOptions, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(file, args, options, (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve({ stdout: stdout as string, stderr: stderr as string }); + } + }); + }); +} + +const HANDY_BIN = process.env.HANDY_BIN || '/usr/bin/handy'; const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB const DOWNLOAD_TIMEOUT_MS = 30_000; // 30s for Discord CDN download -const WHISPER_TIMEOUT_MS = 60_000; // 60s for Whisper API transcription +const FFMPEG_TIMEOUT_MS = 30_000; +const HANDY_TIMEOUT_MS = Number(process.env.HANDY_TIMEOUT_MS || 120_000); export function isVoiceEnabled(): boolean { - return !!getOpenAIApiKey(); + return existsSync(HANDY_BIN); +} + +export function getHandyBin(): string { + return HANDY_BIN; } function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise { @@ -15,56 +43,62 @@ function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer)); } +function extractJson(stdout: string): Record { + const start = stdout.indexOf('{'); + const end = stdout.lastIndexOf('}'); + if (start === -1 || end === -1 || end <= start) { + throw new Error('Handy did not return JSON output'); + } + return JSON.parse(stdout.slice(start, end + 1)) as Record; +} + export async function transcribe(attachmentUrl: string, fileSize?: number): Promise { if (fileSize && fileSize > MAX_FILE_SIZE) { throw new Error('File size exceeds 25MB limit'); } - const apiKey = getOpenAIApiKey(); - if (!apiKey) { - throw new Error('OpenAI API key is not configured'); - } - - // Download audio from Discord CDN - const audioResponse = await fetchWithTimeout(attachmentUrl, {}, DOWNLOAD_TIMEOUT_MS); - if (!audioResponse.ok) { - throw new Error(`Failed to download audio: HTTP ${audioResponse.status}`); + if (!existsSync(HANDY_BIN)) { + throw new Error(`Handy binary not found: ${HANDY_BIN}`); } - const audioBuffer = await audioResponse.arrayBuffer(); + const tmpDir = mkdtempSync(join(tmpdir(), 'opencode-handy-')); + const inputPath = join(tmpDir, 'voice.ogg'); + const wavPath = join(tmpDir, 'voice.wav'); - // Build FormData for Whisper API - const formData = new FormData(); - const audioBlob = new Blob([audioBuffer], { type: 'audio/ogg' }); - formData.append('file', audioBlob, 'voice.ogg'); - formData.append('model', 'whisper-1'); - formData.append('response_format', 'text'); + try { + // Download audio from Discord CDN + const audioResponse = await fetchWithTimeout(attachmentUrl, {}, DOWNLOAD_TIMEOUT_MS); + if (!audioResponse.ok) { + throw new Error(`Failed to download audio: HTTP ${audioResponse.status}`); + } + const audioBuffer = Buffer.from(await audioResponse.arrayBuffer()); + writeFileSync(inputPath, audioBuffer); - // POST to OpenAI Whisper API - const response = await fetchWithTimeout(WHISPER_API_URL, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - }, - body: formData, - }, WHISPER_TIMEOUT_MS); + // Convert to 16 kHz mono WAV (Handy input format) + await execFileP( + ffmpegPath, + ['-y', '-i', inputPath, '-ar', '16000', '-ac', '1', '-f', 'wav', wavPath], + { timeout: FFMPEG_TIMEOUT_MS }, + ); - if (!response.ok) { - let errorDetail = ''; - try { - errorDetail = await response.text(); - } catch { - // ignore + const args = ['--transcribe-file', wavPath, '--json']; + const model = process.env.HANDY_MODEL; + if (model) { + args.push('--model', model); } - // Log full error detail server-side only - console.error(`[Voice STT] Whisper API error ${response.status}:`, errorDetail); - if (response.status === 401) { - throw new Error('AUTH_FAILURE'); + + const { stdout } = await execFileP(HANDY_BIN, args, { + timeout: HANDY_TIMEOUT_MS, + maxBuffer: 10 * 1024 * 1024, + }); + + const parsed = extractJson(stdout); + const text = (parsed.text ?? '').toString().trim(); + if (!text) { + throw new Error('Handy returned empty transcription'); } - throw new Error(`Whisper API error (HTTP ${response.status})`); + return text; + } finally { + rmSync(tmpDir, { recursive: true, force: true }); } - - // response_format: 'text' returns plain text string - const text = await response.text(); - return text.trim(); } diff --git a/src/types/index.ts b/src/types/index.ts index f6ae651..36d0f4b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -13,6 +13,31 @@ export interface ChannelBinding { model?: string; } +export type AgentMode = 'build' | 'plan'; + +export interface HubConfig { + hubChannelId: string; + categoryId: string; + projectAlias: string; + model: string; + agent: AgentMode; +} + +export interface ArchivedSession { + channelId: string; + sessionId?: string; + projectPath?: string; + port?: number; + model?: string; + title?: string; + archivedAt: number; +} + +export interface ChannelAgent { + channelId: string; + agent: AgentMode; +} + export interface DataStore { projects: ProjectConfig[]; bindings: ChannelBinding[]; @@ -21,6 +46,9 @@ export interface DataStore { passthroughThreads?: PassthroughThread[]; queues?: Record; queueSettings?: Record; + hub?: HubConfig; + archivedSessions?: ArchivedSession[]; + channelAgents?: ChannelAgent[]; } export interface QueuedMessage { From b19a6315fb046b92861108703e317e014a713b3d Mon Sep 17 00:00:00 2001 From: Felipe-258 Date: Thu, 27 Aug 2026 09:26:01 -0300 Subject: [PATCH 2/3] chore: bump version a 1.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2b0c5f1..1aabccb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "remote-opencode", - "version": "1.5.3", + "version": "1.6.0", "description": "Discord bot for remote OpenCode CLI access", "main": "dist/src/index.js", "bin": { From 4ed41cdd84166046343f73be78c21741a8612000 Mon Sep 17 00:00:00 2001 From: Felipe-258 Date: Thu, 27 Aug 2026 09:26:01 -0300 Subject: [PATCH 3/3] docs: actualizar readme y changelog con hub, canales de sesion y voz --- CHANGELOG.md | 18 ++ README.md | 669 +++++++++++++++++++++------------------------------ 2 files changed, 296 insertions(+), 391 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ba0751..0b84820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this project will be documented in this file. +## [1.6.0] - 2026-08-27 + +### Added + +- **Hub (`/hub`)**: launcher channel with a pinned button panel (new session, sessions list, model, project). Remembers default project, model and category. +- **Session channels**: creating a session opens a dedicated text channel `🤖 ` in a `Sesiones` category, bound to the hub's project/model, with passthrough enabled and a pinned panel of sticky buttons (Plan/Build toggle, Interrupt, Diff, Model, Archive, Undo, Status, Compact, Init). +- **Sessions list**: `📋 Sesiones` now lists the OpenCode sessions persisted on disk (ordered by most recent activity), marks channel-mapped and archived ones, lets you resume an existing OpenCode session in a new Discord channel ("Resumir sesión en Discord"), and reopen archived sessions. +- **Session title sync**: the session channel is renamed automatically when OpenCode updates the session title. +- **Local voice transcription with Handy**: replaces the OpenAI Whisper integration. Voice messages in session channels are downloaded, converted to 16 kHz mono WAV with a bundled `ffmpeg-static` binary, and transcribed by the local Handy app (`handy --transcribe-file --json`). Configurable via `HANDY_BIN`, `HANDY_MODEL` and `HANDY_TIMEOUT_MS`. +- **Passthrough in text channels**: `messageHandler` now accepts passthrough in regular text channels (session channels), not only threads. + +### Changed + +- **`/opencode`**: in the hub (or any non-session channel) it creates a session channel; in a session channel it runs the prompt there. Legacy thread flow preserved. +- **`/voice`**: now only reports status (`/voice status`) and reflects the Handy engine. Removed the OpenAI `voice set/remove` CLI subcommands. +- **Permissions**: the bot now requires `MANAGE_CHANNELS`/`MANAGE_ROLES` to create and hide session channels (invite link uses `permissions=275901456`). +- **README**: rewritten to document the hub/session-channel UX, Handy voice setup, required Discord permissions, systemd service example and Docker limitations. + ## [Unreleased] ## [1.5.3] - 2026-04-28 diff --git a/README.md b/README.md index 2005d8f..d32a0c8 100644 --- a/README.md +++ b/README.md @@ -8,18 +8,21 @@ remote-opencode logo -> 🆕 **New in v1.5!** Session management — browse, attach, and manage OpenCode CLI sessions from Discord with `/session`. Plus: model autocomplete for `/model set`. [See changelog](#changelog) +> 🆕 **New in v1.6!** **Hub** with buttons, **one Discord channel per session** named after the session, sticky action buttons (Plan/Build, Interrupt, Diff, Model, Archive), resume your existing OpenCode sessions directly in Discord, and **voice transcription powered by the local Handy app**. > -> 🎤 **v1.4:** Voice message support — send voice messages that are automatically transcribed and processed. [See demo](#-voice-mode-demo) +> 🧵 **v1.5:** Session management — browse, attach, and manage OpenCode CLI sessions from Discord with `/session`. +> +> 🎤 **v1.4:** Voice message support — send voice messages that are automatically transcribed and processed. **remote-opencode** is a Discord bot that bridges your local [OpenCode CLI](https://github.com/sst/opencode) to Discord, enabling you to interact with your AI coding assistant remotely. Perfect for developers who want to: - 📱 **Code from mobile** — Send coding tasks from your phone while away from your desk - 💻 **Access from any device** — Use your powerful dev machine from a laptop or tablet - 🌍 **Work remotely** — Control your home/office workstation from anywhere +- 🎛️ **Console-like UX** — A hub to launch sessions, one channel per session, and buttons for everything (no slash-command typing on your phone) - 👥 **Collaborate** — Share AI coding sessions with team members in Discord - 🤖 **Automated Workflows** — Queue up multiple tasks and let the bot process them sequentially -- 🎤 **Voice Messages** — Send voice messages that are automatically transcribed and processed as text +- 🎤 **Voice Messages** — Send voice messages that are transcribed locally by **Handy** and processed as text ## How It Works @@ -29,12 +32,14 @@ flowchart LR subgraph Workflow direction TB - B --> C["💻 OpenCode CLI"] + B --> H["🖥️ Hub (launcher)"] + H --> S["🧵 Session channels (one per session)"] + S --> C["💻 OpenCode CLI"] C --> D["📁 Your Codebase"] end ``` -The bot runs on your development machine alongside OpenCode. When you send a command via Discord, it's forwarded to OpenCode, and the output streams back to you in real-time. +The bot runs on your development machine alongside OpenCode. You launch sessions from a hub channel, each session gets its own text channel named after the session, and the output streams back in real-time. ## Demo @@ -50,13 +55,16 @@ https://github.com/user-attachments/assets/59cf162a-ec86-41b5-a1f3-9b1379acd9fd - [Installation](#installation) - [Quick Start](#quick-start) -- [Proxy Support](#proxy-support) +- [Hub & Session Channels](#hub--session-channels) - [Discord Bot Setup](#discord-bot-setup) +- [Voice Transcription (Handy)](#voice-transcription-handy) - [CLI Commands](#cli-commands) - [Discord Slash Commands](#discord-slash-commands) - [Usage Workflow](#usage-workflow) - [Access Control](#access-control) - [Configuration](#configuration) +- [Run as a systemd service](#run-as-a-systemd-service) +- [Proxy Support](#proxy-support) - [Troubleshooting](#troubleshooting) - [Development](#development) - [Changelog](#changelog) @@ -69,28 +77,32 @@ https://github.com/user-attachments/assets/59cf162a-ec86-41b5-a1f3-9b1379acd9fd ### Prerequisites - **Node.js 22+** — [Download](https://nodejs.org/) +- **Build tools** — `node-pty` compiles from source on install (`build-essential` + `python3` on Debian/Ubuntu) - **OpenCode CLI** — Must be installed and working on your machine - **Discord Account** — With a server where you have admin permissions +- **Handy** *(optional, for voice)* — Local speech-to-text app, see [Voice Transcription (Handy)](#voice-transcription-handy) + +### Install from source (recommended) + +```bash +git clone https://github.com/Felipe-258/remote-opencode.git +cd remote-opencode +npm install +npm run build +npm link # Makes 'remote-opencode' available globally +``` ### Install via npm ```bash -# Global installation (recommended) +# Global installation npm install -g remote-opencode # Or run directly with npx npx remote-opencode ``` -### Install from source - -```bash -git clone https://github.com/RoundTable02/remote-opencode.git -cd remote-opencode -npm install -npm run build -npm link # Makes 'remote-opencode' available globally -``` +> **Note:** The npm package is the upstream build and does **not** include the v1.6 hub / session-channel features. Use the source install above (or the fork) for the latest features. --- @@ -104,37 +116,62 @@ remote-opencode setup remote-opencode start ``` -That's it! Now use Discord slash commands to interact with OpenCode. +Then, in your Discord server: + +1. Run `/hub` in any text channel — this becomes your **launcher**. +2. Press **🆕 Nueva sesión**, type a name (e.g. `24 august`), and a session channel `🤖 24 august` is created. +3. Type your prompt directly in that channel — it goes straight to OpenCode. --- -## Proxy Support +## Hub & Session Channels -`remote-opencode` supports HTTP proxy environments for Discord and other external API requests. +The v1.6 UX is built around a **hub** and **one Discord channel per session**, so you almost never need to type slash commands from your phone. -Supported environment variables: +### The Hub -- `HTTP_PROXY` -- `HTTPS_PROXY` -- `ALL_PROXY` -- `NO_PROXY` +Run `/hub` in any text channel. The bot pins a launcher panel with buttons: -Proxy settings are applied app-wide. Local OpenCode traffic is kept direct automatically, so `localhost`, `127.0.0.1`, and `::1` are always excluded from proxying. +| Button | Action | +|---|---| +| 🆕 **Nueva sesión** | Opens a modal where you name the session, then creates a session channel | +| 📋 **Sesiones** | Lists your sessions (including existing OpenCode sessions) and lets you resume or reopen them | +| 🧠 **Modelo** | Sets the default model used for new sessions | +| 📂 **Proyecto** | Sets the default project used for new sessions | -### Example +The hub remembers its project alias, model, and category. By default the model is `deepseek/deepseek-v4-flash` (set per project with the **🧠 Modelo** button). -```bash -export HTTPS_PROXY=http://proxy.company.local:8080 -export NO_PROXY=internal.company.local -remote-opencode start -``` +### Session channels -If your network uses a single proxy for everything, `ALL_PROXY` is also supported: +When you create a session: -```bash -export ALL_PROXY=http://proxy.company.local:8080 -remote-opencode start -``` +- A text channel `🤖 ` is created inside a **Sesiones** category (created automatically). +- The channel is bound to the hub's project and model. +- A matching OpenCode session is created **with that title**. +- **Passthrough is ON**: any message you type in the channel is sent directly to OpenCode. +- A pinned message shows sticky buttons: + +| Button | Action | +|---|---| +| 🎯 **Plan / 🔨 Build** | Toggle agent mode (like `Tab` in the TUI) | +| ⏹️ **Interrupt** | Abort the current task | +| 📊 **Diff** | Show `git diff` of the project | +| 🧠 **Modelo** | Change the model for this channel | +| 🗑 **Archivar** | Archive the session (channel becomes `🔒 ` and is hidden) | +| ↩️ **Undo** | Revert the last user message | +| ℹ️ **Status** | Project, branch, model, mode, session and busy state | +| 🧹 **Compactar** | Summarize/compact the context | +| ⚡ **Init** | Generate/update `AGENTS.md` for the project | + +If OpenCode renames the session (it auto-titles sessions after the first reply), the channel is renamed to match. + +### Sessions list & resume + +**📋 Sesiones** shows, sorted by most recent activity: + +- Sessions already mapped to Discord channels (🟢 active / ⚪ idle, 🔒 archived). +- **Other sessions from OpenCode** — including ones you created earlier in the terminal. Use the **"Resumir sesión en Discord"** select to create a channel attached to an existing OpenCode session and continue the conversation. +- **Archived sessions** — reopen any archived session (channel becomes visible again and passthrough is restored). --- @@ -152,20 +189,63 @@ Just run `remote-opencode setup` and follow the prompts — no manual URL copyin
📖 Manual setup reference (click to expand) -If you prefer manual setup or need to troubleshoot: - 1. **Create Application**: Go to [Discord Developer Portal](https://discord.com/developers/applications), create a new application 2. **Enable Intents**: In "Bot" section, enable SERVER MEMBERS INTENT and MESSAGE CONTENT INTENT 3. **Get Bot Token**: In "Bot" section, reset/view token and copy it 4. **Get Guild ID**: Enable Developer Mode in Discord settings, right-click your server → Copy Server ID -5. **Invite Bot**: Use this URL format: +5. **Invite Bot**: The bot needs to **create channels, manage categories and hide channels**. Use this URL (permissions `275901456`): ``` - https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=2147534848&scope=bot+applications.commands + https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=275901456&scope=bot+applications.commands ``` 6. **Check Channel Access**: For private or restricted channels, make sure the bot user or bot role can access the channel.
+### Required permissions + +| Permission | Why | +|---|---| +| Manage Channels | Create the `Sesiones` category and session channels, hide archived ones | +| View Channel / Send Messages | Interact in channels and threads | +| Manage Messages | Edit streamed messages and pin the sticky panels | +| Embed Links / Read Message History | Render embeds and read the conversation | +| Create Public Threads / Send Messages in Threads | Legacy thread-based flows (`/work`, `/opencode` in a channel) | +| Attach Files | Voice/download flows | + +--- + +## Voice Transcription (Handy) + +Voice messages in session channels are transcribed **locally** using **Handy** — a speech-to-text desktop app (Whisper-family models via transcribe-cpp). No API keys, no cloud, audio never leaves your machine. + +### How it works + +1. You send a **voice message** (🎤) in a session channel. +2. The bot downloads it, converts it to 16 kHz mono WAV with a bundled `ffmpeg-static` binary (no system ffmpeg needed). +3. It runs `handy --transcribe-file --json` and sends the text as a prompt to OpenCode (queued if the agent is busy). + +### Install Handy + +```bash +sudo apt install handy +``` + +Open the Handy app, pick a model (e.g. Parakeet V3 or Nemotron Streaming) that handles your language well, and leave it running (system tray). The bot uses the same model you selected in the app. + +### Environment variables + +| Variable | Default | Description | +|---|---|---| +| `HANDY_BIN` | `/usr/bin/handy` | Path to the Handy binary | +| `HANDY_MODEL` | *(Handy's selected model)* | Optional model id to force for transcription | +| `HANDY_TIMEOUT_MS` | `120000` | Max time for one transcription | + +`/voice status` reports whether Handy was found. + +> **Running under systemd:** Handy initializes GTK even in headless mode, so the service needs the graphical session environment: `DISPLAY`, `WAYLAND_DISPLAY` and `XDG_RUNTIME_DIR`. See [Run as a systemd service](#run-as-a-systemd-service). +> +> **Docker note:** Handy is a desktop app, so voice transcription is only supported when the bot runs natively on the same host. The bundled `ffmpeg-static` binary also targets glibc — the alpine-based `Dockerfile` is fine for the rest of the bot but not for voice. + --- ## CLI Commands @@ -182,9 +262,7 @@ If you prefer manual setup or need to troubleshoot: | `remote-opencode allow remove ` | Remove a Discord user ID from the allowlist | | `remote-opencode allow list` | List all user IDs in the allowlist | | `remote-opencode allow reset` | Clear the entire allowlist (removes access control) | -| `remote-opencode voice set ` | Set OpenAI API key for voice message transcription | -| `remote-opencode voice remove` | Remove the stored OpenAI API key | -| `remote-opencode voice status` | Show voice transcription status and API key source | +| `remote-opencode voice status` | Show voice transcription status (Handy) | --- @@ -192,6 +270,16 @@ If you prefer manual setup or need to troubleshoot: Once the bot is running, use these commands in your Discord server: +### `/hub` — Set Up the Launcher + +Make the current channel your hub. Pins the launcher panel (new session, sessions list, model, project). + +``` +/hub +``` + +--- + ### `/setpath` — Register a Project Register a local project path with an alias for easy reference. @@ -225,18 +313,23 @@ After binding, all `/opencode` commands in that channel will work on the specifi ### `/opencode` — Send Command to AI -The main command — sends a prompt to OpenCode and streams the response. +Sends a prompt to OpenCode and streams the response. ``` /opencode prompt:Add a dark mode toggle to the settings page ``` +**Behavior in v1.6:** + +- In a **session channel** → runs the prompt in that session. +- In the **hub** (or any non-session channel) → creates a session channel named after the prompt and runs it there. +- **Legacy:** in a thread, it continues the thread's conversation. + **Features:** -- 🧵 **Auto-creates a thread** for each conversation - ⚡ **Real-time streaming** — see output as it's generated (1-second updates) - ⏸️ **Interrupt button** — stop the current task if needed -- 📝 **Session persistence** — continue conversations in the same thread +- 📝 **Session persistence** — continue conversations in the same channel/thread ### `/work` — Create a Git Worktree @@ -259,11 +352,9 @@ Start isolated work on a new branch with its own worktree. - 🗑️ **Delete button** — removes worktree and archives thread - 🚀 **Create PR button** — automatically creates a pull request -This is perfect for working on multiple features simultaneously without branch switching. - ### `/code` — Toggle Passthrough Mode -Enable passthrough mode in a thread to send messages directly to OpenCode without slash commands. +Enable passthrough mode to send messages directly to OpenCode without slash commands. ``` /code @@ -271,34 +362,16 @@ Enable passthrough mode in a thread to send messages directly to OpenCode withou **How it works:** -1. Run `/code` in any thread to enable passthrough mode +1. Run `/code` in a session channel (or a thread) to enable passthrough mode 2. Type messages naturally — they're sent directly to OpenCode 3. Run `/code` again to disable -**Example:** - -``` -You: /code -Bot: ✅ Passthrough mode enabled for this thread. - Your messages will be sent directly to OpenCode. - -You: Add a dark mode toggle to settings -Bot: 📌 Prompt: Add a dark mode toggle to settings - [streaming response...] - -You: Now add a keyboard shortcut for it -Bot: 📌 Prompt: Now add a keyboard shortcut for it - [streaming response...] - -You: /code -Bot: ❌ Passthrough mode disabled. -``` +> Session channels created from the hub have passthrough **already enabled** — no need to run `/code`. **Features:** - 📱 **Mobile-friendly** — no more typing slash commands on phone -- 🧵 **Thread-scoped** — only affects the specific thread, not the whole channel -- ⏳ **Busy indicator** — shows ⏳ reaction if previous task is still running +- ⏳ **Busy indicator** — shows 📥 reaction and queues if the previous task is still running - 🔒 **Safe** — ignores bot messages (no infinite loops) ### `/autowork` — Toggle Automatic Worktree Creation @@ -309,43 +382,17 @@ Enable automatic worktree creation for a project. When enabled, new `/opencode` /autowork ``` -**How it works:** - -1. Run `/autowork` in a channel bound to a project -2. The setting toggles on/off for that project -3. When enabled, new sessions automatically create worktrees with branch names like `auto/abc12345-1738600000000` - -**Features:** - -- 🌳 **Automatic isolation** — each session gets its own branch and worktree -- 📱 **Mobile-friendly** — no need to type `/work` with branch names -- 🗑️ **Delete button** — removes worktree when done -- 🚀 **Create PR button** — easily create pull requests from worktree -- ⚡ **Per-project setting** — enable/disable independently for each project - ### `/autocode` — Toggle Automatic Passthrough Mode -Enable automatic passthrough mode for a project. When enabled, every new thread the bot creates (via `/work` or `/opencode`) will already have passthrough mode on, so plain messages are sent to OpenCode without needing to run `/code` first. +Enable automatic passthrough mode for a project. When enabled, every new thread the bot creates will already have passthrough mode on. ``` /autocode ``` -**How it works:** - -1. Run `/autocode` in a channel bound to a project -2. The setting toggles on/off for that project -3. New threads in that project skip the manual `/code` step - -**Features:** - -- 📱 **Mobile-friendly** — one less command to type per thread -- 🧵 **Thread-scoped** — each new thread starts with passthrough enabled; `/code` still toggles it within a thread -- ⚡ **Per-project setting** — enable/disable independently for each project - ### `/queue` — Manage Message Queue -Control the automated job queue for the current thread. +Control the automated job queue for the current channel/thread. ``` /queue list @@ -355,16 +402,10 @@ Control the automated job queue for the current thread. /queue settings continue_on_failure:True fresh_context:False ``` -**How it works:** - -1. Send multiple messages to a thread (or use `/opencode` multiple times) -2. If the bot is busy, it reacts with `📥` and adds the task to the queue -3. Once the current job is done, the bot automatically picks up the next one - **Settings:** - `continue_on_failure`: If `True`, the bot moves to the next task even if the current one fails. -- `fresh_context`: If `True`, the AI forgets previous chat history for each new queued task, starting a fresh session while maintaining the same code state. Default: `False` (conversation context is preserved within the same thread). +- `fresh_context`: If `True`, the AI forgets previous chat history for each new queued task. Default: `False`. ### `/diff` — View Git Diff @@ -383,24 +424,6 @@ Show git diffs for the current project directly in Discord — perfect for revie | `stat` | Show `--stat` summary only instead of full diff (default: `false`) | | `base` | Base branch for `target:branch` diff (default: `main`) | -**How it works:** - -- Inside a **worktree thread** → diffs the worktree path for that branch -- In a **regular channel** → diffs the channel-bound project path -- Output is formatted in a `diff` code block (truncated if over Discord's 2000-char limit) - -**Examples:** - -``` -/diff → unstaged changes (git diff) -/diff target:staged → staged changes (git diff --cached) -/diff target:branch → changes vs main (git diff main...HEAD) -/diff target:branch base:dev → changes vs dev branch -/diff stat:true → summary only (git diff --stat) -``` - ---- - ### `/allow` — Manage Allowlist Manage the user allowlist directly from Discord. This command is only available when the allowlist has already been initialized (at least one user exists). @@ -411,43 +434,13 @@ Manage the user allowlist directly from Discord. This command is only available /allow action:list ``` -| Parameter | Description | -| --------- | --------------------------------------------- | -| `action` | `add`, `remove`, or `list` | -| `user` | Target user (required for `add` and `remove`) | - -**Behavior:** - -- **Requires authorization** — only users already on the allowlist can use this command -- **Cannot remove last user** — prevents accidental lockout -- **Disabled when allowlist is empty** — initial setup must be done via CLI or setup wizard (see [Access Control](#access-control)) - ---- - -### `/voice` — Manage Voice Transcription - -Manage voice message transcription settings. Requires an OpenAI API key (set via CLI). +### `/voice` — Voice Transcription Status ``` -/voice status Show voice transcription status -/voice remove Remove the stored OpenAI API key +/voice status ``` -| Parameter | Description | -| --------- | ------------------------------------ | -| (none) | Subcommands only: `status`, `remove` | - -**How it works:** - -1. Set your OpenAI API key via CLI: `remote-opencode voice set ` -2. Enable passthrough mode in a thread with `/code` -3. Send a voice message using Discord's 🎤 button -4. The bot adds a 🎙️ reaction, transcribes the audio via OpenAI Whisper, and processes it as a text prompt -5. If the bot is busy, voice messages are queued (with 📥 reaction) and transcribed when dequeued - -> **Note:** The `set` subcommand is intentionally CLI-only to avoid API key exposure in Discord command history. - ---- +Shows whether Handy is available and transcription is enabled. ### `/model` — List & Set AI Model @@ -466,15 +459,11 @@ View available AI models or set the model for the current channel. **Features:** - 🔍 **Autocomplete** — start typing a model name and get instant suggestions -- 📋 **Full model list** — all models are shown with automatic message splitting when output exceeds Discord's limit - 💾 **Per-channel persistence** — model preferences are saved per channel/thread -- ⚡ **Fast validation** — model names are validated against a background-cached list (no blocking CLI calls) - ---- ### `/session` — Browse & Manage Sessions -Browse OpenCode CLI sessions and manage session-thread mappings. Useful for resuming previous conversations or sharing sessions across threads. +Browse OpenCode CLI sessions and manage session-channel mappings. Useful for resuming previous conversations or sharing sessions. ``` /session list @@ -490,62 +479,37 @@ Browse OpenCode CLI sessions and manage session-thread mappings. Useful for resu | `detach` | Disconnect the session from this thread | | `info` | Show detailed status of the attached session | -**How it works:** - -1. Use `/session list` to see all sessions for the bound project -2. In a thread, use `/session attach` → select a session from the dropdown -3. The thread now continues that session's conversation (with `freshContext` automatically disabled) -4. Use `/session info` to check if the session is alive, see its port, creation time, etc. -5. Use `/session detach` to disconnect and start fresh - -**Features:** - -- 📋 **Merged view** — combines active server sessions with persisted thread mappings -- 🔗 **Interactive attach** — dropdown menu shows session title, ID, mapping status, and recency -- ⚠️ **Cross-thread warning** — notifies when attaching a session already used in another thread -- 📊 **Rich info embed** — session status, port, SSE state, timestamps in a clean embed -- 🧹 **Clean detach** — properly disconnects SSE and clears session mapping - --- ## Usage Workflow -### Basic Workflow +### Hub Workflow (v1.6, recommended — mobile friendly) -1. **Register your project:** +1. **Set up the hub:** ``` - /setpath alias:webapp path:/home/user/my-webapp + /hub ``` -2. **Bind to a channel:** +2. **Register and bind your project** (once): ``` - /use alias:webapp + /setpath alias:webapp path:/home/user/my-webapp ``` -3. **Start coding remotely:** - - ``` - /opencode prompt:Refactor the authentication module to use JWT - ``` + Then press **📂 Proyecto** in the hub and pick `webapp`. -4. **Continue the conversation** in the created thread: - ``` - /opencode prompt:Now add refresh token support - ``` +3. **Create a session** — press **🆕 Nueva sesión**, type a name. +4. **Code** — type prompts directly in the new `🤖 ` channel. Use the sticky buttons for Interrupt, Diff, Model, Undo, Archive. +5. **Resume later** — press **📋 Sesiones** in the hub: pick an existing OpenCode session to resume it in Discord, or reopen an archived one. ### Mobile Workflow -Perfect for when you're away from your desk: - 1. 📱 Open Discord on your phone -2. Navigate to your bound channel -3. Use `/opencode` to send tasks -4. Watch real-time progress -5. Use the **Interrupt** button if needed - -**Pro tip:** Enable passthrough mode with `/code` in a thread for an even smoother mobile experience — just type messages directly without slash commands! You can also send **voice messages** via the 🎤 button — they're automatically transcribed and processed as text. +2. Open the hub channel and press **🆕 Nueva sesión** +3. Type prompts directly in the session channel (passthrough is on) +4. Use buttons to Interrupt / Diff / Archive — no slash commands needed +5. Send **voice messages** (🎤) — they're transcribed locally by Handy and sent as prompts ### Team Collaboration Workflow @@ -554,26 +518,17 @@ Share AI coding sessions with your team: 1. Create a dedicated Discord channel for your project 2. Bind the project: `/use alias:team-project` 3. Team members can watch sessions in real-time -4. Discuss in threads while AI works +4. Discuss in channels while AI works + +> ⚠️ If the server has people you don't trust, configure the [allowlist](#access-control) first. ### Automated Iteration Workflow Perfect for "setting and forgetting" several tasks: -1. **Send multiple instructions:** - - ``` - You: Refactor the API - Bot: [Starts working] - You: Add documentation to the new methods - Bot: 📥 [Queued] - You: Run tests and fix any issues - Bot: 📥 [Queued] - ``` - -2. **The bot will finish the API refactor, then automatically start the documentation task, then run the tests.** - -3. **Monitor progress:** Use `/queue list` to see pending tasks. +1. **Send multiple instructions** — if the bot is busy, tasks are queued (📥). +2. The bot finishes the current task, then processes the queue in order. +3. **Monitor progress:** `/queue list`. --- @@ -608,57 +563,21 @@ remote-opencode allow add 123456789012345678 remote-opencode allow list ``` -### Managing the Allowlist - -Once at least one user is on the allowlist, authorized users can manage it from Discord: - -``` -/allow action:add user:@teammate -/allow action:remove user:@teammate -/allow action:list -``` - -Or via CLI at any time: - -```bash -remote-opencode allow add -remote-opencode allow remove -remote-opencode allow list -remote-opencode allow reset # Clears entire allowlist (disables access control) -``` - ### Safety Guardrails -- **Cannot remove the last user** via Discord `/allow` or CLI `allow remove` — prevents accidental lockout -- **`allow reset`** is the only way to fully clear the allowlist (intentional action to disable access control) +- **Cannot remove the last user** — prevents accidental lockout +- **`allow reset`** is the only way to fully clear the allowlist (intentional action) - **Discord `/allow` is disabled when allowlist is empty** — prevents bootstrap attacks - **Config file permissions** are set to `0o600` (owner-read/write only) ### OpenCode server password (optional) -`remote-opencode` communicates with a local `opencode serve` process bound to -`127.0.0.1`. If you already run `opencode serve` with upstream HTTP Basic auth -enabled via `OPENCODE_SERVER_PASSWORD` (and optionally `OPENCODE_SERVER_USERNAME`), -the bot will automatically pick up the same credentials from its own environment -and apply them to all internal communication — session HTTP calls, the SSE -`/event` stream, and readiness probes. +If you run `opencode serve` with HTTP Basic auth enabled via `OPENCODE_SERVER_PASSWORD` (and optionally `OPENCODE_SERVER_USERNAME`), the bot picks up the same credentials and applies them to all internal communication — session HTTP calls, the SSE `/event` stream, and readiness probes. ```bash -# Example: start the bot with upstream opencode auth enabled OPENCODE_SERVER_PASSWORD='your-password' remote-opencode start ``` -Notes: - -- Behavior is **unchanged when these env vars are not set** — this is purely - optional hardening / compatibility for users who already rely on upstream - `opencode serve` auth. -- This is **not a replacement** for the Discord allowlist described above; it is - an additional layer for the local HTTP surface only. -- `OPENCODE_SERVER_USERNAME` defaults to `opencode` to match upstream. -- Misconfiguration produces a clear error (e.g. `opencode server rejected -credentials (HTTP 401) ...`) rather than a vague connection failure. - --- ## Configuration @@ -668,7 +587,7 @@ All configuration is stored in `~/.remote-opencode/`: | File | Purpose | | ------------- | --------------------------------------------- | | `config.json` | Bot credentials (token, client ID, guild ID) | -| `data.json` | Project paths, channel bindings, session data | +| `data.json` | Projects, bindings, sessions, hub, archives | ### config.json Structure @@ -677,32 +596,96 @@ All configuration is stored in `~/.remote-opencode/`: "discordToken": "your-bot-token", "clientId": "your-application-id", "guildId": "your-server-id", - "allowedUserIds": ["123456789012345678"], - "openaiApiKey": "sk-..." + "allowedUserIds": ["123456789012345678"] } ``` -> `allowedUserIds` is optional. When omitted or empty, access control is disabled and all users can use the bot. -> `openaiApiKey` is optional. When omitted, voice message transcription is disabled. Can also be set via `OPENAI_API_KEY` environment variable (takes priority). +> `allowedUserIds` is optional. When omitted or empty, access control is disabled. ### data.json Structure ```json { "projects": [ - { "alias": "myapp", "path": "/Users/you/projects/my-app", "autoWorktree": true } + { "alias": "myapp", "path": "/Users/you/projects/my-app" } ], "bindings": [ - { "channelId": "channel-id", "projectAlias": "myapp" } + { "channelId": "channel-id", "projectAlias": "myapp", "model": "deepseek/deepseek-v4-flash" } ], "threadSessions": [ ... ], - "worktreeMappings": [ ... ] + "worktreeMappings": [ ... ], + "hub": { + "hubChannelId": "hub-channel-id", + "categoryId": "sesiones-category-id", + "projectAlias": "myapp", + "model": "deepseek/deepseek-v4-flash", + "agent": "build" + }, + "channelAgents": [ { "channelId": "channel-id", "agent": "build" } ], + "archivedSessions": [ ... ] } ``` -| Field | Description | -| ------------------------- | --------------------------------------------------------- | -| `projects[].autoWorktree` | Optional. When `true`, new sessions auto-create worktrees | +| Field | Description | +| ---------------- | ------------------------------------------------------------------ | +| `hub` | Hub channel, category, default project alias, model and agent mode | +| `channelAgents` | Per-channel agent mode (`build` / `plan`) | +| `archivedSessions` | Archived session channels, ready to be reopened | + +### Environment variables + +| Variable | Description | +|---|---| +| `HANDY_BIN` | Path to the Handy binary (default `/usr/bin/handy`) | +| `HANDY_MODEL` | Force a Handy model id for transcription | +| `HANDY_TIMEOUT_MS` | Handy transcription timeout in ms (default `120000`) | +| `OPENCODE_SERVER_PASSWORD` | Optional HTTP Basic auth password for `opencode serve` | +| `OPENCODE_SERVER_USERNAME` | Optional auth username (default `opencode`) | +| `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` | Proxy support for outbound requests | + +--- + +## Run as a systemd service + +Run the bot in the background and start it on boot: + +`/etc/systemd/system/remote-opencode.service`: + +```ini +[Unit] +Description=remote-opencode Discord bot +After=network.target + +[Service] +Type=simple +User=felipe +WorkingDirectory=/home/felipe/Documents/Github/remote-opencode +Environment=PATH=/home/felipe/.nvm/versions/node/v24.15.0/bin:/home/felipe/.opencode/bin:/usr/local/bin:/usr/bin:/bin +# Required so Handy can initialize GTK for local transcription +Environment=DISPLAY=:0 +Environment=WAYLAND_DISPLAY=wayland-0 +Environment=XDG_RUNTIME_DIR=/run/user/1000 +ExecStart=/home/felipe/.nvm/versions/node/v24.15.0/bin/node --no-deprecation /home/felipe/Documents/Github/remote-opencode/dist/src/cli.js start +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now remote-opencode +sudo journalctl -u remote-opencode -f +``` + +Adjust the paths, user and display variables to your environment. If you don't use voice, the `DISPLAY`/`WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR` lines can be omitted. + +--- + +## Proxy Support + +`remote-opencode` supports HTTP proxy environments for Discord and other external API requests via `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY`. Local OpenCode traffic is always kept direct (`localhost`, `127.0.0.1`, `::1` are excluded automatically). --- @@ -711,36 +694,43 @@ All configuration is stored in `~/.remote-opencode/`: ### Bot doesn't respond to commands 1. **Check bot is online:** Look for the bot in your server's member list -2. **Verify permissions:** Bot needs these permissions: - - Send Messages - - Create Public Threads - - Send Messages in Threads - - Embed Links - - Read Message History +2. **Verify permissions:** the bot needs Manage Channels, Send Messages, Manage Messages, Embed Links, Read Message History, and (for legacy flows) Create Public Threads 3. **Redeploy commands:** ```bash remote-opencode deploy ``` +### Voice transcription fails with "Failed to initialize GTK" + +Handy needs a display server even in headless mode. When running under systemd, add the graphical session environment to the unit: + +```ini +Environment=DISPLAY=:0 +Environment=WAYLAND_DISPLAY=wayland-0 +Environment=XDG_RUNTIME_DIR=/run/user/1000 +``` + +Then restart the service. + ### "No project set for this channel" -You need to bind a project to the channel: +Bind a project first: ``` /setpath alias:myproject path:/path/to/project /use alias:myproject ``` -### "Cannot create thread" +Or set the default project from the hub with **📂 Proyecto**. -For private or restricted channels, make sure the bot user or bot role can access the target channel and has these permissions: View Channel, Send Messages, Create Public Threads, Send Messages in Threads, and Read Message History. +### "Cannot create thread" / session channel not created -### Commands not appearing in Discord +Make sure the bot has **Manage Channels** and can access the channel/category. -Slash commands can take up to an hour to propagate globally. For faster updates: +### Commands not appearing in Discord 1. Kick the bot from your server -2. Re-invite it +2. Re-invite it with the invite URL above 3. Run `remote-opencode deploy` ### OpenCode server errors @@ -752,33 +742,14 @@ Slash commands can take up to an hour to propagate globally. For faster updates: 2. **Check if another process is using the port** 3. **Ensure the project path exists and is accessible** -### Session connection issues - -The bot maintains persistent sessions. If you encounter issues: - -1. Start a new thread with `/opencode` instead of continuing in an old one -2. Restart the bot: `remote-opencode start` - ### Bot crashes on startup 1. **Check Node.js version:** ```bash node --version # Should be 22+ ``` -2. **Verify configuration:** - ```bash - remote-opencode config - ``` -3. **Re-run setup:** - ```bash - remote-opencode setup - ``` - -### Proxy environments still fail - -1. Confirm `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` is set in the same shell where you start the bot -2. Check whether a custom `NO_PROXY` value is excluding a required remote host -3. Leave loopback traffic direct; the bot already auto-adds `localhost`, `127.0.0.1`, and `::1` +2. **Verify configuration:** `remote-opencode config` +3. **Re-run setup:** `remote-opencode setup` --- @@ -787,7 +758,7 @@ The bot maintains persistent sessions. If you encounter issues: ### Run from source ```bash -git clone https://github.com/RoundTable02/remote-opencode.git +git clone https://github.com/Felipe-258/remote-opencode.git cd remote-opencode npm install @@ -813,6 +784,7 @@ src/ ├── cli.ts # CLI entry point ├── bot.ts # Discord client initialization ├── commands/ # Slash command definitions +│ ├── hub.ts # Hub / launcher panel with buttons │ ├── opencode.ts # Main AI interaction command │ ├── code.ts # Passthrough mode toggle │ ├── work.ts # Worktree management @@ -820,27 +792,28 @@ src/ │ ├── model.ts # AI model list/set with autocomplete │ ├── session.ts # Session browsing and management │ ├── allow.ts # Allowlist management -│ ├── voice.ts # Voice transcription settings +│ ├── voice.ts # Voice transcription status │ ├── setpath.ts # Project registration │ ├── projects.ts # List projects │ └── use.ts # Channel binding ├── handlers/ # Interaction handlers -│ ├── interactionHandler.ts -│ ├── buttonHandler.ts -│ └── messageHandler.ts # Passthrough + voice message handling +│ ├── interactionHandler.ts # Commands, modals, select menus +│ ├── buttonHandler.ts # Buttons (sticky panels, hub) +│ └── messageHandler.ts # Passthrough + voice message handling ├── services/ # Core business logic │ ├── serveManager.ts # OpenCode process management │ ├── sessionManager.ts # Session state management +│ ├── sessionFlow.ts # Session channels, sticky messages, archive/reopen │ ├── queueManager.ts # Automated job queuing (incl. voice) │ ├── executionService.ts # Core prompt execution logic -│ ├── voiceService.ts # Voice message STT (OpenAI Whisper) +│ ├── voiceService.ts # Voice transcription (Handy local) │ ├── sseClient.ts # Real-time event streaming │ ├── dataStore.ts # Persistent storage │ ├── configStore.ts # Bot configuration │ └── worktreeManager.ts # Git worktree operations ├── setup/ # Setup wizard -│ ├── wizard.ts # Interactive setup (incl. voice opt-in) -│ └── deploy.ts # Command deployment +│ ├── wizard.ts +│ └── deploy.ts └── utils/ # Utilities ├── messageFormatter.ts └── threadHelper.ts @@ -852,92 +825,6 @@ src/ See [CHANGELOG.md](CHANGELOG.md) for a full history of changes. -### [1.5.1] - 2026-03-24 - -#### Added - -- **Proxy Support**: HTTP proxy environments for Discord requests via `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY`. Local OpenCode traffic is automatically excluded. - -#### Fixed - -- **Shell Spawn Removed**: OpenCode is now launched directly instead of through a shell, fixing service-environment failures. -- **Silent Error Swallowing**: Discord message edit failures now fall back to sending new messages instead of silently dropping AI responses. -- **Model Provider Prefix**: `/model set` no longer strips the provider prefix, fixing "Model not found" errors. Carriage returns in model names are now sanitized. - -### [1.5.0] - 2026-03-16 - -#### Added - -- **`/session` Command**: Browse, attach, detach, and inspect OpenCode CLI sessions from Discord — resume previous conversations or share sessions across threads. -- **Model Autocomplete**: `/model set` now suggests model names as you type. -- **Full Model List**: `/model list` shows all available models without per-provider caps. - -#### Changed - -- Model validation now uses a fast in-memory cache instead of blocking CLI calls. - -### [1.4.0] - 2026-03-10 - -#### Added - -- **Voice Message Transcription**: Send voice messages in `/code` passthrough threads — automatically transcribed via OpenAI Whisper and processed as text prompts. -- **`/voice` Slash Command**: Check status and manage voice transcription settings from Discord. -- **CLI Voice Management**: `remote-opencode voice set|remove|status` commands for managing the OpenAI API key. -- **Setup Wizard Integration**: Optional step to configure voice transcription during initial setup. - -### [1.3.0] - 2026-03-02 - -#### Added - -- **`/diff` Command**: View git diffs directly from Discord — ideal for reviewing AI-made changes on mobile. - -### [1.2.0] - 2026-02-15 - -#### Added - -- **Owner/Admin Authentication**: User allowlist system to restrict bot access to authorized Discord users only. -- **`/allow` Slash Command**: Manage the allowlist directly from Discord (add, remove, list users). -- **CLI Allowlist Management**: `remote-opencode allow add|remove|list|reset` commands for managing access control from the terminal. -- **Setup Wizard Integration**: Step 5 prompts for owner Discord user ID during initial setup. - -#### Security - -- Initial allowlist setup is restricted to CLI and setup wizard only — prevents bootstrap attacks from Discord. -- Config file permissions hardened to `0o600` (owner-read/write only). -- Discord user ID validation enforces snowflake format (`/^\d{17,20}$/`). -- Cannot remove the last authorized user via Discord or CLI `remove` — prevents lockout. - -### [1.1.0] - 2026-02-05 - -#### Added - -- **Automated Message Queuing**: Added a new system to queue multiple prompts in a thread. If the bot is busy, new messages are automatically queued and processed sequentially. -- **Queue Management**: New `/queue` slash command suite to list, clear, pause, resume, and configure queue settings. - -### [1.0.10] - 2026-02-04 - -#### Added - -- New `/setports` slash command to configure the port range for OpenCode server instances. - -#### Fixed - -- Fixed Windows-specific spawning issue (targeting `opencode.cmd`). -- Resolved `spawn EINVAL` errors on Windows. -- Improved server reliability and suppressed `DEP0190` security warnings. - -### [1.0.9] - 2026-02-04 - -#### Added - -- New `/model` slash command to set AI models per channel. -- Support for `--model` flag in OpenCode server instances. - -#### Fixed - -- Fixed connection timeout issues. -- Standardized internal communication to use `127.0.0.1`. - --- ## License