claude-threads 0.12.0

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 (46) hide show
  1. package/CHANGELOG.md +473 -0
  2. package/LICENSE +21 -0
  3. package/README.md +303 -0
  4. package/dist/changelog.d.ts +20 -0
  5. package/dist/changelog.js +134 -0
  6. package/dist/claude/cli.d.ts +42 -0
  7. package/dist/claude/cli.js +173 -0
  8. package/dist/claude/session.d.ts +256 -0
  9. package/dist/claude/session.js +1964 -0
  10. package/dist/config.d.ts +27 -0
  11. package/dist/config.js +94 -0
  12. package/dist/git/worktree.d.ts +50 -0
  13. package/dist/git/worktree.js +228 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +371 -0
  16. package/dist/logo.d.ts +31 -0
  17. package/dist/logo.js +57 -0
  18. package/dist/mattermost/api.d.ts +85 -0
  19. package/dist/mattermost/api.js +124 -0
  20. package/dist/mattermost/api.test.d.ts +1 -0
  21. package/dist/mattermost/api.test.js +319 -0
  22. package/dist/mattermost/client.d.ts +56 -0
  23. package/dist/mattermost/client.js +321 -0
  24. package/dist/mattermost/emoji.d.ts +43 -0
  25. package/dist/mattermost/emoji.js +65 -0
  26. package/dist/mattermost/emoji.test.d.ts +1 -0
  27. package/dist/mattermost/emoji.test.js +131 -0
  28. package/dist/mattermost/types.d.ts +71 -0
  29. package/dist/mattermost/types.js +1 -0
  30. package/dist/mcp/permission-server.d.ts +2 -0
  31. package/dist/mcp/permission-server.js +201 -0
  32. package/dist/onboarding.d.ts +1 -0
  33. package/dist/onboarding.js +116 -0
  34. package/dist/persistence/session-store.d.ts +65 -0
  35. package/dist/persistence/session-store.js +127 -0
  36. package/dist/update-notifier.d.ts +3 -0
  37. package/dist/update-notifier.js +31 -0
  38. package/dist/utils/logger.d.ts +34 -0
  39. package/dist/utils/logger.js +42 -0
  40. package/dist/utils/logger.test.d.ts +1 -0
  41. package/dist/utils/logger.test.js +121 -0
  42. package/dist/utils/tool-formatter.d.ts +56 -0
  43. package/dist/utils/tool-formatter.js +247 -0
  44. package/dist/utils/tool-formatter.test.d.ts +1 -0
  45. package/dist/utils/tool-formatter.test.js +357 -0
  46. package/package.json +85 -0
@@ -0,0 +1,319 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { mattermostApi, getMe, getUser, createPost, updatePost, addReaction, isUserAllowed, createInteractivePost, } from './api.js';
3
+ const mockConfig = {
4
+ url: 'https://mattermost.example.com',
5
+ token: 'test-token',
6
+ };
7
+ describe('mattermostApi', () => {
8
+ beforeEach(() => {
9
+ vi.stubGlobal('fetch', vi.fn());
10
+ });
11
+ afterEach(() => {
12
+ vi.unstubAllGlobals();
13
+ });
14
+ it('adds authorization header', async () => {
15
+ const mockFetch = vi.fn().mockResolvedValue({
16
+ ok: true,
17
+ json: () => Promise.resolve({ id: '123' }),
18
+ });
19
+ vi.stubGlobal('fetch', mockFetch);
20
+ await mattermostApi(mockConfig, 'GET', '/users/me');
21
+ expect(mockFetch).toHaveBeenCalledWith('https://mattermost.example.com/api/v4/users/me', expect.objectContaining({
22
+ headers: expect.objectContaining({
23
+ Authorization: 'Bearer test-token',
24
+ }),
25
+ }));
26
+ });
27
+ it('includes Content-Type header', async () => {
28
+ const mockFetch = vi.fn().mockResolvedValue({
29
+ ok: true,
30
+ json: () => Promise.resolve({}),
31
+ });
32
+ vi.stubGlobal('fetch', mockFetch);
33
+ await mattermostApi(mockConfig, 'POST', '/posts', { message: 'test' });
34
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
35
+ headers: expect.objectContaining({
36
+ 'Content-Type': 'application/json',
37
+ }),
38
+ }));
39
+ });
40
+ it('stringifies body for POST requests', async () => {
41
+ const mockFetch = vi.fn().mockResolvedValue({
42
+ ok: true,
43
+ json: () => Promise.resolve({}),
44
+ });
45
+ vi.stubGlobal('fetch', mockFetch);
46
+ await mattermostApi(mockConfig, 'POST', '/posts', { message: 'hello' });
47
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
48
+ body: JSON.stringify({ message: 'hello' }),
49
+ }));
50
+ });
51
+ it('throws on non-ok response', async () => {
52
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
53
+ ok: false,
54
+ status: 401,
55
+ text: () => Promise.resolve('Unauthorized'),
56
+ }));
57
+ await expect(mattermostApi(mockConfig, 'GET', '/users/me')).rejects.toThrow('Mattermost API error 401');
58
+ });
59
+ it('includes error details in thrown error', async () => {
60
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
61
+ ok: false,
62
+ status: 403,
63
+ text: () => Promise.resolve('Access denied'),
64
+ }));
65
+ await expect(mattermostApi(mockConfig, 'GET', '/users/me')).rejects.toThrow('Access denied');
66
+ });
67
+ });
68
+ describe('getMe', () => {
69
+ beforeEach(() => {
70
+ vi.stubGlobal('fetch', vi.fn());
71
+ });
72
+ afterEach(() => {
73
+ vi.unstubAllGlobals();
74
+ });
75
+ it('returns the current user', async () => {
76
+ const mockUser = { id: 'bot123', username: 'bot' };
77
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
78
+ ok: true,
79
+ json: () => Promise.resolve(mockUser),
80
+ }));
81
+ const result = await getMe(mockConfig);
82
+ expect(result).toEqual(mockUser);
83
+ });
84
+ });
85
+ describe('getUser', () => {
86
+ beforeEach(() => {
87
+ vi.stubGlobal('fetch', vi.fn());
88
+ });
89
+ afterEach(() => {
90
+ vi.unstubAllGlobals();
91
+ });
92
+ it('returns user when found', async () => {
93
+ const mockUser = { id: 'user123', username: 'testuser' };
94
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
95
+ ok: true,
96
+ json: () => Promise.resolve(mockUser),
97
+ }));
98
+ const result = await getUser(mockConfig, 'user123');
99
+ expect(result).toEqual(mockUser);
100
+ });
101
+ it('returns null when user not found', async () => {
102
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
103
+ ok: false,
104
+ status: 404,
105
+ text: () => Promise.resolve('Not found'),
106
+ }));
107
+ const result = await getUser(mockConfig, 'nonexistent');
108
+ expect(result).toBeNull();
109
+ });
110
+ });
111
+ describe('createPost', () => {
112
+ beforeEach(() => {
113
+ vi.stubGlobal('fetch', vi.fn());
114
+ });
115
+ afterEach(() => {
116
+ vi.unstubAllGlobals();
117
+ });
118
+ it('creates a post with correct parameters', async () => {
119
+ const mockPost = { id: 'post123', channel_id: 'channel1', message: 'Hello' };
120
+ const mockFetch = vi.fn().mockResolvedValue({
121
+ ok: true,
122
+ json: () => Promise.resolve(mockPost),
123
+ });
124
+ vi.stubGlobal('fetch', mockFetch);
125
+ await createPost(mockConfig, 'channel1', 'Hello');
126
+ expect(mockFetch).toHaveBeenCalledWith('https://mattermost.example.com/api/v4/posts', expect.objectContaining({
127
+ method: 'POST',
128
+ body: JSON.stringify({
129
+ channel_id: 'channel1',
130
+ message: 'Hello',
131
+ root_id: undefined,
132
+ }),
133
+ }));
134
+ });
135
+ it('includes root_id for thread replies', async () => {
136
+ const mockFetch = vi.fn().mockResolvedValue({
137
+ ok: true,
138
+ json: () => Promise.resolve({}),
139
+ });
140
+ vi.stubGlobal('fetch', mockFetch);
141
+ await createPost(mockConfig, 'channel1', 'Reply', 'thread123');
142
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
143
+ body: JSON.stringify({
144
+ channel_id: 'channel1',
145
+ message: 'Reply',
146
+ root_id: 'thread123',
147
+ }),
148
+ }));
149
+ });
150
+ });
151
+ describe('updatePost', () => {
152
+ beforeEach(() => {
153
+ vi.stubGlobal('fetch', vi.fn());
154
+ });
155
+ afterEach(() => {
156
+ vi.unstubAllGlobals();
157
+ });
158
+ it('updates a post with correct parameters', async () => {
159
+ const mockFetch = vi.fn().mockResolvedValue({
160
+ ok: true,
161
+ json: () => Promise.resolve({ id: 'post123', message: 'Updated' }),
162
+ });
163
+ vi.stubGlobal('fetch', mockFetch);
164
+ await updatePost(mockConfig, 'post123', 'Updated');
165
+ expect(mockFetch).toHaveBeenCalledWith('https://mattermost.example.com/api/v4/posts/post123', expect.objectContaining({
166
+ method: 'PUT',
167
+ body: JSON.stringify({
168
+ id: 'post123',
169
+ message: 'Updated',
170
+ }),
171
+ }));
172
+ });
173
+ });
174
+ describe('addReaction', () => {
175
+ beforeEach(() => {
176
+ vi.stubGlobal('fetch', vi.fn());
177
+ });
178
+ afterEach(() => {
179
+ vi.unstubAllGlobals();
180
+ });
181
+ it('adds a reaction with correct parameters', async () => {
182
+ const mockFetch = vi.fn().mockResolvedValue({
183
+ ok: true,
184
+ json: () => Promise.resolve({}),
185
+ });
186
+ vi.stubGlobal('fetch', mockFetch);
187
+ await addReaction(mockConfig, 'post123', 'bot123', '+1');
188
+ expect(mockFetch).toHaveBeenCalledWith('https://mattermost.example.com/api/v4/reactions', expect.objectContaining({
189
+ method: 'POST',
190
+ body: JSON.stringify({
191
+ user_id: 'bot123',
192
+ post_id: 'post123',
193
+ emoji_name: '+1',
194
+ }),
195
+ }));
196
+ });
197
+ });
198
+ describe('isUserAllowed', () => {
199
+ it('returns true when allowlist is empty', () => {
200
+ expect(isUserAllowed('anyone', [])).toBe(true);
201
+ });
202
+ it('returns true when user is in allowlist', () => {
203
+ expect(isUserAllowed('alice', ['alice', 'bob'])).toBe(true);
204
+ });
205
+ it('returns false when user is not in allowlist', () => {
206
+ expect(isUserAllowed('eve', ['alice', 'bob'])).toBe(false);
207
+ });
208
+ it('is case-sensitive', () => {
209
+ expect(isUserAllowed('Alice', ['alice'])).toBe(false);
210
+ });
211
+ });
212
+ describe('createInteractivePost', () => {
213
+ let consoleSpy;
214
+ beforeEach(() => {
215
+ vi.stubGlobal('fetch', vi.fn());
216
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
217
+ });
218
+ afterEach(() => {
219
+ vi.unstubAllGlobals();
220
+ consoleSpy.mockRestore();
221
+ });
222
+ it('creates a post and adds reactions', async () => {
223
+ const mockPost = { id: 'post123', channel_id: 'channel1', message: 'Hello' };
224
+ const mockFetch = vi.fn().mockResolvedValue({
225
+ ok: true,
226
+ json: () => Promise.resolve(mockPost),
227
+ });
228
+ vi.stubGlobal('fetch', mockFetch);
229
+ const result = await createInteractivePost(mockConfig, 'channel1', 'Hello', ['+1', '-1'], undefined, 'bot123');
230
+ expect(result).toEqual(mockPost);
231
+ // First call: createPost, second+third: addReaction
232
+ expect(mockFetch).toHaveBeenCalledTimes(3);
233
+ });
234
+ it('creates post in a thread when rootId is provided', async () => {
235
+ const mockPost = { id: 'post123', channel_id: 'channel1', message: 'Reply' };
236
+ const mockFetch = vi.fn().mockResolvedValue({
237
+ ok: true,
238
+ json: () => Promise.resolve(mockPost),
239
+ });
240
+ vi.stubGlobal('fetch', mockFetch);
241
+ await createInteractivePost(mockConfig, 'channel1', 'Reply', ['+1'], 'thread123', 'bot123');
242
+ // Check the first call (createPost) includes root_id
243
+ expect(mockFetch).toHaveBeenNthCalledWith(1, 'https://mattermost.example.com/api/v4/posts', expect.objectContaining({
244
+ body: JSON.stringify({
245
+ channel_id: 'channel1',
246
+ message: 'Reply',
247
+ root_id: 'thread123',
248
+ }),
249
+ }));
250
+ });
251
+ it('continues adding reactions even if one fails', async () => {
252
+ const mockPost = { id: 'post123', channel_id: 'channel1', message: 'Hello' };
253
+ let callCount = 0;
254
+ const mockFetch = vi.fn().mockImplementation(() => {
255
+ callCount++;
256
+ if (callCount === 1) {
257
+ // createPost succeeds
258
+ return Promise.resolve({
259
+ ok: true,
260
+ json: () => Promise.resolve(mockPost),
261
+ });
262
+ }
263
+ else if (callCount === 2) {
264
+ // First reaction fails
265
+ return Promise.resolve({
266
+ ok: false,
267
+ status: 500,
268
+ text: () => Promise.resolve('Server error'),
269
+ });
270
+ }
271
+ else {
272
+ // Second reaction succeeds
273
+ return Promise.resolve({
274
+ ok: true,
275
+ json: () => Promise.resolve({}),
276
+ });
277
+ }
278
+ });
279
+ vi.stubGlobal('fetch', mockFetch);
280
+ const result = await createInteractivePost(mockConfig, 'channel1', 'Hello', ['+1', '-1'], undefined, 'bot123');
281
+ // Should still return the post
282
+ expect(result).toEqual(mockPost);
283
+ // All three calls should have been made
284
+ expect(mockFetch).toHaveBeenCalledTimes(3);
285
+ // Error should have been logged
286
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to add reaction'), expect.any(Error));
287
+ });
288
+ it('returns the post even with no reactions', async () => {
289
+ const mockPost = { id: 'post123', channel_id: 'channel1', message: 'Hello' };
290
+ const mockFetch = vi.fn().mockResolvedValue({
291
+ ok: true,
292
+ json: () => Promise.resolve(mockPost),
293
+ });
294
+ vi.stubGlobal('fetch', mockFetch);
295
+ const result = await createInteractivePost(mockConfig, 'channel1', 'Hello', [], undefined, 'bot123');
296
+ expect(result).toEqual(mockPost);
297
+ // Only createPost call, no reactions
298
+ expect(mockFetch).toHaveBeenCalledTimes(1);
299
+ });
300
+ it('adds reactions in the correct order', async () => {
301
+ const mockPost = { id: 'post123', channel_id: 'channel1', message: 'Hello' };
302
+ const mockFetch = vi.fn().mockResolvedValue({
303
+ ok: true,
304
+ json: () => Promise.resolve(mockPost),
305
+ });
306
+ vi.stubGlobal('fetch', mockFetch);
307
+ await createInteractivePost(mockConfig, 'channel1', 'Hello', ['+1', 'white_check_mark', '-1'], undefined, 'bot123');
308
+ // Check reaction calls are in order
309
+ expect(mockFetch).toHaveBeenNthCalledWith(2, 'https://mattermost.example.com/api/v4/reactions', expect.objectContaining({
310
+ body: expect.stringContaining('+1'),
311
+ }));
312
+ expect(mockFetch).toHaveBeenNthCalledWith(3, 'https://mattermost.example.com/api/v4/reactions', expect.objectContaining({
313
+ body: expect.stringContaining('white_check_mark'),
314
+ }));
315
+ expect(mockFetch).toHaveBeenNthCalledWith(4, 'https://mattermost.example.com/api/v4/reactions', expect.objectContaining({
316
+ body: expect.stringContaining('-1'),
317
+ }));
318
+ });
319
+ });
@@ -0,0 +1,56 @@
1
+ import { EventEmitter } from 'events';
2
+ import type { Config } from '../config.js';
3
+ import type { MattermostPost, MattermostUser, MattermostReaction } from './types.js';
4
+ export interface MattermostClientEvents {
5
+ connected: () => void;
6
+ disconnected: () => void;
7
+ error: (error: Error) => void;
8
+ message: (post: MattermostPost, user: MattermostUser | null) => void;
9
+ reaction: (reaction: MattermostReaction, user: MattermostUser | null) => void;
10
+ }
11
+ export declare class MattermostClient extends EventEmitter {
12
+ private ws;
13
+ private config;
14
+ private reconnectAttempts;
15
+ private maxReconnectAttempts;
16
+ private reconnectDelay;
17
+ private userCache;
18
+ private botUserId;
19
+ private pingInterval;
20
+ private lastMessageAt;
21
+ private readonly PING_INTERVAL_MS;
22
+ private readonly PING_TIMEOUT_MS;
23
+ constructor(config: Config);
24
+ private api;
25
+ getBotUser(): Promise<MattermostUser>;
26
+ getUser(userId: string): Promise<MattermostUser | null>;
27
+ createPost(message: string, threadId?: string): Promise<MattermostPost>;
28
+ updatePost(postId: string, message: string): Promise<MattermostPost>;
29
+ addReaction(postId: string, emojiName: string): Promise<void>;
30
+ /**
31
+ * Create a post with reaction options for user interaction
32
+ *
33
+ * This is a common pattern for interactive posts that need user response
34
+ * via reactions (e.g., approval prompts, questions, permission requests).
35
+ *
36
+ * @param message - Post message content
37
+ * @param reactions - Array of emoji names to add as reaction options
38
+ * @param threadId - Optional thread root ID
39
+ * @returns The created post
40
+ */
41
+ createInteractivePost(message: string, reactions: string[], threadId?: string): Promise<MattermostPost>;
42
+ downloadFile(fileId: string): Promise<Buffer>;
43
+ getFileInfo(fileId: string): Promise<import('./types.js').MattermostFile>;
44
+ getPost(postId: string): Promise<MattermostPost | null>;
45
+ connect(): Promise<void>;
46
+ private handleEvent;
47
+ private scheduleReconnect;
48
+ private startHeartbeat;
49
+ private stopHeartbeat;
50
+ isUserAllowed(username: string): boolean;
51
+ isBotMentioned(message: string): boolean;
52
+ extractPrompt(message: string): string;
53
+ getBotName(): string;
54
+ sendTyping(parentId?: string): void;
55
+ disconnect(): void;
56
+ }
@@ -0,0 +1,321 @@
1
+ import WebSocket from 'ws';
2
+ import { EventEmitter } from 'events';
3
+ import { wsLogger } from '../utils/logger.js';
4
+ // Escape special regex characters to prevent regex injection
5
+ function escapeRegExp(string) {
6
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
+ }
8
+ export class MattermostClient extends EventEmitter {
9
+ ws = null;
10
+ config;
11
+ reconnectAttempts = 0;
12
+ maxReconnectAttempts = 10;
13
+ reconnectDelay = 1000;
14
+ userCache = new Map();
15
+ botUserId = null;
16
+ // Heartbeat to detect dead connections
17
+ pingInterval = null;
18
+ lastMessageAt = Date.now();
19
+ PING_INTERVAL_MS = 30000; // Send ping every 30s
20
+ PING_TIMEOUT_MS = 60000; // Reconnect if no message for 60s
21
+ constructor(config) {
22
+ super();
23
+ this.config = config;
24
+ }
25
+ // REST API helper
26
+ async api(method, path, body) {
27
+ const url = `${this.config.mattermost.url}/api/v4${path}`;
28
+ const response = await fetch(url, {
29
+ method,
30
+ headers: {
31
+ Authorization: `Bearer ${this.config.mattermost.token}`,
32
+ 'Content-Type': 'application/json',
33
+ },
34
+ body: body ? JSON.stringify(body) : undefined,
35
+ });
36
+ if (!response.ok) {
37
+ const text = await response.text();
38
+ throw new Error(`Mattermost API error ${response.status}: ${text}`);
39
+ }
40
+ return response.json();
41
+ }
42
+ // Get current bot user info
43
+ async getBotUser() {
44
+ const user = await this.api('GET', '/users/me');
45
+ this.botUserId = user.id;
46
+ return user;
47
+ }
48
+ // Get user by ID (cached)
49
+ async getUser(userId) {
50
+ if (this.userCache.has(userId)) {
51
+ return this.userCache.get(userId);
52
+ }
53
+ try {
54
+ const user = await this.api('GET', `/users/${userId}`);
55
+ this.userCache.set(userId, user);
56
+ return user;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ // Post a message
63
+ async createPost(message, threadId) {
64
+ const request = {
65
+ channel_id: this.config.mattermost.channelId,
66
+ message,
67
+ root_id: threadId,
68
+ };
69
+ return this.api('POST', '/posts', request);
70
+ }
71
+ // Update a message (for streaming updates)
72
+ async updatePost(postId, message) {
73
+ const request = {
74
+ id: postId,
75
+ message,
76
+ };
77
+ return this.api('PUT', `/posts/${postId}`, request);
78
+ }
79
+ // Add a reaction to a post
80
+ async addReaction(postId, emojiName) {
81
+ await this.api('POST', '/reactions', {
82
+ user_id: this.botUserId,
83
+ post_id: postId,
84
+ emoji_name: emojiName,
85
+ });
86
+ }
87
+ /**
88
+ * Create a post with reaction options for user interaction
89
+ *
90
+ * This is a common pattern for interactive posts that need user response
91
+ * via reactions (e.g., approval prompts, questions, permission requests).
92
+ *
93
+ * @param message - Post message content
94
+ * @param reactions - Array of emoji names to add as reaction options
95
+ * @param threadId - Optional thread root ID
96
+ * @returns The created post
97
+ */
98
+ async createInteractivePost(message, reactions, threadId) {
99
+ const post = await this.createPost(message, threadId);
100
+ // Add each reaction option, continuing even if some fail
101
+ for (const emoji of reactions) {
102
+ try {
103
+ await this.addReaction(post.id, emoji);
104
+ }
105
+ catch (err) {
106
+ console.error(` ⚠️ Failed to add reaction ${emoji}:`, err);
107
+ }
108
+ }
109
+ return post;
110
+ }
111
+ // Download a file attachment
112
+ async downloadFile(fileId) {
113
+ const url = `${this.config.mattermost.url}/api/v4/files/${fileId}`;
114
+ const response = await fetch(url, {
115
+ headers: {
116
+ Authorization: `Bearer ${this.config.mattermost.token}`,
117
+ },
118
+ });
119
+ if (!response.ok) {
120
+ throw new Error(`Failed to download file ${fileId}: ${response.status}`);
121
+ }
122
+ const arrayBuffer = await response.arrayBuffer();
123
+ return Buffer.from(arrayBuffer);
124
+ }
125
+ // Get file info (metadata)
126
+ async getFileInfo(fileId) {
127
+ return this.api('GET', `/files/${fileId}/info`);
128
+ }
129
+ // Get a post by ID (used to verify thread still exists on resume)
130
+ async getPost(postId) {
131
+ try {
132
+ return await this.api('GET', `/posts/${postId}`);
133
+ }
134
+ catch {
135
+ return null; // Post doesn't exist or was deleted
136
+ }
137
+ }
138
+ // Connect to WebSocket
139
+ async connect() {
140
+ // Get bot user first
141
+ await this.getBotUser();
142
+ wsLogger.debug(`Bot user ID: ${this.botUserId}`);
143
+ const wsUrl = this.config.mattermost.url
144
+ .replace(/^http/, 'ws')
145
+ .concat('/api/v4/websocket');
146
+ return new Promise((resolve, reject) => {
147
+ this.ws = new WebSocket(wsUrl);
148
+ this.ws.on('open', () => {
149
+ wsLogger.debug('WebSocket connected');
150
+ // Authenticate
151
+ this.ws.send(JSON.stringify({
152
+ seq: 1,
153
+ action: 'authentication_challenge',
154
+ data: { token: this.config.mattermost.token },
155
+ }));
156
+ });
157
+ this.ws.on('message', (data) => {
158
+ this.lastMessageAt = Date.now(); // Track activity for heartbeat
159
+ try {
160
+ const event = JSON.parse(data.toString());
161
+ this.handleEvent(event);
162
+ // Authentication success
163
+ if (event.event === 'hello') {
164
+ this.reconnectAttempts = 0;
165
+ this.startHeartbeat();
166
+ this.emit('connected');
167
+ resolve();
168
+ }
169
+ }
170
+ catch (err) {
171
+ wsLogger.debug(`Failed to parse message: ${err}`);
172
+ }
173
+ });
174
+ this.ws.on('close', () => {
175
+ wsLogger.debug('WebSocket disconnected');
176
+ this.stopHeartbeat();
177
+ this.emit('disconnected');
178
+ this.scheduleReconnect();
179
+ });
180
+ this.ws.on('error', (err) => {
181
+ wsLogger.debug(`WebSocket error: ${err}`);
182
+ this.emit('error', err);
183
+ reject(err);
184
+ });
185
+ this.ws.on('pong', () => {
186
+ this.lastMessageAt = Date.now(); // Pong received, connection is alive
187
+ wsLogger.debug('Pong received');
188
+ });
189
+ });
190
+ }
191
+ handleEvent(event) {
192
+ // Handle posted events
193
+ if (event.event === 'posted') {
194
+ const data = event.data;
195
+ if (!data.post)
196
+ return;
197
+ try {
198
+ const post = JSON.parse(data.post);
199
+ // Ignore messages from ourselves
200
+ if (post.user_id === this.botUserId)
201
+ return;
202
+ // Only handle messages in our channel
203
+ if (post.channel_id !== this.config.mattermost.channelId)
204
+ return;
205
+ // Get user info and emit
206
+ this.getUser(post.user_id).then((user) => {
207
+ this.emit('message', post, user);
208
+ });
209
+ }
210
+ catch (err) {
211
+ wsLogger.debug(`Failed to parse post: ${err}`);
212
+ }
213
+ return;
214
+ }
215
+ // Handle reaction_added events
216
+ if (event.event === 'reaction_added') {
217
+ const data = event.data;
218
+ if (!data.reaction)
219
+ return;
220
+ try {
221
+ const reaction = JSON.parse(data.reaction);
222
+ // Ignore reactions from ourselves
223
+ if (reaction.user_id === this.botUserId)
224
+ return;
225
+ // Get user info and emit
226
+ this.getUser(reaction.user_id).then((user) => {
227
+ this.emit('reaction', reaction, user);
228
+ });
229
+ }
230
+ catch (err) {
231
+ wsLogger.debug(`Failed to parse reaction: ${err}`);
232
+ }
233
+ }
234
+ }
235
+ scheduleReconnect() {
236
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
237
+ console.error(' ⚠️ Max reconnection attempts reached');
238
+ return;
239
+ }
240
+ this.reconnectAttempts++;
241
+ const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
242
+ console.log(` 🔄 Reconnecting... (attempt ${this.reconnectAttempts})`);
243
+ setTimeout(() => {
244
+ this.connect().catch((err) => {
245
+ console.error(` ❌ Reconnection failed: ${err}`);
246
+ });
247
+ }, delay);
248
+ }
249
+ startHeartbeat() {
250
+ this.stopHeartbeat(); // Clear any existing
251
+ this.lastMessageAt = Date.now();
252
+ this.pingInterval = setInterval(() => {
253
+ const silentFor = Date.now() - this.lastMessageAt;
254
+ // If no message received for too long, connection is dead
255
+ if (silentFor > this.PING_TIMEOUT_MS) {
256
+ console.log(` 💔 Connection dead (no activity for ${Math.round(silentFor / 1000)}s), reconnecting...`);
257
+ this.stopHeartbeat();
258
+ if (this.ws) {
259
+ this.ws.terminate(); // Force close (triggers reconnect via 'close' event)
260
+ }
261
+ return;
262
+ }
263
+ // Send ping to keep connection alive and verify it's working
264
+ if (this.ws?.readyState === WebSocket.OPEN) {
265
+ this.ws.ping();
266
+ wsLogger.debug(`Ping sent (last activity ${Math.round(silentFor / 1000)}s ago)`);
267
+ }
268
+ }, this.PING_INTERVAL_MS);
269
+ }
270
+ stopHeartbeat() {
271
+ if (this.pingInterval) {
272
+ clearInterval(this.pingInterval);
273
+ this.pingInterval = null;
274
+ }
275
+ }
276
+ // Check if user is allowed to use the bot
277
+ isUserAllowed(username) {
278
+ if (this.config.allowedUsers.length === 0) {
279
+ // If no allowlist configured, allow all
280
+ return true;
281
+ }
282
+ return this.config.allowedUsers.includes(username);
283
+ }
284
+ // Check if message mentions the bot
285
+ isBotMentioned(message) {
286
+ const botName = escapeRegExp(this.config.mattermost.botName);
287
+ // Match @botname at start or with space before
288
+ const mentionPattern = new RegExp(`(^|\\s)@${botName}\\b`, 'i');
289
+ return mentionPattern.test(message);
290
+ }
291
+ // Extract prompt from message (remove bot mention)
292
+ extractPrompt(message) {
293
+ const botName = escapeRegExp(this.config.mattermost.botName);
294
+ return message
295
+ .replace(new RegExp(`(^|\\s)@${botName}\\b`, 'gi'), ' ')
296
+ .trim();
297
+ }
298
+ // Get the bot name
299
+ getBotName() {
300
+ return this.config.mattermost.botName;
301
+ }
302
+ // Send typing indicator via WebSocket
303
+ sendTyping(parentId) {
304
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
305
+ return;
306
+ this.ws.send(JSON.stringify({
307
+ action: 'user_typing',
308
+ seq: Date.now(),
309
+ data: {
310
+ channel_id: this.config.mattermost.channelId,
311
+ parent_id: parentId || '',
312
+ },
313
+ }));
314
+ }
315
+ disconnect() {
316
+ if (this.ws) {
317
+ this.ws.close();
318
+ this.ws = null;
319
+ }
320
+ }
321
+ }