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