mattermost-claude-code 0.4.0 → 0.5.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.
@@ -19,6 +19,14 @@ interface PendingApproval {
19
19
  postId: string;
20
20
  type: 'plan' | 'action';
21
21
  }
22
+ /**
23
+ * Pending message from unauthorized user awaiting approval
24
+ */
25
+ interface PendingMessageApproval {
26
+ postId: string;
27
+ originalMessage: string;
28
+ fromUser: string;
29
+ }
22
30
  /**
23
31
  * Represents a single Claude Code session tied to a Mattermost thread.
24
32
  * Each session has its own Claude CLI process and state.
@@ -28,12 +36,16 @@ interface Session {
28
36
  startedBy: string;
29
37
  startedAt: Date;
30
38
  lastActivityAt: Date;
39
+ sessionNumber: number;
31
40
  claude: ClaudeCli;
32
41
  currentPostId: string | null;
33
42
  pendingContent: string;
34
43
  pendingApproval: PendingApproval | null;
35
44
  pendingQuestionSet: PendingQuestionSet | null;
45
+ pendingMessageApproval: PendingMessageApproval | null;
36
46
  planApproved: boolean;
47
+ sessionAllowedUsers: Set<string>;
48
+ sessionStartPostId: string | null;
37
49
  tasksPostId: string | null;
38
50
  activeSubagents: Map<string, string>;
39
51
  updateTimer: ReturnType<typeof setTimeout> | null;
@@ -58,6 +70,11 @@ export declare class SessionManager {
58
70
  private registerPost;
59
71
  /** Find session by post ID (for reaction routing) */
60
72
  private getSessionByPost;
73
+ /**
74
+ * Check if a user is allowed in a specific session.
75
+ * Checks global allowlist first, then session-specific allowlist.
76
+ */
77
+ isUserAllowedInSession(threadId: string, username: string): boolean;
61
78
  startSession(options: {
62
79
  prompt: string;
63
80
  }, username: string, replyToPostId?: string): Promise<void>;
@@ -71,6 +88,7 @@ export declare class SessionManager {
71
88
  private handleReaction;
72
89
  private handleQuestionReaction;
73
90
  private handleApprovalReaction;
91
+ private handleMessageApprovalReaction;
74
92
  private formatEvent;
75
93
  private formatToolUse;
76
94
  private appendContent;
@@ -89,6 +107,14 @@ export declare class SessionManager {
89
107
  killSession(threadId: string): void;
90
108
  /** Cancel a session with user feedback */
91
109
  cancelSession(threadId: string, username: string): Promise<void>;
110
+ /** Invite a user to participate in a specific session */
111
+ inviteUser(threadId: string, invitedUser: string, invitedBy: string): Promise<void>;
112
+ /** Kick a user from a specific session */
113
+ kickUser(threadId: string, kickedUser: string, kickedBy: string): Promise<void>;
114
+ /** Update the session header post with current participants */
115
+ private updateSessionHeader;
116
+ /** Request approval for a message from an unauthorized user */
117
+ requestMessageApproval(threadId: string, username: string, message: string): Promise<void>;
92
118
  /** Kill all active sessions (for graceful shutdown) */
93
119
  killAllSessions(): void;
94
120
  /** Cleanup idle sessions that have exceeded timeout */
@@ -65,6 +65,20 @@ export class SessionManager {
65
65
  const threadId = this.postIndex.get(postId);
66
66
  return threadId ? this.sessions.get(threadId) : undefined;
67
67
  }
68
+ /**
69
+ * Check if a user is allowed in a specific session.
70
+ * Checks global allowlist first, then session-specific allowlist.
71
+ */
72
+ isUserAllowedInSession(threadId, username) {
73
+ // Check global allowlist first
74
+ if (this.mattermost.isUserAllowed(username))
75
+ return true;
76
+ // Check session-specific allowlist
77
+ const session = this.sessions.get(threadId);
78
+ if (session?.sessionAllowedUsers.has(username))
79
+ return true;
80
+ return false;
81
+ }
68
82
  // ---------------------------------------------------------------------------
69
83
  // Session Lifecycle
70
84
  // ---------------------------------------------------------------------------
@@ -82,26 +96,8 @@ export class SessionManager {
82
96
  await this.mattermost.createPost(`⚠️ **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, replyToPostId);
83
97
  return;
84
98
  }
85
- // Post session start message
86
- const shortDir = this.workingDir.replace(process.env.HOME || '', '~');
87
- const sessionNum = this.sessions.size + 1;
88
- const permMode = this.skipPermissions ? '⚡ Auto' : '🔐 Interactive';
89
- const promptPreview = options.prompt.length > 60
90
- ? options.prompt.substring(0, 60) + '…'
91
- : options.prompt;
92
- const msg = [
93
- `### 🤖 mm-claude \`v${pkg.version}\``,
94
- ``,
95
- `| | |`,
96
- `|:--|:--|`,
97
- `| 📂 **Directory** | \`${shortDir}\` |`,
98
- `| 👤 **Started by** | @${username} |`,
99
- `| 🔢 **Session** | #${sessionNum} of ${MAX_SESSIONS} max |`,
100
- `| ${permMode.split(' ')[0]} **Permissions** | ${permMode.split(' ')[1]} |`,
101
- ``,
102
- `> ${promptPreview}`,
103
- ].join('\n');
104
- const post = await this.mattermost.createPost(msg, replyToPostId);
99
+ // Post initial session message (will be updated by updateSessionHeader)
100
+ const post = await this.mattermost.createPost(`### 🤖 mm-claude \`v${pkg.version}\`\n\n*Starting session...*`, replyToPostId);
105
101
  const actualThreadId = replyToPostId || post.id;
106
102
  // Create Claude CLI with options
107
103
  const cliOptions = {
@@ -116,12 +112,16 @@ export class SessionManager {
116
112
  startedBy: username,
117
113
  startedAt: new Date(),
118
114
  lastActivityAt: new Date(),
115
+ sessionNumber: this.sessions.size + 1,
119
116
  claude,
120
117
  currentPostId: null,
121
118
  pendingContent: '',
122
119
  pendingApproval: null,
123
120
  pendingQuestionSet: null,
121
+ pendingMessageApproval: null,
124
122
  planApproved: false,
123
+ sessionAllowedUsers: new Set([username]), // Owner is always allowed
124
+ sessionStartPostId: post.id, // Track for updating participants
125
125
  tasksPostId: null,
126
126
  activeSubagents: new Map(),
127
127
  updateTimer: null,
@@ -132,6 +132,8 @@ export class SessionManager {
132
132
  this.registerPost(post.id, actualThreadId); // For cancel reactions on session start post
133
133
  const shortId = actualThreadId.substring(0, 8);
134
134
  console.log(` ▶ Session #${this.sessions.size} started (${shortId}…) by @${username}`);
135
+ // Update the header with full session info
136
+ await this.updateSessionHeader(session);
135
137
  // Start typing indicator immediately so user sees activity
136
138
  this.startTyping(session);
137
139
  // Bind event handlers with closure over threadId
@@ -404,6 +406,11 @@ export class SessionManager {
404
406
  await this.handleQuestionReaction(session, postId, emojiName, username);
405
407
  return;
406
408
  }
409
+ // Handle message approval reactions
410
+ if (session.pendingMessageApproval && session.pendingMessageApproval.postId === postId) {
411
+ await this.handleMessageApprovalReaction(session, emojiName, username);
412
+ return;
413
+ }
407
414
  }
408
415
  async handleQuestionReaction(session, postId, emojiName, username) {
409
416
  if (!session.pendingQuestionSet)
@@ -479,6 +486,44 @@ export class SessionManager {
479
486
  this.startTyping(session);
480
487
  }
481
488
  }
489
+ async handleMessageApprovalReaction(session, emoji, approver) {
490
+ const pending = session.pendingMessageApproval;
491
+ if (!pending)
492
+ return;
493
+ // Only session owner or globally allowed users can approve
494
+ if (session.startedBy !== approver && !this.mattermost.isUserAllowed(approver)) {
495
+ return;
496
+ }
497
+ const isAllow = emoji === '+1' || emoji === 'thumbsup';
498
+ const isInvite = emoji === 'white_check_mark' || emoji === 'heavy_check_mark';
499
+ const isDeny = emoji === '-1' || emoji === 'thumbsdown';
500
+ if (!isAllow && !isInvite && !isDeny)
501
+ return;
502
+ if (isAllow) {
503
+ // Allow this single message
504
+ await this.mattermost.updatePost(pending.postId, `✅ Message from @${pending.fromUser} approved by @${approver}`);
505
+ session.claude.sendMessage(pending.originalMessage);
506
+ session.lastActivityAt = new Date();
507
+ this.startTyping(session);
508
+ console.log(` ✅ Message from @${pending.fromUser} approved by @${approver}`);
509
+ }
510
+ else if (isInvite) {
511
+ // Invite user to session
512
+ session.sessionAllowedUsers.add(pending.fromUser);
513
+ await this.mattermost.updatePost(pending.postId, `✅ @${pending.fromUser} invited to session by @${approver}`);
514
+ await this.updateSessionHeader(session);
515
+ session.claude.sendMessage(pending.originalMessage);
516
+ session.lastActivityAt = new Date();
517
+ this.startTyping(session);
518
+ console.log(` 👋 @${pending.fromUser} invited to session by @${approver}`);
519
+ }
520
+ else if (isDeny) {
521
+ // Deny
522
+ await this.mattermost.updatePost(pending.postId, `❌ Message from @${pending.fromUser} denied by @${approver}`);
523
+ console.log(` ❌ Message from @${pending.fromUser} denied by @${approver}`);
524
+ }
525
+ session.pendingMessageApproval = null;
526
+ }
482
527
  formatEvent(session, e) {
483
528
  switch (e.type) {
484
529
  case 'assistant': {
@@ -724,6 +769,112 @@ export class SessionManager {
724
769
  await this.mattermost.createPost(`🛑 **Session cancelled** by @${username}`, threadId);
725
770
  this.killSession(threadId);
726
771
  }
772
+ /** Invite a user to participate in a specific session */
773
+ async inviteUser(threadId, invitedUser, invitedBy) {
774
+ const session = this.sessions.get(threadId);
775
+ if (!session)
776
+ return;
777
+ // Only session owner or globally allowed users can invite
778
+ if (session.startedBy !== invitedBy && !this.mattermost.isUserAllowed(invitedBy)) {
779
+ await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can invite others`, threadId);
780
+ return;
781
+ }
782
+ session.sessionAllowedUsers.add(invitedUser);
783
+ await this.mattermost.createPost(`✅ @${invitedUser} can now participate in this session (invited by @${invitedBy})`, threadId);
784
+ console.log(` 👋 @${invitedUser} invited to session by @${invitedBy}`);
785
+ await this.updateSessionHeader(session);
786
+ }
787
+ /** Kick a user from a specific session */
788
+ async kickUser(threadId, kickedUser, kickedBy) {
789
+ const session = this.sessions.get(threadId);
790
+ if (!session)
791
+ return;
792
+ // Only session owner or globally allowed users can kick
793
+ if (session.startedBy !== kickedBy && !this.mattermost.isUserAllowed(kickedBy)) {
794
+ await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can kick others`, threadId);
795
+ return;
796
+ }
797
+ // Can't kick session owner
798
+ if (kickedUser === session.startedBy) {
799
+ await this.mattermost.createPost(`⚠️ Cannot kick session owner @${session.startedBy}`, threadId);
800
+ return;
801
+ }
802
+ // Can't kick globally allowed users (they'll still have access)
803
+ if (this.mattermost.isUserAllowed(kickedUser)) {
804
+ await this.mattermost.createPost(`⚠️ @${kickedUser} is globally allowed and cannot be kicked from individual sessions`, threadId);
805
+ return;
806
+ }
807
+ if (session.sessionAllowedUsers.delete(kickedUser)) {
808
+ await this.mattermost.createPost(`🚫 @${kickedUser} removed from this session by @${kickedBy}`, threadId);
809
+ console.log(` 🚫 @${kickedUser} kicked from session by @${kickedBy}`);
810
+ await this.updateSessionHeader(session);
811
+ }
812
+ else {
813
+ await this.mattermost.createPost(`⚠️ @${kickedUser} was not in this session`, threadId);
814
+ }
815
+ }
816
+ /** Update the session header post with current participants */
817
+ async updateSessionHeader(session) {
818
+ if (!session.sessionStartPostId)
819
+ return;
820
+ const shortDir = this.workingDir.replace(process.env.HOME || '', '~');
821
+ const permMode = this.skipPermissions ? '⚡ Auto' : '🔐 Interactive';
822
+ // Build participants list (excluding owner who is shown in "Started by")
823
+ const otherParticipants = [...session.sessionAllowedUsers]
824
+ .filter(u => u !== session.startedBy)
825
+ .map(u => `@${u}`)
826
+ .join(', ');
827
+ const rows = [
828
+ `| 📂 **Directory** | \`${shortDir}\` |`,
829
+ `| 👤 **Started by** | @${session.startedBy} |`,
830
+ ];
831
+ if (otherParticipants) {
832
+ rows.push(`| 👥 **Participants** | ${otherParticipants} |`);
833
+ }
834
+ rows.push(`| 🔢 **Session** | #${session.sessionNumber} of ${MAX_SESSIONS} max |`);
835
+ rows.push(`| ${permMode.split(' ')[0]} **Permissions** | ${permMode.split(' ')[1]} |`);
836
+ const msg = [
837
+ `### 🤖 mm-claude \`v${pkg.version}\``,
838
+ ``,
839
+ `| | |`,
840
+ `|:--|:--|`,
841
+ ...rows,
842
+ ].join('\n');
843
+ try {
844
+ await this.mattermost.updatePost(session.sessionStartPostId, msg);
845
+ }
846
+ catch (err) {
847
+ console.error('[Session] Failed to update session header:', err);
848
+ }
849
+ }
850
+ /** Request approval for a message from an unauthorized user */
851
+ async requestMessageApproval(threadId, username, message) {
852
+ const session = this.sessions.get(threadId);
853
+ if (!session)
854
+ return;
855
+ // If there's already a pending message approval, ignore
856
+ if (session.pendingMessageApproval) {
857
+ return;
858
+ }
859
+ const preview = message.length > 100 ? message.substring(0, 100) + '…' : message;
860
+ const post = await this.mattermost.createPost(`🔒 **@${username}** wants to send a message:\n> ${preview}\n\n` +
861
+ `React 👍 to allow this message, ✅ to invite them to the session, 👎 to deny`, threadId);
862
+ session.pendingMessageApproval = {
863
+ postId: post.id,
864
+ originalMessage: message,
865
+ fromUser: username,
866
+ };
867
+ this.registerPost(post.id, threadId);
868
+ // Add reaction options
869
+ try {
870
+ await this.mattermost.addReaction(post.id, '+1');
871
+ await this.mattermost.addReaction(post.id, 'white_check_mark');
872
+ await this.mattermost.addReaction(post.id, '-1');
873
+ }
874
+ catch (err) {
875
+ console.error('[Session] Failed to add message approval reactions:', err);
876
+ }
877
+ }
727
878
  /** Kill all active sessions (for graceful shutdown) */
728
879
  killAllSessions() {
729
880
  const count = this.sessions.size;
package/dist/index.js CHANGED
@@ -72,16 +72,40 @@ async function main() {
72
72
  const threadRoot = post.root_id || post.id;
73
73
  // Follow-up in active thread
74
74
  if (session.isInSessionThread(threadRoot)) {
75
- if (!mattermost.isUserAllowed(username))
76
- return;
75
+ // If message starts with @mention to someone else, ignore it (side conversation)
76
+ const mentionMatch = message.trim().match(/^@(\w+)/);
77
+ if (mentionMatch && mentionMatch[1].toLowerCase() !== mattermost.getBotName().toLowerCase()) {
78
+ return; // Side conversation, don't interrupt
79
+ }
77
80
  const content = mattermost.isBotMentioned(message)
78
81
  ? mattermost.extractPrompt(message)
79
82
  : message.trim();
80
- // Check for stop/cancel commands
81
83
  const lowerContent = content.toLowerCase();
84
+ // Check for stop/cancel commands (only from allowed users)
82
85
  if (lowerContent === '/stop' || lowerContent === 'stop' ||
83
86
  lowerContent === '/cancel' || lowerContent === 'cancel') {
84
- await session.cancelSession(threadRoot, username);
87
+ if (session.isUserAllowedInSession(threadRoot, username)) {
88
+ await session.cancelSession(threadRoot, username);
89
+ }
90
+ return;
91
+ }
92
+ // Check for /invite command
93
+ const inviteMatch = content.match(/^\/invite\s+@?(\w+)/i);
94
+ if (inviteMatch) {
95
+ await session.inviteUser(threadRoot, inviteMatch[1], username);
96
+ return;
97
+ }
98
+ // Check for /kick command
99
+ const kickMatch = content.match(/^\/kick\s+@?(\w+)/i);
100
+ if (kickMatch) {
101
+ await session.kickUser(threadRoot, kickMatch[1], username);
102
+ return;
103
+ }
104
+ // Check if user is allowed in this session
105
+ if (!session.isUserAllowedInSession(threadRoot, username)) {
106
+ // Request approval for their message
107
+ if (content)
108
+ await session.requestMessageApproval(threadRoot, username, content);
85
109
  return;
86
110
  }
87
111
  if (content)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mattermost-claude-code",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",