@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.
- package/.opencode/command/mattermost-connect.md +5 -0
- package/.opencode/command/mattermost-disconnect.md +5 -0
- package/.opencode/command/mattermost-monitor.md +12 -0
- package/.opencode/command/mattermost-status.md +5 -0
- package/.opencode/command/speckit.analyze.md +184 -0
- package/.opencode/command/speckit.checklist.md +294 -0
- package/.opencode/command/speckit.clarify.md +181 -0
- package/.opencode/command/speckit.constitution.md +82 -0
- package/.opencode/command/speckit.implement.md +135 -0
- package/.opencode/command/speckit.plan.md +89 -0
- package/.opencode/command/speckit.specify.md +258 -0
- package/.opencode/command/speckit.tasks.md +137 -0
- package/.opencode/command/speckit.taskstoissues.md +30 -0
- package/.opencode/plugin/mattermost-control/event-handlers/compaction.ts +61 -0
- package/.opencode/plugin/mattermost-control/event-handlers/file.ts +36 -0
- package/.opencode/plugin/mattermost-control/event-handlers/index.ts +14 -0
- package/.opencode/plugin/mattermost-control/event-handlers/message.ts +124 -0
- package/.opencode/plugin/mattermost-control/event-handlers/permission.ts +34 -0
- package/.opencode/plugin/mattermost-control/event-handlers/question.ts +92 -0
- package/.opencode/plugin/mattermost-control/event-handlers/session.ts +100 -0
- package/.opencode/plugin/mattermost-control/event-handlers/todo.ts +33 -0
- package/.opencode/plugin/mattermost-control/event-handlers/tool.ts +76 -0
- package/.opencode/plugin/mattermost-control/formatters.ts +202 -0
- package/.opencode/plugin/mattermost-control/index.ts +964 -0
- package/.opencode/plugin/mattermost-control/package.json +12 -0
- package/.opencode/plugin/mattermost-control/state.ts +180 -0
- package/.opencode/plugin/mattermost-control/timers.ts +96 -0
- package/.opencode/plugin/mattermost-control/tools/connect.ts +563 -0
- package/.opencode/plugin/mattermost-control/tools/file.ts +41 -0
- package/.opencode/plugin/mattermost-control/tools/index.ts +12 -0
- package/.opencode/plugin/mattermost-control/tools/monitor.ts +183 -0
- package/.opencode/plugin/mattermost-control/tools/schedule.ts +253 -0
- package/.opencode/plugin/mattermost-control/tools/session.ts +120 -0
- package/.opencode/plugin/mattermost-control/types.ts +107 -0
- package/LICENSE +21 -0
- package/README.md +1280 -0
- package/opencode-shared +359 -0
- package/opencode-shared-restart +495 -0
- package/opencode-shared-stop +90 -0
- package/package.json +65 -0
- package/src/clients/mattermost-client.ts +221 -0
- package/src/clients/websocket-client.ts +199 -0
- package/src/command-handler.ts +1035 -0
- package/src/config.ts +170 -0
- package/src/context-builder.ts +309 -0
- package/src/file-completion-handler.ts +521 -0
- package/src/file-handler.ts +242 -0
- package/src/guest-approval-handler.ts +223 -0
- package/src/logger.ts +73 -0
- package/src/merge-handler.ts +335 -0
- package/src/message-router.ts +151 -0
- package/src/models/index.ts +197 -0
- package/src/models/routing.ts +50 -0
- package/src/models/thread-mapping.ts +40 -0
- package/src/monitor-service.ts +222 -0
- package/src/notification-service.ts +118 -0
- package/src/opencode-session-registry.ts +370 -0
- package/src/persistence/team-store.ts +396 -0
- package/src/persistence/thread-mapping-store.ts +258 -0
- package/src/question-handler.ts +401 -0
- package/src/reaction-handler.ts +111 -0
- package/src/response-streamer.ts +364 -0
- package/src/scheduler/schedule-store.ts +261 -0
- package/src/scheduler/scheduler-service.ts +349 -0
- package/src/session-manager.ts +142 -0
- package/src/session-ownership-handler.ts +253 -0
- package/src/status-indicator.ts +279 -0
- package/src/thread-manager.ts +231 -0
- package/src/todo-manager.ts +162 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import type { MattermostClient } from "./clients/mattermost-client.js";
|
|
2
|
+
import type { StreamingConfig } from "./config.js";
|
|
3
|
+
import type { UserSession } from "./session-manager.js";
|
|
4
|
+
import { StatusIndicator, createStatusIndicator } from "./status-indicator.js";
|
|
5
|
+
import { log } from "./logger.js";
|
|
6
|
+
|
|
7
|
+
export interface StreamContext {
|
|
8
|
+
postId: string;
|
|
9
|
+
channelId: string;
|
|
10
|
+
threadRootPostId?: string;
|
|
11
|
+
buffer: string;
|
|
12
|
+
lastUpdateTime: number;
|
|
13
|
+
totalChunks: number;
|
|
14
|
+
isCancelled: boolean;
|
|
15
|
+
continuationPostIds: string[];
|
|
16
|
+
currentPostContent: string;
|
|
17
|
+
statusIndicator?: StatusIndicator;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ResponseStreamer {
|
|
21
|
+
private mmClient: MattermostClient;
|
|
22
|
+
private config: StreamingConfig;
|
|
23
|
+
private activeStreams: Map<string, StreamContext> = new Map();
|
|
24
|
+
|
|
25
|
+
constructor(mmClient: MattermostClient, config: StreamingConfig) {
|
|
26
|
+
this.mmClient = mmClient;
|
|
27
|
+
this.config = config;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async startStream(session: UserSession, threadRootPostId?: string, initialText: string = ""): Promise<StreamContext> {
|
|
31
|
+
const statusIndicator = await createStatusIndicator(
|
|
32
|
+
this.mmClient,
|
|
33
|
+
session.dmChannelId,
|
|
34
|
+
threadRootPostId,
|
|
35
|
+
"Checking session status..."
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const ctx: StreamContext = {
|
|
39
|
+
postId: statusIndicator.getPostId(),
|
|
40
|
+
channelId: session.dmChannelId,
|
|
41
|
+
threadRootPostId,
|
|
42
|
+
buffer: initialText,
|
|
43
|
+
lastUpdateTime: Date.now(),
|
|
44
|
+
totalChunks: 0,
|
|
45
|
+
isCancelled: false,
|
|
46
|
+
continuationPostIds: [],
|
|
47
|
+
currentPostContent: initialText,
|
|
48
|
+
statusIndicator,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
this.activeStreams.set(ctx.postId, ctx);
|
|
52
|
+
return ctx;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async startStreamWithStatus(
|
|
56
|
+
session: UserSession,
|
|
57
|
+
threadRootPostId?: string,
|
|
58
|
+
initialReason: string = "Checking session status...",
|
|
59
|
+
overrideChannelId?: string
|
|
60
|
+
): Promise<{ streamCtx: StreamContext; statusIndicator: StatusIndicator }> {
|
|
61
|
+
const targetChannelId = overrideChannelId || session.dmChannelId;
|
|
62
|
+
const statusIndicator = await createStatusIndicator(
|
|
63
|
+
this.mmClient,
|
|
64
|
+
targetChannelId,
|
|
65
|
+
threadRootPostId,
|
|
66
|
+
initialReason
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const ctx: StreamContext = {
|
|
70
|
+
postId: statusIndicator.getPostId(),
|
|
71
|
+
channelId: targetChannelId,
|
|
72
|
+
threadRootPostId,
|
|
73
|
+
buffer: "",
|
|
74
|
+
lastUpdateTime: Date.now(),
|
|
75
|
+
totalChunks: 0,
|
|
76
|
+
isCancelled: false,
|
|
77
|
+
continuationPostIds: [],
|
|
78
|
+
currentPostContent: "",
|
|
79
|
+
statusIndicator,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
this.activeStreams.set(ctx.postId, ctx);
|
|
83
|
+
return { streamCtx: ctx, statusIndicator };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async appendChunk(ctx: StreamContext, chunk: string): Promise<void> {
|
|
87
|
+
if (ctx.isCancelled) return;
|
|
88
|
+
|
|
89
|
+
ctx.buffer += chunk;
|
|
90
|
+
ctx.totalChunks++;
|
|
91
|
+
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const timeSinceLastUpdate = now - ctx.lastUpdateTime;
|
|
94
|
+
const shouldUpdate =
|
|
95
|
+
ctx.buffer.length >= this.config.bufferSize || timeSinceLastUpdate >= this.config.maxDelay;
|
|
96
|
+
|
|
97
|
+
if (shouldUpdate) {
|
|
98
|
+
await this.flushBuffer(ctx);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async flushBuffer(ctx: StreamContext): Promise<void> {
|
|
103
|
+
if (ctx.isCancelled) return;
|
|
104
|
+
|
|
105
|
+
const timeSinceLastUpdate = Date.now() - ctx.lastUpdateTime;
|
|
106
|
+
const minInterval = 1000 / this.config.editRateLimit;
|
|
107
|
+
|
|
108
|
+
if (timeSinceLastUpdate < minInterval) {
|
|
109
|
+
await this.sleep(minInterval - timeSinceLastUpdate);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await this.retryOperation(
|
|
114
|
+
() => this.updateWithSplitting(ctx, ctx.buffer + " ..."),
|
|
115
|
+
1,
|
|
116
|
+
200
|
|
117
|
+
);
|
|
118
|
+
ctx.lastUpdateTime = Date.now();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
log.warn("[ResponseStreamer] Failed to update post (non-critical):", error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async updateStream(ctx: StreamContext, fullText: string): Promise<void> {
|
|
125
|
+
if (ctx.isCancelled) return;
|
|
126
|
+
|
|
127
|
+
ctx.buffer = fullText;
|
|
128
|
+
|
|
129
|
+
if (ctx.statusIndicator && !ctx.statusIndicator.hasContentStarted()) {
|
|
130
|
+
ctx.statusIndicator.markContentStarted();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const timeSinceLastUpdate = now - ctx.lastUpdateTime;
|
|
135
|
+
const minInterval = 1000 / this.config.editRateLimit;
|
|
136
|
+
|
|
137
|
+
if (timeSinceLastUpdate >= minInterval) {
|
|
138
|
+
try {
|
|
139
|
+
await this.retryOperation(
|
|
140
|
+
() => this.updateWithSplitting(ctx, ctx.buffer + " ..."),
|
|
141
|
+
1,
|
|
142
|
+
200
|
|
143
|
+
);
|
|
144
|
+
ctx.lastUpdateTime = Date.now();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
log.warn("[ResponseStreamer] Failed to update post (non-critical):", error);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async endStream(ctx: StreamContext): Promise<void> {
|
|
152
|
+
if (ctx.isCancelled) return;
|
|
153
|
+
|
|
154
|
+
this.activeStreams.delete(ctx.postId);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
if (ctx.statusIndicator) {
|
|
158
|
+
await ctx.statusIndicator.setComplete();
|
|
159
|
+
}
|
|
160
|
+
const finalContent = ctx.buffer || "(No response)";
|
|
161
|
+
await this.retryOperation(
|
|
162
|
+
() => this.updateWithSplitting(ctx, finalContent),
|
|
163
|
+
3,
|
|
164
|
+
500
|
|
165
|
+
);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
log.error("[ResponseStreamer] Failed to update post after retries, attempting fallback:", error);
|
|
168
|
+
try {
|
|
169
|
+
const finalContent = ctx.buffer || "(No response)";
|
|
170
|
+
await this.mmClient.createPost(
|
|
171
|
+
ctx.channelId,
|
|
172
|
+
`*(Response recovered)*\n\n${finalContent}`,
|
|
173
|
+
ctx.threadRootPostId
|
|
174
|
+
);
|
|
175
|
+
log.info("[ResponseStreamer] Successfully posted final content via fallback");
|
|
176
|
+
} catch (fallbackError) {
|
|
177
|
+
log.error("[ResponseStreamer] Fallback also failed - response may be incomplete:", fallbackError);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async cancelStream(ctx: StreamContext): Promise<void> {
|
|
183
|
+
ctx.isCancelled = true;
|
|
184
|
+
this.activeStreams.delete(ctx.postId);
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const cancelledContent = ctx.buffer + "\n\n*(Cancelled)*";
|
|
188
|
+
await this.updateWithSplitting(ctx, cancelledContent);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
log.error("[ResponseStreamer] Failed to mark post as cancelled:", error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async recreateStreamAtBottom(ctx: StreamContext, finalizeOldContent?: string): Promise<StreamContext> {
|
|
195
|
+
this.activeStreams.delete(ctx.postId);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
if (finalizeOldContent !== undefined) {
|
|
199
|
+
await this.mmClient.updatePost(ctx.postId, finalizeOldContent);
|
|
200
|
+
} else {
|
|
201
|
+
await this.mmClient.deletePost(ctx.postId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const contPostId of ctx.continuationPostIds) {
|
|
205
|
+
try {
|
|
206
|
+
await this.mmClient.deletePost(contPostId);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
log.debug(`[ResponseStreamer] Could not delete continuation post ${contPostId}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
log.error("[ResponseStreamer] Failed to delete old stream post:", error);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const newPost = await this.mmClient.createPost(
|
|
216
|
+
ctx.channelId,
|
|
217
|
+
ctx.buffer || "...",
|
|
218
|
+
ctx.threadRootPostId
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const newCtx: StreamContext = {
|
|
222
|
+
postId: newPost.id,
|
|
223
|
+
channelId: ctx.channelId,
|
|
224
|
+
threadRootPostId: ctx.threadRootPostId,
|
|
225
|
+
buffer: ctx.buffer,
|
|
226
|
+
lastUpdateTime: Date.now(),
|
|
227
|
+
totalChunks: ctx.totalChunks,
|
|
228
|
+
isCancelled: false,
|
|
229
|
+
continuationPostIds: [],
|
|
230
|
+
currentPostContent: ctx.buffer,
|
|
231
|
+
statusIndicator: ctx.statusIndicator,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
if (newCtx.statusIndicator) {
|
|
235
|
+
newCtx.statusIndicator.updatePostId(newPost.id);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.activeStreams.set(newCtx.postId, newCtx);
|
|
239
|
+
return newCtx;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async updateWithSplitting(ctx: StreamContext, content: string): Promise<void> {
|
|
243
|
+
const maxLen = this.config.maxPostLength;
|
|
244
|
+
|
|
245
|
+
if (content.length <= maxLen) {
|
|
246
|
+
await this.mmClient.updatePost(ctx.postId, content);
|
|
247
|
+
ctx.currentPostContent = content;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const parts = this.splitMessage(content, maxLen);
|
|
252
|
+
|
|
253
|
+
const firstPartWithContinuation = parts.length > 1
|
|
254
|
+
? parts[0] + "\n\n*(continued below...)*"
|
|
255
|
+
: parts[0];
|
|
256
|
+
|
|
257
|
+
await this.mmClient.updatePost(ctx.postId, firstPartWithContinuation);
|
|
258
|
+
ctx.currentPostContent = firstPartWithContinuation;
|
|
259
|
+
|
|
260
|
+
for (let i = 1; i < parts.length; i++) {
|
|
261
|
+
const isLast = i === parts.length - 1;
|
|
262
|
+
const partContent = isLast
|
|
263
|
+
? `*(continued ${i + 1}/${parts.length})*\n\n${parts[i]}`
|
|
264
|
+
: `*(continued ${i + 1}/${parts.length})*\n\n${parts[i]}\n\n*(continued below...)*`;
|
|
265
|
+
|
|
266
|
+
if (ctx.continuationPostIds[i - 1]) {
|
|
267
|
+
await this.mmClient.updatePost(ctx.continuationPostIds[i - 1], partContent);
|
|
268
|
+
} else {
|
|
269
|
+
const post = await this.mmClient.createPost(
|
|
270
|
+
ctx.channelId,
|
|
271
|
+
partContent,
|
|
272
|
+
ctx.threadRootPostId
|
|
273
|
+
);
|
|
274
|
+
ctx.continuationPostIds.push(post.id);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const extraPosts = ctx.continuationPostIds.length - (parts.length - 1);
|
|
279
|
+
if (extraPosts > 0) {
|
|
280
|
+
for (let i = 0; i < extraPosts; i++) {
|
|
281
|
+
const postIdToRemove = ctx.continuationPostIds.pop();
|
|
282
|
+
if (postIdToRemove) {
|
|
283
|
+
try {
|
|
284
|
+
await this.mmClient.updatePost(postIdToRemove, "*(message consolidated above)*");
|
|
285
|
+
} catch (e) {
|
|
286
|
+
log.debug("[ResponseStreamer] Could not update orphaned continuation post");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private splitMessage(content: string, maxLen: number): string[] {
|
|
294
|
+
if (content.length <= maxLen) {
|
|
295
|
+
return [content];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const parts: string[] = [];
|
|
299
|
+
let remaining = content;
|
|
300
|
+
const reservedSpace = 50;
|
|
301
|
+
const effectiveMax = maxLen - reservedSpace;
|
|
302
|
+
|
|
303
|
+
while (remaining.length > 0) {
|
|
304
|
+
if (remaining.length <= effectiveMax) {
|
|
305
|
+
parts.push(remaining);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
let splitPoint = this.findSplitPoint(remaining, effectiveMax);
|
|
310
|
+
parts.push(remaining.substring(0, splitPoint).trimEnd());
|
|
311
|
+
remaining = remaining.substring(splitPoint).trimStart();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return parts;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private findSplitPoint(text: string, maxLen: number): number {
|
|
318
|
+
const doubleNewline = text.lastIndexOf("\n\n", maxLen);
|
|
319
|
+
if (doubleNewline > maxLen * 0.5) {
|
|
320
|
+
return doubleNewline + 2;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const singleNewline = text.lastIndexOf("\n", maxLen);
|
|
324
|
+
if (singleNewline > maxLen * 0.5) {
|
|
325
|
+
return singleNewline + 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const space = text.lastIndexOf(" ", maxLen);
|
|
329
|
+
if (space > maxLen * 0.7) {
|
|
330
|
+
return space + 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return maxLen;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
isStreaming(postId: string): boolean {
|
|
337
|
+
return this.activeStreams.has(postId);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private sleep(ms: number): Promise<void> {
|
|
341
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async retryOperation<T>(
|
|
345
|
+
operation: () => Promise<T>,
|
|
346
|
+
maxRetries: number = 3,
|
|
347
|
+
baseDelayMs: number = 500
|
|
348
|
+
): Promise<T> {
|
|
349
|
+
let lastError: unknown;
|
|
350
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
351
|
+
try {
|
|
352
|
+
return await operation();
|
|
353
|
+
} catch (error) {
|
|
354
|
+
lastError = error;
|
|
355
|
+
if (attempt < maxRetries) {
|
|
356
|
+
const delay = baseDelayMs * Math.pow(2, attempt);
|
|
357
|
+
log.debug(`[ResponseStreamer] Retry ${attempt + 1}/${maxRetries} after ${delay}ms`);
|
|
358
|
+
await this.sleep(delay);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
throw lastError;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join, dirname } from "path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { log } from "../logger.js";
|
|
6
|
+
|
|
7
|
+
export const ScheduleConfigSchema = z.object({
|
|
8
|
+
id: z.string(),
|
|
9
|
+
name: z.string(),
|
|
10
|
+
cron: z.string(),
|
|
11
|
+
timezone: z.string().default("UTC"),
|
|
12
|
+
prompt: z.string(),
|
|
13
|
+
sessionId: z.string(),
|
|
14
|
+
targetUserId: z.string(),
|
|
15
|
+
targetUsername: z.string(),
|
|
16
|
+
enabled: z.boolean().default(true),
|
|
17
|
+
createdAt: z.string(),
|
|
18
|
+
lastRunAt: z.string().optional(),
|
|
19
|
+
lastRunSuccess: z.boolean().optional(),
|
|
20
|
+
lastRunError: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
export type ScheduleConfig = z.infer<typeof ScheduleConfigSchema>;
|
|
23
|
+
|
|
24
|
+
export const ScheduleFileSchema = z.object({
|
|
25
|
+
version: z.literal(1),
|
|
26
|
+
schedules: z.array(ScheduleConfigSchema),
|
|
27
|
+
lastModified: z.string(),
|
|
28
|
+
});
|
|
29
|
+
export type ScheduleFileV1 = z.infer<typeof ScheduleFileSchema>;
|
|
30
|
+
|
|
31
|
+
const PRIMARY_DIR = join(homedir(), ".config", "opencode");
|
|
32
|
+
const FALLBACK_DIR = join(homedir(), ".opencode");
|
|
33
|
+
const FILENAME = "mattermost-schedules.json";
|
|
34
|
+
|
|
35
|
+
export class ScheduleStore {
|
|
36
|
+
private schedules: Map<string, ScheduleConfig> = new Map();
|
|
37
|
+
private byUserId: Map<string, ScheduleConfig[]> = new Map();
|
|
38
|
+
private bySessionId: Map<string, ScheduleConfig[]> = new Map();
|
|
39
|
+
private filePath: string;
|
|
40
|
+
private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
|
+
private saveDebounceMs: number = 2000;
|
|
42
|
+
|
|
43
|
+
constructor() {
|
|
44
|
+
this.filePath = this.resolveFilePath();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private resolveFilePath(): string {
|
|
48
|
+
if (existsSync(PRIMARY_DIR)) {
|
|
49
|
+
return join(PRIMARY_DIR, FILENAME);
|
|
50
|
+
}
|
|
51
|
+
if (existsSync(FALLBACK_DIR)) {
|
|
52
|
+
return join(FALLBACK_DIR, FILENAME);
|
|
53
|
+
}
|
|
54
|
+
mkdirSync(PRIMARY_DIR, { recursive: true });
|
|
55
|
+
return join(PRIMARY_DIR, FILENAME);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async load(): Promise<ScheduleConfig[]> {
|
|
59
|
+
try {
|
|
60
|
+
if (!existsSync(this.filePath)) {
|
|
61
|
+
log.debug("[ScheduleStore] No existing file, starting fresh");
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const raw = readFileSync(this.filePath, "utf-8");
|
|
66
|
+
const parsed = JSON.parse(raw);
|
|
67
|
+
const validated = ScheduleFileSchema.safeParse(parsed);
|
|
68
|
+
|
|
69
|
+
if (!validated.success) {
|
|
70
|
+
log.warn("[ScheduleStore] Invalid file format, filtering valid entries");
|
|
71
|
+
const schedules: ScheduleConfig[] = [];
|
|
72
|
+
if (Array.isArray(parsed?.schedules)) {
|
|
73
|
+
for (const s of parsed.schedules) {
|
|
74
|
+
const result = ScheduleConfigSchema.safeParse(s);
|
|
75
|
+
if (result.success) {
|
|
76
|
+
schedules.push(result.data);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
this.setSchedules(schedules);
|
|
81
|
+
return schedules;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
this.setSchedules(validated.data.schedules);
|
|
85
|
+
log.info(`[ScheduleStore] Loaded ${validated.data.schedules.length} schedules`);
|
|
86
|
+
return validated.data.schedules;
|
|
87
|
+
} catch (e) {
|
|
88
|
+
log.error("[ScheduleStore] Failed to load:", e);
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async save(): Promise<void> {
|
|
94
|
+
try {
|
|
95
|
+
const data: ScheduleFileV1 = {
|
|
96
|
+
version: 1,
|
|
97
|
+
schedules: Array.from(this.schedules.values()),
|
|
98
|
+
lastModified: new Date().toISOString(),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const dir = dirname(this.filePath);
|
|
102
|
+
if (!existsSync(dir)) {
|
|
103
|
+
mkdirSync(dir, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tempPath = `${this.filePath}.tmp.${Date.now()}`;
|
|
107
|
+
writeFileSync(tempPath, JSON.stringify(data, null, 2));
|
|
108
|
+
renameSync(tempPath, this.filePath);
|
|
109
|
+
log.debug(`[ScheduleStore] Saved ${this.schedules.size} schedules`);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
log.error("[ScheduleStore] Failed to save:", e);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
scheduleSave(): void {
|
|
116
|
+
if (this.saveDebounceTimer) {
|
|
117
|
+
clearTimeout(this.saveDebounceTimer);
|
|
118
|
+
}
|
|
119
|
+
this.saveDebounceTimer = setTimeout(() => {
|
|
120
|
+
this.saveDebounceTimer = null;
|
|
121
|
+
this.save().catch((e) => log.error("[ScheduleStore] Debounced save failed:", e));
|
|
122
|
+
}, this.saveDebounceMs);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private setSchedules(schedules: ScheduleConfig[]): void {
|
|
126
|
+
this.schedules.clear();
|
|
127
|
+
this.byUserId.clear();
|
|
128
|
+
this.bySessionId.clear();
|
|
129
|
+
|
|
130
|
+
for (const s of schedules) {
|
|
131
|
+
this.addToIndexes(s);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private addToIndexes(schedule: ScheduleConfig): void {
|
|
136
|
+
this.schedules.set(schedule.id, schedule);
|
|
137
|
+
|
|
138
|
+
const userSchedules = this.byUserId.get(schedule.targetUserId) || [];
|
|
139
|
+
userSchedules.push(schedule);
|
|
140
|
+
this.byUserId.set(schedule.targetUserId, userSchedules);
|
|
141
|
+
|
|
142
|
+
const sessionSchedules = this.bySessionId.get(schedule.sessionId) || [];
|
|
143
|
+
sessionSchedules.push(schedule);
|
|
144
|
+
this.bySessionId.set(schedule.sessionId, sessionSchedules);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private removeFromIndexes(schedule: ScheduleConfig): void {
|
|
148
|
+
this.schedules.delete(schedule.id);
|
|
149
|
+
|
|
150
|
+
const userSchedules = this.byUserId.get(schedule.targetUserId);
|
|
151
|
+
if (userSchedules) {
|
|
152
|
+
const filtered = userSchedules.filter((s) => s.id !== schedule.id);
|
|
153
|
+
if (filtered.length > 0) {
|
|
154
|
+
this.byUserId.set(schedule.targetUserId, filtered);
|
|
155
|
+
} else {
|
|
156
|
+
this.byUserId.delete(schedule.targetUserId);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const sessionSchedules = this.bySessionId.get(schedule.sessionId);
|
|
161
|
+
if (sessionSchedules) {
|
|
162
|
+
const filtered = sessionSchedules.filter((s) => s.id !== schedule.id);
|
|
163
|
+
if (filtered.length > 0) {
|
|
164
|
+
this.bySessionId.set(schedule.sessionId, filtered);
|
|
165
|
+
} else {
|
|
166
|
+
this.bySessionId.delete(schedule.sessionId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
add(schedule: ScheduleConfig): void {
|
|
172
|
+
this.addToIndexes(schedule);
|
|
173
|
+
this.scheduleSave();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
update(schedule: ScheduleConfig): void {
|
|
177
|
+
const existing = this.schedules.get(schedule.id);
|
|
178
|
+
if (existing) {
|
|
179
|
+
this.removeFromIndexes(existing);
|
|
180
|
+
}
|
|
181
|
+
this.addToIndexes(schedule);
|
|
182
|
+
this.scheduleSave();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
remove(scheduleId: string): boolean {
|
|
186
|
+
const existing = this.schedules.get(scheduleId);
|
|
187
|
+
if (existing) {
|
|
188
|
+
this.removeFromIndexes(existing);
|
|
189
|
+
this.scheduleSave();
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
getById(scheduleId: string): ScheduleConfig | null {
|
|
196
|
+
return this.schedules.get(scheduleId) || null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
getByName(name: string): ScheduleConfig | null {
|
|
200
|
+
for (const schedule of this.schedules.values()) {
|
|
201
|
+
if (schedule.name === name) {
|
|
202
|
+
return schedule;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
getByUserId(userId: string): ScheduleConfig[] {
|
|
209
|
+
return this.byUserId.get(userId) || [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
getBySessionId(sessionId: string): ScheduleConfig[] {
|
|
213
|
+
return this.bySessionId.get(sessionId) || [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
listAll(): ScheduleConfig[] {
|
|
217
|
+
return Array.from(this.schedules.values());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
listEnabled(): ScheduleConfig[] {
|
|
221
|
+
return this.listAll().filter((s) => s.enabled);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
count(): number {
|
|
225
|
+
return this.schedules.size;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
updateLastRun(scheduleId: string, success: boolean, error?: string): void {
|
|
229
|
+
const schedule = this.schedules.get(scheduleId);
|
|
230
|
+
if (schedule) {
|
|
231
|
+
schedule.lastRunAt = new Date().toISOString();
|
|
232
|
+
schedule.lastRunSuccess = success;
|
|
233
|
+
schedule.lastRunError = error;
|
|
234
|
+
this.scheduleSave();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
setEnabled(scheduleId: string, enabled: boolean): boolean {
|
|
239
|
+
const schedule = this.schedules.get(scheduleId);
|
|
240
|
+
if (schedule) {
|
|
241
|
+
schedule.enabled = enabled;
|
|
242
|
+
this.scheduleSave();
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
shutdown(): void {
|
|
249
|
+
if (this.saveDebounceTimer) {
|
|
250
|
+
clearTimeout(this.saveDebounceTimer);
|
|
251
|
+
this.saveDebounceTimer = null;
|
|
252
|
+
}
|
|
253
|
+
this.save().catch((e) => log.error("[ScheduleStore] Shutdown save failed:", e));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function generateScheduleId(): string {
|
|
258
|
+
const timestamp = Date.now().toString(36);
|
|
259
|
+
const random = Math.random().toString(36).substring(2, 8);
|
|
260
|
+
return `sched_${timestamp}_${random}`;
|
|
261
|
+
}
|