Skip to content

Commit a2c4512

Browse files
Merge pull request #5 from Shakudo-io/fix/validate-session-before-prompt
feat: teammate delegated sessions - start sessions on behalf of owner
2 parents d98f9db + 51f889a commit a2c4512

File tree

7 files changed

+217
-17
lines changed

7 files changed

+217
-17
lines changed

.opencode/plugin/mattermost-control/index.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,17 @@ export const MattermostControlPlugin: Plugin = async ({ client, project, directo
566566
const { mmClient, threadManager, openCodeSessionRegistry, threadMappingStore } = PluginState;
567567
if (!mmClient || !threadManager) return null;
568568

569+
// Check for delegated session creation (teammate starting session on behalf of owner)
570+
const delegatedOwnerUserId = (post as any)._delegatedOwnerUserId as string | undefined;
571+
const delegatedOwnerUsername = (post as any)._delegatedOwnerUsername as string | undefined;
572+
const delegatedInitiatorUserId = (post as any)._delegatedInitiatorUserId as string | undefined;
573+
const delegatedInitiatorUsername = (post as any)._delegatedInitiatorUsername as string | undefined;
574+
const isDelegated = !!delegatedOwnerUserId && !!delegatedOwnerUsername;
575+
576+
// For delegated sessions, the owner is the mentioned user, not the poster
577+
const sessionOwnerUserId = isDelegated ? delegatedOwnerUserId : userSession.mattermostUserId;
578+
const sessionOwnerUsername = isDelegated ? delegatedOwnerUsername : userSession.mattermostUsername;
579+
569580
try {
570581
const result = await client.session.create({
571582
body: {},
@@ -576,45 +587,67 @@ export const MattermostControlPlugin: Plugin = async ({ client, project, directo
576587
throw new Error("Failed to create session - no data returned");
577588
}
578589

590+
const sessionTitle = isDelegated
591+
? `Session started by @${delegatedInitiatorUsername} for @${sessionOwnerUsername}`
592+
: `Mattermost DM session`;
593+
579594
const sessionInfo: OpenCodeSessionInfo = {
580595
id: result.data.id,
581596
shortId: result.data.id.substring(0, 8),
582597
projectName: projectName,
583598
directory: directory,
584-
title: result.data.title || `Mattermost DM session`,
599+
title: result.data.title || sessionTitle,
585600
lastUpdated: new Date(),
586601
isAvailable: true,
587602
};
588603

589604
const threadRootId = post.root_id || post.id;
590-
log.info(`[CreateSession] post.id=${post.id}, post.root_id=${post.root_id}, threadRootId=${threadRootId}, post.channel_id=${post.channel_id}`);
605+
log.info(`[CreateSession] post.id=${post.id}, post.root_id=${post.root_id}, threadRootId=${threadRootId}, post.channel_id=${post.channel_id}${isDelegated ? ` (delegated by @${delegatedInitiatorUsername} for @${sessionOwnerUsername})` : ''}`);
591606
const mapping = await threadManager.createThread(
592607
sessionInfo,
593-
userSession.mattermostUserId,
608+
sessionOwnerUserId,
594609
userSession.dmChannelId,
595610
threadRootId,
596611
post.channel_id,
597-
userSession.mattermostUsername
612+
sessionOwnerUsername,
613+
isDelegated ? delegatedInitiatorUsername : undefined
598614
);
599615

600616
const approvalPolicy = (post as any)._approvalPolicy as string | undefined;
601-
if (approvalPolicy && threadMappingStore) {
617+
if (threadMappingStore) {
602618
const updatedMapping = threadMappingStore.getBySessionId(result.data.id);
603619
if (updatedMapping) {
620+
// Apply approval policy if set
604621
if (approvalPolicy === "approve_all") {
605622
updatedMapping.approveAllUsers = true;
606623
log.info(`[CreateSession] Applied approve_all policy to session ${sessionInfo.shortId}`);
607624
} else if (approvalPolicy === "approve_next") {
608625
updatedMapping.approveNextMessage = true;
609626
log.info(`[CreateSession] Applied approve_next policy to session ${sessionInfo.shortId}`);
610627
}
628+
629+
// For delegated sessions, auto-approve the initiating teammate
630+
if (isDelegated && delegatedInitiatorUserId) {
631+
if (!updatedMapping.approvedUsers) {
632+
updatedMapping.approvedUsers = [];
633+
}
634+
if (!updatedMapping.approvedUsers.includes(delegatedInitiatorUserId)) {
635+
updatedMapping.approvedUsers.push(delegatedInitiatorUserId);
636+
log.info(`[Delegation] Auto-approved initiator @${delegatedInitiatorUsername} (${delegatedInitiatorUserId}) for session ${sessionInfo.shortId}`);
637+
}
638+
}
639+
611640
threadMappingStore.update(updatedMapping);
612641
}
613642
}
614643

615644
await openCodeSessionRegistry?.refresh();
616645

617-
log.info(`[CreateSession] Created new session ${sessionInfo.shortId} for @${userSession.mattermostUsername} in channel ${post.channel_id}`);
646+
if (isDelegated) {
647+
log.info(`[Delegation] Created delegated session ${sessionInfo.shortId} - owner: @${sessionOwnerUsername}, initiated by: @${delegatedInitiatorUsername} in channel ${post.channel_id}`);
648+
} else {
649+
log.info(`[CreateSession] Created new session ${sessionInfo.shortId} for @${sessionOwnerUsername} in channel ${post.channel_id}`);
650+
}
618651

619652
return {
620653
sessionId: result.data.id,

.opencode/plugin/mattermost-control/tools/connect.ts

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -502,12 +502,15 @@ function setupWebSocketListeners(
502502
const mapping = threadMappingStore?.getByThreadRootPostId(threadRootId);
503503

504504
if (!mapping) {
505-
// No existing session - only owner can create new sessions
505+
// No existing session in this thread
506+
const sessionOwnershipHandler = PluginState.sessionOwnershipHandler;
507+
const mmClient = PluginState.mmClient;
508+
if (!mmClient || !sessionOwnershipHandler) return;
509+
510+
const ownerUserId = config.mattermost.ownerUserId;
511+
506512
if (isOwner) {
507-
const sessionOwnershipHandler = PluginState.sessionOwnershipHandler;
508-
const mmClient = PluginState.mmClient;
509-
if (!mmClient || !sessionOwnershipHandler) return;
510-
513+
// Owner herself @mentioned bot - standard ownership confirmation flow
511514
let userUsername = "unknown";
512515
try {
513516
const user = await mmClient.getUserById(postData.user_id);
@@ -524,12 +527,78 @@ function setupWebSocketListeners(
524527
channel.id
525528
);
526529
return;
527-
} else {
528-
// Team members and guests cannot create sessions - silently ignore
529-
// (their own OpenCode instance may handle it)
530-
log.debug(`[Channel] Non-owner @mention in unmapped thread - ignoring to allow other bots to handle (channel: ${channel.id})`);
530+
}
531+
532+
// Non-owner mentioned bot - only allow delegated session creation
533+
// They MUST also mention the session owner (e.g., "@kaji @christine fix this")
534+
// Just mentioning @kaji alone does nothing for non-owners
535+
if (ownerUserId && botUser) {
536+
const mentionedUsers = sessionOwnershipHandler.detectMentionedUsers(
537+
postData.message,
538+
botUser.username,
539+
botUser.id,
540+
postData.user_id
541+
);
542+
543+
// Check if the owner was @mentioned
544+
let ownerMentioned = false;
545+
let ownerUsername = "unknown";
546+
if (mentionedUsers.length > 0) {
547+
try {
548+
const ownerUser = await mmClient.getUserById(ownerUserId);
549+
ownerUsername = ownerUser.username;
550+
ownerMentioned = mentionedUsers.some(
551+
m => m.toLowerCase() === ownerUsername.toLowerCase()
552+
);
553+
} catch (e) {
554+
log.warn(`[Delegation] Could not fetch owner user info: ${e}`);
555+
}
556+
}
557+
558+
if (!ownerMentioned) {
559+
// Non-owner mentioned only @kaji without the owner - silently ignore
560+
log.debug(`[Channel] Non-owner @mentioned bot without owner - ignoring (channel: ${channel.id})`);
561+
return;
562+
}
563+
564+
// Owner was mentioned - verify the owner is actually in this channel
565+
try {
566+
const members = await mmClient.getChannelMembers(channel.id);
567+
const ownerInChannel = members.some(m => m.user_id === ownerUserId);
568+
if (!ownerInChannel) {
569+
log.info(`[Delegation] Owner @${ownerUsername} is not a member of channel ${channel.id} - ignoring`);
570+
return;
571+
}
572+
} catch (e) {
573+
log.warn(`[Delegation] Could not check channel membership: ${e}`);
574+
}
575+
576+
let initiatorUsername = "unknown";
577+
try {
578+
const initiatorUser = await mmClient.getUserById(postData.user_id);
579+
initiatorUsername = initiatorUser.username;
580+
} catch (e) {
581+
log.warn(`[Delegation] Could not fetch initiator username: ${e}`);
582+
}
583+
584+
log.info(`[Delegation] @${initiatorUsername} mentioned both bot and owner @${ownerUsername} - creating delegated session`);
585+
586+
// Set delegation flags on the post for handleUserMessage to process
587+
const delegatedPost = { ...postData } as any;
588+
delegatedPost._ownershipConfirmed = true;
589+
delegatedPost._approvalPolicy = "none";
590+
delegatedPost._delegatedOwnerUserId = ownerUserId;
591+
delegatedPost._delegatedOwnerUsername = ownerUsername;
592+
delegatedPost._delegatedInitiatorUserId = postData.user_id;
593+
delegatedPost._delegatedInitiatorUsername = initiatorUsername;
594+
595+
await handleUserMessage(delegatedPost);
531596
return;
532597
}
598+
599+
// No owner configured or bot user not available - ignore
600+
log.debug(`[Channel] Non-owner @mention in unmapped thread - ignoring (channel: ${channel.id})`);
601+
return;
533602
}
534603

535604
// Existing session - check access permissions

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,57 @@
55

66
Control [OpenCode](https://opencode.ai) remotely via Mattermost direct messages. Send prompts to your OpenCode session by messaging a bot user, and receive real-time streaming responses.
77

8+
---
9+
10+
## Release Notes
11+
12+
### v0.3.94 — Teammate Delegated Sessions (New Feature)
13+
14+
**Teammates can now summon the session owner's Kaji on their behalf.**
15+
16+
#### Setup
17+
18+
The session owner adds teammates via DM with Kaji:
19+
20+
```
21+
!team add @bonnie
22+
!team add @yiran
23+
!team list
24+
```
25+
26+
#### How It Works
27+
28+
In any channel where **Kaji, the owner, and the teammate** are all members, the teammate tags both:
29+
30+
```
31+
@kaji @christine please fix the login bug on line 42
32+
```
33+
34+
This creates a session **owned by @christine**, using her Kaji instance. The teammate is auto-approved and can keep sending prompts and approve others to collaborate in the thread.
35+
36+
#### Rules
37+
38+
| Scenario | Result |
39+
|----------|--------|
40+
| Teammate tags `@kaji @owner` in a shared channel | Session created under owner's Kaji |
41+
| Teammate tags only `@kaji` (without owner) | Owner's Kaji stays silent; teammate's own Kaji may respond |
42+
| Teammate tags `@kaji @owner` in a channel the owner isn't in | Nothing happens |
43+
| Non-teammate tags `@kaji @owner` | Owner's Kaji does not respond |
44+
| Owner tags `@kaji` directly | Standard session creation flow (unchanged) |
45+
46+
#### What the session announcement looks like
47+
48+
```
49+
:rocket: OpenCode Session Started
50+
51+
Owner: @christine
52+
Started by: @bonnie
53+
Project: my-project
54+
Session: a1b2c3d4
55+
```
56+
57+
---
58+
859
## Features
960

1061
### Core Features

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-mattermost-control",
3-
"version": "0.3.93",
3+
"version": "0.3.94",
44
"description": "OpenCode plugin for remote control via Mattermost DMs",
55
"type": "module",
66
"main": ".opencode/plugin/mattermost-control/index.ts",

src/models/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,6 @@ export interface ThreadRootPostContent {
194194
startedAt: Date;
195195
sessionTitle?: string;
196196
ownerUsername?: string;
197+
/** Username of the teammate who initiated a delegated session (if any) */
198+
delegatedByUsername?: string;
197199
}

src/session-ownership-handler.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ export interface ExistingSessionOwner {
2222
found: boolean;
2323
}
2424

25+
export interface DelegatedSessionRequest {
26+
/** The user ID of the person who should own the session (the mentioned session owner) */
27+
ownerUserId: string;
28+
/** The username of the session owner */
29+
ownerUsername: string;
30+
/** The user ID of the person who initiated the request (the teammate) */
31+
initiatorUserId: string;
32+
/** The username of the initiator */
33+
initiatorUsername: string;
34+
}
35+
2536
export class SessionOwnershipHandler {
2637
private mmClient: any;
2738
private botUserId: string | null = null;
@@ -291,6 +302,31 @@ _Reply with a number (1, 2, or 3)_`;
291302
}
292303
}
293304

305+
/**
306+
* Detect @mentions in a message, excluding the bot.
307+
* Used to check if a teammate mentioned the session owner for delegation.
308+
* Returns usernames (without @) of all mentioned users excluding the bot.
309+
*/
310+
detectMentionedUsers(
311+
message: string,
312+
botUsername: string,
313+
botUserId: string,
314+
senderUserId: string
315+
): string[] {
316+
const mentionRegex = /@(\w[\w.-]*)/gi;
317+
const mentions: string[] = [];
318+
let match;
319+
320+
while ((match = mentionRegex.exec(message)) !== null) {
321+
const mentionedUsername = match[1].toLowerCase();
322+
if (mentionedUsername !== botUsername.toLowerCase()) {
323+
mentions.push(match[1]); // Keep original casing
324+
}
325+
}
326+
327+
return mentions;
328+
}
329+
294330
cleanupExpired(): number {
295331
let cleaned = 0;
296332
const now = Date.now();

src/thread-manager.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ export class ThreadManager {
1919
dmChannelId: string,
2020
userPostId?: string,
2121
channelId?: string,
22-
ownerUsername?: string
22+
ownerUsername?: string,
23+
delegatedByUsername?: string
2324
): Promise<ThreadSessionMapping> {
2425
const existing = this.store.getBySessionId(sessionInfo.id);
2526
if (existing) {
@@ -37,6 +38,7 @@ export class ThreadManager {
3738
startedAt: new Date(),
3839
sessionTitle: sessionInfo.title,
3940
ownerUsername,
41+
delegatedByUsername,
4042
};
4143

4244
const message = this.formatThreadRootPost(content);
@@ -197,6 +199,13 @@ export class ThreadManager {
197199
lines.splice(2, 0, `**Owner**: @${content.ownerUsername}`);
198200
}
199201

202+
if (content.delegatedByUsername) {
203+
const ownerIndex = lines.findIndex(l => l.startsWith("**Owner**:"));
204+
if (ownerIndex >= 0) {
205+
lines.splice(ownerIndex + 1, 0, `**Started by**: @${content.delegatedByUsername}`);
206+
}
207+
}
208+
200209
lines.push(``, `_Reply in this thread to send prompts to this session._`);
201210

202211
return lines.join("\n");

0 commit comments

Comments
 (0)