@shakudo/opencode-mattermost-control 0.3.45
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/.opencode/command/mattermost-connect.md +5 -0
- package/.opencode/command/mattermost-disconnect.md +5 -0
- package/.opencode/command/mattermost-monitor.md +12 -0
- package/.opencode/command/mattermost-status.md +5 -0
- package/.opencode/command/speckit.analyze.md +184 -0
- package/.opencode/command/speckit.checklist.md +294 -0
- package/.opencode/command/speckit.clarify.md +181 -0
- package/.opencode/command/speckit.constitution.md +82 -0
- package/.opencode/command/speckit.implement.md +135 -0
- package/.opencode/command/speckit.plan.md +89 -0
- package/.opencode/command/speckit.specify.md +258 -0
- package/.opencode/command/speckit.tasks.md +137 -0
- package/.opencode/command/speckit.taskstoissues.md +30 -0
- package/.opencode/plugin/mattermost-control/event-handlers/compaction.ts +61 -0
- package/.opencode/plugin/mattermost-control/event-handlers/file.ts +36 -0
- package/.opencode/plugin/mattermost-control/event-handlers/index.ts +14 -0
- package/.opencode/plugin/mattermost-control/event-handlers/message.ts +124 -0
- package/.opencode/plugin/mattermost-control/event-handlers/permission.ts +34 -0
- package/.opencode/plugin/mattermost-control/event-handlers/question.ts +92 -0
- package/.opencode/plugin/mattermost-control/event-handlers/session.ts +100 -0
- package/.opencode/plugin/mattermost-control/event-handlers/todo.ts +33 -0
- package/.opencode/plugin/mattermost-control/event-handlers/tool.ts +76 -0
- package/.opencode/plugin/mattermost-control/formatters.ts +202 -0
- package/.opencode/plugin/mattermost-control/index.ts +964 -0
- package/.opencode/plugin/mattermost-control/package.json +12 -0
- package/.opencode/plugin/mattermost-control/state.ts +180 -0
- package/.opencode/plugin/mattermost-control/timers.ts +96 -0
- package/.opencode/plugin/mattermost-control/tools/connect.ts +563 -0
- package/.opencode/plugin/mattermost-control/tools/file.ts +41 -0
- package/.opencode/plugin/mattermost-control/tools/index.ts +12 -0
- package/.opencode/plugin/mattermost-control/tools/monitor.ts +183 -0
- package/.opencode/plugin/mattermost-control/tools/schedule.ts +253 -0
- package/.opencode/plugin/mattermost-control/tools/session.ts +120 -0
- package/.opencode/plugin/mattermost-control/types.ts +107 -0
- package/LICENSE +21 -0
- package/README.md +1280 -0
- package/opencode-shared +359 -0
- package/opencode-shared-restart +495 -0
- package/opencode-shared-stop +90 -0
- package/package.json +65 -0
- package/src/clients/mattermost-client.ts +221 -0
- package/src/clients/websocket-client.ts +199 -0
- package/src/command-handler.ts +1035 -0
- package/src/config.ts +170 -0
- package/src/context-builder.ts +309 -0
- package/src/file-completion-handler.ts +521 -0
- package/src/file-handler.ts +242 -0
- package/src/guest-approval-handler.ts +223 -0
- package/src/logger.ts +73 -0
- package/src/merge-handler.ts +335 -0
- package/src/message-router.ts +151 -0
- package/src/models/index.ts +197 -0
- package/src/models/routing.ts +50 -0
- package/src/models/thread-mapping.ts +40 -0
- package/src/monitor-service.ts +222 -0
- package/src/notification-service.ts +118 -0
- package/src/opencode-session-registry.ts +370 -0
- package/src/persistence/team-store.ts +396 -0
- package/src/persistence/thread-mapping-store.ts +258 -0
- package/src/question-handler.ts +401 -0
- package/src/reaction-handler.ts +111 -0
- package/src/response-streamer.ts +364 -0
- package/src/scheduler/schedule-store.ts +261 -0
- package/src/scheduler/scheduler-service.ts +349 -0
- package/src/session-manager.ts +142 -0
- package/src/session-ownership-handler.ts +253 -0
- package/src/status-indicator.ts +279 -0
- package/src/thread-manager.ts +231 -0
- package/src/todo-manager.ts +162 -0
|
@@ -0,0 +1,1035 @@
|
|
|
1
|
+
import type { MattermostClient } from "./clients/mattermost-client.js";
|
|
2
|
+
import type { OpenCodeSessionRegistry, OpenCodeSessionInfo } from "./opencode-session-registry.js";
|
|
3
|
+
import type { UserSession } from "./session-manager.js";
|
|
4
|
+
import type { ParsedCommand } from "./message-router.js";
|
|
5
|
+
import type { ThreadMappingStore } from "./persistence/thread-mapping-store.js";
|
|
6
|
+
import type { TeamStore } from "./persistence/team-store.js";
|
|
7
|
+
import type { QuestionHandler } from "./question-handler.js";
|
|
8
|
+
import type { ModelSelection } from "./models/index.js";
|
|
9
|
+
import { MergeHandler } from "./merge-handler.js";
|
|
10
|
+
import { log } from "./logger.js";
|
|
11
|
+
|
|
12
|
+
export interface ProviderModel {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
providerID: string;
|
|
16
|
+
providerName: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CommandContext {
|
|
20
|
+
userSession: UserSession;
|
|
21
|
+
registry: OpenCodeSessionRegistry;
|
|
22
|
+
mmClient: MattermostClient;
|
|
23
|
+
threadMappingStore?: ThreadMappingStore | null;
|
|
24
|
+
teamStore?: TeamStore | null;
|
|
25
|
+
ownerUserId?: string | null;
|
|
26
|
+
questionHandler?: QuestionHandler | null;
|
|
27
|
+
opencodeClient?: any;
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
threadRootPostId?: string;
|
|
30
|
+
channelId?: string;
|
|
31
|
+
mattermostBaseUrl?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CommandResult = {
|
|
35
|
+
success: boolean;
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type CommandExecutor = (
|
|
40
|
+
command: ParsedCommand,
|
|
41
|
+
context: CommandContext
|
|
42
|
+
) => Promise<CommandResult>;
|
|
43
|
+
|
|
44
|
+
export class CommandHandler {
|
|
45
|
+
private commands: Map<string, CommandExecutor> = new Map();
|
|
46
|
+
private commandPrefix: string;
|
|
47
|
+
|
|
48
|
+
constructor(commandPrefix: string = "!") {
|
|
49
|
+
this.commandPrefix = commandPrefix;
|
|
50
|
+
this.registerBuiltinCommands();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private registerBuiltinCommands(): void {
|
|
54
|
+
this.commands.set("sessions", this.handleSessions.bind(this));
|
|
55
|
+
this.commands.set("use", this.handleUse.bind(this));
|
|
56
|
+
this.commands.set("current", this.handleCurrent.bind(this));
|
|
57
|
+
this.commands.set("help", this.handleHelp.bind(this));
|
|
58
|
+
this.commands.set("models", this.handleModels.bind(this));
|
|
59
|
+
this.commands.set("model", this.handleModel.bind(this));
|
|
60
|
+
this.commands.set("costs", this.handleCosts.bind(this));
|
|
61
|
+
this.commands.set("stop", this.handleStop.bind(this));
|
|
62
|
+
this.commands.set("merge", this.handleMerge.bind(this));
|
|
63
|
+
this.commands.set("team", this.handleTeam.bind(this));
|
|
64
|
+
this.commands.set("reject", this.handleReject.bind(this));
|
|
65
|
+
this.commands.set("cancel", this.handleReject.bind(this)); // alias
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private cachedModels: ProviderModel[] = [];
|
|
69
|
+
private modelsCacheTime: number = 0;
|
|
70
|
+
private MODEL_CACHE_TTL_MS = 60000; // 1 minute
|
|
71
|
+
|
|
72
|
+
async execute(command: ParsedCommand, context: CommandContext): Promise<CommandResult> {
|
|
73
|
+
const executor = this.commands.get(command.name);
|
|
74
|
+
|
|
75
|
+
if (!executor) {
|
|
76
|
+
return {
|
|
77
|
+
success: false,
|
|
78
|
+
message: `Unknown command: \`${this.commandPrefix}${command.name}\`\n\nType \`${this.commandPrefix}help\` for available commands.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
return await executor(command, context);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
log.error(`[CommandHandler] Error executing command ${command.name}:`, e);
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
message: `Error executing command: ${e instanceof Error ? e.message : String(e)}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async handleSessions(
|
|
94
|
+
_command: ParsedCommand,
|
|
95
|
+
context: CommandContext
|
|
96
|
+
): Promise<CommandResult> {
|
|
97
|
+
const { registry, userSession, threadMappingStore, channelId } = context;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await registry.refresh();
|
|
101
|
+
} catch (e) {
|
|
102
|
+
log.warn("[CommandHandler] Failed to refresh sessions:", e);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sessions = registry.listAvailable();
|
|
106
|
+
|
|
107
|
+
if (sessions.length === 0) {
|
|
108
|
+
return {
|
|
109
|
+
success: true,
|
|
110
|
+
message: "No active OpenCode sessions found.\n\nStart OpenCode in a project directory to create a session.",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const currentTarget = userSession.targetOpenCodeSessionId;
|
|
115
|
+
const lines = this.formatSessionList(sessions, currentTarget, threadMappingStore, channelId);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
success: true,
|
|
119
|
+
message: lines.join("\n"),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private formatSessionList(
|
|
124
|
+
sessions: OpenCodeSessionInfo[],
|
|
125
|
+
currentTargetId: string | null,
|
|
126
|
+
threadMappingStore?: ThreadMappingStore | null,
|
|
127
|
+
channelId?: string
|
|
128
|
+
): string[] {
|
|
129
|
+
const defaultSession = sessions.find(s => s.id === currentTargetId);
|
|
130
|
+
|
|
131
|
+
const filteredSessions = channelId && threadMappingStore
|
|
132
|
+
? sessions.filter(session => {
|
|
133
|
+
const mapping = threadMappingStore.getBySessionId(session.id);
|
|
134
|
+
if (!mapping) return false;
|
|
135
|
+
const mappingChannelId = mapping.channelId || mapping.dmChannelId;
|
|
136
|
+
return mappingChannelId === channelId;
|
|
137
|
+
})
|
|
138
|
+
: sessions;
|
|
139
|
+
|
|
140
|
+
const lines: string[] = [
|
|
141
|
+
`:clipboard: **Sessions in this channel** (${filteredSessions.length}):`,
|
|
142
|
+
"",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
if (filteredSessions.length === 0) {
|
|
146
|
+
lines.push("_No sessions in this channel yet._");
|
|
147
|
+
lines.push("");
|
|
148
|
+
lines.push("Send a message to start a new session.");
|
|
149
|
+
return lines;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
filteredSessions.forEach((session, index) => {
|
|
153
|
+
const isCurrent = session.id === currentTargetId;
|
|
154
|
+
const marker = isCurrent ? " :white_check_mark:" : "";
|
|
155
|
+
const truncatedTitle = this.truncateString(session.title, 50);
|
|
156
|
+
const relativeTime = this.formatRelativeTime(session.lastUpdated);
|
|
157
|
+
|
|
158
|
+
const mapping = threadMappingStore?.getBySessionId(session.id);
|
|
159
|
+
const threadLink = mapping ? ` [:thread: thread](/_redirect/pl/${mapping.threadRootPostId})` : "";
|
|
160
|
+
|
|
161
|
+
lines.push(`**${index + 1}.** \`${session.shortId}\`${marker}${threadLink}`);
|
|
162
|
+
lines.push(` ${truncatedTitle}`);
|
|
163
|
+
lines.push(` _${session.projectName}_ • ${relativeTime}`);
|
|
164
|
+
lines.push("");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (defaultSession && filteredSessions.some(s => s.id === defaultSession.id)) {
|
|
168
|
+
lines.push(`:white_check_mark: = current target (\`${defaultSession.shortId}\`)`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (threadMappingStore) {
|
|
172
|
+
lines.push(":thread: = click to open session thread");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
lines.push("");
|
|
176
|
+
lines.push(`**Commands:** \`${this.commandPrefix}use <id>\` to switch, \`${this.commandPrefix}current\` for details`);
|
|
177
|
+
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private truncateString(str: string, maxLen: number): string {
|
|
182
|
+
if (str.length <= maxLen) return str;
|
|
183
|
+
return str.slice(0, maxLen - 3) + "...";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private formatRelativeTime(date: Date): string {
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const diff = now - date.getTime();
|
|
189
|
+
const minutes = Math.floor(diff / 60000);
|
|
190
|
+
const hours = Math.floor(minutes / 60);
|
|
191
|
+
const days = Math.floor(hours / 24);
|
|
192
|
+
|
|
193
|
+
if (minutes < 1) return "just now";
|
|
194
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
195
|
+
if (hours < 24) return `${hours}h ago`;
|
|
196
|
+
return `${days}d ago`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private truncateDirectory(dir: string, maxLen: number): string {
|
|
200
|
+
if (dir.length <= maxLen) return dir;
|
|
201
|
+
return "..." + dir.slice(-(maxLen - 3));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async handleUse(
|
|
205
|
+
command: ParsedCommand,
|
|
206
|
+
context: CommandContext
|
|
207
|
+
): Promise<CommandResult> {
|
|
208
|
+
const { registry, userSession } = context;
|
|
209
|
+
const targetId = command.rawArgs.trim();
|
|
210
|
+
|
|
211
|
+
if (!targetId) {
|
|
212
|
+
return {
|
|
213
|
+
success: false,
|
|
214
|
+
message: `Usage: \`${this.commandPrefix}use <session-id>\`\n\nUse \`${this.commandPrefix}sessions\` to see available sessions.`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const session = registry.get(targetId);
|
|
219
|
+
|
|
220
|
+
if (!session) {
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
message: `Session not found: \`${targetId}\`\n\nUse \`${this.commandPrefix}sessions\` to see available sessions.`,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!session.isAvailable) {
|
|
228
|
+
return {
|
|
229
|
+
success: false,
|
|
230
|
+
message: `Session \`${session.shortId}\` (${session.projectName}) is no longer available.\n\nUse \`${this.commandPrefix}sessions\` to see current sessions.`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
userSession.targetOpenCodeSessionId = session.id;
|
|
235
|
+
log.info(`[CommandHandler] User ${userSession.mattermostUsername} switched to session ${session.shortId} (${session.projectName})`);
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
success: true,
|
|
239
|
+
message: [
|
|
240
|
+
`:white_check_mark: **Session Changed**`,
|
|
241
|
+
"",
|
|
242
|
+
`Now targeting: **${session.projectName}** (\`${session.shortId}\`)`,
|
|
243
|
+
`Directory: \`${session.directory}\``,
|
|
244
|
+
"",
|
|
245
|
+
"All your prompts will go to this session.",
|
|
246
|
+
].join("\n"),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async handleCurrent(
|
|
251
|
+
_command: ParsedCommand,
|
|
252
|
+
context: CommandContext
|
|
253
|
+
): Promise<CommandResult> {
|
|
254
|
+
const { registry, userSession } = context;
|
|
255
|
+
const targetId = userSession.targetOpenCodeSessionId;
|
|
256
|
+
|
|
257
|
+
if (!targetId) {
|
|
258
|
+
const defaultSession = registry.getDefault();
|
|
259
|
+
if (defaultSession) {
|
|
260
|
+
return {
|
|
261
|
+
success: true,
|
|
262
|
+
message: [
|
|
263
|
+
`:information_source: **No explicit session selected**`,
|
|
264
|
+
"",
|
|
265
|
+
`Using default: **${defaultSession.projectName}** (\`${defaultSession.shortId}\`)`,
|
|
266
|
+
"",
|
|
267
|
+
`Use \`${this.commandPrefix}use <id>\` to select a specific session.`,
|
|
268
|
+
].join("\n"),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
success: true,
|
|
273
|
+
message: `No session selected and no default available.\n\nUse \`${this.commandPrefix}sessions\` to see available sessions.`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const session = registry.get(targetId);
|
|
278
|
+
|
|
279
|
+
if (!session || !session.isAvailable) {
|
|
280
|
+
userSession.targetOpenCodeSessionId = null;
|
|
281
|
+
return {
|
|
282
|
+
success: false,
|
|
283
|
+
message: `:warning: Previously selected session is no longer available.\n\nUse \`${this.commandPrefix}sessions\` to select a new one.`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
success: true,
|
|
289
|
+
message: [
|
|
290
|
+
`:dart: **Current Session**`,
|
|
291
|
+
"",
|
|
292
|
+
`Project: **${session.projectName}**`,
|
|
293
|
+
`ID: \`${session.shortId}\``,
|
|
294
|
+
`Directory: \`${session.directory}\``,
|
|
295
|
+
`Last updated: ${session.lastUpdated.toISOString()}`,
|
|
296
|
+
].join("\n"),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async handleHelp(
|
|
301
|
+
_command: ParsedCommand,
|
|
302
|
+
context: CommandContext
|
|
303
|
+
): Promise<CommandResult> {
|
|
304
|
+
const hasThreads = !!context.threadMappingStore;
|
|
305
|
+
|
|
306
|
+
const lines = [
|
|
307
|
+
`:question: **Available Commands**`,
|
|
308
|
+
"",
|
|
309
|
+
`| Command | Description |`,
|
|
310
|
+
`|---------|-------------|`,
|
|
311
|
+
`| \`${this.commandPrefix}sessions\` | List available OpenCode sessions |`,
|
|
312
|
+
`| \`${this.commandPrefix}use <id>\` | Switch to a different session |`,
|
|
313
|
+
`| \`${this.commandPrefix}current\` | Show currently targeted session |`,
|
|
314
|
+
`| \`${this.commandPrefix}costs\` | Show LLM costs for all active sessions |`,
|
|
315
|
+
`| \`${this.commandPrefix}models\` | List available AI models (use in thread) |`,
|
|
316
|
+
`| \`${this.commandPrefix}model\` | Show current model for this session |`,
|
|
317
|
+
`| \`${this.commandPrefix}merge <url>\` | Merge another thread into this session |`,
|
|
318
|
+
`| \`${this.commandPrefix}stop\` | Stop/abort the current session (use in thread) |`,
|
|
319
|
+
`| \`${this.commandPrefix}reject\` | Skip/reject a pending AI question |`,
|
|
320
|
+
`| \`${this.commandPrefix}team\` | Manage team members (owner only) |`,
|
|
321
|
+
`| \`${this.commandPrefix}help\` | Show this help message |`,
|
|
322
|
+
"",
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
if (hasThreads) {
|
|
326
|
+
lines.push("**Thread-Based Workflow:**");
|
|
327
|
+
lines.push("- Each OpenCode session has its own thread");
|
|
328
|
+
lines.push("- Send prompts by replying in a session's thread");
|
|
329
|
+
lines.push("- Use `" + this.commandPrefix + "sessions` to see thread links");
|
|
330
|
+
lines.push("- Commands work in main DM, prompts must go in threads");
|
|
331
|
+
lines.push("");
|
|
332
|
+
lines.push("**Model Switching:**");
|
|
333
|
+
lines.push("- Use `" + this.commandPrefix + "models` in a thread to see available models");
|
|
334
|
+
lines.push("- Reply with a number to select a model for that session");
|
|
335
|
+
lines.push("");
|
|
336
|
+
lines.push("**AI Questions:**");
|
|
337
|
+
lines.push("- When the AI asks a question, reply with a number or type your answer");
|
|
338
|
+
lines.push("- Use `" + this.commandPrefix + "reject` or `" + this.commandPrefix + "cancel` to skip the question");
|
|
339
|
+
} else {
|
|
340
|
+
lines.push("Any message not starting with `" + this.commandPrefix + "` is sent as a prompt to OpenCode.");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
success: true,
|
|
345
|
+
message: lines.join("\n"),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async fetchModels(opencodeClient: any): Promise<ProviderModel[]> {
|
|
350
|
+
const now = Date.now();
|
|
351
|
+
if (this.cachedModels.length > 0 && (now - this.modelsCacheTime) < this.MODEL_CACHE_TTL_MS) {
|
|
352
|
+
return this.cachedModels;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
const result = await opencodeClient.provider.list();
|
|
357
|
+
const providers = result.data;
|
|
358
|
+
|
|
359
|
+
if (!providers?.all || !providers?.connected) {
|
|
360
|
+
log.warn("[CommandHandler] No providers data returned");
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const models: ProviderModel[] = [];
|
|
365
|
+
const connectedProviders = new Set(providers.connected);
|
|
366
|
+
|
|
367
|
+
for (const provider of providers.all) {
|
|
368
|
+
if (!connectedProviders.has(provider.id)) continue;
|
|
369
|
+
|
|
370
|
+
for (const [modelId, model] of Object.entries(provider.models || {})) {
|
|
371
|
+
const m = model as any;
|
|
372
|
+
models.push({
|
|
373
|
+
id: modelId,
|
|
374
|
+
name: m.name || modelId,
|
|
375
|
+
providerID: provider.id,
|
|
376
|
+
providerName: provider.name,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
models.sort((a, b) => {
|
|
382
|
+
if (a.providerID !== b.providerID) {
|
|
383
|
+
return a.providerID.localeCompare(b.providerID);
|
|
384
|
+
}
|
|
385
|
+
return a.name.localeCompare(b.name);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
this.cachedModels = models;
|
|
389
|
+
this.modelsCacheTime = now;
|
|
390
|
+
|
|
391
|
+
log.debug(`[CommandHandler] Cached ${models.length} models from ${connectedProviders.size} providers`);
|
|
392
|
+
return models;
|
|
393
|
+
} catch (e) {
|
|
394
|
+
log.error("[CommandHandler] Failed to fetch models:", e);
|
|
395
|
+
return this.cachedModels;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private async handleModels(
|
|
400
|
+
_command: ParsedCommand,
|
|
401
|
+
context: CommandContext
|
|
402
|
+
): Promise<CommandResult> {
|
|
403
|
+
const { threadMappingStore, opencodeClient, sessionId, threadRootPostId } = context;
|
|
404
|
+
|
|
405
|
+
if (!opencodeClient) {
|
|
406
|
+
return {
|
|
407
|
+
success: false,
|
|
408
|
+
message: "OpenCode client not available.",
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!sessionId || !threadRootPostId) {
|
|
413
|
+
return {
|
|
414
|
+
success: false,
|
|
415
|
+
message: `Use \`${this.commandPrefix}models\` inside a session thread to switch models for that session.`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const models = await this.fetchModels(opencodeClient);
|
|
420
|
+
|
|
421
|
+
if (models.length === 0) {
|
|
422
|
+
return {
|
|
423
|
+
success: false,
|
|
424
|
+
message: "No models available. Check that providers are configured in OpenCode.",
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const mapping = threadMappingStore?.getBySessionId(sessionId);
|
|
429
|
+
const currentModel = mapping?.model;
|
|
430
|
+
|
|
431
|
+
let currentProvider = "";
|
|
432
|
+
const lines: string[] = [
|
|
433
|
+
`:robot_face: **Available Models**`,
|
|
434
|
+
"",
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
models.forEach((model, index) => {
|
|
438
|
+
if (model.providerID !== currentProvider) {
|
|
439
|
+
currentProvider = model.providerID;
|
|
440
|
+
lines.push(`**${model.providerName}**`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const isCurrent = currentModel?.providerID === model.providerID &&
|
|
444
|
+
currentModel?.modelID === model.id;
|
|
445
|
+
const marker = isCurrent ? " :white_check_mark:" : "";
|
|
446
|
+
lines.push(` \`${index + 1}\` ${model.name}${marker}`);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
lines.push("");
|
|
450
|
+
lines.push("Reply with a **number** to select a model for this session.");
|
|
451
|
+
|
|
452
|
+
if (currentModel) {
|
|
453
|
+
lines.push("");
|
|
454
|
+
lines.push(`:white_check_mark: Current: **${currentModel.displayName || currentModel.modelID}**`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (mapping && threadMappingStore) {
|
|
458
|
+
mapping.pendingModelSelection = true;
|
|
459
|
+
threadMappingStore.update(mapping);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
success: true,
|
|
464
|
+
message: lines.join("\n"),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private async handleModel(
|
|
469
|
+
_command: ParsedCommand,
|
|
470
|
+
context: CommandContext
|
|
471
|
+
): Promise<CommandResult> {
|
|
472
|
+
const { threadMappingStore, sessionId } = context;
|
|
473
|
+
|
|
474
|
+
if (!sessionId) {
|
|
475
|
+
return {
|
|
476
|
+
success: false,
|
|
477
|
+
message: `Use \`${this.commandPrefix}model\` inside a session thread to see the current model.`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const mapping = threadMappingStore?.getBySessionId(sessionId);
|
|
482
|
+
|
|
483
|
+
if (!mapping?.model) {
|
|
484
|
+
return {
|
|
485
|
+
success: true,
|
|
486
|
+
message: `:information_source: No model explicitly set for this session. Using OpenCode default.\n\nUse \`${this.commandPrefix}models\` to select a specific model.`,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
success: true,
|
|
492
|
+
message: [
|
|
493
|
+
`:robot_face: **Current Model**`,
|
|
494
|
+
"",
|
|
495
|
+
`Provider: **${mapping.model.providerID}**`,
|
|
496
|
+
`Model: **${mapping.model.displayName || mapping.model.modelID}**`,
|
|
497
|
+
"",
|
|
498
|
+
`Use \`${this.commandPrefix}models\` to change.`,
|
|
499
|
+
].join("\n"),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private async handleCosts(
|
|
504
|
+
_command: ParsedCommand,
|
|
505
|
+
context: CommandContext
|
|
506
|
+
): Promise<CommandResult> {
|
|
507
|
+
const { registry, opencodeClient, threadMappingStore } = context;
|
|
508
|
+
|
|
509
|
+
if (!opencodeClient) {
|
|
510
|
+
return {
|
|
511
|
+
success: false,
|
|
512
|
+
message: "OpenCode client not available.",
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
await registry.refresh();
|
|
518
|
+
} catch (e) {
|
|
519
|
+
log.warn("[CommandHandler] Failed to refresh sessions:", e);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const allSessions = registry.listAvailable();
|
|
523
|
+
|
|
524
|
+
if (allSessions.length === 0) {
|
|
525
|
+
return {
|
|
526
|
+
success: true,
|
|
527
|
+
message: "No active OpenCode sessions found.",
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Filter to only sessions with thread mappings (active DM sessions)
|
|
532
|
+
// This avoids fetching costs for orphaned or unused sessions
|
|
533
|
+
const sessionsWithMappings = threadMappingStore
|
|
534
|
+
? allSessions.filter(s => threadMappingStore.getBySessionId(s.id))
|
|
535
|
+
: allSessions;
|
|
536
|
+
|
|
537
|
+
// Limit to max 30 sessions to avoid timeout
|
|
538
|
+
const MAX_SESSIONS = 30;
|
|
539
|
+
const sessions = sessionsWithMappings.slice(0, MAX_SESSIONS);
|
|
540
|
+
const totalAvailable = allSessions.length;
|
|
541
|
+
const limited = sessionsWithMappings.length > MAX_SESSIONS;
|
|
542
|
+
|
|
543
|
+
log.info(`[CommandHandler] !costs: Fetching costs for ${sessions.length} sessions (${totalAvailable} total available, ${sessionsWithMappings.length} with mappings)`);
|
|
544
|
+
|
|
545
|
+
// Fetch costs in parallel for speed
|
|
546
|
+
const costPromises = sessions.map(async (session) => {
|
|
547
|
+
let totalCost = 0;
|
|
548
|
+
try {
|
|
549
|
+
const messagesResult = await opencodeClient.session.messages({ path: { id: session.id } });
|
|
550
|
+
const messages = messagesResult.data || [];
|
|
551
|
+
for (const message of messages) {
|
|
552
|
+
if (message.info.role === "assistant") {
|
|
553
|
+
totalCost += (message.info as any).cost || 0;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
} catch (e) {
|
|
557
|
+
log.debug(`[CommandHandler] Could not fetch messages for session ${session.shortId}: ${e}`);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const mapping = threadMappingStore?.getBySessionId(session.id);
|
|
561
|
+
const threadLink = mapping ? `/_redirect/pl/${mapping.threadRootPostId}` : null;
|
|
562
|
+
|
|
563
|
+
return { session, cost: totalCost, threadLink };
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const sessionCosts = await Promise.all(costPromises);
|
|
567
|
+
|
|
568
|
+
log.info(`[CommandHandler] !costs: Fetched costs for ${sessionCosts.length} sessions`);
|
|
569
|
+
|
|
570
|
+
sessionCosts.sort((a, b) => b.cost - a.cost);
|
|
571
|
+
|
|
572
|
+
const grandTotal = sessionCosts.reduce((sum, sc) => sum + sc.cost, 0);
|
|
573
|
+
|
|
574
|
+
const lines: string[] = [
|
|
575
|
+
`:moneybag: **Session Costs**`,
|
|
576
|
+
"",
|
|
577
|
+
`| Session | Title | Cost |`,
|
|
578
|
+
`|---------|-------|------|`,
|
|
579
|
+
];
|
|
580
|
+
|
|
581
|
+
for (const { session, cost, threadLink } of sessionCosts) {
|
|
582
|
+
const title = this.truncateString(session.title || session.projectName, 40);
|
|
583
|
+
const costStr = this.formatCost(cost);
|
|
584
|
+
const sessionLink = threadLink
|
|
585
|
+
? `[\`${session.shortId}\`](${threadLink})`
|
|
586
|
+
: `\`${session.shortId}\``;
|
|
587
|
+
lines.push(`| ${sessionLink} | ${title} | ${costStr} |`);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
lines.push(`| | **Total** | **${this.formatCost(grandTotal)}** |`);
|
|
591
|
+
lines.push("");
|
|
592
|
+
|
|
593
|
+
if (limited) {
|
|
594
|
+
lines.push(`_Showing ${sessions.length} of ${sessionsWithMappings.length} sessions with threads (${totalAvailable} total)_`);
|
|
595
|
+
} else {
|
|
596
|
+
lines.push(`_${sessions.length} session(s) with threads (${totalAvailable} total available)_`);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
success: true,
|
|
601
|
+
message: lines.join("\n"),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private formatCost(cost: number): string {
|
|
606
|
+
if (cost >= 1) return `$${cost.toFixed(2)}`;
|
|
607
|
+
if (cost >= 0.01) return `$${cost.toFixed(2)}`;
|
|
608
|
+
if (cost >= 0.001) return `$${cost.toFixed(3)}`;
|
|
609
|
+
return `$${cost.toFixed(4)}`;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async handleStop(
|
|
613
|
+
_command: ParsedCommand,
|
|
614
|
+
context: CommandContext
|
|
615
|
+
): Promise<CommandResult> {
|
|
616
|
+
const { opencodeClient, sessionId, threadMappingStore } = context;
|
|
617
|
+
|
|
618
|
+
if (!opencodeClient) {
|
|
619
|
+
return {
|
|
620
|
+
success: false,
|
|
621
|
+
message: "OpenCode client not available.",
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (!sessionId) {
|
|
626
|
+
return {
|
|
627
|
+
success: false,
|
|
628
|
+
message: `Use \`${this.commandPrefix}stop\` inside a session thread to stop that session.`,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
await opencodeClient.session.abort({ path: { id: sessionId } });
|
|
634
|
+
log.info(`[CommandHandler] Aborted session ${sessionId.substring(0, 8)}`);
|
|
635
|
+
|
|
636
|
+
const mapping = threadMappingStore?.getBySessionId(sessionId);
|
|
637
|
+
const shortId = mapping?.shortId || sessionId.substring(0, 8);
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
success: true,
|
|
641
|
+
message: [
|
|
642
|
+
`:stop_sign: **Session Stopped**`,
|
|
643
|
+
"",
|
|
644
|
+
`Session \`${shortId}\` has been interrupted.`,
|
|
645
|
+
"",
|
|
646
|
+
"The AI will stop processing and any running tools will be cancelled.",
|
|
647
|
+
].join("\n"),
|
|
648
|
+
};
|
|
649
|
+
} catch (e) {
|
|
650
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
651
|
+
log.error(`[CommandHandler] Failed to abort session ${sessionId.substring(0, 8)}: ${errorMsg}`);
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
success: false,
|
|
655
|
+
message: `Failed to stop session: ${errorMsg}`,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private async handleReject(
|
|
661
|
+
_command: ParsedCommand,
|
|
662
|
+
context: CommandContext
|
|
663
|
+
): Promise<CommandResult> {
|
|
664
|
+
const { sessionId, questionHandler } = context;
|
|
665
|
+
|
|
666
|
+
if (!sessionId) {
|
|
667
|
+
return {
|
|
668
|
+
success: false,
|
|
669
|
+
message: `Use \`${this.commandPrefix}reject\` inside a session thread to reject a pending question.`,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (!questionHandler) {
|
|
674
|
+
return {
|
|
675
|
+
success: false,
|
|
676
|
+
message: "Question handler not available.",
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (!questionHandler.hasPendingQuestion(sessionId)) {
|
|
681
|
+
return {
|
|
682
|
+
success: false,
|
|
683
|
+
message: "No pending question to reject for this session.",
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const questionInfo = questionHandler.getPendingQuestionInfo(sessionId);
|
|
688
|
+
questionHandler.cancelSessionQuestions(sessionId);
|
|
689
|
+
|
|
690
|
+
const questionHeader = questionInfo?.request.questions[0]?.header || "Unknown";
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
success: true,
|
|
694
|
+
message: [
|
|
695
|
+
`:x: **Question Rejected**`,
|
|
696
|
+
"",
|
|
697
|
+
`Skipped question: "${questionHeader}"`,
|
|
698
|
+
"",
|
|
699
|
+
"The AI will continue without your input (using default or making its own choice).",
|
|
700
|
+
].join("\n"),
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private async handleMerge(
|
|
705
|
+
command: ParsedCommand,
|
|
706
|
+
context: CommandContext
|
|
707
|
+
): Promise<CommandResult> {
|
|
708
|
+
const { mmClient, threadMappingStore, opencodeClient, sessionId, threadRootPostId, channelId, userSession, mattermostBaseUrl } = context;
|
|
709
|
+
|
|
710
|
+
if (!opencodeClient) {
|
|
711
|
+
return {
|
|
712
|
+
success: false,
|
|
713
|
+
message: "OpenCode client not available.",
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (!threadMappingStore) {
|
|
718
|
+
return {
|
|
719
|
+
success: false,
|
|
720
|
+
message: "Thread mapping store not available.",
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (!sessionId || !threadRootPostId || !channelId) {
|
|
725
|
+
return {
|
|
726
|
+
success: false,
|
|
727
|
+
message: `Use \`${this.commandPrefix}merge <thread-url>\` inside a session thread to merge another thread's context into this one.`,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const url = command.rawArgs.trim();
|
|
732
|
+
if (!url) {
|
|
733
|
+
return {
|
|
734
|
+
success: false,
|
|
735
|
+
message: [
|
|
736
|
+
`**Usage:** \`${this.commandPrefix}merge <thread-url>\``,
|
|
737
|
+
"",
|
|
738
|
+
"Merge another Mattermost thread's conversation into this session.",
|
|
739
|
+
"",
|
|
740
|
+
"**Example:**",
|
|
741
|
+
`\`${this.commandPrefix}merge https://mattermost.example.com/team/pl/abc123xyz\``,
|
|
742
|
+
"",
|
|
743
|
+
"The source thread will be summarized and the summary injected here.",
|
|
744
|
+
"The source thread will be marked as merged and locked.",
|
|
745
|
+
].join("\n"),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const baseUrl = mattermostBaseUrl || process.env.MATTERMOST_URL || "";
|
|
750
|
+
const mergeHandler = new MergeHandler(mmClient, threadMappingStore, opencodeClient, baseUrl);
|
|
751
|
+
|
|
752
|
+
const result = await mergeHandler.executeMerge(
|
|
753
|
+
url,
|
|
754
|
+
sessionId,
|
|
755
|
+
threadRootPostId,
|
|
756
|
+
channelId,
|
|
757
|
+
userSession.mattermostUserId
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
success: result.success,
|
|
762
|
+
message: result.message,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private async handleTeam(
|
|
767
|
+
command: ParsedCommand,
|
|
768
|
+
context: CommandContext
|
|
769
|
+
): Promise<CommandResult> {
|
|
770
|
+
const { teamStore, ownerUserId, userSession, mmClient } = context;
|
|
771
|
+
|
|
772
|
+
if (!teamStore) {
|
|
773
|
+
return {
|
|
774
|
+
success: false,
|
|
775
|
+
message: "Team system not available.",
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const isOwner = ownerUserId && userSession.mattermostUserId === ownerUserId;
|
|
780
|
+
if (!isOwner) {
|
|
781
|
+
return {
|
|
782
|
+
success: false,
|
|
783
|
+
message: "Only the owner can manage team members.",
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const args = command.rawArgs.trim().split(/\s+/);
|
|
788
|
+
const subcommand = args[0]?.toLowerCase() || "";
|
|
789
|
+
|
|
790
|
+
if (!subcommand || subcommand === "list") {
|
|
791
|
+
return this.handleTeamList(teamStore);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (subcommand === "add") {
|
|
795
|
+
const mention = args[1];
|
|
796
|
+
if (!mention) {
|
|
797
|
+
return {
|
|
798
|
+
success: false,
|
|
799
|
+
message: `**Usage:** \`${this.commandPrefix}team add @username\``,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
return this.handleTeamAdd(teamStore, mmClient, mention, ownerUserId);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (subcommand === "remove") {
|
|
806
|
+
const mention = args[1];
|
|
807
|
+
if (!mention) {
|
|
808
|
+
return {
|
|
809
|
+
success: false,
|
|
810
|
+
message: `**Usage:** \`${this.commandPrefix}team remove @username\``,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
return this.handleTeamRemove(teamStore, mmClient, mention);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (subcommand === "clear") {
|
|
817
|
+
return this.handleTeamClear(teamStore);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
success: false,
|
|
822
|
+
message: [
|
|
823
|
+
`:busts_in_silhouette: **Team Commands**`,
|
|
824
|
+
"",
|
|
825
|
+
`| Command | Description |`,
|
|
826
|
+
`|---------|-------------|`,
|
|
827
|
+
`| \`${this.commandPrefix}team\` | Show team members |`,
|
|
828
|
+
`| \`${this.commandPrefix}team add @user\` | Add a team member |`,
|
|
829
|
+
`| \`${this.commandPrefix}team remove @user\` | Remove a team member |`,
|
|
830
|
+
`| \`${this.commandPrefix}team clear\` | Remove all team members |`,
|
|
831
|
+
"",
|
|
832
|
+
"Team members can bypass guest approval and create sessions.",
|
|
833
|
+
].join("\n"),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private handleTeamList(teamStore: TeamStore): CommandResult {
|
|
838
|
+
const members = teamStore.getMembers();
|
|
839
|
+
const config = teamStore.getConfig();
|
|
840
|
+
|
|
841
|
+
if (!config) {
|
|
842
|
+
return {
|
|
843
|
+
success: true,
|
|
844
|
+
message: "No team configured yet. Use `!team add @user` to add your first team member.",
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const lines: string[] = [
|
|
849
|
+
`:busts_in_silhouette: **Team: ${config.name}**`,
|
|
850
|
+
"",
|
|
851
|
+
];
|
|
852
|
+
|
|
853
|
+
if (members.length === 0) {
|
|
854
|
+
lines.push("_No team members yet._");
|
|
855
|
+
lines.push("");
|
|
856
|
+
lines.push(`Use \`${this.commandPrefix}team add @username\` to add members.`);
|
|
857
|
+
} else {
|
|
858
|
+
lines.push(`| Member | Added | Role |`);
|
|
859
|
+
lines.push(`|--------|-------|------|`);
|
|
860
|
+
|
|
861
|
+
for (const member of members) {
|
|
862
|
+
const addedDate = new Date(member.addedAt).toLocaleDateString();
|
|
863
|
+
lines.push(`| @${member.username} | ${addedDate} | ${member.role} |`);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
lines.push("");
|
|
867
|
+
lines.push(`**${members.length}** team member(s)`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
lines.push("");
|
|
871
|
+
lines.push("Team members bypass guest approval and can create sessions.");
|
|
872
|
+
|
|
873
|
+
return {
|
|
874
|
+
success: true,
|
|
875
|
+
message: lines.join("\n"),
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private async handleTeamAdd(
|
|
880
|
+
teamStore: TeamStore,
|
|
881
|
+
mmClient: MattermostClient,
|
|
882
|
+
mention: string,
|
|
883
|
+
addedBy: string
|
|
884
|
+
): Promise<CommandResult> {
|
|
885
|
+
const username = mention.replace(/^@/, "");
|
|
886
|
+
|
|
887
|
+
try {
|
|
888
|
+
const user = await mmClient.getUserByUsername(username);
|
|
889
|
+
|
|
890
|
+
if (teamStore.isOwner(user.id)) {
|
|
891
|
+
return {
|
|
892
|
+
success: false,
|
|
893
|
+
message: `@${username} is the owner, not a team member.`,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const added = teamStore.addMember(user.id, username, addedBy);
|
|
898
|
+
|
|
899
|
+
if (!added) {
|
|
900
|
+
return {
|
|
901
|
+
success: false,
|
|
902
|
+
message: `@${username} is already a team member.`,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return {
|
|
907
|
+
success: true,
|
|
908
|
+
message: [
|
|
909
|
+
`:white_check_mark: **Team Member Added**`,
|
|
910
|
+
"",
|
|
911
|
+
`@${username} can now:`,
|
|
912
|
+
"- Bypass guest approval in channels",
|
|
913
|
+
"- Create sessions without confirmation",
|
|
914
|
+
"- Send prompts directly to the bot",
|
|
915
|
+
].join("\n"),
|
|
916
|
+
};
|
|
917
|
+
} catch (e) {
|
|
918
|
+
log.error(`[CommandHandler] Failed to add team member @${username}:`, e);
|
|
919
|
+
return {
|
|
920
|
+
success: false,
|
|
921
|
+
message: `Could not find user @${username}. Make sure the username is correct.`,
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
private async handleTeamRemove(
|
|
927
|
+
teamStore: TeamStore,
|
|
928
|
+
mmClient: MattermostClient,
|
|
929
|
+
mention: string
|
|
930
|
+
): Promise<CommandResult> {
|
|
931
|
+
const username = mention.replace(/^@/, "");
|
|
932
|
+
|
|
933
|
+
try {
|
|
934
|
+
const user = await mmClient.getUserByUsername(username);
|
|
935
|
+
const removed = teamStore.removeMember(user.id);
|
|
936
|
+
|
|
937
|
+
if (!removed) {
|
|
938
|
+
return {
|
|
939
|
+
success: false,
|
|
940
|
+
message: `@${username} is not a team member.`,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return {
|
|
945
|
+
success: true,
|
|
946
|
+
message: `:white_check_mark: Removed @${username} from the team.`,
|
|
947
|
+
};
|
|
948
|
+
} catch (e) {
|
|
949
|
+
log.error(`[CommandHandler] Failed to remove team member @${username}:`, e);
|
|
950
|
+
return {
|
|
951
|
+
success: false,
|
|
952
|
+
message: `Could not find user @${username}. Make sure the username is correct.`,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
private handleTeamClear(teamStore: TeamStore): CommandResult {
|
|
958
|
+
const count = teamStore.clearMembers();
|
|
959
|
+
|
|
960
|
+
if (count === 0) {
|
|
961
|
+
return {
|
|
962
|
+
success: true,
|
|
963
|
+
message: "No team members to remove.",
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return {
|
|
968
|
+
success: true,
|
|
969
|
+
message: `:white_check_mark: Removed **${count}** team member(s). Only the owner has access now.`,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async handleModelSelection(
|
|
974
|
+
selection: number,
|
|
975
|
+
context: CommandContext
|
|
976
|
+
): Promise<CommandResult | null> {
|
|
977
|
+
const { threadMappingStore, opencodeClient, sessionId } = context;
|
|
978
|
+
|
|
979
|
+
if (!sessionId || !threadMappingStore || !opencodeClient) {
|
|
980
|
+
return null;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const mapping = threadMappingStore.getBySessionId(sessionId);
|
|
984
|
+
if (!mapping?.pendingModelSelection) {
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const models = await this.fetchModels(opencodeClient);
|
|
989
|
+
|
|
990
|
+
if (selection < 1 || selection > models.length) {
|
|
991
|
+
return {
|
|
992
|
+
success: false,
|
|
993
|
+
message: `Invalid selection. Enter a number between 1 and ${models.length}.`,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const selectedModel = models[selection - 1];
|
|
998
|
+
|
|
999
|
+
mapping.model = {
|
|
1000
|
+
providerID: selectedModel.providerID,
|
|
1001
|
+
modelID: selectedModel.id,
|
|
1002
|
+
displayName: selectedModel.name,
|
|
1003
|
+
};
|
|
1004
|
+
mapping.pendingModelSelection = false;
|
|
1005
|
+
threadMappingStore.update(mapping);
|
|
1006
|
+
|
|
1007
|
+
log.info(`[CommandHandler] Model set for session ${mapping.shortId}: ${selectedModel.providerID}/${selectedModel.id}`);
|
|
1008
|
+
|
|
1009
|
+
return {
|
|
1010
|
+
success: true,
|
|
1011
|
+
message: [
|
|
1012
|
+
`:white_check_mark: **Model Changed**`,
|
|
1013
|
+
"",
|
|
1014
|
+
`Now using: **${selectedModel.name}**`,
|
|
1015
|
+
`Provider: ${selectedModel.providerName}`,
|
|
1016
|
+
"",
|
|
1017
|
+
"All prompts in this thread will use this model.",
|
|
1018
|
+
].join("\n"),
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
isPendingModelSelection(sessionId: string, threadMappingStore: ThreadMappingStore | null): boolean {
|
|
1023
|
+
if (!threadMappingStore) return false;
|
|
1024
|
+
const mapping = threadMappingStore.getBySessionId(sessionId);
|
|
1025
|
+
return mapping?.pendingModelSelection === true;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
isKnownCommand(name: string): boolean {
|
|
1029
|
+
return this.commands.has(name);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
getAvailableCommands(): string[] {
|
|
1033
|
+
return Array.from(this.commands.keys());
|
|
1034
|
+
}
|
|
1035
|
+
}
|