groove-dev 0.19.9 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CLAUDE.md +1 -1
  3. package/node_modules/@groove-dev/cli/bin/groove.js +1 -1
  4. package/node_modules/@groove-dev/daemon/package.json +4 -0
  5. package/node_modules/@groove-dev/daemon/src/api.js +86 -0
  6. package/node_modules/@groove-dev/daemon/src/gateways/base.js +87 -0
  7. package/node_modules/@groove-dev/daemon/src/gateways/discord.js +220 -0
  8. package/node_modules/@groove-dev/daemon/src/gateways/formatter.js +201 -0
  9. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +695 -0
  10. package/node_modules/@groove-dev/daemon/src/gateways/slack.js +165 -0
  11. package/node_modules/@groove-dev/daemon/src/gateways/telegram.js +265 -0
  12. package/node_modules/@groove-dev/daemon/src/index.js +4 -0
  13. package/node_modules/@groove-dev/daemon/src/validate.js +55 -0
  14. package/node_modules/@groove-dev/gui/.groove/audit.log +2 -0
  15. package/node_modules/@groove-dev/gui/.groove/codebase-index.json +1 -1
  16. package/node_modules/@groove-dev/gui/.groove/config.json +2 -2
  17. package/node_modules/@groove-dev/gui/.groove/daemon.pid +1 -1
  18. package/node_modules/@groove-dev/gui/.groove/timeline.json +3000 -0
  19. package/node_modules/@groove-dev/gui/AGENTS_REGISTRY.md +1 -1
  20. package/node_modules/@groove-dev/gui/dist/assets/index-CNqM3_F2.js +552 -0
  21. package/node_modules/@groove-dev/gui/dist/assets/index-ChDhUvQR.css +1 -0
  22. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  23. package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -0
  24. package/node_modules/@groove-dev/gui/src/views/settings.jsx +382 -25
  25. package/package.json +1 -1
  26. package/packages/cli/bin/groove.js +1 -1
  27. package/packages/daemon/package.json +4 -0
  28. package/packages/daemon/src/api.js +86 -0
  29. package/packages/daemon/src/gateways/base.js +87 -0
  30. package/packages/daemon/src/gateways/discord.js +220 -0
  31. package/packages/daemon/src/gateways/formatter.js +201 -0
  32. package/packages/daemon/src/gateways/manager.js +695 -0
  33. package/packages/daemon/src/gateways/slack.js +165 -0
  34. package/packages/daemon/src/gateways/telegram.js +265 -0
  35. package/packages/daemon/src/index.js +4 -0
  36. package/packages/daemon/src/validate.js +55 -0
  37. package/packages/gui/dist/assets/index-CNqM3_F2.js +552 -0
  38. package/packages/gui/dist/assets/index-ChDhUvQR.css +1 -0
  39. package/packages/gui/dist/index.html +2 -2
  40. package/packages/gui/src/stores/groove.js +7 -0
  41. package/packages/gui/src/views/settings.jsx +382 -25
  42. package/node_modules/@groove-dev/gui/dist/assets/index-CdbNHOqF.css +0 -1
  43. package/node_modules/@groove-dev/gui/dist/assets/index-Db0ZssmH.js +0 -537
  44. package/packages/gui/dist/assets/index-CdbNHOqF.css +0 -1
  45. package/packages/gui/dist/assets/index-Db0ZssmH.js +0 -537
@@ -0,0 +1,165 @@
1
+ // GROOVE — Slack Gateway (@slack/bolt Socket Mode)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { BaseGateway } from './base.js';
5
+ import { truncate, statusEmoji, formatTokens } from './formatter.js';
6
+
7
+ export class SlackGateway extends BaseGateway {
8
+ static type = 'slack';
9
+ static displayName = 'Slack';
10
+ static description = 'Slack bot for notifications and agent commands';
11
+ static credentialKeys = [
12
+ { key: 'bot_token', label: 'Bot Token (xoxb-...)', required: true, help: 'Slack App \u2192 OAuth & Permissions' },
13
+ { key: 'app_token', label: 'App Token (xapp-...)', required: true, help: 'Socket Mode requires an app-level token' },
14
+ ];
15
+
16
+ constructor(daemon, config) {
17
+ super(daemon, config);
18
+ this.app = null;
19
+ }
20
+
21
+ async connect() {
22
+ const botToken = this._getCredential('bot_token');
23
+ const appToken = this._getCredential('app_token');
24
+ if (!botToken) throw new Error('Slack bot token not configured');
25
+ if (!appToken) throw new Error('Slack app token not configured (required for Socket Mode)');
26
+
27
+ let App;
28
+ try {
29
+ const bolt = await import('@slack/bolt');
30
+ App = bolt.default?.App || bolt.App;
31
+ } catch {
32
+ throw new Error('Slack gateway requires @slack/bolt. Install with: npm i @slack/bolt');
33
+ }
34
+
35
+ this.app = new App({
36
+ token: botToken,
37
+ appToken,
38
+ socketMode: true,
39
+ });
40
+
41
+ // Handle messages starting with /
42
+ this.app.message(/^\/\w+/, async ({ message, say }) => {
43
+ const userId = message.user;
44
+ const [command, ...args] = message.text.slice(1).split(/\s+/);
45
+
46
+ // Auto-capture channelId
47
+ if (!this.config.chatId) {
48
+ this.config.chatId = message.channel;
49
+ this.daemon.gateways._save(this.config.id);
50
+ }
51
+
52
+ const response = await this.handleCommand(command, args, userId);
53
+ if (response) {
54
+ await say(this._buildReply(response));
55
+ }
56
+ });
57
+
58
+ // Handle approve button action
59
+ this.app.action('groove_approve', async ({ action, ack, respond }) => {
60
+ await ack();
61
+ const approvalId = action.value;
62
+ try {
63
+ this.daemon.supervisor.approve(approvalId);
64
+ await respond({
65
+ replace_original: true,
66
+ text: `\u2705 Approved: ${approvalId}`,
67
+ blocks: [
68
+ { type: 'section', text: { type: 'mrkdwn', text: `\u2705 *Approved:* \`${approvalId}\`` } },
69
+ ],
70
+ });
71
+ } catch (err) {
72
+ await respond({ text: `Error: ${err.message}` });
73
+ }
74
+ });
75
+
76
+ // Handle reject button action
77
+ this.app.action('groove_reject', async ({ action, ack, respond }) => {
78
+ await ack();
79
+ const approvalId = action.value;
80
+ try {
81
+ this.daemon.supervisor.reject(approvalId);
82
+ await respond({
83
+ replace_original: true,
84
+ text: `\u274c Rejected: ${approvalId}`,
85
+ blocks: [
86
+ { type: 'section', text: { type: 'mrkdwn', text: `\u274c *Rejected:* \`${approvalId}\`` } },
87
+ ],
88
+ });
89
+ } catch (err) {
90
+ await respond({ text: `Error: ${err.message}` });
91
+ }
92
+ });
93
+
94
+ await this.app.start();
95
+
96
+ this.connected = true;
97
+ console.log('[Groove:Slack] Connected via Socket Mode');
98
+ }
99
+
100
+ async disconnect() {
101
+ if (this.app) {
102
+ await this.app.stop();
103
+ this.app = null;
104
+ }
105
+ this.connected = false;
106
+ console.log('[Groove:Slack] Disconnected');
107
+ }
108
+
109
+ async send(text, options = {}) {
110
+ if (!this.app || !this.config.chatId) return;
111
+
112
+ const payload = { channel: this.config.chatId };
113
+
114
+ if (options.approvalId) {
115
+ // Block Kit message with approve/reject buttons
116
+ payload.text = text; // Fallback for notifications
117
+ payload.blocks = [
118
+ { type: 'section', text: { type: 'mrkdwn', text: `\ud83d\udea8 *Approval Required*\n${truncate(text, 2800)}` } },
119
+ { type: 'divider' },
120
+ {
121
+ type: 'actions',
122
+ elements: [
123
+ {
124
+ type: 'button',
125
+ text: { type: 'plain_text', text: '\u2705 Approve' },
126
+ style: 'primary',
127
+ action_id: 'groove_approve',
128
+ value: options.approvalId,
129
+ },
130
+ {
131
+ type: 'button',
132
+ text: { type: 'plain_text', text: '\u274c Reject' },
133
+ style: 'danger',
134
+ action_id: 'groove_reject',
135
+ value: options.approvalId,
136
+ },
137
+ ],
138
+ },
139
+ ];
140
+ } else {
141
+ // Standard Block Kit message
142
+ payload.text = truncate(text, 3000); // Fallback
143
+ payload.blocks = [
144
+ { type: 'section', text: { type: 'mrkdwn', text: truncate(text, 3000) } },
145
+ ];
146
+ }
147
+
148
+ await this.app.client.chat.postMessage(payload);
149
+ }
150
+
151
+ /**
152
+ * Build a reply payload from a command response.
153
+ */
154
+ _buildReply(response) {
155
+ if (!response) return {};
156
+ const text = response.text || '';
157
+ // Wrap in a code block for command output readability
158
+ return {
159
+ text,
160
+ blocks: [
161
+ { type: 'section', text: { type: 'mrkdwn', text: '```' + truncate(text, 2900) + '```' } },
162
+ ],
163
+ };
164
+ }
165
+ }
@@ -0,0 +1,265 @@
1
+ // GROOVE — Telegram Gateway (Zero-dependency Bot API)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import { BaseGateway } from './base.js';
5
+
6
+ const POLL_TIMEOUT = 30; // seconds — Telegram long-poll
7
+ const BACKOFF_BASE = 3000; // ms
8
+ const BACKOFF_MAX = 30000; // ms
9
+
10
+ export class TelegramGateway extends BaseGateway {
11
+ static type = 'telegram';
12
+ static displayName = 'Telegram';
13
+ static description = 'Telegram bot for notifications and agent commands';
14
+ static credentialKeys = [
15
+ { key: 'bot_token', label: 'Bot Token', required: true, help: 'Create a bot via @BotFather on Telegram' },
16
+ ];
17
+
18
+ constructor(daemon, config) {
19
+ super(daemon, config);
20
+ this.token = null;
21
+ this.botInfo = null;
22
+ this._pollOffset = 0;
23
+ this._polling = false;
24
+ this._abort = null;
25
+ this._backoff = BACKOFF_BASE;
26
+ }
27
+
28
+ async connect() {
29
+ this.token = this._getCredential('bot_token');
30
+ if (!this.token) throw new Error('Telegram bot token not configured');
31
+
32
+ // Validate token with getMe
33
+ this.botInfo = await this._api('getMe');
34
+ this.connected = true;
35
+ this._backoff = BACKOFF_BASE;
36
+
37
+ console.log(`[Groove:Telegram] Connected as @${this.botInfo.username}`);
38
+
39
+ // Start polling loop (non-blocking)
40
+ this._startPolling();
41
+ }
42
+
43
+ async disconnect() {
44
+ this._polling = false;
45
+ if (this._abort) {
46
+ this._abort.abort();
47
+ this._abort = null;
48
+ }
49
+ this.connected = false;
50
+ console.log('[Groove:Telegram] Disconnected');
51
+ }
52
+
53
+ async send(text, options = {}) {
54
+ const chatId = this.config.chatId;
55
+ if (!chatId) return; // No chat configured yet — will auto-capture on first command
56
+
57
+ const params = {
58
+ chat_id: chatId,
59
+ text,
60
+ parse_mode: 'HTML',
61
+ disable_web_page_preview: true,
62
+ };
63
+
64
+ // Add inline keyboard for approval actions
65
+ if (options.approvalId) {
66
+ params.reply_markup = {
67
+ inline_keyboard: [[
68
+ { text: '\u2705 Approve', callback_data: `approve:${options.approvalId}` },
69
+ { text: '\u274c Reject', callback_data: `reject:${options.approvalId}` },
70
+ ]],
71
+ };
72
+ }
73
+
74
+ await this._api('sendMessage', params);
75
+ }
76
+
77
+ getStatus() {
78
+ return {
79
+ ...super.getStatus(),
80
+ botUsername: this.botInfo?.username || null,
81
+ };
82
+ }
83
+
84
+ // -------------------------------------------------------------------
85
+ // Long-Polling Loop
86
+ // -------------------------------------------------------------------
87
+
88
+ async _startPolling() {
89
+ this._polling = true;
90
+
91
+ while (this._polling) {
92
+ try {
93
+ this._abort = new AbortController();
94
+ const updates = await this._api('getUpdates', {
95
+ offset: this._pollOffset,
96
+ timeout: POLL_TIMEOUT,
97
+ allowed_updates: ['message', 'callback_query'],
98
+ }, this._abort.signal);
99
+
100
+ // Reset backoff on success
101
+ this._backoff = BACKOFF_BASE;
102
+
103
+ for (const update of updates) {
104
+ this._pollOffset = update.update_id + 1;
105
+ this._handleUpdate(update).catch((err) => {
106
+ console.log(`[Groove:Telegram] Error handling update: ${err.message}`);
107
+ });
108
+ }
109
+ } catch (err) {
110
+ if (err.name === 'AbortError') break;
111
+ console.log(`[Groove:Telegram] Poll error: ${err.message}`);
112
+ // Exponential backoff
113
+ await new Promise((r) => setTimeout(r, this._backoff));
114
+ this._backoff = Math.min(this._backoff * 1.5, BACKOFF_MAX);
115
+ }
116
+ }
117
+ }
118
+
119
+ // -------------------------------------------------------------------
120
+ // Update Handlers
121
+ // -------------------------------------------------------------------
122
+
123
+ async _handleUpdate(update) {
124
+ if (update.callback_query) {
125
+ return this._handleCallbackQuery(update.callback_query);
126
+ }
127
+
128
+ const msg = update.message;
129
+ if (!msg?.text) return;
130
+
131
+ // Auto-capture chatId from first message
132
+ if (!this.config.chatId) {
133
+ this.config.chatId = String(msg.chat.id);
134
+ // Persist the captured chatId
135
+ this.daemon.gateways._save(this.config.id);
136
+ console.log(`[Groove:Telegram] Auto-captured chatId: ${this.config.chatId}`);
137
+ }
138
+
139
+ // Only process commands (starts with /)
140
+ if (!msg.text.startsWith('/')) return;
141
+
142
+ const userId = String(msg.from.id);
143
+ const text = msg.text.split('@')[0]; // Remove @botname suffix from group commands
144
+ const [command, ...args] = text.slice(1).split(/\s+/);
145
+
146
+ const response = await this.handleCommand(command, args, userId);
147
+ if (response) {
148
+ await this._reply(msg.chat.id, response.text, response.options);
149
+ }
150
+ }
151
+
152
+ async _handleCallbackQuery(query) {
153
+ const userId = String(query.from.id);
154
+
155
+ // Authorization check
156
+ if (!this._isAuthorized(userId)) {
157
+ await this._answerCallback(query.id, 'Unauthorized');
158
+ return;
159
+ }
160
+
161
+ const data = query.data || '';
162
+ const [action, ...rest] = data.split(':');
163
+ const approvalId = rest.join(':');
164
+
165
+ if (!approvalId) {
166
+ await this._answerCallback(query.id, 'Invalid action');
167
+ return;
168
+ }
169
+
170
+ let responseText;
171
+ try {
172
+ if (action === 'approve') {
173
+ this.daemon.supervisor.approve(approvalId);
174
+ responseText = `\u2705 Approved: ${approvalId}`;
175
+ } else if (action === 'reject') {
176
+ this.daemon.supervisor.reject(approvalId);
177
+ responseText = `\u274c Rejected: ${approvalId}`;
178
+ } else {
179
+ await this._answerCallback(query.id, 'Unknown action');
180
+ return;
181
+ }
182
+ } catch (err) {
183
+ await this._answerCallback(query.id, `Error: ${err.message}`);
184
+ return;
185
+ }
186
+
187
+ // Acknowledge the callback
188
+ await this._answerCallback(query.id, responseText);
189
+
190
+ // Update the original message to reflect the action
191
+ if (query.message) {
192
+ try {
193
+ await this._api('editMessageReplyMarkup', {
194
+ chat_id: query.message.chat.id,
195
+ message_id: query.message.message_id,
196
+ reply_markup: { inline_keyboard: [] }, // Remove buttons
197
+ });
198
+ await this._api('editMessageText', {
199
+ chat_id: query.message.chat.id,
200
+ message_id: query.message.message_id,
201
+ text: `${query.message.text}\n\n${responseText}`,
202
+ parse_mode: 'HTML',
203
+ });
204
+ } catch { /* best effort — message may be too old to edit */ }
205
+ }
206
+ }
207
+
208
+ // -------------------------------------------------------------------
209
+ // Telegram Bot API Helpers
210
+ // -------------------------------------------------------------------
211
+
212
+ async _reply(chatId, text, options = {}) {
213
+ const params = {
214
+ chat_id: chatId,
215
+ text,
216
+ parse_mode: 'HTML',
217
+ disable_web_page_preview: true,
218
+ };
219
+ if (options.approvalId) {
220
+ params.reply_markup = {
221
+ inline_keyboard: [[
222
+ { text: '\u2705 Approve', callback_data: `approve:${options.approvalId}` },
223
+ { text: '\u274c Reject', callback_data: `reject:${options.approvalId}` },
224
+ ]],
225
+ };
226
+ }
227
+ await this._api('sendMessage', params);
228
+ }
229
+
230
+ async _answerCallback(callbackQueryId, text) {
231
+ await this._api('answerCallbackQuery', {
232
+ callback_query_id: callbackQueryId,
233
+ text,
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Call the Telegram Bot API.
239
+ * @param {string} method — API method name
240
+ * @param {object} [body] — request body (JSON)
241
+ * @param {AbortSignal} [signal] — abort signal
242
+ * @returns {any} — result from Telegram API
243
+ */
244
+ async _api(method, body, signal) {
245
+ const url = `https://api.telegram.org/bot${this.token}/${method}`;
246
+ const options = {
247
+ method: body ? 'POST' : 'GET',
248
+ signal,
249
+ };
250
+
251
+ if (body) {
252
+ options.headers = { 'Content-Type': 'application/json' };
253
+ options.body = JSON.stringify(body);
254
+ }
255
+
256
+ const res = await fetch(url, options);
257
+ const data = await res.json();
258
+
259
+ if (!data.ok) {
260
+ throw new Error(data.description || `Telegram API error: ${method}`);
261
+ }
262
+
263
+ return data.result;
264
+ }
265
+ }
@@ -33,6 +33,7 @@ import { Scheduler } from './scheduler.js';
33
33
  import { FileWatcher } from './filewatcher.js';
34
34
  import { TimelineTracker } from './timeline.js';
35
35
  import { TerminalManager } from './terminal-pty.js';
36
+ import { GatewayManager } from './gateways/manager.js';
36
37
  import { isFirstRun, runFirstTimeSetup, loadConfig, saveConfig, printWelcome } from './firstrun.js';
37
38
 
38
39
  const DEFAULT_PORT = 31415;
@@ -127,6 +128,7 @@ export class Daemon {
127
128
  this.scheduler = new Scheduler(this);
128
129
  this.fileWatcher = new FileWatcher(this);
129
130
  this.terminalManager = new TerminalManager(this);
131
+ this.gateways = new GatewayManager(this);
130
132
 
131
133
  // HTTP + WebSocket server
132
134
  this.app = express();
@@ -296,6 +298,7 @@ export class Daemon {
296
298
  this.rotator.start();
297
299
  this.scheduler.start();
298
300
  this.timeline.start();
301
+ this.gateways.start();
299
302
  this._startGarbageCollector();
300
303
 
301
304
  // Scan codebase for workspace/structure awareness
@@ -368,6 +371,7 @@ export class Daemon {
368
371
  this.state.save();
369
372
 
370
373
  // Stop background services
374
+ await this.gateways.stop();
371
375
  this.journalist.stop();
372
376
  this.rotator.stop();
373
377
  this.scheduler.stop();
@@ -134,6 +134,61 @@ export function sanitizeForFilename(name) {
134
134
  return sanitized;
135
135
  }
136
136
 
137
+ const VALID_GATEWAY_TYPES = ['telegram', 'discord', 'slack'];
138
+ const VALID_NOTIFICATION_PRESETS = ['critical', 'lifecycle', 'all', 'custom'];
139
+
140
+ export function validateGatewayConfig(config) {
141
+ if (!config || typeof config !== 'object') {
142
+ throw new Error('Invalid gateway config');
143
+ }
144
+
145
+ if (!config.type || !VALID_GATEWAY_TYPES.includes(config.type)) {
146
+ throw new Error(`Invalid gateway type. Must be one of: ${VALID_GATEWAY_TYPES.join(', ')}`);
147
+ }
148
+
149
+ if (config.chatId !== undefined && config.chatId !== null) {
150
+ if (typeof config.chatId !== 'string' || config.chatId.length > 100) {
151
+ throw new Error('Invalid chatId');
152
+ }
153
+ }
154
+
155
+ if (config.allowedUsers !== undefined && config.allowedUsers !== null) {
156
+ if (!Array.isArray(config.allowedUsers)) {
157
+ throw new Error('allowedUsers must be an array');
158
+ }
159
+ if (config.allowedUsers.length > 50) {
160
+ throw new Error('Too many allowed users (max 50)');
161
+ }
162
+ for (const u of config.allowedUsers) {
163
+ if (typeof u !== 'string' || u.length > 100) {
164
+ throw new Error('Invalid user ID in allowedUsers');
165
+ }
166
+ }
167
+ }
168
+
169
+ if (config.notifications !== undefined && config.notifications !== null) {
170
+ if (typeof config.notifications !== 'object') {
171
+ throw new Error('notifications must be an object');
172
+ }
173
+ if (config.notifications.preset && !VALID_NOTIFICATION_PRESETS.includes(config.notifications.preset)) {
174
+ throw new Error(`Invalid notification preset. Must be one of: ${VALID_NOTIFICATION_PRESETS.join(', ')}`);
175
+ }
176
+ }
177
+
178
+ if (config.commandPermission !== undefined && !['full', 'read-only'].includes(config.commandPermission)) {
179
+ throw new Error('Invalid commandPermission. Must be "full" or "read-only"');
180
+ }
181
+
182
+ return {
183
+ type: config.type,
184
+ enabled: config.enabled !== false,
185
+ chatId: config.chatId || null,
186
+ allowedUsers: (config.allowedUsers || []).map(String),
187
+ notifications: config.notifications || { preset: 'critical' },
188
+ commandPermission: config.commandPermission || 'full',
189
+ };
190
+ }
191
+
137
192
  export function escapeMd(text) {
138
193
  if (!text) return '';
139
194
  // Escape markdown special chars that could break table rendering or inject formatting.