@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,242 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { MattermostClient } from "./clients/mattermost-client.js";
|
|
4
|
+
import type { FilesConfig } from "./config.js";
|
|
5
|
+
import type { UserSession } from "./session-manager.js";
|
|
6
|
+
import { log } from "./logger.js";
|
|
7
|
+
|
|
8
|
+
export class FileHandler {
|
|
9
|
+
private mmClient: MattermostClient;
|
|
10
|
+
private config: FilesConfig;
|
|
11
|
+
private tempFiles: Set<string> = new Set();
|
|
12
|
+
|
|
13
|
+
constructor(mmClient: MattermostClient, config: FilesConfig) {
|
|
14
|
+
this.mmClient = mmClient;
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.ensureTempDir();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private ensureTempDir(): void {
|
|
20
|
+
if (!fs.existsSync(this.config.tempDir)) {
|
|
21
|
+
fs.mkdirSync(this.config.tempDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async processInboundAttachments(fileIds: string[]): Promise<string[]> {
|
|
26
|
+
const filePaths: string[] = [];
|
|
27
|
+
|
|
28
|
+
for (const fileId of fileIds) {
|
|
29
|
+
try {
|
|
30
|
+
const fileInfo = await this.mmClient.getFileInfo(fileId);
|
|
31
|
+
|
|
32
|
+
if (fileInfo.size > this.config.maxFileSize) {
|
|
33
|
+
log.warn(`[FileHandler] File ${fileInfo.name} exceeds max size, skipping`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
this.config.allowedExtensions[0] !== "*" &&
|
|
39
|
+
!this.config.allowedExtensions.includes(fileInfo.extension)
|
|
40
|
+
) {
|
|
41
|
+
log.warn(`[FileHandler] File extension ${fileInfo.extension} not allowed, skipping`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fileData = await this.mmClient.downloadFile(fileId);
|
|
46
|
+
const tempFileName = `${Date.now()}-${fileInfo.name}`;
|
|
47
|
+
const tempFilePath = path.join(this.config.tempDir, tempFileName);
|
|
48
|
+
|
|
49
|
+
fs.writeFileSync(tempFilePath, fileData);
|
|
50
|
+
this.tempFiles.add(tempFilePath);
|
|
51
|
+
filePaths.push(tempFilePath);
|
|
52
|
+
|
|
53
|
+
log.debug(`[FileHandler] Downloaded file: ${fileInfo.name} -> ${tempFilePath}`);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
log.error(`[FileHandler] Failed to process file ${fileId}:`, error);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return filePaths;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async processInboundAttachmentsAsFileParts(fileIds: string[]): Promise<{
|
|
63
|
+
fileParts: Array<{ type: "file"; mime: string; filename: string; url: string }>;
|
|
64
|
+
textFilePaths: string[];
|
|
65
|
+
}> {
|
|
66
|
+
const fileParts: Array<{ type: "file"; mime: string; filename: string; url: string }> = [];
|
|
67
|
+
const textFilePaths: string[] = [];
|
|
68
|
+
|
|
69
|
+
for (const fileId of fileIds) {
|
|
70
|
+
try {
|
|
71
|
+
const fileInfo = await this.mmClient.getFileInfo(fileId);
|
|
72
|
+
|
|
73
|
+
if (fileInfo.size > this.config.maxFileSize) {
|
|
74
|
+
log.warn(`[FileHandler] File ${fileInfo.name} exceeds max size, skipping`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
this.config.allowedExtensions[0] !== "*" &&
|
|
80
|
+
!this.config.allowedExtensions.includes(fileInfo.extension)
|
|
81
|
+
) {
|
|
82
|
+
log.warn(`[FileHandler] File extension ${fileInfo.extension} not allowed, skipping`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const fileData = await this.mmClient.downloadFile(fileId);
|
|
87
|
+
const tempFileName = `${Date.now()}-${fileInfo.name}`;
|
|
88
|
+
const tempFilePath = path.join(this.config.tempDir, tempFileName);
|
|
89
|
+
|
|
90
|
+
fs.writeFileSync(tempFilePath, fileData);
|
|
91
|
+
this.tempFiles.add(tempFilePath);
|
|
92
|
+
|
|
93
|
+
const mimeType = this.getMimeType(tempFilePath);
|
|
94
|
+
|
|
95
|
+
if (this.isImageOrPdf(mimeType)) {
|
|
96
|
+
const base64Data = fileData.toString("base64");
|
|
97
|
+
fileParts.push({
|
|
98
|
+
type: "file",
|
|
99
|
+
mime: mimeType,
|
|
100
|
+
filename: fileInfo.name,
|
|
101
|
+
url: `data:${mimeType};base64,${base64Data}`,
|
|
102
|
+
});
|
|
103
|
+
log.debug(`[FileHandler] Processed file as FilePart: ${fileInfo.name} (${mimeType})`);
|
|
104
|
+
} else {
|
|
105
|
+
textFilePaths.push(tempFilePath);
|
|
106
|
+
log.debug(`[FileHandler] Downloaded text file: ${fileInfo.name} -> ${tempFilePath}`);
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
log.error(`[FileHandler] Failed to process file ${fileId}:`, error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { fileParts, textFilePaths };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async sendOutboundFile(
|
|
117
|
+
session: UserSession,
|
|
118
|
+
filePath: string,
|
|
119
|
+
message?: string
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
try {
|
|
122
|
+
const fileName = path.basename(filePath);
|
|
123
|
+
const fileData = fs.readFileSync(filePath);
|
|
124
|
+
const mimeType = this.getMimeType(filePath);
|
|
125
|
+
|
|
126
|
+
const uploadResult = await this.mmClient.uploadFile(
|
|
127
|
+
session.dmChannelId,
|
|
128
|
+
fileName,
|
|
129
|
+
fileData,
|
|
130
|
+
mimeType
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const fileIds = uploadResult.file_infos.map((f) => f.id);
|
|
134
|
+
const displayMessage = message || `File: \`${fileName}\``;
|
|
135
|
+
|
|
136
|
+
await this.mmClient.createPost(session.dmChannelId, displayMessage, undefined, fileIds);
|
|
137
|
+
|
|
138
|
+
log.debug(`[FileHandler] Sent file: ${fileName}`);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
log.error(`[FileHandler] Failed to send file ${filePath}:`, error);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Send a file to a specific Mattermost channel and thread.
|
|
147
|
+
* Used by the send_file_to_mattermost tool to post files in the correct conversation thread.
|
|
148
|
+
*/
|
|
149
|
+
async sendFileToThread(
|
|
150
|
+
channelId: string,
|
|
151
|
+
threadRootPostId: string,
|
|
152
|
+
filePath: string,
|
|
153
|
+
message?: string
|
|
154
|
+
): Promise<{ success: boolean; fileName: string; error?: string }> {
|
|
155
|
+
try {
|
|
156
|
+
if (!fs.existsSync(filePath)) {
|
|
157
|
+
return { success: false, fileName: path.basename(filePath), error: `File not found: ${filePath}` };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const fileName = path.basename(filePath);
|
|
161
|
+
const fileData = fs.readFileSync(filePath);
|
|
162
|
+
const mimeType = this.getMimeType(filePath);
|
|
163
|
+
const fileSize = fileData.length;
|
|
164
|
+
|
|
165
|
+
if (fileSize > this.config.maxFileSize) {
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
fileName,
|
|
169
|
+
error: `File exceeds maximum size (${(fileSize / 1024 / 1024).toFixed(2)}MB > ${(this.config.maxFileSize / 1024 / 1024).toFixed(2)}MB)`
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const uploadResult = await this.mmClient.uploadFile(
|
|
174
|
+
channelId,
|
|
175
|
+
fileName,
|
|
176
|
+
fileData,
|
|
177
|
+
mimeType
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const fileIds = uploadResult.file_infos.map((f) => f.id);
|
|
181
|
+
const displayMessage = message || `File: \`${fileName}\``;
|
|
182
|
+
|
|
183
|
+
await this.mmClient.createPost(channelId, displayMessage, threadRootPostId, fileIds);
|
|
184
|
+
|
|
185
|
+
log.info(`[FileHandler] Sent file to thread: ${fileName} (${(fileSize / 1024).toFixed(1)}KB)`);
|
|
186
|
+
return { success: true, fileName };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
189
|
+
log.error(`[FileHandler] Failed to send file to thread ${filePath}:`, error);
|
|
190
|
+
return { success: false, fileName: path.basename(filePath), error: errorMessage };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
getMimeType(filePath: string): string {
|
|
195
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
196
|
+
const mimeTypes: Record<string, string> = {
|
|
197
|
+
".txt": "text/plain",
|
|
198
|
+
".md": "text/markdown",
|
|
199
|
+
".json": "application/json",
|
|
200
|
+
".js": "application/javascript",
|
|
201
|
+
".ts": "application/typescript",
|
|
202
|
+
".py": "text/x-python",
|
|
203
|
+
".html": "text/html",
|
|
204
|
+
".css": "text/css",
|
|
205
|
+
".png": "image/png",
|
|
206
|
+
".jpg": "image/jpeg",
|
|
207
|
+
".jpeg": "image/jpeg",
|
|
208
|
+
".gif": "image/gif",
|
|
209
|
+
".webp": "image/webp",
|
|
210
|
+
".svg": "image/svg+xml",
|
|
211
|
+
".pdf": "application/pdf",
|
|
212
|
+
".zip": "application/zip",
|
|
213
|
+
".csv": "text/csv",
|
|
214
|
+
".xml": "application/xml",
|
|
215
|
+
".yaml": "application/x-yaml",
|
|
216
|
+
".yml": "application/x-yaml",
|
|
217
|
+
};
|
|
218
|
+
return mimeTypes[ext] || "application/octet-stream";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
isImageOrPdf(mimeType: string): boolean {
|
|
222
|
+
return mimeType.startsWith("image/") || mimeType === "application/pdf";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
cleanupTempFiles(): void {
|
|
226
|
+
for (const filePath of this.tempFiles) {
|
|
227
|
+
try {
|
|
228
|
+
if (fs.existsSync(filePath)) {
|
|
229
|
+
fs.unlinkSync(filePath);
|
|
230
|
+
log.debug(`[FileHandler] Cleaned up temp file: ${filePath}`);
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
log.error(`[FileHandler] Failed to cleanup temp file ${filePath}:`, error);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
this.tempFiles.clear();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
cleanupSessionFiles(session: UserSession): void {
|
|
240
|
+
this.cleanupTempFiles();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { log } from "./logger.js";
|
|
2
|
+
import type { Post } from "./models/index.js";
|
|
3
|
+
import type { ThreadMappingStore } from "./persistence/thread-mapping-store.js";
|
|
4
|
+
import type { TeamStore } from "./persistence/team-store.js";
|
|
5
|
+
|
|
6
|
+
export interface PendingApproval {
|
|
7
|
+
requestPostId: string;
|
|
8
|
+
guestUserId: string;
|
|
9
|
+
guestUsername: string;
|
|
10
|
+
originalPost: Post;
|
|
11
|
+
threadRootPostId: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
createdAt: Date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class GuestApprovalHandler {
|
|
17
|
+
private mmClient: any;
|
|
18
|
+
private pendingApprovals: Map<string, PendingApproval> = new Map();
|
|
19
|
+
private readonly APPROVAL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
constructor(mmClient: any) {
|
|
22
|
+
this.mmClient = mmClient;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async requestApproval(
|
|
26
|
+
post: Post,
|
|
27
|
+
guestUsername: string,
|
|
28
|
+
threadRootPostId: string,
|
|
29
|
+
sessionId: string,
|
|
30
|
+
channelId: string
|
|
31
|
+
): Promise<string> {
|
|
32
|
+
const messagePreview = post.message.length > 200
|
|
33
|
+
? post.message.substring(0, 200) + "..."
|
|
34
|
+
: post.message;
|
|
35
|
+
|
|
36
|
+
const approvalMessage = `🔔 **Guest Access Request**
|
|
37
|
+
|
|
38
|
+
@${guestUsername} wants to send a prompt in this session:
|
|
39
|
+
|
|
40
|
+
> ${messagePreview}
|
|
41
|
+
|
|
42
|
+
**Reply with a number to respond:**
|
|
43
|
+
\`1\` - Approve this message only
|
|
44
|
+
\`2\` - Approve all future messages from @${guestUsername} in this thread
|
|
45
|
+
\`3\` - Approve all users in this thread
|
|
46
|
+
|
|
47
|
+
_Reply \`deny\` or \`0\` to reject_`;
|
|
48
|
+
|
|
49
|
+
const requestPost = await this.mmClient.createPost(
|
|
50
|
+
channelId,
|
|
51
|
+
approvalMessage,
|
|
52
|
+
threadRootPostId
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const pending: PendingApproval = {
|
|
56
|
+
requestPostId: requestPost.id,
|
|
57
|
+
guestUserId: post.user_id,
|
|
58
|
+
guestUsername,
|
|
59
|
+
originalPost: post,
|
|
60
|
+
threadRootPostId,
|
|
61
|
+
sessionId,
|
|
62
|
+
createdAt: new Date(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
this.pendingApprovals.set(sessionId, pending);
|
|
66
|
+
log.info(`[GuestApproval] Requested approval for @${guestUsername} in session ${sessionId.substring(0, 8)}`);
|
|
67
|
+
|
|
68
|
+
return requestPost.id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
hasPendingApproval(sessionId: string): boolean {
|
|
72
|
+
const pending = this.pendingApprovals.get(sessionId);
|
|
73
|
+
if (!pending) return false;
|
|
74
|
+
|
|
75
|
+
const elapsed = Date.now() - pending.createdAt.getTime();
|
|
76
|
+
if (elapsed > this.APPROVAL_TIMEOUT_MS) {
|
|
77
|
+
this.pendingApprovals.delete(sessionId);
|
|
78
|
+
log.info(`[GuestApproval] Approval request expired for session ${sessionId.substring(0, 8)}`);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getPendingApproval(sessionId: string): PendingApproval | undefined {
|
|
86
|
+
if (!this.hasPendingApproval(sessionId)) return undefined;
|
|
87
|
+
return this.pendingApprovals.get(sessionId);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if a message looks like a guest approval response.
|
|
92
|
+
* Used to determine if owner's message should be interpreted as approval or passed through as prompt.
|
|
93
|
+
*/
|
|
94
|
+
looksLikeApprovalResponse(text: string): boolean {
|
|
95
|
+
const trimmed = text.trim().toLowerCase();
|
|
96
|
+
// Explicit approval syntax: "approve 1", "approve 2", "approve 3"
|
|
97
|
+
if (/^approve\s+[0-3]$/i.test(trimmed)) return true;
|
|
98
|
+
// Simple choices: "0", "1", "2", "3", "deny", "no"
|
|
99
|
+
if (/^[0-3]$/.test(trimmed)) return true;
|
|
100
|
+
if (trimmed === "deny" || trimmed === "no") return true;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async handleOwnerReply(
|
|
105
|
+
sessionId: string,
|
|
106
|
+
replyText: string,
|
|
107
|
+
threadMappingStore: ThreadMappingStore,
|
|
108
|
+
channelId: string
|
|
109
|
+
): Promise<{ approved: boolean; post?: Post; message: string; wasApprovalResponse: boolean }> {
|
|
110
|
+
const pending = this.pendingApprovals.get(sessionId);
|
|
111
|
+
if (!pending) {
|
|
112
|
+
return { approved: false, message: "No pending approval request found.", wasApprovalResponse: false };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const trimmed = replyText.trim().toLowerCase();
|
|
116
|
+
const mapping = threadMappingStore.getBySessionId(sessionId);
|
|
117
|
+
|
|
118
|
+
const approveMatch = trimmed.match(/^approve\s+(\d)$/);
|
|
119
|
+
const effectiveChoice = approveMatch ? approveMatch[1] : trimmed;
|
|
120
|
+
|
|
121
|
+
if (effectiveChoice === "0" || effectiveChoice === "deny" || effectiveChoice === "no") {
|
|
122
|
+
this.pendingApprovals.delete(sessionId);
|
|
123
|
+
await this.mmClient.createPost(
|
|
124
|
+
channelId,
|
|
125
|
+
`❌ Request from @${pending.guestUsername} was denied.`,
|
|
126
|
+
pending.threadRootPostId
|
|
127
|
+
);
|
|
128
|
+
log.info(`[GuestApproval] Denied request from @${pending.guestUsername} in session ${sessionId.substring(0, 8)}`);
|
|
129
|
+
return { approved: false, message: "Request denied.", wasApprovalResponse: true };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (effectiveChoice === "1") {
|
|
133
|
+
this.pendingApprovals.delete(sessionId);
|
|
134
|
+
await this.mmClient.createPost(
|
|
135
|
+
channelId,
|
|
136
|
+
`✅ Approved message from @${pending.guestUsername}.`,
|
|
137
|
+
pending.threadRootPostId
|
|
138
|
+
);
|
|
139
|
+
log.info(`[GuestApproval] Approved single message from @${pending.guestUsername} in session ${sessionId.substring(0, 8)}`);
|
|
140
|
+
return { approved: true, post: pending.originalPost, message: "Message approved.", wasApprovalResponse: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (effectiveChoice === "2") {
|
|
144
|
+
this.pendingApprovals.delete(sessionId);
|
|
145
|
+
if (mapping) {
|
|
146
|
+
const approvedUsers = mapping.approvedUsers || [];
|
|
147
|
+
if (!approvedUsers.includes(pending.guestUserId)) {
|
|
148
|
+
approvedUsers.push(pending.guestUserId);
|
|
149
|
+
}
|
|
150
|
+
mapping.approvedUsers = approvedUsers;
|
|
151
|
+
threadMappingStore.update(mapping);
|
|
152
|
+
}
|
|
153
|
+
await this.mmClient.createPost(
|
|
154
|
+
channelId,
|
|
155
|
+
`✅ Approved @${pending.guestUsername} for all future messages in this thread.`,
|
|
156
|
+
pending.threadRootPostId
|
|
157
|
+
);
|
|
158
|
+
log.info(`[GuestApproval] Approved @${pending.guestUsername} for all future messages in session ${sessionId.substring(0, 8)}`);
|
|
159
|
+
return { approved: true, post: pending.originalPost, message: "User approved for thread.", wasApprovalResponse: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (effectiveChoice === "3") {
|
|
163
|
+
this.pendingApprovals.delete(sessionId);
|
|
164
|
+
if (mapping) {
|
|
165
|
+
mapping.approveAllUsers = true;
|
|
166
|
+
threadMappingStore.update(mapping);
|
|
167
|
+
}
|
|
168
|
+
await this.mmClient.createPost(
|
|
169
|
+
channelId,
|
|
170
|
+
`✅ Approved all users for this thread. Anyone can now send prompts here.`,
|
|
171
|
+
pending.threadRootPostId
|
|
172
|
+
);
|
|
173
|
+
log.info(`[GuestApproval] Approved all users for session ${sessionId.substring(0, 8)}`);
|
|
174
|
+
return { approved: true, post: pending.originalPost, message: "All users approved for thread.", wasApprovalResponse: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Message doesn't look like an approval response - let it pass through as a regular prompt
|
|
178
|
+
log.info(`[GuestApproval] Owner message "${replyText.slice(0, 50)}..." is not an approval response, passing through as prompt`);
|
|
179
|
+
return { approved: false, message: "Not an approval response.", wasApprovalResponse: false };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
isUserApproved(
|
|
183
|
+
userId: string,
|
|
184
|
+
mapping: { approvedUsers?: string[]; approveAllUsers?: boolean; approveNextMessage?: boolean } | null,
|
|
185
|
+
teamStore?: TeamStore | null
|
|
186
|
+
): boolean {
|
|
187
|
+
if (teamStore?.hasTeamAccess(userId)) return true;
|
|
188
|
+
if (!mapping) return false;
|
|
189
|
+
if (mapping.approveAllUsers) return true;
|
|
190
|
+
if (mapping.approveNextMessage) return true;
|
|
191
|
+
if (mapping.approvedUsers?.includes(userId)) return true;
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
consumeNextMessageApproval(
|
|
196
|
+
mapping: { approveNextMessage?: boolean } | null,
|
|
197
|
+
threadMappingStore: ThreadMappingStore
|
|
198
|
+
): void {
|
|
199
|
+
if (mapping && mapping.approveNextMessage) {
|
|
200
|
+
(mapping as any).approveNextMessage = false;
|
|
201
|
+
threadMappingStore.update(mapping as any);
|
|
202
|
+
log.info(`[GuestApproval] Consumed one-time approveNextMessage approval`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
clearPendingApproval(sessionId: string): void {
|
|
207
|
+
this.pendingApprovals.delete(sessionId);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
cleanupExpired(): number {
|
|
211
|
+
let cleaned = 0;
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
|
|
214
|
+
for (const [sessionId, pending] of this.pendingApprovals.entries()) {
|
|
215
|
+
if (now - pending.createdAt.getTime() > this.APPROVAL_TIMEOUT_MS) {
|
|
216
|
+
this.pendingApprovals.delete(sessionId);
|
|
217
|
+
cleaned++;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return cleaned;
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
|
|
4
|
+
const LOG_FILE = process.env.MM_PLUGIN_LOG_FILE || "/tmp/opencode-mattermost-plugin.log";
|
|
5
|
+
|
|
6
|
+
function ensureLogDir(): void {
|
|
7
|
+
const dir = dirname(LOG_FILE);
|
|
8
|
+
if (!existsSync(dir)) {
|
|
9
|
+
mkdirSync(dir, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatTimestamp(): string {
|
|
14
|
+
return new Date().toISOString();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function serializeArg(arg: unknown): string {
|
|
18
|
+
if (arg === null || arg === undefined) {
|
|
19
|
+
return String(arg);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Handle Error objects specially (including Axios errors)
|
|
23
|
+
if (arg instanceof Error) {
|
|
24
|
+
const errorObj: Record<string, unknown> = {
|
|
25
|
+
message: arg.message,
|
|
26
|
+
name: arg.name,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Capture Axios-specific error properties
|
|
30
|
+
const axiosError = arg as { response?: { status?: number; data?: unknown }; code?: string; config?: { url?: string; method?: string } };
|
|
31
|
+
if (axiosError.response) {
|
|
32
|
+
errorObj.status = axiosError.response.status;
|
|
33
|
+
errorObj.responseData = axiosError.response.data;
|
|
34
|
+
}
|
|
35
|
+
if (axiosError.code) {
|
|
36
|
+
errorObj.code = axiosError.code;
|
|
37
|
+
}
|
|
38
|
+
if (axiosError.config) {
|
|
39
|
+
errorObj.url = axiosError.config.url;
|
|
40
|
+
errorObj.method = axiosError.config.method;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return JSON.stringify(errorObj);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof arg === "object") {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.stringify(arg);
|
|
49
|
+
} catch {
|
|
50
|
+
return String(arg);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return String(arg);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeLog(level: string, message: string, ...args: unknown[]): void {
|
|
58
|
+
try {
|
|
59
|
+
ensureLogDir();
|
|
60
|
+
const formattedArgs = args.length > 0
|
|
61
|
+
? " " + args.map(serializeArg).join(" ")
|
|
62
|
+
: "";
|
|
63
|
+
const line = `[${formatTimestamp()}] [${level}] ${message}${formattedArgs}\n`;
|
|
64
|
+
appendFileSync(LOG_FILE, line);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const log = {
|
|
69
|
+
info: (message: string, ...args: unknown[]) => writeLog("INFO", message, ...args),
|
|
70
|
+
error: (message: string, ...args: unknown[]) => writeLog("ERROR", message, ...args),
|
|
71
|
+
debug: (message: string, ...args: unknown[]) => writeLog("DEBUG", message, ...args),
|
|
72
|
+
warn: (message: string, ...args: unknown[]) => writeLog("WARN", message, ...args),
|
|
73
|
+
};
|