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.
- package/CHANGELOG.md +473 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/dist/changelog.d.ts +20 -0
- package/dist/changelog.js +134 -0
- package/dist/claude/cli.d.ts +42 -0
- package/dist/claude/cli.js +173 -0
- package/dist/claude/session.d.ts +256 -0
- package/dist/claude/session.js +1964 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +94 -0
- package/dist/git/worktree.d.ts +50 -0
- package/dist/git/worktree.js +228 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +371 -0
- package/dist/logo.d.ts +31 -0
- package/dist/logo.js +57 -0
- package/dist/mattermost/api.d.ts +85 -0
- package/dist/mattermost/api.js +124 -0
- package/dist/mattermost/api.test.d.ts +1 -0
- package/dist/mattermost/api.test.js +319 -0
- package/dist/mattermost/client.d.ts +56 -0
- package/dist/mattermost/client.js +321 -0
- package/dist/mattermost/emoji.d.ts +43 -0
- package/dist/mattermost/emoji.js +65 -0
- package/dist/mattermost/emoji.test.d.ts +1 -0
- package/dist/mattermost/emoji.test.js +131 -0
- package/dist/mattermost/types.d.ts +71 -0
- package/dist/mattermost/types.js +1 -0
- package/dist/mcp/permission-server.d.ts +2 -0
- package/dist/mcp/permission-server.js +201 -0
- package/dist/onboarding.d.ts +1 -0
- package/dist/onboarding.js +116 -0
- package/dist/persistence/session-store.d.ts +65 -0
- package/dist/persistence/session-store.js +127 -0
- package/dist/update-notifier.d.ts +3 -0
- package/dist/update-notifier.js +31 -0
- package/dist/utils/logger.d.ts +34 -0
- package/dist/utils/logger.js +42 -0
- package/dist/utils/logger.test.d.ts +1 -0
- package/dist/utils/logger.test.js +121 -0
- package/dist/utils/tool-formatter.d.ts +56 -0
- package/dist/utils/tool-formatter.js +247 -0
- package/dist/utils/tool-formatter.test.d.ts +1 -0
- package/dist/utils/tool-formatter.test.js +357 -0
- 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
|
+
}
|