@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,401 @@
|
|
|
1
|
+
import type { MattermostClient } from "./clients/mattermost-client.js";
|
|
2
|
+
import { log } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
export interface QuestionOption {
|
|
5
|
+
label: string;
|
|
6
|
+
description: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface QuestionInfo {
|
|
10
|
+
question: string;
|
|
11
|
+
header: string;
|
|
12
|
+
options: QuestionOption[];
|
|
13
|
+
multiple?: boolean;
|
|
14
|
+
custom?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface QuestionRequest {
|
|
18
|
+
id: string;
|
|
19
|
+
sessionID: string;
|
|
20
|
+
questions: QuestionInfo[];
|
|
21
|
+
tool?: {
|
|
22
|
+
messageID: string;
|
|
23
|
+
callID: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PendingQuestion {
|
|
28
|
+
request: QuestionRequest;
|
|
29
|
+
channelId: string;
|
|
30
|
+
threadRootPostId?: string;
|
|
31
|
+
questionPostId: string;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
currentQuestionIndex: number;
|
|
34
|
+
answers: string[][];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// OpenCode server question response structure
|
|
38
|
+
interface OpenCodeQuestion {
|
|
39
|
+
id: string;
|
|
40
|
+
sessionID: string;
|
|
41
|
+
questions: QuestionInfo[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class QuestionHandler {
|
|
45
|
+
private mmClient: MattermostClient;
|
|
46
|
+
private pendingQuestions: Map<string, PendingQuestion> = new Map();
|
|
47
|
+
private sessionToQuestion: Map<string, string> = new Map();
|
|
48
|
+
private opencodeBaseUrl: string = "";
|
|
49
|
+
private opencodeDirectory: string = "";
|
|
50
|
+
|
|
51
|
+
constructor(mmClient: MattermostClient) {
|
|
52
|
+
this.mmClient = mmClient;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Configure the OpenCode server connection for question state verification.
|
|
57
|
+
*/
|
|
58
|
+
setOpenCodeConfig(baseUrl: string, directory: string): void {
|
|
59
|
+
this.opencodeBaseUrl = baseUrl;
|
|
60
|
+
this.opencodeDirectory = directory;
|
|
61
|
+
log.info(`[QuestionHandler] OpenCode config set: baseUrl=${baseUrl}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async handleQuestionAsked(
|
|
65
|
+
request: QuestionRequest,
|
|
66
|
+
channelId: string,
|
|
67
|
+
threadRootPostId?: string
|
|
68
|
+
): Promise<string> {
|
|
69
|
+
log.info(`[QuestionHandler] Received question request: ${request.id} with ${request.questions.length} question(s)`);
|
|
70
|
+
|
|
71
|
+
const formattedMessage = this.formatQuestionPost(request, 0);
|
|
72
|
+
|
|
73
|
+
const post = await this.mmClient.createPost(
|
|
74
|
+
channelId,
|
|
75
|
+
formattedMessage,
|
|
76
|
+
threadRootPostId
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const pending: PendingQuestion = {
|
|
80
|
+
request,
|
|
81
|
+
channelId,
|
|
82
|
+
threadRootPostId,
|
|
83
|
+
questionPostId: post.id,
|
|
84
|
+
createdAt: Date.now(),
|
|
85
|
+
currentQuestionIndex: 0,
|
|
86
|
+
answers: request.questions.map(() => []),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
this.pendingQuestions.set(request.id, pending);
|
|
90
|
+
this.sessionToQuestion.set(request.sessionID, request.id);
|
|
91
|
+
|
|
92
|
+
log.info(`[QuestionHandler] Posted question ${request.id} as post ${post.id}`);
|
|
93
|
+
return post.id;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private formatQuestionPost(request: QuestionRequest, questionIndex: number): string {
|
|
97
|
+
const q = request.questions[questionIndex];
|
|
98
|
+
const totalQuestions = request.questions.length;
|
|
99
|
+
|
|
100
|
+
let message = "";
|
|
101
|
+
|
|
102
|
+
if (totalQuestions > 1) {
|
|
103
|
+
message += `### ❓ Question ${questionIndex + 1}/${totalQuestions}: ${q.header}\n\n`;
|
|
104
|
+
} else {
|
|
105
|
+
message += `### ❓ ${q.header}\n\n`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
message += `${q.question}\n\n`;
|
|
109
|
+
|
|
110
|
+
q.options.forEach((opt, idx) => {
|
|
111
|
+
message += `**${idx + 1}.** ${opt.label}`;
|
|
112
|
+
if (opt.description) {
|
|
113
|
+
message += ` - _${opt.description}_`;
|
|
114
|
+
}
|
|
115
|
+
message += "\n";
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const allowCustom = q.custom !== false;
|
|
119
|
+
if (allowCustom) {
|
|
120
|
+
message += `**${q.options.length + 1}.** Other - _Type your own answer_\n`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
message += "\n---\n";
|
|
124
|
+
|
|
125
|
+
if (q.multiple) {
|
|
126
|
+
message += "_Reply with one or more numbers separated by commas (e.g., `1, 3`) or type your answer_\n";
|
|
127
|
+
} else {
|
|
128
|
+
message += "_Reply with a number or type your answer_\n";
|
|
129
|
+
}
|
|
130
|
+
message += "_Use `!reject` to skip this question_";
|
|
131
|
+
|
|
132
|
+
return message;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async handleUserReply(
|
|
136
|
+
sessionId: string,
|
|
137
|
+
userMessage: string,
|
|
138
|
+
channelId: string,
|
|
139
|
+
threadRootPostId?: string
|
|
140
|
+
): Promise<{ handled: boolean; answers?: string[][]; requestId?: string; rejected?: boolean }> {
|
|
141
|
+
const questionId = this.sessionToQuestion.get(sessionId);
|
|
142
|
+
if (!questionId) {
|
|
143
|
+
return { handled: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pending = this.pendingQuestions.get(questionId);
|
|
147
|
+
if (!pending) {
|
|
148
|
+
this.sessionToQuestion.delete(sessionId);
|
|
149
|
+
return { handled: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (channelId !== pending.channelId) {
|
|
153
|
+
return { handled: false };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const trimmedMessage = userMessage.trim();
|
|
157
|
+
const lowerMessage = trimmedMessage.toLowerCase();
|
|
158
|
+
const isRejectCommand = lowerMessage === "!reject" || lowerMessage === "!cancel";
|
|
159
|
+
|
|
160
|
+
if (isRejectCommand) {
|
|
161
|
+
this.pendingQuestions.delete(questionId);
|
|
162
|
+
this.sessionToQuestion.delete(sessionId);
|
|
163
|
+
|
|
164
|
+
await this.mmClient.createPost(
|
|
165
|
+
channelId,
|
|
166
|
+
`:x: Question rejected. The AI will receive an empty response.`,
|
|
167
|
+
threadRootPostId
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
log.info(`[QuestionHandler] Question ${questionId} rejected by user`);
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
handled: true,
|
|
174
|
+
answers: pending.request.questions.map(() => []),
|
|
175
|
+
requestId: questionId,
|
|
176
|
+
rejected: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const currentQuestion = pending.request.questions[pending.currentQuestionIndex];
|
|
180
|
+
|
|
181
|
+
const selectedLabels = this.parseUserResponse(trimmedMessage, currentQuestion);
|
|
182
|
+
|
|
183
|
+
if (selectedLabels.length === 0) {
|
|
184
|
+
await this.mmClient.createPost(
|
|
185
|
+
channelId,
|
|
186
|
+
`⚠️ I didn't understand your response. Please reply with a number (1-${currentQuestion.options.length + 1}) or type your answer.`,
|
|
187
|
+
threadRootPostId
|
|
188
|
+
);
|
|
189
|
+
return { handled: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
pending.answers[pending.currentQuestionIndex] = selectedLabels;
|
|
193
|
+
|
|
194
|
+
if (pending.currentQuestionIndex < pending.request.questions.length - 1) {
|
|
195
|
+
pending.currentQuestionIndex++;
|
|
196
|
+
const nextQuestionMessage = this.formatQuestionPost(pending.request, pending.currentQuestionIndex);
|
|
197
|
+
await this.mmClient.createPost(channelId, nextQuestionMessage, threadRootPostId);
|
|
198
|
+
return { handled: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.pendingQuestions.delete(questionId);
|
|
202
|
+
this.sessionToQuestion.delete(sessionId);
|
|
203
|
+
|
|
204
|
+
const selectionSummary = pending.answers.map((ans, idx) => {
|
|
205
|
+
const q = pending.request.questions[idx];
|
|
206
|
+
return `**${q.header}**: ${ans.join(", ")}`;
|
|
207
|
+
}).join("\n");
|
|
208
|
+
|
|
209
|
+
await this.mmClient.createPost(
|
|
210
|
+
channelId,
|
|
211
|
+
`✅ Got it!\n${selectionSummary}`,
|
|
212
|
+
threadRootPostId
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
log.info(`[QuestionHandler] Question ${questionId} answered: ${JSON.stringify(pending.answers)}`);
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
handled: true,
|
|
219
|
+
answers: pending.answers,
|
|
220
|
+
requestId: questionId,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private parseUserResponse(message: string, question: QuestionInfo): string[] {
|
|
225
|
+
const selectedLabels: string[] = [];
|
|
226
|
+
const allowCustom = question.custom !== false;
|
|
227
|
+
const otherOptionNumber = question.options.length + 1;
|
|
228
|
+
|
|
229
|
+
const numberPattern = /^[\d,\s]+$/;
|
|
230
|
+
if (numberPattern.test(message)) {
|
|
231
|
+
const numbers = message.split(/[,\s]+/).map(n => parseInt(n.trim(), 10)).filter(n => !isNaN(n));
|
|
232
|
+
|
|
233
|
+
for (const num of numbers) {
|
|
234
|
+
if (num >= 1 && num <= question.options.length) {
|
|
235
|
+
selectedLabels.push(question.options[num - 1].label);
|
|
236
|
+
} else if (allowCustom && num === otherOptionNumber) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (selectedLabels.length > 0) {
|
|
242
|
+
return question.multiple ? selectedLabels : [selectedLabels[0]];
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const opt of question.options) {
|
|
247
|
+
if (opt.label.toLowerCase() === message.toLowerCase()) {
|
|
248
|
+
return [opt.label];
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (allowCustom && message.length > 0) {
|
|
253
|
+
return [message];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
hasPendingQuestion(sessionId: string): boolean {
|
|
260
|
+
return this.sessionToQuestion.has(sessionId);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Verify with OpenCode server that a question is still pending.
|
|
265
|
+
* Returns true only if both plugin AND server have the question pending.
|
|
266
|
+
* If the server no longer has the question, cleans up plugin state.
|
|
267
|
+
*/
|
|
268
|
+
async verifyQuestionStillPending(sessionId: string): Promise<{
|
|
269
|
+
pending: boolean;
|
|
270
|
+
reason?: "no_plugin_state" | "server_no_longer_pending" | "server_error";
|
|
271
|
+
}> {
|
|
272
|
+
const questionId = this.sessionToQuestion.get(sessionId);
|
|
273
|
+
if (!questionId) {
|
|
274
|
+
return { pending: false, reason: "no_plugin_state" };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const pending = this.pendingQuestions.get(questionId);
|
|
278
|
+
if (!pending) {
|
|
279
|
+
this.sessionToQuestion.delete(sessionId);
|
|
280
|
+
return { pending: false, reason: "no_plugin_state" };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!this.opencodeBaseUrl) {
|
|
284
|
+
log.warn(`[QuestionHandler] OpenCode config not set, assuming question ${questionId} is still pending`);
|
|
285
|
+
return { pending: true };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const response = await fetch(`${this.opencodeBaseUrl}/question`, {
|
|
290
|
+
method: "GET",
|
|
291
|
+
headers: {
|
|
292
|
+
"x-opencode-directory": this.opencodeDirectory,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
log.warn(`[QuestionHandler] Failed to fetch server questions: HTTP ${response.status}`);
|
|
298
|
+
return { pending: true };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const serverQuestions: OpenCodeQuestion[] = await response.json();
|
|
302
|
+
const isStillPending = serverQuestions.some(q => q.id === questionId);
|
|
303
|
+
|
|
304
|
+
if (!isStillPending) {
|
|
305
|
+
log.info(`[QuestionHandler] Question ${questionId} no longer pending on server, cleaning up plugin state`);
|
|
306
|
+
this.pendingQuestions.delete(questionId);
|
|
307
|
+
this.sessionToQuestion.delete(sessionId);
|
|
308
|
+
return { pending: false, reason: "server_no_longer_pending" };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { pending: true };
|
|
312
|
+
} catch (error) {
|
|
313
|
+
log.error(`[QuestionHandler] Error verifying question state:`, error);
|
|
314
|
+
return { pending: true };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
getPendingQuestionId(sessionId: string): string | undefined {
|
|
319
|
+
return this.sessionToQuestion.get(sessionId);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
getPendingQuestionInfo(sessionId: string): PendingQuestion | undefined {
|
|
323
|
+
const questionId = this.sessionToQuestion.get(sessionId);
|
|
324
|
+
if (!questionId) return undefined;
|
|
325
|
+
return this.pendingQuestions.get(questionId);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
cancelQuestion(questionId: string): void {
|
|
329
|
+
const pending = this.pendingQuestions.get(questionId);
|
|
330
|
+
if (pending) {
|
|
331
|
+
this.sessionToQuestion.delete(pending.request.sessionID);
|
|
332
|
+
this.pendingQuestions.delete(questionId);
|
|
333
|
+
log.info(`[QuestionHandler] Cancelled question ${questionId}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
cancelSessionQuestions(sessionId: string): void {
|
|
338
|
+
const questionId = this.sessionToQuestion.get(sessionId);
|
|
339
|
+
if (questionId) {
|
|
340
|
+
this.cancelQuestion(questionId);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
cleanupExpired(maxAgeMs: number = 30 * 60 * 1000): number {
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
let cleaned = 0;
|
|
347
|
+
|
|
348
|
+
for (const [id, pending] of this.pendingQuestions.entries()) {
|
|
349
|
+
if (now - pending.createdAt > maxAgeMs) {
|
|
350
|
+
this.sessionToQuestion.delete(pending.request.sessionID);
|
|
351
|
+
this.pendingQuestions.delete(id);
|
|
352
|
+
cleaned++;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (cleaned > 0) {
|
|
357
|
+
log.info(`[QuestionHandler] Cleaned up ${cleaned} expired questions`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return cleaned;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async syncWithServer(): Promise<{ synced: number; removed: number }> {
|
|
364
|
+
if (!this.opencodeBaseUrl) {
|
|
365
|
+
return { synced: 0, removed: 0 };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let removed = 0;
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
const response = await fetch(`${this.opencodeBaseUrl}/question`, {
|
|
372
|
+
method: "GET",
|
|
373
|
+
headers: {
|
|
374
|
+
"x-opencode-directory": this.opencodeDirectory,
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (!response.ok) {
|
|
379
|
+
log.warn(`[QuestionHandler] Failed to sync with server: HTTP ${response.status}`);
|
|
380
|
+
return { synced: 0, removed: 0 };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const serverQuestions: OpenCodeQuestion[] = await response.json();
|
|
384
|
+
const serverQuestionIds = new Set(serverQuestions.map(q => q.id));
|
|
385
|
+
|
|
386
|
+
for (const [questionId, pending] of this.pendingQuestions.entries()) {
|
|
387
|
+
if (!serverQuestionIds.has(questionId)) {
|
|
388
|
+
log.info(`[QuestionHandler] Sync: Question ${questionId} no longer on server, removing`);
|
|
389
|
+
this.sessionToQuestion.delete(pending.request.sessionID);
|
|
390
|
+
this.pendingQuestions.delete(questionId);
|
|
391
|
+
removed++;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { synced: serverQuestions.length, removed };
|
|
396
|
+
} catch (error) {
|
|
397
|
+
log.error(`[QuestionHandler] Error syncing with server:`, error);
|
|
398
|
+
return { synced: 0, removed: 0 };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { SessionManager, UserSession } from "./session-manager.js";
|
|
2
|
+
import type { NotificationService } from "./notification-service.js";
|
|
3
|
+
import type { WebSocketEvent } from "./models/index.js";
|
|
4
|
+
import { log } from "./logger.js";
|
|
5
|
+
|
|
6
|
+
export type ReactionAction = "approve" | "deny" | "cancel" | "retry" | "clear";
|
|
7
|
+
|
|
8
|
+
const REACTION_MAP: Record<string, ReactionAction> = {
|
|
9
|
+
white_check_mark: "approve",
|
|
10
|
+
heavy_check_mark: "approve",
|
|
11
|
+
x: "deny",
|
|
12
|
+
octagonal_sign: "cancel",
|
|
13
|
+
stop_sign: "cancel",
|
|
14
|
+
arrows_counterclockwise: "retry",
|
|
15
|
+
repeat: "retry",
|
|
16
|
+
wastebasket: "clear",
|
|
17
|
+
trash: "clear",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface ReactionHandlerCallbacks {
|
|
21
|
+
onApprove?: (session: UserSession) => Promise<void>;
|
|
22
|
+
onDeny?: (session: UserSession) => Promise<void>;
|
|
23
|
+
onCancel?: (session: UserSession) => Promise<void>;
|
|
24
|
+
onRetry?: (session: UserSession) => Promise<void>;
|
|
25
|
+
onClear?: (session: UserSession) => Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ReactionHandler {
|
|
29
|
+
private sessionManager: SessionManager;
|
|
30
|
+
private notifications: NotificationService;
|
|
31
|
+
private callbacks: ReactionHandlerCallbacks;
|
|
32
|
+
private botUserId: string | null = null;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
sessionManager: SessionManager,
|
|
36
|
+
notifications: NotificationService,
|
|
37
|
+
callbacks: ReactionHandlerCallbacks = {}
|
|
38
|
+
) {
|
|
39
|
+
this.sessionManager = sessionManager;
|
|
40
|
+
this.notifications = notifications;
|
|
41
|
+
this.callbacks = callbacks;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setBotUserId(botUserId: string): void {
|
|
45
|
+
this.botUserId = botUserId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async handleReaction(event: WebSocketEvent): Promise<void> {
|
|
49
|
+
const reactionData = event.data?.reaction;
|
|
50
|
+
if (!reactionData) return;
|
|
51
|
+
|
|
52
|
+
const { user_id: userId, emoji_name: emojiName, post_id: postId } = reactionData;
|
|
53
|
+
|
|
54
|
+
if (userId === this.botUserId) return;
|
|
55
|
+
|
|
56
|
+
const action = REACTION_MAP[emojiName];
|
|
57
|
+
if (!action) return;
|
|
58
|
+
|
|
59
|
+
const session = this.sessionManager.getSession(userId);
|
|
60
|
+
if (!session) {
|
|
61
|
+
log.warn(`[ReactionHandler] No session found for user ${userId}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
log.debug(`[ReactionHandler] Processing ${action} reaction from ${session.mattermostUsername}`);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
switch (action) {
|
|
69
|
+
case "approve":
|
|
70
|
+
if (session.pendingPermission && this.callbacks.onApprove) {
|
|
71
|
+
await this.callbacks.onApprove(session);
|
|
72
|
+
session.pendingPermission = null;
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case "deny":
|
|
77
|
+
if (session.pendingPermission && this.callbacks.onDeny) {
|
|
78
|
+
await this.callbacks.onDeny(session);
|
|
79
|
+
session.pendingPermission = null;
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
|
|
83
|
+
case "cancel":
|
|
84
|
+
if (session.isProcessing && this.callbacks.onCancel) {
|
|
85
|
+
await this.callbacks.onCancel(session);
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case "retry":
|
|
90
|
+
if (session.lastPrompt && this.callbacks.onRetry) {
|
|
91
|
+
await this.callbacks.onRetry(session);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
|
|
95
|
+
case "clear":
|
|
96
|
+
if (this.callbacks.onClear) {
|
|
97
|
+
await this.callbacks.onClear(session);
|
|
98
|
+
}
|
|
99
|
+
this.sessionManager.destroySession(userId);
|
|
100
|
+
await this.notifications.notifyStatus(session, {
|
|
101
|
+
type: "idle",
|
|
102
|
+
details: "Session cleared",
|
|
103
|
+
});
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
log.error(`[ReactionHandler] Error handling ${action}:`, error);
|
|
108
|
+
await this.notifications.notifyError(session, error as Error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|