@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,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
|
+
}
|