claude-threads 0.12.1 → 0.14.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 +53 -0
- package/README.md +142 -37
- package/dist/claude/cli.d.ts +8 -0
- package/dist/claude/cli.js +16 -8
- package/dist/config/migration.d.ts +45 -0
- package/dist/config/migration.js +35 -0
- package/dist/config.d.ts +12 -18
- package/dist/config.js +7 -94
- package/dist/git/worktree.d.ts +0 -4
- package/dist/git/worktree.js +1 -1
- package/dist/index.js +39 -15
- package/dist/logo.d.ts +3 -20
- package/dist/logo.js +7 -23
- package/dist/mcp/permission-server.js +61 -112
- package/dist/onboarding.d.ts +1 -1
- package/dist/onboarding.js +271 -69
- package/dist/persistence/session-store.d.ts +8 -2
- package/dist/persistence/session-store.js +41 -16
- package/dist/platform/client.d.ts +140 -0
- package/dist/platform/formatter.d.ts +74 -0
- package/dist/platform/index.d.ts +11 -0
- package/dist/platform/index.js +8 -0
- package/dist/platform/mattermost/client.d.ts +70 -0
- package/dist/{mattermost → platform/mattermost}/client.js +117 -34
- package/dist/platform/mattermost/formatter.d.ts +20 -0
- package/dist/platform/mattermost/formatter.js +46 -0
- package/dist/platform/mattermost/permission-api.d.ts +10 -0
- package/dist/platform/mattermost/permission-api.js +139 -0
- package/dist/platform/mattermost/types.js +1 -0
- package/dist/platform/permission-api-factory.d.ts +11 -0
- package/dist/platform/permission-api-factory.js +21 -0
- package/dist/platform/permission-api.d.ts +67 -0
- package/dist/platform/permission-api.js +8 -0
- package/dist/platform/types.d.ts +70 -0
- package/dist/platform/types.js +7 -0
- package/dist/session/commands.d.ts +52 -0
- package/dist/session/commands.js +323 -0
- package/dist/session/events.d.ts +25 -0
- package/dist/session/events.js +368 -0
- package/dist/session/index.d.ts +7 -0
- package/dist/session/index.js +6 -0
- package/dist/session/lifecycle.d.ts +70 -0
- package/dist/session/lifecycle.js +456 -0
- package/dist/session/manager.d.ts +96 -0
- package/dist/session/manager.js +537 -0
- package/dist/session/reactions.d.ts +25 -0
- package/dist/session/reactions.js +151 -0
- package/dist/session/streaming.d.ts +47 -0
- package/dist/session/streaming.js +152 -0
- package/dist/session/types.d.ts +78 -0
- package/dist/session/types.js +9 -0
- package/dist/session/worktree.d.ts +56 -0
- package/dist/session/worktree.js +339 -0
- package/dist/{mattermost → utils}/emoji.d.ts +3 -3
- package/dist/{mattermost → utils}/emoji.js +3 -3
- package/dist/utils/emoji.test.d.ts +1 -0
- package/dist/utils/tool-formatter.d.ts +10 -13
- package/dist/utils/tool-formatter.js +48 -43
- package/dist/utils/tool-formatter.test.js +67 -52
- package/package.json +2 -3
- package/dist/claude/session.d.ts +0 -256
- package/dist/claude/session.js +0 -1964
- package/dist/mattermost/client.d.ts +0 -56
- /package/dist/{mattermost/emoji.test.d.ts → platform/client.js} +0 -0
- /package/dist/{mattermost/types.js → platform/formatter.js} +0 -0
- /package/dist/{mattermost → platform/mattermost}/types.d.ts +0 -0
- /package/dist/{mattermost → utils}/emoji.test.js +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-agnostic markdown formatter interface
|
|
3
|
+
*
|
|
4
|
+
* Different platforms have slightly different markdown dialects:
|
|
5
|
+
* - Mattermost: Standard markdown (**bold**, _italic_, @username)
|
|
6
|
+
* - Slack: mrkdwn (*bold*, _italic_, <@U123> for mentions)
|
|
7
|
+
*
|
|
8
|
+
* This interface abstracts those differences.
|
|
9
|
+
*/
|
|
10
|
+
export interface PlatformFormatter {
|
|
11
|
+
/**
|
|
12
|
+
* Format text as bold
|
|
13
|
+
* Mattermost: **text**
|
|
14
|
+
* Slack: *text*
|
|
15
|
+
*/
|
|
16
|
+
formatBold(text: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Format text as italic
|
|
19
|
+
* Mattermost: _text_ or *text*
|
|
20
|
+
* Slack: _text_
|
|
21
|
+
*/
|
|
22
|
+
formatItalic(text: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Format text as inline code
|
|
25
|
+
* Both: `code`
|
|
26
|
+
*/
|
|
27
|
+
formatCode(text: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Format text as code block with optional language
|
|
30
|
+
* Both: ```lang\ncode\n```
|
|
31
|
+
*/
|
|
32
|
+
formatCodeBlock(code: string, language?: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Format a user mention
|
|
35
|
+
* Mattermost: @username
|
|
36
|
+
* Slack: <@U123456> (requires user ID)
|
|
37
|
+
*/
|
|
38
|
+
formatUserMention(username: string, userId?: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Format a hyperlink
|
|
41
|
+
* Mattermost: [text](url)
|
|
42
|
+
* Slack: <url|text>
|
|
43
|
+
*/
|
|
44
|
+
formatLink(text: string, url: string): string;
|
|
45
|
+
/**
|
|
46
|
+
* Format a bulleted list item
|
|
47
|
+
* Both: - item or * item
|
|
48
|
+
*/
|
|
49
|
+
formatListItem(text: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Format a numbered list item
|
|
52
|
+
* Both: 1. item
|
|
53
|
+
*/
|
|
54
|
+
formatNumberedListItem(number: number, text: string): string;
|
|
55
|
+
/**
|
|
56
|
+
* Format a blockquote
|
|
57
|
+
* Both: > text
|
|
58
|
+
*/
|
|
59
|
+
formatBlockquote(text: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* Format a horizontal rule
|
|
62
|
+
* Both: ---
|
|
63
|
+
*/
|
|
64
|
+
formatHorizontalRule(): string;
|
|
65
|
+
/**
|
|
66
|
+
* Format a heading
|
|
67
|
+
* Both: # Heading (level 1), ## Heading (level 2), etc.
|
|
68
|
+
*/
|
|
69
|
+
formatHeading(text: string, level: number): string;
|
|
70
|
+
/**
|
|
71
|
+
* Escape special characters in text to prevent formatting
|
|
72
|
+
*/
|
|
73
|
+
escapeText(text: string): string;
|
|
74
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform abstraction layer
|
|
3
|
+
*
|
|
4
|
+
* This module provides platform-agnostic interfaces and types that allow
|
|
5
|
+
* claude-threads to work with multiple chat platforms (Mattermost, Slack, etc.)
|
|
6
|
+
* without coupling the core logic to any specific platform.
|
|
7
|
+
*/
|
|
8
|
+
export type { PlatformClient, PlatformClientEvents } from './client.js';
|
|
9
|
+
export type { PlatformFormatter } from './formatter.js';
|
|
10
|
+
export type { PermissionApi, PermissionApiConfig, ReactionEvent, PostedMessage, } from './permission-api.js';
|
|
11
|
+
export type { PlatformUser, PlatformPost, PlatformReaction, PlatformFile, CreatePostRequest, UpdatePostRequest, AddReactionRequest, } from './types.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform abstraction layer
|
|
3
|
+
*
|
|
4
|
+
* This module provides platform-agnostic interfaces and types that allow
|
|
5
|
+
* claude-threads to work with multiple chat platforms (Mattermost, Slack, etc.)
|
|
6
|
+
* without coupling the core logic to any specific platform.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import type { MattermostPlatformConfig } from '../../config/migration.js';
|
|
3
|
+
import type { PlatformClient, PlatformUser, PlatformPost, PlatformFile } from '../index.js';
|
|
4
|
+
import type { PlatformFormatter } from '../formatter.js';
|
|
5
|
+
export declare class MattermostClient extends EventEmitter implements PlatformClient {
|
|
6
|
+
readonly platformId: string;
|
|
7
|
+
readonly platformType: "mattermost";
|
|
8
|
+
readonly displayName: string;
|
|
9
|
+
private ws;
|
|
10
|
+
private url;
|
|
11
|
+
private token;
|
|
12
|
+
private channelId;
|
|
13
|
+
private botName;
|
|
14
|
+
private allowedUsers;
|
|
15
|
+
private reconnectAttempts;
|
|
16
|
+
private maxReconnectAttempts;
|
|
17
|
+
private reconnectDelay;
|
|
18
|
+
private userCache;
|
|
19
|
+
private botUserId;
|
|
20
|
+
private readonly formatter;
|
|
21
|
+
private pingInterval;
|
|
22
|
+
private lastMessageAt;
|
|
23
|
+
private readonly PING_INTERVAL_MS;
|
|
24
|
+
private readonly PING_TIMEOUT_MS;
|
|
25
|
+
constructor(platformConfig: MattermostPlatformConfig);
|
|
26
|
+
private normalizePlatformUser;
|
|
27
|
+
private normalizePlatformPost;
|
|
28
|
+
private normalizePlatformReaction;
|
|
29
|
+
private normalizePlatformFile;
|
|
30
|
+
private api;
|
|
31
|
+
getBotUser(): Promise<PlatformUser>;
|
|
32
|
+
getUser(userId: string): Promise<PlatformUser | null>;
|
|
33
|
+
createPost(message: string, threadId?: string): Promise<PlatformPost>;
|
|
34
|
+
updatePost(postId: string, message: string): Promise<PlatformPost>;
|
|
35
|
+
addReaction(postId: string, emojiName: string): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Create a post with reaction options for user interaction
|
|
38
|
+
*
|
|
39
|
+
* This is a common pattern for interactive posts that need user response
|
|
40
|
+
* via reactions (e.g., approval prompts, questions, permission requests).
|
|
41
|
+
*
|
|
42
|
+
* @param message - Post message content
|
|
43
|
+
* @param reactions - Array of emoji names to add as reaction options
|
|
44
|
+
* @param threadId - Optional thread root ID
|
|
45
|
+
* @returns The created post
|
|
46
|
+
*/
|
|
47
|
+
createInteractivePost(message: string, reactions: string[], threadId?: string): Promise<PlatformPost>;
|
|
48
|
+
downloadFile(fileId: string): Promise<Buffer>;
|
|
49
|
+
getFileInfo(fileId: string): Promise<PlatformFile>;
|
|
50
|
+
getPost(postId: string): Promise<PlatformPost | null>;
|
|
51
|
+
connect(): Promise<void>;
|
|
52
|
+
private handleEvent;
|
|
53
|
+
private scheduleReconnect;
|
|
54
|
+
private startHeartbeat;
|
|
55
|
+
private stopHeartbeat;
|
|
56
|
+
isUserAllowed(username: string): boolean;
|
|
57
|
+
isBotMentioned(message: string): boolean;
|
|
58
|
+
extractPrompt(message: string): string;
|
|
59
|
+
getBotName(): string;
|
|
60
|
+
getMcpConfig(): {
|
|
61
|
+
type: string;
|
|
62
|
+
url: string;
|
|
63
|
+
token: string;
|
|
64
|
+
channelId: string;
|
|
65
|
+
allowedUsers: string[];
|
|
66
|
+
};
|
|
67
|
+
getFormatter(): PlatformFormatter;
|
|
68
|
+
sendTyping(parentId?: string): void;
|
|
69
|
+
disconnect(): void;
|
|
70
|
+
}
|
|
@@ -1,34 +1,96 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
|
-
import { wsLogger } from '
|
|
3
|
+
import { wsLogger } from '../../utils/logger.js';
|
|
4
|
+
import { MattermostFormatter } from './formatter.js';
|
|
4
5
|
// Escape special regex characters to prevent regex injection
|
|
5
6
|
function escapeRegExp(string) {
|
|
6
7
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
7
8
|
}
|
|
8
9
|
export class MattermostClient extends EventEmitter {
|
|
10
|
+
// Platform identity (required by PlatformClient)
|
|
11
|
+
platformId;
|
|
12
|
+
platformType = 'mattermost';
|
|
13
|
+
displayName;
|
|
9
14
|
ws = null;
|
|
10
|
-
|
|
15
|
+
url;
|
|
16
|
+
token;
|
|
17
|
+
channelId;
|
|
18
|
+
botName;
|
|
19
|
+
allowedUsers;
|
|
11
20
|
reconnectAttempts = 0;
|
|
12
21
|
maxReconnectAttempts = 10;
|
|
13
22
|
reconnectDelay = 1000;
|
|
14
23
|
userCache = new Map();
|
|
15
24
|
botUserId = null;
|
|
25
|
+
formatter = new MattermostFormatter();
|
|
16
26
|
// Heartbeat to detect dead connections
|
|
17
27
|
pingInterval = null;
|
|
18
28
|
lastMessageAt = Date.now();
|
|
19
29
|
PING_INTERVAL_MS = 30000; // Send ping every 30s
|
|
20
30
|
PING_TIMEOUT_MS = 60000; // Reconnect if no message for 60s
|
|
21
|
-
constructor(
|
|
31
|
+
constructor(platformConfig) {
|
|
22
32
|
super();
|
|
23
|
-
this.
|
|
33
|
+
this.platformId = platformConfig.id;
|
|
34
|
+
this.displayName = platformConfig.displayName;
|
|
35
|
+
this.url = platformConfig.url;
|
|
36
|
+
this.token = platformConfig.token;
|
|
37
|
+
this.channelId = platformConfig.channelId;
|
|
38
|
+
this.botName = platformConfig.botName;
|
|
39
|
+
this.allowedUsers = platformConfig.allowedUsers;
|
|
40
|
+
}
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Type Normalization (Mattermost → Platform)
|
|
43
|
+
// ============================================================================
|
|
44
|
+
normalizePlatformUser(mattermostUser) {
|
|
45
|
+
return {
|
|
46
|
+
id: mattermostUser.id,
|
|
47
|
+
username: mattermostUser.username,
|
|
48
|
+
email: mattermostUser.email,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
normalizePlatformPost(mattermostPost) {
|
|
52
|
+
// Normalize metadata.files if present
|
|
53
|
+
const metadata = mattermostPost.metadata
|
|
54
|
+
? {
|
|
55
|
+
...mattermostPost.metadata,
|
|
56
|
+
files: mattermostPost.metadata.files?.map((f) => this.normalizePlatformFile(f)),
|
|
57
|
+
}
|
|
58
|
+
: undefined;
|
|
59
|
+
return {
|
|
60
|
+
id: mattermostPost.id,
|
|
61
|
+
platformId: this.platformId,
|
|
62
|
+
channelId: mattermostPost.channel_id,
|
|
63
|
+
userId: mattermostPost.user_id,
|
|
64
|
+
message: mattermostPost.message,
|
|
65
|
+
rootId: mattermostPost.root_id,
|
|
66
|
+
createAt: mattermostPost.create_at,
|
|
67
|
+
metadata,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
normalizePlatformReaction(mattermostReaction) {
|
|
71
|
+
return {
|
|
72
|
+
userId: mattermostReaction.user_id,
|
|
73
|
+
postId: mattermostReaction.post_id,
|
|
74
|
+
emojiName: mattermostReaction.emoji_name,
|
|
75
|
+
createAt: mattermostReaction.create_at,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
normalizePlatformFile(mattermostFile) {
|
|
79
|
+
return {
|
|
80
|
+
id: mattermostFile.id,
|
|
81
|
+
name: mattermostFile.name,
|
|
82
|
+
size: mattermostFile.size,
|
|
83
|
+
mimeType: mattermostFile.mime_type,
|
|
84
|
+
extension: mattermostFile.extension,
|
|
85
|
+
};
|
|
24
86
|
}
|
|
25
87
|
// REST API helper
|
|
26
88
|
async api(method, path, body) {
|
|
27
|
-
const url = `${this.
|
|
89
|
+
const url = `${this.url}/api/v4${path}`;
|
|
28
90
|
const response = await fetch(url, {
|
|
29
91
|
method,
|
|
30
92
|
headers: {
|
|
31
|
-
Authorization: `Bearer ${this.
|
|
93
|
+
Authorization: `Bearer ${this.token}`,
|
|
32
94
|
'Content-Type': 'application/json',
|
|
33
95
|
},
|
|
34
96
|
body: body ? JSON.stringify(body) : undefined,
|
|
@@ -43,17 +105,18 @@ export class MattermostClient extends EventEmitter {
|
|
|
43
105
|
async getBotUser() {
|
|
44
106
|
const user = await this.api('GET', '/users/me');
|
|
45
107
|
this.botUserId = user.id;
|
|
46
|
-
return user;
|
|
108
|
+
return this.normalizePlatformUser(user);
|
|
47
109
|
}
|
|
48
110
|
// Get user by ID (cached)
|
|
49
111
|
async getUser(userId) {
|
|
50
|
-
|
|
51
|
-
|
|
112
|
+
const cached = this.userCache.get(userId);
|
|
113
|
+
if (cached) {
|
|
114
|
+
return this.normalizePlatformUser(cached);
|
|
52
115
|
}
|
|
53
116
|
try {
|
|
54
117
|
const user = await this.api('GET', `/users/${userId}`);
|
|
55
118
|
this.userCache.set(userId, user);
|
|
56
|
-
return user;
|
|
119
|
+
return this.normalizePlatformUser(user);
|
|
57
120
|
}
|
|
58
121
|
catch {
|
|
59
122
|
return null;
|
|
@@ -62,11 +125,12 @@ export class MattermostClient extends EventEmitter {
|
|
|
62
125
|
// Post a message
|
|
63
126
|
async createPost(message, threadId) {
|
|
64
127
|
const request = {
|
|
65
|
-
channel_id: this.
|
|
128
|
+
channel_id: this.channelId,
|
|
66
129
|
message,
|
|
67
130
|
root_id: threadId,
|
|
68
131
|
};
|
|
69
|
-
|
|
132
|
+
const post = await this.api('POST', '/posts', request);
|
|
133
|
+
return this.normalizePlatformPost(post);
|
|
70
134
|
}
|
|
71
135
|
// Update a message (for streaming updates)
|
|
72
136
|
async updatePost(postId, message) {
|
|
@@ -74,7 +138,8 @@ export class MattermostClient extends EventEmitter {
|
|
|
74
138
|
id: postId,
|
|
75
139
|
message,
|
|
76
140
|
};
|
|
77
|
-
|
|
141
|
+
const post = await this.api('PUT', `/posts/${postId}`, request);
|
|
142
|
+
return this.normalizePlatformPost(post);
|
|
78
143
|
}
|
|
79
144
|
// Add a reaction to a post
|
|
80
145
|
async addReaction(postId, emojiName) {
|
|
@@ -110,10 +175,10 @@ export class MattermostClient extends EventEmitter {
|
|
|
110
175
|
}
|
|
111
176
|
// Download a file attachment
|
|
112
177
|
async downloadFile(fileId) {
|
|
113
|
-
const url = `${this.
|
|
178
|
+
const url = `${this.url}/api/v4/files/${fileId}`;
|
|
114
179
|
const response = await fetch(url, {
|
|
115
180
|
headers: {
|
|
116
|
-
Authorization: `Bearer ${this.
|
|
181
|
+
Authorization: `Bearer ${this.token}`,
|
|
117
182
|
},
|
|
118
183
|
});
|
|
119
184
|
if (!response.ok) {
|
|
@@ -124,12 +189,14 @@ export class MattermostClient extends EventEmitter {
|
|
|
124
189
|
}
|
|
125
190
|
// Get file info (metadata)
|
|
126
191
|
async getFileInfo(fileId) {
|
|
127
|
-
|
|
192
|
+
const file = await this.api('GET', `/files/${fileId}/info`);
|
|
193
|
+
return this.normalizePlatformFile(file);
|
|
128
194
|
}
|
|
129
195
|
// Get a post by ID (used to verify thread still exists on resume)
|
|
130
196
|
async getPost(postId) {
|
|
131
197
|
try {
|
|
132
|
-
|
|
198
|
+
const post = await this.api('GET', `/posts/${postId}`);
|
|
199
|
+
return this.normalizePlatformPost(post);
|
|
133
200
|
}
|
|
134
201
|
catch {
|
|
135
202
|
return null; // Post doesn't exist or was deleted
|
|
@@ -140,7 +207,7 @@ export class MattermostClient extends EventEmitter {
|
|
|
140
207
|
// Get bot user first
|
|
141
208
|
await this.getBotUser();
|
|
142
209
|
wsLogger.debug(`Bot user ID: ${this.botUserId}`);
|
|
143
|
-
const wsUrl = this.
|
|
210
|
+
const wsUrl = this.url
|
|
144
211
|
.replace(/^http/, 'ws')
|
|
145
212
|
.concat('/api/v4/websocket');
|
|
146
213
|
return new Promise((resolve, reject) => {
|
|
@@ -148,11 +215,13 @@ export class MattermostClient extends EventEmitter {
|
|
|
148
215
|
this.ws.on('open', () => {
|
|
149
216
|
wsLogger.debug('WebSocket connected');
|
|
150
217
|
// Authenticate
|
|
151
|
-
this.ws
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
218
|
+
if (this.ws) {
|
|
219
|
+
this.ws.send(JSON.stringify({
|
|
220
|
+
seq: 1,
|
|
221
|
+
action: 'authentication_challenge',
|
|
222
|
+
data: { token: this.token },
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
156
225
|
});
|
|
157
226
|
this.ws.on('message', (data) => {
|
|
158
227
|
this.lastMessageAt = Date.now(); // Track activity for heartbeat
|
|
@@ -200,11 +269,11 @@ export class MattermostClient extends EventEmitter {
|
|
|
200
269
|
if (post.user_id === this.botUserId)
|
|
201
270
|
return;
|
|
202
271
|
// Only handle messages in our channel
|
|
203
|
-
if (post.channel_id !== this.
|
|
272
|
+
if (post.channel_id !== this.channelId)
|
|
204
273
|
return;
|
|
205
|
-
// Get user info and emit
|
|
274
|
+
// Get user info and emit (with normalized types)
|
|
206
275
|
this.getUser(post.user_id).then((user) => {
|
|
207
|
-
this.emit('message', post, user);
|
|
276
|
+
this.emit('message', this.normalizePlatformPost(post), user);
|
|
208
277
|
});
|
|
209
278
|
}
|
|
210
279
|
catch (err) {
|
|
@@ -222,9 +291,9 @@ export class MattermostClient extends EventEmitter {
|
|
|
222
291
|
// Ignore reactions from ourselves
|
|
223
292
|
if (reaction.user_id === this.botUserId)
|
|
224
293
|
return;
|
|
225
|
-
// Get user info and emit
|
|
294
|
+
// Get user info and emit (with normalized types)
|
|
226
295
|
this.getUser(reaction.user_id).then((user) => {
|
|
227
|
-
this.emit('reaction', reaction, user);
|
|
296
|
+
this.emit('reaction', this.normalizePlatformReaction(reaction), user);
|
|
228
297
|
});
|
|
229
298
|
}
|
|
230
299
|
catch (err) {
|
|
@@ -275,29 +344,43 @@ export class MattermostClient extends EventEmitter {
|
|
|
275
344
|
}
|
|
276
345
|
// Check if user is allowed to use the bot
|
|
277
346
|
isUserAllowed(username) {
|
|
278
|
-
if (this.
|
|
347
|
+
if (this.allowedUsers.length === 0) {
|
|
279
348
|
// If no allowlist configured, allow all
|
|
280
349
|
return true;
|
|
281
350
|
}
|
|
282
|
-
return this.
|
|
351
|
+
return this.allowedUsers.includes(username);
|
|
283
352
|
}
|
|
284
353
|
// Check if message mentions the bot
|
|
285
354
|
isBotMentioned(message) {
|
|
286
|
-
const botName = escapeRegExp(this.
|
|
355
|
+
const botName = escapeRegExp(this.botName);
|
|
287
356
|
// Match @botname at start or with space before
|
|
288
357
|
const mentionPattern = new RegExp(`(^|\\s)@${botName}\\b`, 'i');
|
|
289
358
|
return mentionPattern.test(message);
|
|
290
359
|
}
|
|
291
360
|
// Extract prompt from message (remove bot mention)
|
|
292
361
|
extractPrompt(message) {
|
|
293
|
-
const botName = escapeRegExp(this.
|
|
362
|
+
const botName = escapeRegExp(this.botName);
|
|
294
363
|
return message
|
|
295
364
|
.replace(new RegExp(`(^|\\s)@${botName}\\b`, 'gi'), ' ')
|
|
296
365
|
.trim();
|
|
297
366
|
}
|
|
298
367
|
// Get the bot name
|
|
299
368
|
getBotName() {
|
|
300
|
-
return this.
|
|
369
|
+
return this.botName;
|
|
370
|
+
}
|
|
371
|
+
// Get MCP config for permission server
|
|
372
|
+
getMcpConfig() {
|
|
373
|
+
return {
|
|
374
|
+
type: 'mattermost',
|
|
375
|
+
url: this.url,
|
|
376
|
+
token: this.token,
|
|
377
|
+
channelId: this.channelId,
|
|
378
|
+
allowedUsers: this.allowedUsers,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
// Get platform-specific markdown formatter
|
|
382
|
+
getFormatter() {
|
|
383
|
+
return this.formatter;
|
|
301
384
|
}
|
|
302
385
|
// Send typing indicator via WebSocket
|
|
303
386
|
sendTyping(parentId) {
|
|
@@ -307,7 +390,7 @@ export class MattermostClient extends EventEmitter {
|
|
|
307
390
|
action: 'user_typing',
|
|
308
391
|
seq: Date.now(),
|
|
309
392
|
data: {
|
|
310
|
-
channel_id: this.
|
|
393
|
+
channel_id: this.channelId,
|
|
311
394
|
parent_id: parentId || '',
|
|
312
395
|
},
|
|
313
396
|
}));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PlatformFormatter } from '../formatter.js';
|
|
2
|
+
/**
|
|
3
|
+
* Mattermost markdown formatter
|
|
4
|
+
*
|
|
5
|
+
* Mattermost uses standard markdown syntax.
|
|
6
|
+
*/
|
|
7
|
+
export declare class MattermostFormatter implements PlatformFormatter {
|
|
8
|
+
formatBold(text: string): string;
|
|
9
|
+
formatItalic(text: string): string;
|
|
10
|
+
formatCode(text: string): string;
|
|
11
|
+
formatCodeBlock(code: string, language?: string): string;
|
|
12
|
+
formatUserMention(username: string): string;
|
|
13
|
+
formatLink(text: string, url: string): string;
|
|
14
|
+
formatListItem(text: string): string;
|
|
15
|
+
formatNumberedListItem(number: number, text: string): string;
|
|
16
|
+
formatBlockquote(text: string): string;
|
|
17
|
+
formatHorizontalRule(): string;
|
|
18
|
+
formatHeading(text: string, level: number): string;
|
|
19
|
+
escapeText(text: string): string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mattermost markdown formatter
|
|
3
|
+
*
|
|
4
|
+
* Mattermost uses standard markdown syntax.
|
|
5
|
+
*/
|
|
6
|
+
export class MattermostFormatter {
|
|
7
|
+
formatBold(text) {
|
|
8
|
+
return `**${text}**`;
|
|
9
|
+
}
|
|
10
|
+
formatItalic(text) {
|
|
11
|
+
return `_${text}_`;
|
|
12
|
+
}
|
|
13
|
+
formatCode(text) {
|
|
14
|
+
return `\`${text}\``;
|
|
15
|
+
}
|
|
16
|
+
formatCodeBlock(code, language) {
|
|
17
|
+
const lang = language || '';
|
|
18
|
+
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
|
19
|
+
}
|
|
20
|
+
formatUserMention(username) {
|
|
21
|
+
return `@${username}`;
|
|
22
|
+
}
|
|
23
|
+
formatLink(text, url) {
|
|
24
|
+
return `[${text}](${url})`;
|
|
25
|
+
}
|
|
26
|
+
formatListItem(text) {
|
|
27
|
+
return `- ${text}`;
|
|
28
|
+
}
|
|
29
|
+
formatNumberedListItem(number, text) {
|
|
30
|
+
return `${number}. ${text}`;
|
|
31
|
+
}
|
|
32
|
+
formatBlockquote(text) {
|
|
33
|
+
return `> ${text}`;
|
|
34
|
+
}
|
|
35
|
+
formatHorizontalRule() {
|
|
36
|
+
return '---';
|
|
37
|
+
}
|
|
38
|
+
formatHeading(text, level) {
|
|
39
|
+
const hashes = '#'.repeat(Math.min(Math.max(level, 1), 6));
|
|
40
|
+
return `${hashes} ${text}`;
|
|
41
|
+
}
|
|
42
|
+
escapeText(text) {
|
|
43
|
+
// Escape markdown special characters
|
|
44
|
+
return text.replace(/([*_`[\]()#+\-.!])/g, '\\$1');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mattermost implementation of Permission API
|
|
3
|
+
*
|
|
4
|
+
* Handles permission requests via Mattermost API and WebSocket.
|
|
5
|
+
*/
|
|
6
|
+
import type { PermissionApi, PermissionApiConfig } from '../permission-api.js';
|
|
7
|
+
/**
|
|
8
|
+
* Create a Mattermost permission API instance
|
|
9
|
+
*/
|
|
10
|
+
export declare function createMattermostPermissionApi(config: PermissionApiConfig): PermissionApi;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mattermost implementation of Permission API
|
|
3
|
+
*
|
|
4
|
+
* Handles permission requests via Mattermost API and WebSocket.
|
|
5
|
+
*/
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
import { MattermostFormatter } from './formatter.js';
|
|
8
|
+
import { getMe, getUser, createInteractivePost, updatePost, isUserAllowed, } from '../../mattermost/api.js';
|
|
9
|
+
import { mcpLogger } from '../../utils/logger.js';
|
|
10
|
+
/**
|
|
11
|
+
* Mattermost Permission API implementation
|
|
12
|
+
*/
|
|
13
|
+
class MattermostPermissionApi {
|
|
14
|
+
apiConfig;
|
|
15
|
+
config;
|
|
16
|
+
formatter = new MattermostFormatter();
|
|
17
|
+
botUserIdCache = null;
|
|
18
|
+
constructor(config) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.apiConfig = {
|
|
21
|
+
url: config.url,
|
|
22
|
+
token: config.token,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
getFormatter() {
|
|
26
|
+
return this.formatter;
|
|
27
|
+
}
|
|
28
|
+
async getBotUserId() {
|
|
29
|
+
if (this.botUserIdCache)
|
|
30
|
+
return this.botUserIdCache;
|
|
31
|
+
const me = await getMe(this.apiConfig);
|
|
32
|
+
this.botUserIdCache = me.id;
|
|
33
|
+
return me.id;
|
|
34
|
+
}
|
|
35
|
+
async getUsername(userId) {
|
|
36
|
+
try {
|
|
37
|
+
const user = await getUser(this.apiConfig, userId);
|
|
38
|
+
return user?.username ?? null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
isUserAllowed(username) {
|
|
45
|
+
return isUserAllowed(username, this.config.allowedUsers);
|
|
46
|
+
}
|
|
47
|
+
async createInteractivePost(message, reactions, threadId) {
|
|
48
|
+
const botUserId = await this.getBotUserId();
|
|
49
|
+
const post = await createInteractivePost(this.apiConfig, this.config.channelId, message, reactions, threadId, botUserId);
|
|
50
|
+
return { id: post.id };
|
|
51
|
+
}
|
|
52
|
+
async updatePost(postId, message) {
|
|
53
|
+
await updatePost(this.apiConfig, postId, message);
|
|
54
|
+
}
|
|
55
|
+
async waitForReaction(postId, botUserId, timeoutMs) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
// Parse WebSocket URL from HTTP URL
|
|
58
|
+
const wsUrl = this.config.url.replace(/^http/, 'ws') + '/api/v4/websocket';
|
|
59
|
+
mcpLogger.debug(`Connecting to WebSocket: ${wsUrl}`);
|
|
60
|
+
const ws = new WebSocket(wsUrl);
|
|
61
|
+
let resolved = false;
|
|
62
|
+
const cleanup = () => {
|
|
63
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
64
|
+
ws.close();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const timeout = setTimeout(() => {
|
|
68
|
+
if (!resolved) {
|
|
69
|
+
resolved = true;
|
|
70
|
+
cleanup();
|
|
71
|
+
resolve(null);
|
|
72
|
+
}
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
ws.on('open', () => {
|
|
75
|
+
mcpLogger.debug('WebSocket connected, sending auth...');
|
|
76
|
+
ws.send(JSON.stringify({
|
|
77
|
+
seq: 1,
|
|
78
|
+
action: 'authentication_challenge',
|
|
79
|
+
data: { token: this.config.token },
|
|
80
|
+
}));
|
|
81
|
+
});
|
|
82
|
+
ws.on('message', async (data) => {
|
|
83
|
+
if (resolved)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
const event = JSON.parse(data.toString());
|
|
87
|
+
mcpLogger.debug(`WebSocket event: ${event.event}`);
|
|
88
|
+
if (event.event === 'reaction_added') {
|
|
89
|
+
// Mattermost sends reaction as JSON string
|
|
90
|
+
const reaction = typeof event.data.reaction === 'string'
|
|
91
|
+
? JSON.parse(event.data.reaction)
|
|
92
|
+
: event.data.reaction;
|
|
93
|
+
// Must be on our post
|
|
94
|
+
if (reaction.post_id !== postId)
|
|
95
|
+
return;
|
|
96
|
+
// Must not be the bot's own reaction (adding the options)
|
|
97
|
+
if (reaction.user_id === botUserId)
|
|
98
|
+
return;
|
|
99
|
+
mcpLogger.debug(`Reaction received: ${reaction.emoji_name} from user: ${reaction.user_id}`);
|
|
100
|
+
// Got a valid reaction
|
|
101
|
+
resolved = true;
|
|
102
|
+
clearTimeout(timeout);
|
|
103
|
+
cleanup();
|
|
104
|
+
resolve({
|
|
105
|
+
postId: reaction.post_id,
|
|
106
|
+
userId: reaction.user_id,
|
|
107
|
+
emojiName: reaction.emoji_name,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
mcpLogger.debug(`Error parsing WebSocket message: ${err}`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
ws.on('error', (error) => {
|
|
116
|
+
mcpLogger.error(`WebSocket error: ${error.message}`);
|
|
117
|
+
if (!resolved) {
|
|
118
|
+
resolved = true;
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
resolve(null);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
ws.on('close', () => {
|
|
124
|
+
mcpLogger.debug('WebSocket closed');
|
|
125
|
+
if (!resolved) {
|
|
126
|
+
resolved = true;
|
|
127
|
+
clearTimeout(timeout);
|
|
128
|
+
resolve(null);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Create a Mattermost permission API instance
|
|
136
|
+
*/
|
|
137
|
+
export function createMattermostPermissionApi(config) {
|
|
138
|
+
return new MattermostPermissionApi(config);
|
|
139
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|