@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,396 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join, dirname } from "path";
4
+ import { log } from "../logger.js";
5
+
6
+ export interface TeamMember {
7
+ userId: string;
8
+ username: string;
9
+ addedAt: string;
10
+ addedBy: string;
11
+ role: "member" | "admin";
12
+ }
13
+
14
+ export interface TeamSettings {
15
+ allowMembersToCreateSessions: boolean;
16
+ allowMembersToApproveGuests: boolean;
17
+ syncWithMattermostTeam: boolean;
18
+ mattermostTeamId?: string;
19
+ }
20
+
21
+ export interface TeamConfig {
22
+ id: string;
23
+ name: string;
24
+ createdAt: string;
25
+ updatedAt: string;
26
+ ownerId: string;
27
+ members: TeamMember[];
28
+ settings: TeamSettings;
29
+ }
30
+
31
+ export interface TeamConfigFile {
32
+ version: 1;
33
+ team: TeamConfig;
34
+ }
35
+
36
+ const PRIMARY_DIR = join(homedir(), ".config", "opencode");
37
+ const FALLBACK_DIR = join(homedir(), ".opencode");
38
+ const FILENAME = "mattermost-team.json";
39
+
40
+ export class TeamStore {
41
+ private config: TeamConfig | null = null;
42
+ private memberCache: Set<string> = new Set();
43
+ private cacheLastLoaded: number = 0;
44
+ private cacheTtlMs: number = 300000; // 5 minutes default
45
+ private filePath: string;
46
+ private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
47
+ private saveDebounceMs: number = 1000;
48
+
49
+ constructor(cacheTtlMs?: number) {
50
+ this.filePath = this.resolveFilePath();
51
+ if (cacheTtlMs !== undefined) {
52
+ this.cacheTtlMs = cacheTtlMs;
53
+ }
54
+ }
55
+
56
+ private resolveFilePath(): string {
57
+ // Check for custom path from environment
58
+ const customPath = process.env.OPENCODE_MM_TEAM_FILE;
59
+ if (customPath) {
60
+ const expanded = customPath.replace(/^~/, homedir());
61
+ return expanded;
62
+ }
63
+
64
+ if (existsSync(PRIMARY_DIR)) {
65
+ return join(PRIMARY_DIR, FILENAME);
66
+ }
67
+ if (existsSync(FALLBACK_DIR)) {
68
+ return join(FALLBACK_DIR, FILENAME);
69
+ }
70
+ mkdirSync(PRIMARY_DIR, { recursive: true });
71
+ return join(PRIMARY_DIR, FILENAME);
72
+ }
73
+
74
+ /**
75
+ * Load team configuration from disk
76
+ */
77
+ load(): TeamConfig | null {
78
+ try {
79
+ if (!existsSync(this.filePath)) {
80
+ log.debug("[TeamStore] No team file exists, starting fresh");
81
+ return null;
82
+ }
83
+
84
+ const raw = readFileSync(this.filePath, "utf-8");
85
+ const parsed = JSON.parse(raw) as TeamConfigFile;
86
+
87
+ if (parsed.version !== 1) {
88
+ log.warn(`[TeamStore] Unknown team config version: ${parsed.version}`);
89
+ return null;
90
+ }
91
+
92
+ this.config = parsed.team;
93
+ this.rebuildCache();
94
+ log.info(`[TeamStore] Loaded team "${this.config.name}" with ${this.config.members.length} member(s)`);
95
+ return this.config;
96
+ } catch (e) {
97
+ log.error("[TeamStore] Failed to load team config:", e);
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Save team configuration to disk
104
+ */
105
+ save(): void {
106
+ if (!this.config) {
107
+ log.warn("[TeamStore] No config to save");
108
+ return;
109
+ }
110
+
111
+ try {
112
+ const data: TeamConfigFile = {
113
+ version: 1,
114
+ team: {
115
+ ...this.config,
116
+ updatedAt: new Date().toISOString(),
117
+ },
118
+ };
119
+
120
+ const dir = dirname(this.filePath);
121
+ if (!existsSync(dir)) {
122
+ mkdirSync(dir, { recursive: true });
123
+ }
124
+
125
+ const tempPath = `${this.filePath}.tmp.${Date.now()}`;
126
+ writeFileSync(tempPath, JSON.stringify(data, null, 2));
127
+ renameSync(tempPath, this.filePath);
128
+ log.debug(`[TeamStore] Saved team config with ${this.config.members.length} member(s)`);
129
+ } catch (e) {
130
+ log.error("[TeamStore] Failed to save team config:", e);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Schedule a debounced save
136
+ */
137
+ scheduleSave(): void {
138
+ if (this.saveDebounceTimer) {
139
+ clearTimeout(this.saveDebounceTimer);
140
+ }
141
+ this.saveDebounceTimer = setTimeout(() => {
142
+ this.saveDebounceTimer = null;
143
+ this.save();
144
+ }, this.saveDebounceMs);
145
+ }
146
+
147
+ private rebuildCache(): void {
148
+ this.memberCache.clear();
149
+ if (this.config) {
150
+ for (const member of this.config.members) {
151
+ this.memberCache.add(member.userId);
152
+ }
153
+ }
154
+ this.cacheLastLoaded = Date.now();
155
+ }
156
+
157
+ private ensureCacheValid(): void {
158
+ if (Date.now() - this.cacheLastLoaded > this.cacheTtlMs) {
159
+ this.load();
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Create a new team with the given owner
165
+ */
166
+ createTeam(ownerId: string, name: string = "My Team"): TeamConfig {
167
+ const now = new Date().toISOString();
168
+ this.config = {
169
+ id: this.generateUUID(),
170
+ name,
171
+ createdAt: now,
172
+ updatedAt: now,
173
+ ownerId,
174
+ members: [],
175
+ settings: {
176
+ allowMembersToCreateSessions: true,
177
+ allowMembersToApproveGuests: false,
178
+ syncWithMattermostTeam: false,
179
+ },
180
+ };
181
+ this.rebuildCache();
182
+ this.scheduleSave();
183
+ log.info(`[TeamStore] Created new team "${name}" for owner ${ownerId}`);
184
+ return this.config;
185
+ }
186
+
187
+ /**
188
+ * Check if a user is a team member (O(1) lookup with caching)
189
+ */
190
+ isMember(userId: string): boolean {
191
+ this.ensureCacheValid();
192
+ return this.memberCache.has(userId);
193
+ }
194
+
195
+ /**
196
+ * Check if a user is the team owner
197
+ */
198
+ isOwner(userId: string): boolean {
199
+ this.ensureCacheValid();
200
+ return this.config?.ownerId === userId;
201
+ }
202
+
203
+ /**
204
+ * Check if user has team access (owner OR member)
205
+ */
206
+ hasTeamAccess(userId: string): boolean {
207
+ return this.isOwner(userId) || this.isMember(userId);
208
+ }
209
+
210
+ /**
211
+ * Add a member to the team
212
+ */
213
+ addMember(userId: string, username: string, addedBy: string, role: "member" | "admin" = "member"): boolean {
214
+ if (!this.config) {
215
+ log.warn("[TeamStore] Cannot add member - no team exists");
216
+ return false;
217
+ }
218
+
219
+ // Don't add if already a member
220
+ if (this.memberCache.has(userId)) {
221
+ log.debug(`[TeamStore] User ${userId} is already a team member`);
222
+ return false;
223
+ }
224
+
225
+ // Don't add the owner
226
+ if (this.config.ownerId === userId) {
227
+ log.debug(`[TeamStore] Cannot add owner ${userId} as member`);
228
+ return false;
229
+ }
230
+
231
+ const member: TeamMember = {
232
+ userId,
233
+ username,
234
+ addedAt: new Date().toISOString(),
235
+ addedBy,
236
+ role,
237
+ };
238
+
239
+ this.config.members.push(member);
240
+ this.memberCache.add(userId);
241
+ this.scheduleSave();
242
+ log.info(`[TeamStore] Added @${username} (${userId}) to team`);
243
+ return true;
244
+ }
245
+
246
+ /**
247
+ * Remove a member from the team
248
+ */
249
+ removeMember(userId: string): boolean {
250
+ if (!this.config) {
251
+ return false;
252
+ }
253
+
254
+ const index = this.config.members.findIndex((m) => m.userId === userId);
255
+ if (index === -1) {
256
+ return false;
257
+ }
258
+
259
+ const removed = this.config.members.splice(index, 1)[0];
260
+ this.memberCache.delete(userId);
261
+ this.scheduleSave();
262
+ log.info(`[TeamStore] Removed @${removed.username} (${userId}) from team`);
263
+ return true;
264
+ }
265
+
266
+ /**
267
+ * Get all team members
268
+ */
269
+ getMembers(): TeamMember[] {
270
+ this.ensureCacheValid();
271
+ return this.config?.members || [];
272
+ }
273
+
274
+ /**
275
+ * Get a specific member by user ID
276
+ */
277
+ getMember(userId: string): TeamMember | null {
278
+ this.ensureCacheValid();
279
+ return this.config?.members.find((m) => m.userId === userId) || null;
280
+ }
281
+
282
+ /**
283
+ * Clear all members from the team
284
+ */
285
+ clearMembers(): number {
286
+ if (!this.config) {
287
+ return 0;
288
+ }
289
+
290
+ const count = this.config.members.length;
291
+ this.config.members = [];
292
+ this.memberCache.clear();
293
+ this.scheduleSave();
294
+ log.info(`[TeamStore] Cleared ${count} member(s) from team`);
295
+ return count;
296
+ }
297
+
298
+ /**
299
+ * Update team settings
300
+ */
301
+ updateSettings(settings: Partial<TeamSettings>): void {
302
+ if (!this.config) {
303
+ return;
304
+ }
305
+
306
+ this.config.settings = { ...this.config.settings, ...settings };
307
+ this.scheduleSave();
308
+ log.debug(`[TeamStore] Updated team settings`);
309
+ }
310
+
311
+ /**
312
+ * Get team configuration
313
+ */
314
+ getConfig(): TeamConfig | null {
315
+ this.ensureCacheValid();
316
+ return this.config;
317
+ }
318
+
319
+ /**
320
+ * Get team settings
321
+ */
322
+ getSettings(): TeamSettings | null {
323
+ this.ensureCacheValid();
324
+ return this.config?.settings || null;
325
+ }
326
+
327
+ /**
328
+ * Check if team exists
329
+ */
330
+ hasTeam(): boolean {
331
+ this.ensureCacheValid();
332
+ return this.config !== null;
333
+ }
334
+
335
+ /**
336
+ * Get member count
337
+ */
338
+ getMemberCount(): number {
339
+ this.ensureCacheValid();
340
+ return this.config?.members.length || 0;
341
+ }
342
+
343
+ /**
344
+ * Update owner ID (for when MATTERMOST_OWNER_USER_ID changes)
345
+ */
346
+ updateOwnerId(newOwnerId: string): void {
347
+ if (!this.config) {
348
+ return;
349
+ }
350
+
351
+ const oldOwnerId = this.config.ownerId;
352
+ this.config.ownerId = newOwnerId;
353
+
354
+ // Remove new owner from members if they were a member
355
+ this.removeMember(newOwnerId);
356
+
357
+ this.scheduleSave();
358
+ log.info(`[TeamStore] Updated team owner from ${oldOwnerId} to ${newOwnerId}`);
359
+ }
360
+
361
+ /**
362
+ * Rename the team
363
+ */
364
+ setTeamName(name: string): void {
365
+ if (!this.config) {
366
+ return;
367
+ }
368
+ this.config.name = name;
369
+ this.scheduleSave();
370
+ log.debug(`[TeamStore] Renamed team to "${name}"`);
371
+ }
372
+
373
+ /**
374
+ * Shutdown - save any pending changes
375
+ */
376
+ shutdown(): void {
377
+ if (this.saveDebounceTimer) {
378
+ clearTimeout(this.saveDebounceTimer);
379
+ this.saveDebounceTimer = null;
380
+ }
381
+ if (this.config) {
382
+ this.save();
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Generate a simple UUID v4
388
+ */
389
+ private generateUUID(): string {
390
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
391
+ const r = (Math.random() * 16) | 0;
392
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
393
+ return v.toString(16);
394
+ });
395
+ }
396
+ }
@@ -0,0 +1,258 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join, dirname } from "path";
4
+ import type { ThreadSessionMapping } from "../models/index.js";
5
+ import { ThreadMappingFileSchema, type ThreadMappingFileV1 } from "../models/thread-mapping.js";
6
+ import { log } from "../logger.js";
7
+
8
+ const PRIMARY_DIR = join(homedir(), ".config", "opencode");
9
+ const FALLBACK_DIR = join(homedir(), ".opencode");
10
+ const FILENAME = "mattermost-threads.json";
11
+
12
+ export class ThreadMappingStore {
13
+ private mappings: Map<string, ThreadSessionMapping> = new Map();
14
+ private byThreadRootPostId: Map<string, ThreadSessionMapping> = new Map();
15
+ private byMattermostUserId: Map<string, ThreadSessionMapping[]> = new Map();
16
+ private byChannelId: Map<string, ThreadSessionMapping[]> = new Map();
17
+ private filePath: string;
18
+ private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
19
+ private saveDebounceMs: number = 2000;
20
+
21
+ constructor() {
22
+ this.filePath = this.resolveFilePath();
23
+ }
24
+
25
+ private resolveFilePath(): string {
26
+ if (existsSync(PRIMARY_DIR)) {
27
+ return join(PRIMARY_DIR, FILENAME);
28
+ }
29
+ if (existsSync(FALLBACK_DIR)) {
30
+ return join(FALLBACK_DIR, FILENAME);
31
+ }
32
+ mkdirSync(PRIMARY_DIR, { recursive: true });
33
+ return join(PRIMARY_DIR, FILENAME);
34
+ }
35
+
36
+ async load(): Promise<ThreadSessionMapping[]> {
37
+ try {
38
+ if (!existsSync(this.filePath)) {
39
+ log.debug("[ThreadMappingStore] No existing file, starting fresh");
40
+ return [];
41
+ }
42
+
43
+ const raw = readFileSync(this.filePath, "utf-8");
44
+ const parsed = JSON.parse(raw);
45
+ const validated = ThreadMappingFileSchema.safeParse(parsed);
46
+
47
+ if (!validated.success) {
48
+ log.warn("[ThreadMappingStore] Invalid file format, filtering invalid entries");
49
+ const mappings: ThreadSessionMapping[] = [];
50
+ if (Array.isArray(parsed?.mappings)) {
51
+ for (const m of parsed.mappings) {
52
+ if (m?.sessionId && m?.threadRootPostId) {
53
+ mappings.push(m as ThreadSessionMapping);
54
+ }
55
+ }
56
+ }
57
+ this.setMappings(mappings);
58
+ return mappings;
59
+ }
60
+
61
+ this.setMappings(validated.data.mappings);
62
+ log.info(`[ThreadMappingStore] Loaded ${validated.data.mappings.length} mappings`);
63
+ return validated.data.mappings;
64
+ } catch (e) {
65
+ log.error("[ThreadMappingStore] Failed to load:", e);
66
+ return [];
67
+ }
68
+ }
69
+
70
+ async save(): Promise<void> {
71
+ try {
72
+ const data: ThreadMappingFileV1 = {
73
+ version: 1,
74
+ mappings: Array.from(this.mappings.values()),
75
+ lastModified: new Date().toISOString(),
76
+ };
77
+
78
+ const dir = dirname(this.filePath);
79
+ if (!existsSync(dir)) {
80
+ mkdirSync(dir, { recursive: true });
81
+ }
82
+
83
+ const tempPath = `${this.filePath}.tmp.${Date.now()}`;
84
+ writeFileSync(tempPath, JSON.stringify(data, null, 2));
85
+ renameSync(tempPath, this.filePath);
86
+ log.debug(`[ThreadMappingStore] Saved ${this.mappings.size} mappings`);
87
+ } catch (e) {
88
+ log.error("[ThreadMappingStore] Failed to save:", e);
89
+ }
90
+ }
91
+
92
+ scheduleSave(): void {
93
+ if (this.saveDebounceTimer) {
94
+ clearTimeout(this.saveDebounceTimer);
95
+ }
96
+ this.saveDebounceTimer = setTimeout(() => {
97
+ this.saveDebounceTimer = null;
98
+ this.save().catch((e) => log.error("[ThreadMappingStore] Debounced save failed:", e));
99
+ }, this.saveDebounceMs);
100
+ }
101
+
102
+ private setMappings(mappings: ThreadSessionMapping[]): void {
103
+ this.mappings.clear();
104
+ this.byThreadRootPostId.clear();
105
+ this.byMattermostUserId.clear();
106
+ this.byChannelId.clear();
107
+
108
+ for (const m of mappings) {
109
+ this.addToIndexes(m);
110
+ }
111
+ }
112
+
113
+ private addToIndexes(mapping: ThreadSessionMapping): void {
114
+ this.mappings.set(mapping.sessionId, mapping);
115
+ this.byThreadRootPostId.set(mapping.threadRootPostId, mapping);
116
+
117
+ const userMappings = this.byMattermostUserId.get(mapping.mattermostUserId) || [];
118
+ userMappings.push(mapping);
119
+ this.byMattermostUserId.set(mapping.mattermostUserId, userMappings);
120
+
121
+ const channelId = mapping.channelId || mapping.dmChannelId;
122
+ const channelMappings = this.byChannelId.get(channelId) || [];
123
+ channelMappings.push(mapping);
124
+ this.byChannelId.set(channelId, channelMappings);
125
+ }
126
+
127
+ private removeFromIndexes(mapping: ThreadSessionMapping): void {
128
+ this.mappings.delete(mapping.sessionId);
129
+ this.byThreadRootPostId.delete(mapping.threadRootPostId);
130
+
131
+ const userMappings = this.byMattermostUserId.get(mapping.mattermostUserId);
132
+ if (userMappings) {
133
+ const filtered = userMappings.filter((m) => m.sessionId !== mapping.sessionId);
134
+ if (filtered.length > 0) {
135
+ this.byMattermostUserId.set(mapping.mattermostUserId, filtered);
136
+ } else {
137
+ this.byMattermostUserId.delete(mapping.mattermostUserId);
138
+ }
139
+ }
140
+
141
+ const channelId = mapping.channelId || mapping.dmChannelId;
142
+ const channelMappings = this.byChannelId.get(channelId);
143
+ if (channelMappings) {
144
+ const filtered = channelMappings.filter((m) => m.sessionId !== mapping.sessionId);
145
+ if (filtered.length > 0) {
146
+ this.byChannelId.set(channelId, filtered);
147
+ } else {
148
+ this.byChannelId.delete(channelId);
149
+ }
150
+ }
151
+ }
152
+
153
+ add(mapping: ThreadSessionMapping): void {
154
+ this.addToIndexes(mapping);
155
+ this.scheduleSave();
156
+ }
157
+
158
+ update(mapping: ThreadSessionMapping): void {
159
+ const existing = this.mappings.get(mapping.sessionId);
160
+ if (existing) {
161
+ this.removeFromIndexes(existing);
162
+ }
163
+ this.addToIndexes(mapping);
164
+ this.scheduleSave();
165
+ }
166
+
167
+ remove(sessionId: string): void {
168
+ const existing = this.mappings.get(sessionId);
169
+ if (existing) {
170
+ this.removeFromIndexes(existing);
171
+ this.scheduleSave();
172
+ }
173
+ }
174
+
175
+ getBySessionId(sessionId: string): ThreadSessionMapping | null {
176
+ return this.mappings.get(sessionId) || null;
177
+ }
178
+
179
+ getByThreadRootPostId(threadRootPostId: string): ThreadSessionMapping | null {
180
+ return this.byThreadRootPostId.get(threadRootPostId) || null;
181
+ }
182
+
183
+ getByMattermostUserId(mattermostUserId: string): ThreadSessionMapping[] {
184
+ return this.byMattermostUserId.get(mattermostUserId) || [];
185
+ }
186
+
187
+ getActiveMappingsForUser(mattermostUserId: string): ThreadSessionMapping[] {
188
+ return this.getByMattermostUserId(mattermostUserId).filter((m) => m.status === "active");
189
+ }
190
+
191
+ getByChannelId(channelId: string): ThreadSessionMapping[] {
192
+ return this.byChannelId.get(channelId) || [];
193
+ }
194
+
195
+ getActiveMappingsForChannel(channelId: string): ThreadSessionMapping[] {
196
+ return this.getByChannelId(channelId).filter((m) => m.status === "active");
197
+ }
198
+
199
+ listAll(): ThreadSessionMapping[] {
200
+ return Array.from(this.mappings.values());
201
+ }
202
+
203
+ listActive(): ThreadSessionMapping[] {
204
+ return this.listAll().filter((m) => m.status === "active");
205
+ }
206
+
207
+ count(): number {
208
+ return this.mappings.size;
209
+ }
210
+
211
+ merge(diskMappings: ThreadSessionMapping[]): void {
212
+ for (const disk of diskMappings) {
213
+ const existing = this.mappings.get(disk.sessionId);
214
+ if (!existing) {
215
+ this.addToIndexes(disk);
216
+ } else {
217
+ const diskTime = new Date(disk.lastActivityAt).getTime();
218
+ const memTime = new Date(existing.lastActivityAt).getTime();
219
+ if (diskTime > memTime) {
220
+ this.removeFromIndexes(existing);
221
+ this.addToIndexes(disk);
222
+ }
223
+ }
224
+ }
225
+ this.scheduleSave();
226
+ }
227
+
228
+ cleanOrphaned(validSessionIds: Set<string>): number {
229
+ let cleaned = 0;
230
+ for (const mapping of this.listAll()) {
231
+ if (mapping.status === "active" && !validSessionIds.has(mapping.sessionId)) {
232
+ mapping.status = "orphaned";
233
+ this.update(mapping);
234
+ cleaned++;
235
+ }
236
+ }
237
+ return cleaned;
238
+ }
239
+
240
+ reactivate(threadRootPostId: string): boolean {
241
+ const mapping = this.byThreadRootPostId.get(threadRootPostId);
242
+ if (mapping && mapping.status === "orphaned") {
243
+ mapping.status = "active";
244
+ mapping.lastActivityAt = new Date().toISOString();
245
+ this.update(mapping);
246
+ return true;
247
+ }
248
+ return false;
249
+ }
250
+
251
+ shutdown(): void {
252
+ if (this.saveDebounceTimer) {
253
+ clearTimeout(this.saveDebounceTimer);
254
+ this.saveDebounceTimer = null;
255
+ }
256
+ this.save().catch((e) => log.error("[ThreadMappingStore] Shutdown save failed:", e));
257
+ }
258
+ }