@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,563 @@
1
+ import { PluginState } from "../state.js";
2
+ import { startQuestionCleanupTimer, stopQuestionCleanupTimer } from "../timers.js";
3
+ import { MattermostClient } from "../../../../src/clients/mattermost-client.js";
4
+ import { MattermostWebSocketClient } from "../../../../src/clients/websocket-client.js";
5
+ import { SessionManager } from "../../../../src/session-manager.js";
6
+ import { ResponseStreamer } from "../../../../src/response-streamer.js";
7
+ import { NotificationService } from "../../../../src/notification-service.js";
8
+ import { FileHandler } from "../../../../src/file-handler.js";
9
+ import { ReactionHandler } from "../../../../src/reaction-handler.js";
10
+ import { OpenCodeSessionRegistry, type OpenCodeSessionInfo } from "../../../../src/opencode-session-registry.js";
11
+ import { MessageRouter } from "../../../../src/message-router.js";
12
+ import { CommandHandler } from "../../../../src/command-handler.js";
13
+ import { ThreadManager } from "../../../../src/thread-manager.js";
14
+ import { TodoManager } from "../../../../src/todo-manager.js";
15
+ import { QuestionHandler } from "../../../../src/question-handler.js";
16
+ import { GuestApprovalHandler } from "../../../../src/guest-approval-handler.js";
17
+ import { SessionOwnershipHandler } from "../../../../src/session-ownership-handler.js";
18
+ import { FileCompletionHandler } from "../../../../src/file-completion-handler.js";
19
+ import { getSchedulerService } from "../../../../src/scheduler/scheduler-service.js";
20
+ import { isBotMentioned } from "../../../../src/context-builder.js";
21
+ import { loadConfig, type PluginConfig } from "../../../../src/config.js";
22
+ import { log } from "../../../../src/logger.js";
23
+ import { TeamStore } from "../../../../src/persistence/team-store.js";
24
+ import type { WebSocketEvent } from "../../../../src/models/index.js";
25
+
26
+ export interface ConnectionContext {
27
+ client: any;
28
+ directory: string;
29
+ projectName: string;
30
+ opencodeBaseUrl: string;
31
+ handleUserMessage: (post: any) => Promise<void>;
32
+ }
33
+
34
+ export function createConnectTool(ctx: ConnectionContext) {
35
+ return {
36
+ description: "Connect to Mattermost for remote control via DMs",
37
+ args: {},
38
+ async execute() {
39
+ return await handleConnect(ctx);
40
+ },
41
+ };
42
+ }
43
+
44
+ export function createDisconnectTool() {
45
+ return {
46
+ description: "Disconnect from Mattermost remote control",
47
+ args: {},
48
+ async execute() {
49
+ return await handleDisconnect();
50
+ },
51
+ };
52
+ }
53
+
54
+ export function createStatusTool(projectName: string) {
55
+ return {
56
+ description: "Show Mattermost connection status",
57
+ args: {},
58
+ async execute() {
59
+ return handleStatus(projectName);
60
+ },
61
+ };
62
+ }
63
+
64
+ async function handleConnect(ctx: ConnectionContext): Promise<string> {
65
+ if (PluginState.isConnected) {
66
+ return `Already connected to Mattermost as @${PluginState.botUser?.username}. Use /mattermost status for details.`;
67
+ }
68
+
69
+ const config = loadConfig();
70
+
71
+ if (!config.mattermost.token) {
72
+ return "MATTERMOST_TOKEN environment variable is required. Set it before connecting.";
73
+ }
74
+
75
+ if (config.mattermost.baseUrl.includes("your-mattermost-instance.example.com")) {
76
+ return "MATTERMOST_URL environment variable is required. Set it before connecting.";
77
+ }
78
+
79
+ try {
80
+ log.info("Creating Mattermost clients...");
81
+ const mmClient = new MattermostClient(config.mattermost);
82
+ const wsClient = new MattermostWebSocketClient(config.mattermost);
83
+
84
+ await wsClient.connect();
85
+ const botUser = await mmClient.getCurrentUser();
86
+
87
+ const sessionManager = new SessionManager(mmClient, config.sessions);
88
+ await sessionManager.setBotUserId(botUser.id);
89
+
90
+ const streamer = new ResponseStreamer(mmClient, config.streaming);
91
+ const notifications = new NotificationService(mmClient, config.notifications);
92
+ const fileHandler = new FileHandler(mmClient, config.files);
93
+
94
+ const reactionHandler = new ReactionHandler(sessionManager, notifications, {
95
+ onApprove: async (session) => {
96
+ await notifications.notifyStatus(session, { type: "waiting", details: "Permission approved" });
97
+ },
98
+ onDeny: async (session) => {
99
+ await notifications.notifyStatus(session, { type: "waiting", details: "Permission denied" });
100
+ },
101
+ onCancel: async (session) => {
102
+ // Call OpenCode abort API to actually stop the session
103
+ const sessionId = session.targetOpenCodeSessionId;
104
+ if (sessionId) {
105
+ try {
106
+ await ctx.client.session.abort({ path: { id: sessionId } });
107
+ log.info(`[ReactionHandler] Aborted session ${sessionId.substring(0, 8)} via 🛑 reaction`);
108
+ } catch (e) {
109
+ log.error(`[ReactionHandler] Failed to abort session: ${e}`);
110
+ }
111
+ }
112
+ session.isProcessing = false;
113
+ await notifications.notifyStatus(session, { type: "idle", details: "Operation cancelled" });
114
+ },
115
+ onRetry: async (session) => {
116
+ if (session.lastPrompt) {
117
+ await ctx.handleUserMessage(session.lastPrompt);
118
+ }
119
+ },
120
+ onClear: async (session) => {
121
+ fileHandler.cleanupSessionFiles(session);
122
+ },
123
+ });
124
+ reactionHandler.setBotUserId(botUser.id);
125
+
126
+ const openCodeSessionRegistry = new OpenCodeSessionRegistry(config.sessionSelection.refreshIntervalMs);
127
+ openCodeSessionRegistry.initialize(ctx.client.session);
128
+ await openCodeSessionRegistry.refresh();
129
+ openCodeSessionRegistry.startAutoRefresh();
130
+
131
+ const messageRouter = new MessageRouter(config.sessionSelection.commandPrefix);
132
+ const commandHandler = new CommandHandler(config.sessionSelection.commandPrefix);
133
+ const threadMappingStore = PluginState.threadMappingStore;
134
+
135
+ if (threadMappingStore) {
136
+ messageRouter.setThreadLookup((threadRootPostId) =>
137
+ threadMappingStore.getByThreadRootPostId(threadRootPostId)
138
+ );
139
+ }
140
+
141
+ let threadManager: ThreadManager | null = null;
142
+ if (threadMappingStore) {
143
+ threadManager = new ThreadManager(mmClient, threadMappingStore);
144
+ }
145
+
146
+ const todoManager = new TodoManager(mmClient);
147
+ const questionHandler = new QuestionHandler(mmClient);
148
+ questionHandler.setOpenCodeConfig(ctx.opencodeBaseUrl, ctx.directory);
149
+ const guestApprovalHandler = new GuestApprovalHandler(mmClient);
150
+ const sessionOwnershipHandler = new SessionOwnershipHandler(mmClient);
151
+ sessionOwnershipHandler.setBotUserId(botUser.id);
152
+
153
+ const fileCompletionHandler = new FileCompletionHandler(ctx.opencodeBaseUrl, ctx.directory);
154
+ PluginState.setFileCompletionHandler(fileCompletionHandler);
155
+
156
+ setupSessionCallbacks(openCodeSessionRegistry, threadMappingStore, threadManager, sessionManager, config);
157
+
158
+ const availableSessions = openCodeSessionRegistry.listAvailable();
159
+ cleanOrphanedMappings(threadMappingStore, availableSessions);
160
+ await createThreadsForExistingSessions(threadManager, threadMappingStore, sessionManager, availableSessions, config);
161
+
162
+ setupWebSocketListeners(wsClient, botUser.id, config, ctx.handleUserMessage, reactionHandler);
163
+ startQuestionCleanupTimer();
164
+
165
+ const scheduler = getSchedulerService();
166
+ scheduler.setPromptExecutor(async (sessionId: string, prompt: string) => {
167
+ const result = await ctx.client.session.prompt({
168
+ path: { id: sessionId },
169
+ body: { parts: [{ type: "text", text: prompt }] },
170
+ });
171
+ return result.data?.text || "";
172
+ });
173
+ scheduler.setSessionChecker(async (sessionId: string) => {
174
+ const session = openCodeSessionRegistry.get(sessionId);
175
+ return session !== null && session.isAvailable;
176
+ });
177
+ await scheduler.start();
178
+ PluginState.setSchedulerService(scheduler);
179
+ log.info(`[SchedulerService] Initialized with ${scheduler.getStats().enabled} active schedules`);
180
+
181
+ const teamStore = new TeamStore();
182
+ teamStore.load();
183
+ if (!teamStore.hasTeam() && config.mattermost.ownerUserId) {
184
+ teamStore.createTeam(config.mattermost.ownerUserId);
185
+ }
186
+ PluginState.setTeamStore(teamStore);
187
+
188
+ PluginState.setConnected(
189
+ mmClient,
190
+ wsClient,
191
+ sessionManager,
192
+ streamer,
193
+ notifications,
194
+ fileHandler,
195
+ reactionHandler,
196
+ openCodeSessionRegistry,
197
+ messageRouter,
198
+ commandHandler,
199
+ threadManager,
200
+ todoManager,
201
+ questionHandler,
202
+ guestApprovalHandler,
203
+ sessionOwnershipHandler,
204
+ botUser
205
+ );
206
+
207
+ log.info(`Connected to Mattermost as @${botUser.username}`);
208
+ return `Connected to Mattermost as @${botUser.username}\nListening for DMs\nProject: ${ctx.projectName}\n\nDM @${botUser.username} in Mattermost to send prompts remotely.`;
209
+ } catch (error) {
210
+ const errorMsg = error instanceof Error ? error.message : String(error);
211
+ log.error("Connection failed:", errorMsg);
212
+ return `Failed to connect: ${errorMsg}`;
213
+ }
214
+ }
215
+
216
+ async function handleDisconnect(): Promise<string> {
217
+ if (!PluginState.isConnected) {
218
+ return "Not connected to Mattermost.";
219
+ }
220
+
221
+ try {
222
+ stopQuestionCleanupTimer();
223
+ const scheduler = PluginState.schedulerService;
224
+ if (scheduler) {
225
+ await scheduler.stop();
226
+ log.info("[SchedulerService] Stopped");
227
+ }
228
+ PluginState.disconnect();
229
+ log.info("Disconnected from Mattermost");
230
+ return "Disconnected from Mattermost";
231
+ } catch (error) {
232
+ return `Error disconnecting: ${error}`;
233
+ }
234
+ }
235
+
236
+ function handleStatus(projectName: string): string {
237
+ if (!PluginState.isConnected) {
238
+ return "Status: **Disconnected**\n\nUse `/mattermost connect` to enable remote control.";
239
+ }
240
+
241
+ const config = loadConfig();
242
+ const mmSessions = PluginState.sessionManager?.listSessions() || [];
243
+ const wsStatus = PluginState.wsClient?.isConnected() ? "Connected" : "Reconnecting...";
244
+ const availableOpenCodeSessions = PluginState.openCodeSessionRegistry?.countAvailable() || 0;
245
+ const defaultSession = PluginState.openCodeSessionRegistry?.getDefault();
246
+
247
+ const ownerInfo = config.mattermost.ownerUserId
248
+ ? `Owner Filter: ${config.mattermost.ownerUserId}`
249
+ : "Owner Filter: disabled (responds to all users)";
250
+
251
+ return `Status: **Connected**
252
+ Bot: @${PluginState.botUser?.username}
253
+ Project: ${projectName}
254
+ OpenCode Sessions: ${availableOpenCodeSessions} available
255
+ Default Session: ${defaultSession ? `${defaultSession.projectName} (${defaultSession.shortId})` : 'none'}
256
+ Active MM Sessions: ${mmSessions.length}
257
+ WebSocket: ${wsStatus}
258
+ ${ownerInfo}
259
+
260
+ Use \`!sessions\` in DM to see and select OpenCode sessions.`;
261
+ }
262
+
263
+ function setupSessionCallbacks(
264
+ registry: OpenCodeSessionRegistry,
265
+ threadMappingStore: any,
266
+ threadManager: ThreadManager | null,
267
+ sessionManager: SessionManager,
268
+ config: PluginConfig
269
+ ) {
270
+ registry.onNewSession(async (sessionInfo: OpenCodeSessionInfo) => {
271
+ if (!threadManager || !sessionManager) return;
272
+
273
+ const existingMapping = threadMappingStore?.getBySessionId(sessionInfo.id);
274
+ if (existingMapping) return;
275
+
276
+ let mmSessions = sessionManager.listSessions();
277
+ if (mmSessions.length === 0) return;
278
+
279
+ if (config.mattermost.ownerUserId) {
280
+ mmSessions = mmSessions.filter(s => s.mattermostUserId === config.mattermost.ownerUserId);
281
+ if (mmSessions.length === 0) return;
282
+ }
283
+
284
+ for (const mmSession of mmSessions) {
285
+ try {
286
+ await threadManager.createThread(
287
+ sessionInfo,
288
+ mmSession.mattermostUserId,
289
+ mmSession.dmChannelId,
290
+ undefined,
291
+ undefined,
292
+ mmSession.mattermostUsername
293
+ );
294
+ log.info(`[AutoThread] Created thread for session ${sessionInfo.shortId}`);
295
+ } catch (e) {
296
+ log.error(`[AutoThread] Failed to create thread:`, e);
297
+ }
298
+ }
299
+ });
300
+
301
+ registry.onSessionDeleted(async (sessionId) => {
302
+ if (!threadManager) return;
303
+ try {
304
+ await threadManager.endThread(sessionId);
305
+ log.info(`[AutoThread] Ended thread for session ${sessionId.substring(0, 8)}`);
306
+ } catch (e) {
307
+ log.error(`[AutoThread] Failed to end thread:`, e);
308
+ }
309
+ });
310
+ }
311
+
312
+ function cleanOrphanedMappings(threadMappingStore: any, availableSessions: OpenCodeSessionInfo[]) {
313
+ if (!threadMappingStore) return;
314
+ const validSessionIds = new Set(availableSessions.map(s => s.id));
315
+ const cleanedCount = threadMappingStore.cleanOrphaned(validSessionIds);
316
+ if (cleanedCount > 0) {
317
+ log.info(`[AutoThread] Marked ${cleanedCount} orphaned mappings`);
318
+ }
319
+ }
320
+
321
+ async function createThreadsForExistingSessions(
322
+ threadManager: ThreadManager | null,
323
+ threadMappingStore: any,
324
+ sessionManager: SessionManager,
325
+ availableSessions: OpenCodeSessionInfo[],
326
+ config: PluginConfig
327
+ ) {
328
+ if (!threadManager || !sessionManager || availableSessions.length === 0) return;
329
+
330
+ let mmSessions = sessionManager.listSessions();
331
+ if (config.mattermost.ownerUserId) {
332
+ mmSessions = mmSessions.filter(s => s.mattermostUserId === config.mattermost.ownerUserId);
333
+ }
334
+
335
+ if (mmSessions.length === 0) return;
336
+
337
+ for (const sessionInfo of availableSessions) {
338
+ const existingMapping = threadMappingStore?.getBySessionId(sessionInfo.id);
339
+ if (existingMapping) continue;
340
+
341
+ for (const mmSession of mmSessions) {
342
+ try {
343
+ await threadManager.createThread(
344
+ sessionInfo,
345
+ mmSession.mattermostUserId,
346
+ mmSession.dmChannelId,
347
+ undefined,
348
+ undefined,
349
+ mmSession.mattermostUsername
350
+ );
351
+ log.info(`[AutoThread] Created thread for existing session ${sessionInfo.shortId}`);
352
+ } catch (e) {
353
+ log.error(`[AutoThread] Failed to create thread:`, e);
354
+ }
355
+ }
356
+ }
357
+ }
358
+
359
+ function setupWebSocketListeners(
360
+ wsClient: MattermostWebSocketClient,
361
+ botUserId: string,
362
+ config: PluginConfig,
363
+ handleUserMessage: (post: any) => Promise<void>,
364
+ reactionHandler: ReactionHandler
365
+ ) {
366
+ wsClient.on("hello", () => {
367
+ log.info("Received hello event - connection authenticated");
368
+ });
369
+
370
+ const processedPostIds = new Set<string>();
371
+ const POST_DEDUP_TTL_MS = 5000;
372
+
373
+ wsClient.on("posted", async (event: WebSocketEvent) => {
374
+ if (!PluginState.isConnected) return;
375
+
376
+ const mmClient = PluginState.mmClient;
377
+ if (!mmClient) return;
378
+
379
+ try {
380
+ const postData = typeof event.data.post === "string" ? JSON.parse(event.data.post) : event.data.post;
381
+ if (postData.user_id === botUserId) return;
382
+
383
+ if (processedPostIds.has(postData.id)) {
384
+ log.debug(`[Dedup] Ignoring duplicate event for post ${postData.id}`);
385
+ return;
386
+ }
387
+ processedPostIds.add(postData.id);
388
+ setTimeout(() => processedPostIds.delete(postData.id), POST_DEDUP_TTL_MS);
389
+
390
+ const channel = await mmClient.getChannel(postData.channel_id);
391
+
392
+ // Check if channel type is allowed
393
+ const allowedTypes = config.sessions.allowedChannelTypes;
394
+ if (!allowedTypes.includes(channel.type as "D" | "G" | "O" | "P")) {
395
+ log.debug(`Ignoring message from disallowed channel type '${channel.type}' (allowed: ${allowedTypes.join(",")})`);
396
+ return;
397
+ }
398
+
399
+ if (channel.type === "D") {
400
+ if (config.mattermost.ownerUserId && postData.user_id !== config.mattermost.ownerUserId) {
401
+ log.debug(`Ignoring 1:1 DM from non-owner user ${postData.user_id}`);
402
+ return;
403
+ }
404
+ } else if (channel.type === "G" || channel.type === "O" || channel.type === "P") {
405
+ const botUser = PluginState.botUser;
406
+ if (!botUser) {
407
+ log.error(`[Channel] Bot user not available`);
408
+ return;
409
+ }
410
+
411
+ const threadRootId = postData.root_id || postData.id;
412
+ const isOwner = !config.mattermost.ownerUserId || postData.user_id === config.mattermost.ownerUserId;
413
+ const isTeamMember = PluginState.teamStore?.isMember(postData.user_id) || false;
414
+ const hasTeamAccess = isOwner || isTeamMember;
415
+
416
+ const sessionOwnershipHandler = PluginState.sessionOwnershipHandler;
417
+ if (hasTeamAccess && sessionOwnershipHandler?.hasPendingConfirmation(channel.id, threadRootId, postData.user_id)) {
418
+ log.info(`[Channel] Processing ownership confirmation reply from @${postData.user_id}`);
419
+ const pending = sessionOwnershipHandler.getPendingConfirmation(channel.id, threadRootId);
420
+ const confirmResult = await sessionOwnershipHandler.handleReply(
421
+ channel.id,
422
+ threadRootId,
423
+ postData.message.trim()
424
+ );
425
+
426
+ if (confirmResult.confirmed && pending?.originalPost) {
427
+ log.info(`[SessionOwnership] User confirmed with approval policy: ${confirmResult.approvalPolicy || 'none'}`);
428
+ const originalPost = pending.originalPost;
429
+ (originalPost as any)._ownershipConfirmed = true;
430
+ (originalPost as any)._approvalPolicy = confirmResult.approvalPolicy || "none";
431
+ await handleUserMessage(originalPost);
432
+ }
433
+ return;
434
+ }
435
+
436
+ // Check if owner is replying to a pending guest approval (no @mention needed)
437
+ const threadMappingStore = PluginState.threadMappingStore;
438
+ const guestApprovalHandler = PluginState.guestApprovalHandler;
439
+ const mappingForApproval = threadMappingStore?.getByThreadRootPostId(threadRootId);
440
+
441
+ if (hasTeamAccess && mappingForApproval && guestApprovalHandler?.hasPendingApproval(mappingForApproval.sessionId)) {
442
+ const trimmedReply = postData.message.trim().toLowerCase();
443
+ if (/^[0-3]$/.test(trimmedReply) || trimmedReply === "deny" || trimmedReply === "no") {
444
+ log.info(`[Channel] Processing guest approval reply from owner: "${trimmedReply}"`);
445
+ const result = await guestApprovalHandler.handleOwnerReply(
446
+ mappingForApproval.sessionId,
447
+ postData.message.trim(),
448
+ threadMappingStore!,
449
+ mappingForApproval.channelId || mappingForApproval.dmChannelId
450
+ );
451
+
452
+ if (result.approved && result.post) {
453
+ log.info(`[GuestApproval] Approved, processing original guest message`);
454
+ await handleUserMessage(result.post);
455
+ }
456
+ return;
457
+ }
458
+ }
459
+
460
+ const mentioned = isBotMentioned(postData.message, botUser.username, botUser.id);
461
+ if (!mentioned) {
462
+ log.debug(`[Channel] Skipping message - bot not @mentioned (channel: ${channel.id})`);
463
+ return;
464
+ }
465
+
466
+ const mapping = threadMappingStore?.getByThreadRootPostId(threadRootId);
467
+
468
+ if (!mapping) {
469
+ // No existing session - only owner can create new sessions
470
+ if (isOwner) {
471
+ const sessionOwnershipHandler = PluginState.sessionOwnershipHandler;
472
+ const mmClient = PluginState.mmClient;
473
+ if (!mmClient || !sessionOwnershipHandler) return;
474
+
475
+ let userUsername = "unknown";
476
+ try {
477
+ const user = await mmClient.getUserById(postData.user_id);
478
+ userUsername = user.username;
479
+ } catch (e) {
480
+ log.warn(`[Channel] Could not fetch user username: ${e}`);
481
+ }
482
+
483
+ log.info(`[Channel] Owner @mentioned bot in unmapped thread, requesting ownership confirmation`);
484
+ await sessionOwnershipHandler.requestOwnershipConfirmation(
485
+ postData,
486
+ userUsername,
487
+ threadRootId,
488
+ channel.id
489
+ );
490
+ return;
491
+ } else {
492
+ // Team members and guests cannot create sessions - silently ignore
493
+ // (their own OpenCode instance may handle it)
494
+ log.debug(`[Channel] Non-owner @mention in unmapped thread - ignoring to allow other bots to handle (channel: ${channel.id})`);
495
+ return;
496
+ }
497
+ }
498
+
499
+ // Existing session - check access permissions
500
+ if (hasTeamAccess) {
501
+ // Owner and team members can use existing sessions without approval
502
+ log.info(`[Channel] Bot @mentioned by ${isOwner ? 'owner' : 'team member'}, processing message (channel: ${channel.id})`);
503
+ } else {
504
+ if (!mapping) {
505
+ log.debug(`[Channel] Non-owner @mention but no thread mapping found - ignoring (channel: ${channel.id})`);
506
+ return;
507
+ }
508
+
509
+ if (guestApprovalHandler?.isUserApproved(postData.user_id, mapping, PluginState.teamStore)) {
510
+ log.info(`[Channel] Bot @mentioned by approved guest ${postData.user_id}, processing (channel: ${channel.id})`);
511
+ if (mapping.approveNextMessage && threadMappingStore) {
512
+ guestApprovalHandler.consumeNextMessageApproval(mapping, threadMappingStore);
513
+ }
514
+ } else {
515
+ log.info(`[Channel] Bot @mentioned by non-approved guest ${postData.user_id}, requesting approval (channel: ${channel.id})`);
516
+
517
+ const mmClient = PluginState.mmClient;
518
+ if (!mmClient || !guestApprovalHandler) return;
519
+
520
+ let guestUsername = "unknown";
521
+ try {
522
+ const guestUser = await mmClient.getUserById(postData.user_id);
523
+ guestUsername = guestUser.username;
524
+ } catch (e) {
525
+ log.warn(`[Channel] Could not fetch guest username: ${e}`);
526
+ }
527
+
528
+ await guestApprovalHandler.requestApproval(
529
+ postData,
530
+ guestUsername,
531
+ threadRootId,
532
+ mapping.sessionId,
533
+ mapping.channelId || mapping.dmChannelId
534
+ );
535
+ return;
536
+ }
537
+ }
538
+ } else {
539
+ return;
540
+ }
541
+
542
+ log.info("Processing DM message...");
543
+ await handleUserMessage(postData);
544
+ } catch (error) {
545
+ const errorMessage = error instanceof Error ? error.message : String(error);
546
+ const errorStack = error instanceof Error ? error.stack : undefined;
547
+ log.error(`Error handling posted event: ${errorMessage}`);
548
+ if (errorStack) {
549
+ log.error(`Stack trace: ${errorStack}`);
550
+ }
551
+ }
552
+ });
553
+
554
+ wsClient.on("reaction_added", async (event: WebSocketEvent) => {
555
+ if (!PluginState.isConnected) return;
556
+
557
+ if (config.mattermost.ownerUserId && event.data?.user_id !== config.mattermost.ownerUserId) {
558
+ return;
559
+ }
560
+
561
+ await reactionHandler.handleReaction(event);
562
+ });
563
+ }
@@ -0,0 +1,41 @@
1
+ import { tool, type ToolDefinition } from "@opencode-ai/plugin";
2
+ import { PluginState } from "../state.js";
3
+
4
+ export function createSendFileTool(): ToolDefinition {
5
+ return tool({
6
+ description: "Upload a file to the current Mattermost conversation thread. Use this when the user asks you to send them a file you've created or modified.",
7
+ args: {
8
+ filePath: tool.schema.string().describe("Absolute path to the file to send"),
9
+ message: tool.schema.string().optional().describe("Optional message to accompany the file"),
10
+ },
11
+ async execute(args, ctx) {
12
+ const { isConnected, fileHandler, threadMappingStore, mmClient } = PluginState;
13
+
14
+ if (!isConnected || !fileHandler || !threadMappingStore || !mmClient) {
15
+ return "Not connected to Mattermost. Use mattermost_connect first.";
16
+ }
17
+
18
+ const mapping = threadMappingStore.getBySessionId(ctx.sessionID);
19
+ if (!mapping) {
20
+ return `No Mattermost thread associated with session ${ctx.sessionID.substring(0, 8)}. This tool can only be used when responding to a Mattermost conversation.`;
21
+ }
22
+
23
+ if (mapping.status === "ended" || mapping.status === "disconnected") {
24
+ return `The Mattermost thread for session ${ctx.sessionID.substring(0, 8)} is no longer active (status: ${mapping.status}).`;
25
+ }
26
+
27
+ const result = await fileHandler.sendFileToThread(
28
+ mapping.channelId || mapping.dmChannelId,
29
+ mapping.threadRootPostId,
30
+ args.filePath,
31
+ args.message
32
+ );
33
+
34
+ if (result.success) {
35
+ return `File sent to Mattermost: ${result.fileName}`;
36
+ } else {
37
+ return `Failed to send file: ${result.error}`;
38
+ }
39
+ },
40
+ });
41
+ }
@@ -0,0 +1,12 @@
1
+ export { createConnectTool, createDisconnectTool, createStatusTool } from "./connect.js";
2
+ export { createListSessionsTool, createSelectSessionTool, createCurrentSessionTool } from "./session.js";
3
+ export { createMonitorTool, createUnmonitorTool } from "./monitor.js";
4
+ export { createSendFileTool } from "./file.js";
5
+ export {
6
+ createScheduleAddTool,
7
+ createScheduleListTool,
8
+ createScheduleRemoveTool,
9
+ createScheduleEnableTool,
10
+ createScheduleDisableTool,
11
+ createScheduleRunTool,
12
+ } from "./schedule.js";