@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,349 @@
1
+ import * as cron from "node-cron";
2
+ import { log } from "../logger.js";
3
+ import { sendEphemeralAlert } from "../monitor-service.js";
4
+ import { ScheduleStore, type ScheduleConfig, generateScheduleId } from "./schedule-store.js";
5
+
6
+ export interface PromptExecutor {
7
+ (sessionId: string, prompt: string): Promise<string>;
8
+ }
9
+
10
+ export interface SessionChecker {
11
+ (sessionId: string): Promise<boolean>;
12
+ }
13
+
14
+ export interface SchedulerServiceConfig {
15
+ promptExecutor?: PromptExecutor;
16
+ sessionChecker?: SessionChecker;
17
+ }
18
+
19
+ export class SchedulerService {
20
+ private store: ScheduleStore;
21
+ private jobs: Map<string, cron.ScheduledTask> = new Map();
22
+ private promptExecutor: PromptExecutor | null = null;
23
+ private sessionChecker: SessionChecker | null = null;
24
+ private started: boolean = false;
25
+
26
+ /**
27
+ * Track sessions currently running scheduled tasks.
28
+ * Event handlers should skip streaming updates for these sessions
29
+ * to prevent responses from being routed to wrong threads.
30
+ */
31
+ private runningScheduledSessions: Set<string> = new Set();
32
+
33
+ constructor(config: SchedulerServiceConfig = {}) {
34
+ this.store = new ScheduleStore();
35
+ this.promptExecutor = config.promptExecutor || null;
36
+ this.sessionChecker = config.sessionChecker || null;
37
+ }
38
+
39
+ setPromptExecutor(executor: PromptExecutor): void {
40
+ this.promptExecutor = executor;
41
+ }
42
+
43
+ setSessionChecker(checker: SessionChecker): void {
44
+ this.sessionChecker = checker;
45
+ }
46
+
47
+ isRunningScheduledTask(sessionId: string): boolean {
48
+ return this.runningScheduledSessions.has(sessionId);
49
+ }
50
+
51
+ async start(): Promise<void> {
52
+ if (this.started) {
53
+ log.warn("[SchedulerService] Already started");
54
+ return;
55
+ }
56
+
57
+ await this.store.load();
58
+ const schedules = this.store.listEnabled();
59
+
60
+ for (const schedule of schedules) {
61
+ this.startJob(schedule);
62
+ }
63
+
64
+ this.started = true;
65
+ log.info(`[SchedulerService] Started with ${schedules.length} active schedules`);
66
+ }
67
+
68
+ async stop(): Promise<void> {
69
+ if (!this.started) {
70
+ return;
71
+ }
72
+
73
+ for (const [id, job] of this.jobs) {
74
+ job.stop();
75
+ log.debug(`[SchedulerService] Stopped job: ${id}`);
76
+ }
77
+ this.jobs.clear();
78
+
79
+ this.store.shutdown();
80
+ this.started = false;
81
+ log.info("[SchedulerService] Stopped");
82
+ }
83
+
84
+ private startJob(schedule: ScheduleConfig): boolean {
85
+ if (this.jobs.has(schedule.id)) {
86
+ log.warn(`[SchedulerService] Job already running: ${schedule.id}`);
87
+ return false;
88
+ }
89
+
90
+ if (!cron.validate(schedule.cron)) {
91
+ log.error(`[SchedulerService] Invalid cron expression for ${schedule.name}: ${schedule.cron}`);
92
+ return false;
93
+ }
94
+
95
+ const job = cron.schedule(
96
+ schedule.cron,
97
+ async () => {
98
+ await this.runTask(schedule);
99
+ },
100
+ {
101
+ timezone: schedule.timezone,
102
+ }
103
+ );
104
+
105
+ this.jobs.set(schedule.id, job);
106
+ log.info(`[SchedulerService] Started job: ${schedule.name} (${schedule.cron} ${schedule.timezone})`);
107
+ return true;
108
+ }
109
+
110
+ private stopJob(scheduleId: string): boolean {
111
+ const job = this.jobs.get(scheduleId);
112
+ if (job) {
113
+ job.stop();
114
+ this.jobs.delete(scheduleId);
115
+ log.debug(`[SchedulerService] Stopped job: ${scheduleId}`);
116
+ return true;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ private async runTask(schedule: ScheduleConfig): Promise<void> {
122
+ log.info(`[SchedulerService] Running scheduled task: ${schedule.name}`);
123
+
124
+ if (!this.promptExecutor) {
125
+ log.error(`[SchedulerService] No prompt executor configured`);
126
+ await this.sendErrorDm(
127
+ schedule,
128
+ `Schedule "${schedule.name}" could not run: Plugin not fully initialized.`
129
+ );
130
+ this.store.updateLastRun(schedule.id, false, "No prompt executor configured");
131
+ return;
132
+ }
133
+
134
+ if (this.sessionChecker) {
135
+ const sessionExists = await this.sessionChecker(schedule.sessionId);
136
+ if (!sessionExists) {
137
+ log.warn(`[SchedulerService] Session ${schedule.sessionId} not available for schedule ${schedule.name}`);
138
+ await this.sendErrorDm(
139
+ schedule,
140
+ `Schedule "${schedule.name}" could not run: Session \`${schedule.sessionId.slice(0, 8)}\` is no longer available.\n\nYou may need to recreate this schedule in an active session.`
141
+ );
142
+ this.store.updateLastRun(schedule.id, false, "Session not available");
143
+ return;
144
+ }
145
+ }
146
+
147
+ this.runningScheduledSessions.add(schedule.sessionId);
148
+ log.debug(`[SchedulerService] Marked session ${schedule.sessionId.slice(0, 8)} as running scheduled task`);
149
+
150
+ try {
151
+ const prefixedPrompt = `[Scheduled Task: ${schedule.name}]\n${schedule.prompt}\n\n[Important: Format your response for a Mattermost DM. Keep it concise and actionable.]`;
152
+
153
+ log.debug(`[SchedulerService] Injecting prompt into session ${schedule.sessionId.slice(0, 8)}`);
154
+ const response = await this.promptExecutor(schedule.sessionId, prefixedPrompt);
155
+
156
+ if (response) {
157
+ const message = `:bell: **Scheduled: ${schedule.name}**\n\n${response}`;
158
+ await sendEphemeralAlert(schedule.targetUserId, message);
159
+ log.info(`[SchedulerService] Sent scheduled response to @${schedule.targetUsername}`);
160
+ this.store.updateLastRun(schedule.id, true);
161
+ } else {
162
+ log.warn(`[SchedulerService] Empty response from LLM for schedule ${schedule.name}`);
163
+ this.store.updateLastRun(schedule.id, false, "Empty response from LLM");
164
+ }
165
+ } catch (error) {
166
+ const errorMsg = error instanceof Error ? error.message : String(error);
167
+ log.error(`[SchedulerService] Task failed for ${schedule.name}:`, error);
168
+
169
+ await this.sendErrorDm(
170
+ schedule,
171
+ `Schedule "${schedule.name}" failed: ${errorMsg}`
172
+ );
173
+ this.store.updateLastRun(schedule.id, false, errorMsg);
174
+ } finally {
175
+ this.runningScheduledSessions.delete(schedule.sessionId);
176
+ log.debug(`[SchedulerService] Unmarked session ${schedule.sessionId.slice(0, 8)} from running scheduled task`);
177
+ }
178
+ }
179
+
180
+ private async sendErrorDm(schedule: ScheduleConfig, message: string): Promise<void> {
181
+ const errorMessage = `:warning: **Scheduled Task Error**\n\n${message}`;
182
+ await sendEphemeralAlert(schedule.targetUserId, errorMessage);
183
+ }
184
+
185
+ async addSchedule(params: {
186
+ name: string;
187
+ cron: string;
188
+ timezone?: string;
189
+ prompt: string;
190
+ sessionId: string;
191
+ targetUserId: string;
192
+ targetUsername: string;
193
+ enabled?: boolean;
194
+ }): Promise<ScheduleConfig> {
195
+ if (!cron.validate(params.cron)) {
196
+ throw new Error(`Invalid cron expression: ${params.cron}`);
197
+ }
198
+
199
+ const existing = this.store.getByName(params.name);
200
+ if (existing) {
201
+ throw new Error(`Schedule with name "${params.name}" already exists`);
202
+ }
203
+
204
+ const schedule: ScheduleConfig = {
205
+ id: generateScheduleId(),
206
+ name: params.name,
207
+ cron: params.cron,
208
+ timezone: params.timezone || "UTC",
209
+ prompt: params.prompt,
210
+ sessionId: params.sessionId,
211
+ targetUserId: params.targetUserId,
212
+ targetUsername: params.targetUsername,
213
+ enabled: params.enabled ?? true,
214
+ createdAt: new Date().toISOString(),
215
+ };
216
+
217
+ this.store.add(schedule);
218
+
219
+ if (schedule.enabled && this.started) {
220
+ this.startJob(schedule);
221
+ }
222
+
223
+ log.info(`[SchedulerService] Added schedule: ${schedule.name} (${schedule.cron})`);
224
+ return schedule;
225
+ }
226
+
227
+ removeSchedule(scheduleId: string): boolean {
228
+ this.stopJob(scheduleId);
229
+ const removed = this.store.remove(scheduleId);
230
+ if (removed) {
231
+ log.info(`[SchedulerService] Removed schedule: ${scheduleId}`);
232
+ }
233
+ return removed;
234
+ }
235
+
236
+ removeScheduleByName(name: string): boolean {
237
+ const schedule = this.store.getByName(name);
238
+ if (schedule) {
239
+ return this.removeSchedule(schedule.id);
240
+ }
241
+ return false;
242
+ }
243
+
244
+ enableSchedule(scheduleId: string): boolean {
245
+ const schedule = this.store.getById(scheduleId);
246
+ if (!schedule) {
247
+ return false;
248
+ }
249
+
250
+ if (schedule.enabled) {
251
+ return true;
252
+ }
253
+
254
+ this.store.setEnabled(scheduleId, true);
255
+ if (this.started) {
256
+ this.startJob(schedule);
257
+ }
258
+ log.info(`[SchedulerService] Enabled schedule: ${schedule.name}`);
259
+ return true;
260
+ }
261
+
262
+ disableSchedule(scheduleId: string): boolean {
263
+ const schedule = this.store.getById(scheduleId);
264
+ if (!schedule) {
265
+ return false;
266
+ }
267
+
268
+ if (!schedule.enabled) {
269
+ return true;
270
+ }
271
+
272
+ this.stopJob(scheduleId);
273
+ this.store.setEnabled(scheduleId, false);
274
+ log.info(`[SchedulerService] Disabled schedule: ${schedule.name}`);
275
+ return true;
276
+ }
277
+
278
+ listSchedules(): ScheduleConfig[] {
279
+ return this.store.listAll();
280
+ }
281
+
282
+ getSchedule(scheduleId: string): ScheduleConfig | null {
283
+ return this.store.getById(scheduleId);
284
+ }
285
+
286
+ getScheduleByName(name: string): ScheduleConfig | null {
287
+ return this.store.getByName(name);
288
+ }
289
+
290
+ getSchedulesForUser(userId: string): ScheduleConfig[] {
291
+ return this.store.getByUserId(userId);
292
+ }
293
+
294
+ getSchedulesForSession(sessionId: string): ScheduleConfig[] {
295
+ return this.store.getBySessionId(sessionId);
296
+ }
297
+
298
+ isRunning(scheduleId: string): boolean {
299
+ return this.jobs.has(scheduleId);
300
+ }
301
+
302
+ async runNow(scheduleId: string): Promise<boolean> {
303
+ const schedule = this.store.getById(scheduleId);
304
+ if (!schedule) {
305
+ return false;
306
+ }
307
+
308
+ await this.runTask(schedule);
309
+ return true;
310
+ }
311
+
312
+ async runNowByName(name: string): Promise<boolean> {
313
+ const schedule = this.store.getByName(name);
314
+ if (!schedule) {
315
+ return false;
316
+ }
317
+
318
+ await this.runTask(schedule);
319
+ return true;
320
+ }
321
+
322
+ getStats(): {
323
+ total: number;
324
+ enabled: number;
325
+ running: number;
326
+ } {
327
+ return {
328
+ total: this.store.count(),
329
+ enabled: this.store.listEnabled().length,
330
+ running: this.jobs.size,
331
+ };
332
+ }
333
+ }
334
+
335
+ let schedulerInstance: SchedulerService | null = null;
336
+
337
+ export function getSchedulerService(): SchedulerService {
338
+ if (!schedulerInstance) {
339
+ schedulerInstance = new SchedulerService();
340
+ }
341
+ return schedulerInstance;
342
+ }
343
+
344
+ export function resetSchedulerService(): void {
345
+ if (schedulerInstance) {
346
+ schedulerInstance.stop();
347
+ schedulerInstance = null;
348
+ }
349
+ }
@@ -0,0 +1,142 @@
1
+ import type { MattermostClient } from "./clients/mattermost-client.js";
2
+ import type { SessionsConfig } from "./config.js";
3
+ import type { Post } from "./models/index.js";
4
+ import { log } from "./logger.js";
5
+
6
+ export interface PermissionRequest {
7
+ id: string;
8
+ tool: string;
9
+ args: Record<string, any>;
10
+ risk: "low" | "medium" | "high";
11
+ description: string;
12
+ }
13
+
14
+ export interface UserSession {
15
+ id: string;
16
+ mattermostUserId: string;
17
+ mattermostUsername: string;
18
+ dmChannelId: string;
19
+ createdAt: Date;
20
+ lastActivityAt: Date;
21
+ isProcessing: boolean;
22
+ currentPromptPostId: string | null;
23
+ currentResponsePostId: string | null;
24
+ pendingPermission: PermissionRequest | null;
25
+ lastPrompt: Post | null;
26
+ targetOpenCodeSessionId: string | null;
27
+ }
28
+
29
+ export class SessionManager {
30
+ private sessions: Map<string, UserSession> = new Map();
31
+ private mmClient: MattermostClient;
32
+ private config: SessionsConfig;
33
+ private cleanupInterval: NodeJS.Timeout | null = null;
34
+ private botUserId: string | null = null;
35
+
36
+ constructor(mmClient: MattermostClient, config: SessionsConfig) {
37
+ this.mmClient = mmClient;
38
+ this.config = config;
39
+ this.startCleanupTimer();
40
+ }
41
+
42
+ private startCleanupTimer(): void {
43
+ this.cleanupInterval = setInterval(() => {
44
+ this.cleanupExpiredSessions();
45
+ }, 60000);
46
+ }
47
+
48
+ private cleanupExpiredSessions(): void {
49
+ const now = Date.now();
50
+ for (const [userId, session] of this.sessions.entries()) {
51
+ if (now - session.lastActivityAt.getTime() > this.config.timeout) {
52
+ log.debug(`[SessionManager] Cleaning up expired session for user ${session.mattermostUsername}`);
53
+ this.sessions.delete(userId);
54
+ }
55
+ }
56
+ }
57
+
58
+ async setBotUserId(botUserId: string): Promise<void> {
59
+ this.botUserId = botUserId;
60
+ }
61
+
62
+ async getOrCreateSession(mattermostUserId: string): Promise<UserSession> {
63
+ let session = this.sessions.get(mattermostUserId);
64
+
65
+ if (session) {
66
+ session.lastActivityAt = new Date();
67
+ return session;
68
+ }
69
+
70
+ if (this.config.allowedUsers.length > 0 && !this.config.allowedUsers.includes(mattermostUserId)) {
71
+ throw new Error("User not authorized to use this plugin");
72
+ }
73
+
74
+ if (this.sessions.size >= this.config.maxSessions) {
75
+ throw new Error("Maximum number of sessions reached");
76
+ }
77
+
78
+ const user = await this.mmClient.getUserById(mattermostUserId);
79
+ const dmChannel = await this.mmClient.createDirectChannel(mattermostUserId);
80
+
81
+ session = {
82
+ id: mattermostUserId,
83
+ mattermostUserId,
84
+ mattermostUsername: user.username,
85
+ dmChannelId: dmChannel.id,
86
+ createdAt: new Date(),
87
+ lastActivityAt: new Date(),
88
+ isProcessing: false,
89
+ currentPromptPostId: null,
90
+ currentResponsePostId: null,
91
+ pendingPermission: null,
92
+ lastPrompt: null,
93
+ targetOpenCodeSessionId: null,
94
+ };
95
+
96
+ this.sessions.set(mattermostUserId, session);
97
+ log.info(`[SessionManager] Created session for user ${user.username}`);
98
+
99
+ return session;
100
+ }
101
+
102
+ getSession(mattermostUserId: string): UserSession | null {
103
+ const session = this.sessions.get(mattermostUserId);
104
+ if (session) {
105
+ session.lastActivityAt = new Date();
106
+ }
107
+ return session || null;
108
+ }
109
+
110
+ destroySession(mattermostUserId: string): void {
111
+ const session = this.sessions.get(mattermostUserId);
112
+ if (session) {
113
+ log.debug(`[SessionManager] Destroying session for user ${session.mattermostUsername}`);
114
+ this.sessions.delete(mattermostUserId);
115
+ }
116
+ }
117
+
118
+ listSessions(): UserSession[] {
119
+ return Array.from(this.sessions.values());
120
+ }
121
+
122
+ getSessionById(sessionId: string): UserSession | null {
123
+ return this.sessions.get(sessionId) || null;
124
+ }
125
+
126
+ getSessionByDmChannel(channelId: string): UserSession | null {
127
+ for (const session of this.sessions.values()) {
128
+ if (session.dmChannelId === channelId) {
129
+ return session;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+
135
+ shutdown(): void {
136
+ if (this.cleanupInterval) {
137
+ clearInterval(this.cleanupInterval);
138
+ this.cleanupInterval = null;
139
+ }
140
+ this.sessions.clear();
141
+ }
142
+ }