@shakudo/opencode-mattermost-control 0.3.45

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 (69) hide show
  1. package/.opencode/command/mattermost-connect.md +5 -0
  2. package/.opencode/command/mattermost-disconnect.md +5 -0
  3. package/.opencode/command/mattermost-monitor.md +12 -0
  4. package/.opencode/command/mattermost-status.md +5 -0
  5. package/.opencode/command/speckit.analyze.md +184 -0
  6. package/.opencode/command/speckit.checklist.md +294 -0
  7. package/.opencode/command/speckit.clarify.md +181 -0
  8. package/.opencode/command/speckit.constitution.md +82 -0
  9. package/.opencode/command/speckit.implement.md +135 -0
  10. package/.opencode/command/speckit.plan.md +89 -0
  11. package/.opencode/command/speckit.specify.md +258 -0
  12. package/.opencode/command/speckit.tasks.md +137 -0
  13. package/.opencode/command/speckit.taskstoissues.md +30 -0
  14. package/.opencode/plugin/mattermost-control/event-handlers/compaction.ts +61 -0
  15. package/.opencode/plugin/mattermost-control/event-handlers/file.ts +36 -0
  16. package/.opencode/plugin/mattermost-control/event-handlers/index.ts +14 -0
  17. package/.opencode/plugin/mattermost-control/event-handlers/message.ts +124 -0
  18. package/.opencode/plugin/mattermost-control/event-handlers/permission.ts +34 -0
  19. package/.opencode/plugin/mattermost-control/event-handlers/question.ts +92 -0
  20. package/.opencode/plugin/mattermost-control/event-handlers/session.ts +100 -0
  21. package/.opencode/plugin/mattermost-control/event-handlers/todo.ts +33 -0
  22. package/.opencode/plugin/mattermost-control/event-handlers/tool.ts +76 -0
  23. package/.opencode/plugin/mattermost-control/formatters.ts +202 -0
  24. package/.opencode/plugin/mattermost-control/index.ts +964 -0
  25. package/.opencode/plugin/mattermost-control/package.json +12 -0
  26. package/.opencode/plugin/mattermost-control/state.ts +180 -0
  27. package/.opencode/plugin/mattermost-control/timers.ts +96 -0
  28. package/.opencode/plugin/mattermost-control/tools/connect.ts +563 -0
  29. package/.opencode/plugin/mattermost-control/tools/file.ts +41 -0
  30. package/.opencode/plugin/mattermost-control/tools/index.ts +12 -0
  31. package/.opencode/plugin/mattermost-control/tools/monitor.ts +183 -0
  32. package/.opencode/plugin/mattermost-control/tools/schedule.ts +253 -0
  33. package/.opencode/plugin/mattermost-control/tools/session.ts +120 -0
  34. package/.opencode/plugin/mattermost-control/types.ts +107 -0
  35. package/LICENSE +21 -0
  36. package/README.md +1280 -0
  37. package/opencode-shared +359 -0
  38. package/opencode-shared-restart +495 -0
  39. package/opencode-shared-stop +90 -0
  40. package/package.json +65 -0
  41. package/src/clients/mattermost-client.ts +221 -0
  42. package/src/clients/websocket-client.ts +199 -0
  43. package/src/command-handler.ts +1035 -0
  44. package/src/config.ts +170 -0
  45. package/src/context-builder.ts +309 -0
  46. package/src/file-completion-handler.ts +521 -0
  47. package/src/file-handler.ts +242 -0
  48. package/src/guest-approval-handler.ts +223 -0
  49. package/src/logger.ts +73 -0
  50. package/src/merge-handler.ts +335 -0
  51. package/src/message-router.ts +151 -0
  52. package/src/models/index.ts +197 -0
  53. package/src/models/routing.ts +50 -0
  54. package/src/models/thread-mapping.ts +40 -0
  55. package/src/monitor-service.ts +222 -0
  56. package/src/notification-service.ts +118 -0
  57. package/src/opencode-session-registry.ts +370 -0
  58. package/src/persistence/team-store.ts +396 -0
  59. package/src/persistence/thread-mapping-store.ts +258 -0
  60. package/src/question-handler.ts +401 -0
  61. package/src/reaction-handler.ts +111 -0
  62. package/src/response-streamer.ts +364 -0
  63. package/src/scheduler/schedule-store.ts +261 -0
  64. package/src/scheduler/scheduler-service.ts +349 -0
  65. package/src/session-manager.ts +142 -0
  66. package/src/session-ownership-handler.ts +253 -0
  67. package/src/status-indicator.ts +279 -0
  68. package/src/thread-manager.ts +231 -0
  69. package/src/todo-manager.ts +162 -0
@@ -0,0 +1,521 @@
1
+ /**
2
+ * File Completion Handler
3
+ *
4
+ * Provides file path completion via !! trigger in Mattermost messages.
5
+ * Since Mattermost doesn't stream keystrokes, this uses a post-send
6
+ * disambiguation flow:
7
+ *
8
+ * 1. User sends: "Look at !!src/resp"
9
+ * 2. Bot parses !! references and calls OpenCode /find/file API
10
+ * 3. If exact match: auto-attach file content
11
+ * 4. If fuzzy matches: prompt user to select from options
12
+ * 5. User replies with number(s) to resolve
13
+ * 6. Bot processes original message with resolved file contents
14
+ */
15
+
16
+ import { log } from "./logger.js";
17
+
18
+ /** Pattern to match !!<path> references */
19
+ const FILE_REFERENCE_PATTERN = /!!([^\s`]+)/g;
20
+
21
+ /** Pattern to match code blocks (to skip !! inside them) */
22
+ const CODE_BLOCK_PATTERN = /```[\s\S]*?```|`[^`]+`/g;
23
+
24
+ export interface FileMatch {
25
+ path: string;
26
+ score: number;
27
+ }
28
+
29
+ export interface FileReference {
30
+ /** The original !! reference (e.g., "!!src/resp") */
31
+ original: string;
32
+ /** The query part without !! prefix (e.g., "src/resp") */
33
+ query: string;
34
+ /** Matched files from the API */
35
+ matches: FileMatch[];
36
+ /** Resolved file path (if exact match or user selected) */
37
+ resolvedPath?: string;
38
+ }
39
+
40
+ export interface PendingFileCompletion {
41
+ /** Session ID this completion is for */
42
+ sessionId: string;
43
+ /** Thread root post ID */
44
+ threadRootPostId: string;
45
+ /** Channel ID */
46
+ channelId: string;
47
+ /** Original message text */
48
+ originalMessage: string;
49
+ /** File references that need resolution */
50
+ references: FileReference[];
51
+ /** User ID who sent the message */
52
+ userId: string;
53
+ /** Timestamp when the completion was requested */
54
+ createdAt: Date;
55
+ /** Post ID of the disambiguation prompt */
56
+ disambiguationPostId?: string;
57
+ /** File IDs from original message */
58
+ fileIds?: string[];
59
+ }
60
+
61
+ export interface FileCompletionResult {
62
+ /** Whether all references were resolved */
63
+ allResolved: boolean;
64
+ /** The message with file references replaced/resolved */
65
+ processedMessage: string;
66
+ /** File paths that were resolved and should be attached */
67
+ resolvedFilePaths: string[];
68
+ /** Whether disambiguation is needed */
69
+ needsDisambiguation: boolean;
70
+ /** References that need user input */
71
+ unresolvedReferences: FileReference[];
72
+ }
73
+
74
+ export class FileCompletionHandler {
75
+ /** Pending completions by session ID */
76
+ private pendingCompletions: Map<string, PendingFileCompletion> = new Map();
77
+
78
+ /** OpenCode server base URL */
79
+ private opencodeBaseUrl: string;
80
+
81
+ /** Project directory for file searches */
82
+ private directory: string;
83
+
84
+ constructor(opencodeBaseUrl: string, directory: string) {
85
+ this.opencodeBaseUrl = opencodeBaseUrl;
86
+ this.directory = directory;
87
+ }
88
+
89
+ /**
90
+ * Check if a message contains !! file references
91
+ */
92
+ hasFileReferences(message: string): boolean {
93
+ // First, remove code blocks to avoid matching !! inside code
94
+ const withoutCodeBlocks = message.replace(CODE_BLOCK_PATTERN, "");
95
+ return FILE_REFERENCE_PATTERN.test(withoutCodeBlocks);
96
+ }
97
+
98
+ /**
99
+ * Extract all !! file references from a message
100
+ */
101
+ extractFileReferences(message: string): string[] {
102
+ // Remove code blocks first
103
+ const withoutCodeBlocks = message.replace(CODE_BLOCK_PATTERN, "");
104
+
105
+ const references: string[] = [];
106
+ let match;
107
+
108
+ // Reset regex state
109
+ FILE_REFERENCE_PATTERN.lastIndex = 0;
110
+
111
+ while ((match = FILE_REFERENCE_PATTERN.exec(withoutCodeBlocks)) !== null) {
112
+ references.push(match[1]); // The query part without !!
113
+ }
114
+
115
+ // Deduplicate
116
+ return [...new Set(references)];
117
+ }
118
+
119
+ /**
120
+ * Search for files matching a query using OpenCode's /find/file API
121
+ */
122
+ async searchFiles(query: string, limit: number = 5): Promise<FileMatch[]> {
123
+ try {
124
+ const url = `${this.opencodeBaseUrl}/find/file?query=${encodeURIComponent(query)}&limit=${limit}`;
125
+
126
+ const response = await fetch(url, {
127
+ method: "GET",
128
+ headers: {
129
+ "Content-Type": "application/json",
130
+ "x-opencode-directory": this.directory,
131
+ },
132
+ });
133
+
134
+ if (!response.ok) {
135
+ log.error(`[FileCompletion] Search failed: HTTP ${response.status}`);
136
+ return [];
137
+ }
138
+
139
+ const data = await response.json();
140
+
141
+ // API returns a plain array of strings, not { files: [...] }
142
+ if (!Array.isArray(data)) {
143
+ log.debug(`[FileCompletion] Unexpected response format for query: ${query}`, data);
144
+ return [];
145
+ }
146
+
147
+ if (data.length === 0) {
148
+ log.debug(`[FileCompletion] No files returned for query: ${query}`);
149
+ return [];
150
+ }
151
+
152
+ log.debug(`[FileCompletion] Found ${data.length} files for query: ${query}`);
153
+
154
+ return data.map((path: string) => ({
155
+ path,
156
+ score: 0, // API doesn't return scores
157
+ }));
158
+ } catch (error) {
159
+ log.error(`[FileCompletion] Search error:`, error);
160
+ return [];
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Process a message with file references
166
+ * Returns a result indicating if disambiguation is needed
167
+ */
168
+ async processMessage(
169
+ sessionId: string,
170
+ threadRootPostId: string,
171
+ channelId: string,
172
+ message: string,
173
+ userId: string,
174
+ fileIds?: string[]
175
+ ): Promise<FileCompletionResult> {
176
+ const queries = this.extractFileReferences(message);
177
+
178
+ if (queries.length === 0) {
179
+ return {
180
+ allResolved: true,
181
+ processedMessage: message,
182
+ resolvedFilePaths: [],
183
+ needsDisambiguation: false,
184
+ unresolvedReferences: [],
185
+ };
186
+ }
187
+
188
+ log.info(`[FileCompletion] Found ${queries.length} file reference(s): ${queries.join(", ")}`);
189
+
190
+ const references: FileReference[] = [];
191
+ const resolvedFilePaths: string[] = [];
192
+ const unresolvedReferences: FileReference[] = [];
193
+
194
+ // Search for each reference
195
+ for (const query of queries) {
196
+ const matches = await this.searchFiles(query);
197
+
198
+ const reference: FileReference = {
199
+ original: `!!${query}`,
200
+ query,
201
+ matches,
202
+ };
203
+
204
+ // Check for exact match (score = 0 means perfect match in fuzzysort)
205
+ // Or if there's only one result with a very high score
206
+ const exactMatch = matches.find(m =>
207
+ m.path.toLowerCase() === query.toLowerCase() ||
208
+ m.path.toLowerCase().endsWith(`/${query.toLowerCase()}`) ||
209
+ m.path.toLowerCase().endsWith(`\\${query.toLowerCase()}`)
210
+ );
211
+
212
+ if (exactMatch) {
213
+ reference.resolvedPath = exactMatch.path;
214
+ resolvedFilePaths.push(exactMatch.path);
215
+ log.info(`[FileCompletion] Exact match for "${query}": ${exactMatch.path}`);
216
+ } else if (matches.length === 1) {
217
+ // Single fuzzy match - auto-resolve
218
+ reference.resolvedPath = matches[0].path;
219
+ resolvedFilePaths.push(matches[0].path);
220
+ log.info(`[FileCompletion] Single match for "${query}": ${matches[0].path}`);
221
+ } else if (matches.length === 0) {
222
+ log.info(`[FileCompletion] No matches for "${query}"`);
223
+ unresolvedReferences.push(reference);
224
+ } else {
225
+ // Multiple matches - needs disambiguation
226
+ log.info(`[FileCompletion] Multiple matches for "${query}": ${matches.length} options`);
227
+ unresolvedReferences.push(reference);
228
+ }
229
+
230
+ references.push(reference);
231
+ }
232
+
233
+ const needsDisambiguation = unresolvedReferences.length > 0;
234
+
235
+ if (needsDisambiguation) {
236
+ // Store pending completion for later resolution
237
+ const pending: PendingFileCompletion = {
238
+ sessionId,
239
+ threadRootPostId,
240
+ channelId,
241
+ originalMessage: message,
242
+ references,
243
+ userId,
244
+ createdAt: new Date(),
245
+ fileIds,
246
+ };
247
+ this.pendingCompletions.set(sessionId, pending);
248
+ log.info(`[FileCompletion] Stored pending completion for session ${sessionId.substring(0, 8)}`);
249
+ }
250
+
251
+ // Build processed message with resolved paths
252
+ let processedMessage = message;
253
+ for (const ref of references) {
254
+ if (ref.resolvedPath) {
255
+ // Replace !!query with the resolved path (without !!)
256
+ processedMessage = processedMessage.replace(ref.original, ref.resolvedPath);
257
+ }
258
+ }
259
+
260
+ return {
261
+ allResolved: !needsDisambiguation,
262
+ processedMessage,
263
+ resolvedFilePaths,
264
+ needsDisambiguation,
265
+ unresolvedReferences,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Format disambiguation prompt for the user
271
+ */
272
+ formatDisambiguationPrompt(unresolvedReferences: FileReference[]): string {
273
+ const lines: string[] = [
274
+ ":file_folder: **File suggestions**",
275
+ "",
276
+ ];
277
+
278
+ let globalIndex = 1;
279
+ const indexMap: Map<number, { reference: FileReference; matchIndex: number }> = new Map();
280
+
281
+ for (const ref of unresolvedReferences) {
282
+ lines.push(`**\`${ref.original}\`**:`);
283
+
284
+ if (ref.matches.length === 0) {
285
+ lines.push(` _No files found matching "${ref.query}"_`);
286
+ } else {
287
+ for (let i = 0; i < ref.matches.length; i++) {
288
+ const match = ref.matches[i];
289
+ lines.push(` \`${globalIndex}\`. ${match.path}`);
290
+ indexMap.set(globalIndex, { reference: ref, matchIndex: i });
291
+ globalIndex++;
292
+ }
293
+ }
294
+ lines.push("");
295
+ }
296
+
297
+ lines.push("---");
298
+ lines.push("_Reply with number(s) to select (e.g., `1` or `1, 3`)_");
299
+ lines.push("_Or type `!cancel` to skip file completion_");
300
+
301
+ return lines.join("\n");
302
+ }
303
+
304
+ /**
305
+ * Check if there's a pending file completion for a session
306
+ */
307
+ hasPendingCompletion(sessionId: string): boolean {
308
+ const pending = this.pendingCompletions.get(sessionId);
309
+ if (!pending) return false;
310
+
311
+ // Expire after 30 minutes
312
+ const ageMs = Date.now() - pending.createdAt.getTime();
313
+ if (ageMs > 30 * 60 * 1000) {
314
+ this.pendingCompletions.delete(sessionId);
315
+ return false;
316
+ }
317
+
318
+ return true;
319
+ }
320
+
321
+ /**
322
+ * Get pending completion for a session
323
+ */
324
+ getPendingCompletion(sessionId: string): PendingFileCompletion | null {
325
+ return this.pendingCompletions.get(sessionId) || null;
326
+ }
327
+
328
+ /**
329
+ * Set the disambiguation post ID for tracking
330
+ */
331
+ setDisambiguationPostId(sessionId: string, postId: string): void {
332
+ const pending = this.pendingCompletions.get(sessionId);
333
+ if (pending) {
334
+ pending.disambiguationPostId = postId;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * Handle user's reply to disambiguation prompt
340
+ *
341
+ * @param sessionId - Session ID
342
+ * @param reply - User's reply (numbers or !cancel)
343
+ * @returns Resolved result or null if cancelled
344
+ */
345
+ handleDisambiguationReply(
346
+ sessionId: string,
347
+ reply: string
348
+ ): { resolved: boolean; result?: FileCompletionResult; cancelled?: boolean } {
349
+ const pending = this.pendingCompletions.get(sessionId);
350
+ if (!pending) {
351
+ return { resolved: false };
352
+ }
353
+
354
+ const trimmedReply = reply.trim().toLowerCase();
355
+
356
+ // Check for cancel
357
+ if (trimmedReply === "!cancel" || trimmedReply === "cancel") {
358
+ this.pendingCompletions.delete(sessionId);
359
+ log.info(`[FileCompletion] User cancelled file completion for session ${sessionId.substring(0, 8)}`);
360
+ return { resolved: true, cancelled: true };
361
+ }
362
+
363
+ // Parse number selections (e.g., "1" or "1, 3" or "1,3")
364
+ const selections = trimmedReply
365
+ .split(/[,\s]+/)
366
+ .map(s => parseInt(s.trim(), 10))
367
+ .filter(n => !isNaN(n));
368
+
369
+ if (selections.length === 0) {
370
+ // Not a valid selection, might be a regular message
371
+ return { resolved: false };
372
+ }
373
+
374
+ // Build global index map
375
+ let globalIndex = 1;
376
+ const indexMap: Map<number, { reference: FileReference; match: FileMatch }> = new Map();
377
+
378
+ for (const ref of pending.references) {
379
+ if (!ref.resolvedPath && ref.matches.length > 0) {
380
+ for (const match of ref.matches) {
381
+ indexMap.set(globalIndex, { reference: ref, match });
382
+ globalIndex++;
383
+ }
384
+ }
385
+ }
386
+
387
+ // Validate all selections
388
+ for (const sel of selections) {
389
+ if (!indexMap.has(sel)) {
390
+ log.warn(`[FileCompletion] Invalid selection ${sel}, max is ${globalIndex - 1}`);
391
+ return { resolved: false };
392
+ }
393
+ }
394
+
395
+ // Apply selections
396
+ const resolvedFilePaths: string[] = [];
397
+
398
+ for (const sel of selections) {
399
+ const { reference, match } = indexMap.get(sel)!;
400
+ reference.resolvedPath = match.path;
401
+ resolvedFilePaths.push(match.path);
402
+ log.info(`[FileCompletion] User selected ${sel}: ${match.path} for "${reference.query}"`);
403
+ }
404
+
405
+ // Add previously resolved paths
406
+ for (const ref of pending.references) {
407
+ if (ref.resolvedPath && !resolvedFilePaths.includes(ref.resolvedPath)) {
408
+ resolvedFilePaths.push(ref.resolvedPath);
409
+ }
410
+ }
411
+
412
+ // Check if all references are now resolved
413
+ const stillUnresolved = pending.references.filter(r => !r.resolvedPath && r.matches.length > 0);
414
+
415
+ if (stillUnresolved.length > 0) {
416
+ // Still needs more disambiguation
417
+ return { resolved: false };
418
+ }
419
+
420
+ // Build final processed message
421
+ let processedMessage = pending.originalMessage;
422
+ for (const ref of pending.references) {
423
+ if (ref.resolvedPath) {
424
+ processedMessage = processedMessage.replace(ref.original, ref.resolvedPath);
425
+ } else if (ref.matches.length === 0) {
426
+ // No matches found - leave as is or remove
427
+ processedMessage = processedMessage.replace(ref.original, `[file not found: ${ref.query}]`);
428
+ }
429
+ }
430
+
431
+ this.pendingCompletions.delete(sessionId);
432
+
433
+ return {
434
+ resolved: true,
435
+ result: {
436
+ allResolved: true,
437
+ processedMessage,
438
+ resolvedFilePaths,
439
+ needsDisambiguation: false,
440
+ unresolvedReferences: [],
441
+ },
442
+ };
443
+ }
444
+
445
+ /**
446
+ * Clear pending completion for a session
447
+ */
448
+ clearPendingCompletion(sessionId: string): void {
449
+ this.pendingCompletions.delete(sessionId);
450
+ }
451
+
452
+ /**
453
+ * Read file content from the project
454
+ * Uses OpenCode's file reading endpoint or direct fs
455
+ */
456
+ async readFileContent(filePath: string): Promise<string | null> {
457
+ try {
458
+ // Use OpenCode's file content endpoint
459
+ const url = `${this.opencodeBaseUrl}/file/content?path=${encodeURIComponent(filePath)}`;
460
+
461
+ const response = await fetch(url, {
462
+ method: "GET",
463
+ headers: {
464
+ "Content-Type": "application/json",
465
+ "x-opencode-directory": this.directory,
466
+ },
467
+ });
468
+
469
+ if (!response.ok) {
470
+ log.error(`[FileCompletion] Failed to read file ${filePath}: HTTP ${response.status}`);
471
+ return null;
472
+ }
473
+
474
+ const data = await response.json() as { content?: string };
475
+ return data.content || null;
476
+ } catch (error) {
477
+ log.error(`[FileCompletion] Error reading file ${filePath}:`, error);
478
+ return null;
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Format file content for inclusion in prompt
484
+ */
485
+ formatFileContentForPrompt(filePath: string, content: string): string {
486
+ // Determine language hint from extension
487
+ const ext = filePath.split(".").pop()?.toLowerCase() || "";
488
+ const langHints: Record<string, string> = {
489
+ ts: "typescript",
490
+ tsx: "tsx",
491
+ js: "javascript",
492
+ jsx: "jsx",
493
+ py: "python",
494
+ rb: "ruby",
495
+ go: "go",
496
+ rs: "rust",
497
+ java: "java",
498
+ cpp: "cpp",
499
+ c: "c",
500
+ h: "c",
501
+ hpp: "cpp",
502
+ cs: "csharp",
503
+ sh: "bash",
504
+ bash: "bash",
505
+ zsh: "zsh",
506
+ yaml: "yaml",
507
+ yml: "yaml",
508
+ json: "json",
509
+ md: "markdown",
510
+ sql: "sql",
511
+ css: "css",
512
+ scss: "scss",
513
+ html: "html",
514
+ xml: "xml",
515
+ };
516
+
517
+ const lang = langHints[ext] || "";
518
+
519
+ return `\n\n📁 **File: \`${filePath}\`**\n\`\`\`${lang}\n${content}\n\`\`\``;
520
+ }
521
+ }