@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,964 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
|
|
4
|
+
import { PluginState } from "./state.js";
|
|
5
|
+
import { createEmptyResponseContext } from "./types.js";
|
|
6
|
+
import { startResponseTimer, stopResponseTimer, stopActiveToolTimer } from "./timers.js";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createConnectTool,
|
|
10
|
+
createDisconnectTool,
|
|
11
|
+
createStatusTool,
|
|
12
|
+
createListSessionsTool,
|
|
13
|
+
createSelectSessionTool,
|
|
14
|
+
createCurrentSessionTool,
|
|
15
|
+
createMonitorTool,
|
|
16
|
+
createUnmonitorTool,
|
|
17
|
+
createSendFileTool,
|
|
18
|
+
createScheduleAddTool,
|
|
19
|
+
createScheduleListTool,
|
|
20
|
+
createScheduleRemoveTool,
|
|
21
|
+
createScheduleEnableTool,
|
|
22
|
+
createScheduleDisableTool,
|
|
23
|
+
createScheduleRunTool,
|
|
24
|
+
} from "./tools/index.js";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
handlePermissionAsked,
|
|
28
|
+
handleQuestionAsked,
|
|
29
|
+
handleSessionIdle,
|
|
30
|
+
handleSessionStatus,
|
|
31
|
+
handleSessionCompacted,
|
|
32
|
+
handleMessageUpdated,
|
|
33
|
+
handleMessagePartUpdated,
|
|
34
|
+
handleFileEdited,
|
|
35
|
+
handleTodoUpdated,
|
|
36
|
+
handleToolExecuteBefore,
|
|
37
|
+
handleToolExecuteAfter,
|
|
38
|
+
} from "./event-handlers/index.js";
|
|
39
|
+
|
|
40
|
+
import { ThreadMappingStore } from "../../../src/persistence/thread-mapping-store.js";
|
|
41
|
+
import { buildThreadContext, summarizeContextWithHaiku, formatContextForPrompt, stripBotMention } from "../../../src/context-builder.js";
|
|
42
|
+
import { loadConfig } from "../../../src/config.js";
|
|
43
|
+
import { log } from "../../../src/logger.js";
|
|
44
|
+
import type { Post } from "../../../src/models/index.js";
|
|
45
|
+
import type { UserSession } from "../../../src/session-manager.js";
|
|
46
|
+
import type { InboundRouteResult } from "../../../src/models/routing.js";
|
|
47
|
+
import type { OpenCodeSessionInfo } from "../../../src/opencode-session-registry.js";
|
|
48
|
+
|
|
49
|
+
export const MattermostControlPlugin: Plugin = async ({ client, project, directory, serverUrl, $ }) => {
|
|
50
|
+
const config = loadConfig();
|
|
51
|
+
const projectName = directory.split("/").pop() || "opencode";
|
|
52
|
+
const opencodeBaseUrl = serverUrl.origin;
|
|
53
|
+
|
|
54
|
+
PluginState.setProjectName(projectName);
|
|
55
|
+
|
|
56
|
+
const threadMappingStore = new ThreadMappingStore();
|
|
57
|
+
threadMappingStore.load().catch((e) => log.warn("[Plugin] Failed to load thread mappings:", e));
|
|
58
|
+
PluginState.setThreadMappingStore(threadMappingStore);
|
|
59
|
+
|
|
60
|
+
const connectionContext = {
|
|
61
|
+
client,
|
|
62
|
+
directory,
|
|
63
|
+
projectName,
|
|
64
|
+
opencodeBaseUrl,
|
|
65
|
+
handleUserMessage,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (config.mattermost.autoConnect && config.mattermost.token) {
|
|
69
|
+
log.info("Auto-connect enabled, connecting to Mattermost...");
|
|
70
|
+
const connectTool = createConnectTool(connectionContext);
|
|
71
|
+
setTimeout(async () => {
|
|
72
|
+
const result = await connectTool.execute();
|
|
73
|
+
log.info(`Auto-connect result: ${result.split('\n')[0]}`);
|
|
74
|
+
}, 100);
|
|
75
|
+
} else {
|
|
76
|
+
log.info("Loaded (not connected - use /mattermost connect)");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function handleUserMessage(post: Post): Promise<void> {
|
|
80
|
+
const { sessionManager, streamer, notifications, fileHandler, messageRouter, commandHandler, openCodeSessionRegistry, mmClient, threadMappingStore, threadManager } = PluginState;
|
|
81
|
+
|
|
82
|
+
if (!sessionManager || !streamer || !notifications || !fileHandler || !messageRouter || !commandHandler || !openCodeSessionRegistry || !mmClient) return;
|
|
83
|
+
|
|
84
|
+
let userSession: UserSession;
|
|
85
|
+
try {
|
|
86
|
+
userSession = await sessionManager.getOrCreateSession(post.user_id);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
log.error("Failed to get/create session:", error);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (threadManager && threadMappingStore && !(post as any)._ownershipConfirmed) {
|
|
93
|
+
const availableSessions = openCodeSessionRegistry.listAvailable();
|
|
94
|
+
for (const sessionInfo of availableSessions) {
|
|
95
|
+
const existingMapping = threadMappingStore.getBySessionId(sessionInfo.id);
|
|
96
|
+
if (!existingMapping) {
|
|
97
|
+
try {
|
|
98
|
+
await threadManager.createThread(
|
|
99
|
+
sessionInfo,
|
|
100
|
+
userSession.mattermostUserId,
|
|
101
|
+
userSession.dmChannelId,
|
|
102
|
+
undefined,
|
|
103
|
+
undefined,
|
|
104
|
+
userSession.mattermostUsername
|
|
105
|
+
);
|
|
106
|
+
log.info(`[AutoThread] Created thread for session ${sessionInfo.shortId} for user ${userSession.mattermostUsername}`);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
log.error(`[AutoThread] Failed to create thread:`, e);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const routeResult = threadMappingStore
|
|
115
|
+
? messageRouter.routeWithThreads(post)
|
|
116
|
+
: convertLegacyRoute(messageRouter.route(post), post);
|
|
117
|
+
|
|
118
|
+
log.debug(`[ROUTING] type=${routeResult.type}`);
|
|
119
|
+
|
|
120
|
+
switch (routeResult.type) {
|
|
121
|
+
case "main_dm_command": {
|
|
122
|
+
const result = await commandHandler.execute(routeResult.command, {
|
|
123
|
+
userSession,
|
|
124
|
+
registry: openCodeSessionRegistry,
|
|
125
|
+
mmClient,
|
|
126
|
+
threadMappingStore,
|
|
127
|
+
teamStore: PluginState.teamStore,
|
|
128
|
+
ownerUserId: config.mattermost.ownerUserId,
|
|
129
|
+
questionHandler: PluginState.questionHandler,
|
|
130
|
+
opencodeClient: client,
|
|
131
|
+
channelId: post.channel_id,
|
|
132
|
+
});
|
|
133
|
+
await mmClient.createPost(post.channel_id, result.message);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case "main_dm_prompt": {
|
|
138
|
+
if (config.sessionSelection.autoCreateSession) {
|
|
139
|
+
const newSession = await createNewSessionFromDm(userSession, post);
|
|
140
|
+
if (newSession) {
|
|
141
|
+
await handleThreadPrompt({
|
|
142
|
+
sessionId: newSession.sessionId,
|
|
143
|
+
threadRootPostId: newSession.threadRootPostId,
|
|
144
|
+
promptText: post.message.trim(),
|
|
145
|
+
fileIds: post.file_ids,
|
|
146
|
+
}, userSession, post);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await mmClient.createPost(
|
|
152
|
+
post.channel_id,
|
|
153
|
+
`:warning: ${routeResult.errorMessage}\n\n${routeResult.suggestedAction}`
|
|
154
|
+
);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case "unknown_thread": {
|
|
159
|
+
const channel = await mmClient.getChannel(post.channel_id);
|
|
160
|
+
const threadRootPostId = routeResult.threadRootPostId;
|
|
161
|
+
|
|
162
|
+
// Handle ownership confirmation for non-DM channels (Group DM, Public, Private)
|
|
163
|
+
if (channel.type === "G" || channel.type === "O" || channel.type === "P") {
|
|
164
|
+
// Check if this post was already confirmed via _ownershipConfirmed flag
|
|
165
|
+
// This flag is set by connect.ts when user replies "yes" to ownership confirmation
|
|
166
|
+
if ((post as any)._ownershipConfirmed) {
|
|
167
|
+
log.info(`[SessionOwnership] Post has _ownershipConfirmed flag, creating session in ${channel.type === "G" ? "group DM" : channel.type === "O" ? "public" : "private"} channel ${post.channel_id}`);
|
|
168
|
+
const newSession = await createNewSessionFromDm(userSession, post);
|
|
169
|
+
if (newSession) {
|
|
170
|
+
await handleThreadPrompt({
|
|
171
|
+
sessionId: newSession.sessionId,
|
|
172
|
+
threadRootPostId: newSession.threadRootPostId,
|
|
173
|
+
promptText: post.message.trim(),
|
|
174
|
+
fileIds: post.file_ids,
|
|
175
|
+
}, userSession, post);
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { sessionOwnershipHandler } = PluginState;
|
|
181
|
+
if (sessionOwnershipHandler?.hasPendingConfirmation(post.channel_id, threadRootPostId, post.user_id)) {
|
|
182
|
+
const confirmResult = await sessionOwnershipHandler.handleReply(
|
|
183
|
+
post.channel_id,
|
|
184
|
+
threadRootPostId,
|
|
185
|
+
post.message.trim()
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (confirmResult.confirmed && confirmResult.post) {
|
|
189
|
+
log.info(`[SessionOwnership] User confirmed with approval policy: ${confirmResult.approvalPolicy || 'none'}`);
|
|
190
|
+
const postWithPolicy = confirmResult.post as any;
|
|
191
|
+
postWithPolicy._ownershipConfirmed = true;
|
|
192
|
+
postWithPolicy._approvalPolicy = confirmResult.approvalPolicy || "none";
|
|
193
|
+
const newSession = await createNewSessionFromDm(userSession, postWithPolicy);
|
|
194
|
+
if (newSession) {
|
|
195
|
+
await handleThreadPrompt({
|
|
196
|
+
sessionId: newSession.sessionId,
|
|
197
|
+
threadRootPostId: newSession.threadRootPostId,
|
|
198
|
+
promptText: confirmResult.post.message.trim(),
|
|
199
|
+
fileIds: confirmResult.post.file_ids,
|
|
200
|
+
}, userSession, confirmResult.post);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await mmClient.createPost(
|
|
208
|
+
post.channel_id,
|
|
209
|
+
routeResult.errorMessage,
|
|
210
|
+
routeResult.threadRootPostId
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case "ended_session": {
|
|
216
|
+
await mmClient.createPost(
|
|
217
|
+
post.channel_id,
|
|
218
|
+
`:no_entry: ${routeResult.errorMessage}`,
|
|
219
|
+
routeResult.threadRootPostId
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case "merged_session": {
|
|
225
|
+
const destMapping = routeResult.mergedInto
|
|
226
|
+
? threadMappingStore?.getBySessionId(routeResult.mergedInto)
|
|
227
|
+
: null;
|
|
228
|
+
|
|
229
|
+
const baseUrl = config.mattermost.baseUrl.replace(/\/api\/v4$/, "");
|
|
230
|
+
const redirectLink = destMapping
|
|
231
|
+
? `[here](${baseUrl}/_redirect/pl/${destMapping.threadRootPostId})`
|
|
232
|
+
: "another thread";
|
|
233
|
+
|
|
234
|
+
await mmClient.createPost(
|
|
235
|
+
post.channel_id,
|
|
236
|
+
`:lock: **Thread Merged**\n\nThis thread has been merged into ${redirectLink}.\nPlease continue the conversation there.`,
|
|
237
|
+
routeResult.threadRootPostId
|
|
238
|
+
);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case "thread_prompt": {
|
|
243
|
+
let promptText = routeResult.promptText.trim();
|
|
244
|
+
|
|
245
|
+
// Strip bot mention early so commands like "@kaji !merge" work in channels
|
|
246
|
+
const botUser = PluginState.botUser;
|
|
247
|
+
if (botUser) {
|
|
248
|
+
const strippedText = stripBotMention(promptText, botUser.username, botUser.id).trim();
|
|
249
|
+
if (strippedText !== promptText) {
|
|
250
|
+
log.debug(`[ThreadPrompt] Stripped bot mention: "${promptText}" -> "${strippedText}"`);
|
|
251
|
+
promptText = strippedText;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const { fileCompletionHandler } = PluginState;
|
|
256
|
+
if (fileCompletionHandler && fileCompletionHandler.hasPendingCompletion(routeResult.sessionId)) {
|
|
257
|
+
const disambiguationResult = fileCompletionHandler.handleDisambiguationReply(
|
|
258
|
+
routeResult.sessionId,
|
|
259
|
+
promptText
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
if (disambiguationResult.resolved) {
|
|
263
|
+
if (disambiguationResult.cancelled) {
|
|
264
|
+
await mmClient.createPost(
|
|
265
|
+
post.channel_id,
|
|
266
|
+
`:white_check_mark: File completion cancelled.`,
|
|
267
|
+
routeResult.threadRootPostId
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (disambiguationResult.result) {
|
|
273
|
+
log.info(`[FileCompletion] User resolved file references, processing message`);
|
|
274
|
+
await handleThreadPromptWithFiles(
|
|
275
|
+
{
|
|
276
|
+
sessionId: routeResult.sessionId,
|
|
277
|
+
threadRootPostId: routeResult.threadRootPostId,
|
|
278
|
+
promptText: disambiguationResult.result.processedMessage,
|
|
279
|
+
fileIds: routeResult.fileIds,
|
|
280
|
+
},
|
|
281
|
+
userSession,
|
|
282
|
+
post,
|
|
283
|
+
disambiguationResult.result.resolvedFilePaths
|
|
284
|
+
);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (promptText.startsWith(config.sessionSelection.commandPrefix)) {
|
|
291
|
+
const parsed = messageRouter.parseCommand(promptText);
|
|
292
|
+
if (parsed) {
|
|
293
|
+
const result = await commandHandler.execute(parsed, {
|
|
294
|
+
userSession,
|
|
295
|
+
registry: openCodeSessionRegistry,
|
|
296
|
+
mmClient,
|
|
297
|
+
threadMappingStore,
|
|
298
|
+
teamStore: PluginState.teamStore,
|
|
299
|
+
ownerUserId: config.mattermost.ownerUserId,
|
|
300
|
+
questionHandler: PluginState.questionHandler,
|
|
301
|
+
opencodeClient: client,
|
|
302
|
+
sessionId: routeResult.sessionId,
|
|
303
|
+
threadRootPostId: routeResult.threadRootPostId,
|
|
304
|
+
channelId: post.channel_id,
|
|
305
|
+
});
|
|
306
|
+
await mmClient.createPost(post.channel_id, result.message, routeResult.threadRootPostId);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const numericSelection = parseInt(promptText, 10);
|
|
312
|
+
if (!isNaN(numericSelection) && commandHandler.isPendingModelSelection(routeResult.sessionId, threadMappingStore)) {
|
|
313
|
+
const result = await commandHandler.handleModelSelection(numericSelection, {
|
|
314
|
+
userSession,
|
|
315
|
+
registry: openCodeSessionRegistry,
|
|
316
|
+
mmClient,
|
|
317
|
+
threadMappingStore,
|
|
318
|
+
teamStore: PluginState.teamStore,
|
|
319
|
+
ownerUserId: config.mattermost.ownerUserId,
|
|
320
|
+
questionHandler: PluginState.questionHandler,
|
|
321
|
+
opencodeClient: client,
|
|
322
|
+
sessionId: routeResult.sessionId,
|
|
323
|
+
threadRootPostId: routeResult.threadRootPostId,
|
|
324
|
+
});
|
|
325
|
+
if (result) {
|
|
326
|
+
await mmClient.createPost(post.channel_id, result.message, routeResult.threadRootPostId);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { guestApprovalHandler, questionHandler } = PluginState;
|
|
332
|
+
|
|
333
|
+
// Check if BOTH guest approval AND question are pending - this is a collision scenario
|
|
334
|
+
const hasGuestApproval = guestApprovalHandler && threadMappingStore && guestApprovalHandler.hasPendingApproval(routeResult.sessionId);
|
|
335
|
+
const hasQuestion = questionHandler && questionHandler.hasPendingQuestion(routeResult.sessionId);
|
|
336
|
+
|
|
337
|
+
// If both are pending and message looks like a question answer (bare number), prioritize question
|
|
338
|
+
// Questions are more time-sensitive (AI is actively waiting) and guest approval has alternative syntax
|
|
339
|
+
if (hasGuestApproval && hasQuestion) {
|
|
340
|
+
const trimmed = promptText.trim();
|
|
341
|
+
const looksLikeQuestionAnswer = /^\d+$/.test(trimmed) || /^\d+(,\s*\d+)+$/.test(trimmed); // "1" or "1, 2, 3"
|
|
342
|
+
const looksLikeGuestApprovalOnly = /^(deny|no|0)$/i.test(trimmed); // Only guest approval uses these
|
|
343
|
+
|
|
344
|
+
if (looksLikeQuestionAnswer && !looksLikeGuestApprovalOnly) {
|
|
345
|
+
log.info(`[CollisionHandler] Both guest approval and question pending. Message "${trimmed}" looks like question answer - routing to question handler first`);
|
|
346
|
+
// Fall through to question handler below
|
|
347
|
+
} else if (looksLikeGuestApprovalOnly) {
|
|
348
|
+
log.info(`[CollisionHandler] Both pending but message "${trimmed}" is guest approval syntax - routing to guest approval`);
|
|
349
|
+
const approvalResult = await guestApprovalHandler.handleOwnerReply(
|
|
350
|
+
routeResult.sessionId,
|
|
351
|
+
promptText,
|
|
352
|
+
threadMappingStore,
|
|
353
|
+
post.channel_id
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
if (approvalResult.wasApprovalResponse) {
|
|
357
|
+
if (approvalResult.approved && approvalResult.post) {
|
|
358
|
+
log.info(`[GuestApproval] Processing approved guest message`);
|
|
359
|
+
await handleThreadPrompt({
|
|
360
|
+
sessionId: routeResult.sessionId,
|
|
361
|
+
threadRootPostId: routeResult.threadRootPostId,
|
|
362
|
+
promptText: approvalResult.post.message,
|
|
363
|
+
fileIds: approvalResult.post.file_ids,
|
|
364
|
+
}, userSession, approvalResult.post);
|
|
365
|
+
}
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
// Ambiguous text - could be custom answer or unrecognized. Ask user to clarify.
|
|
370
|
+
log.info(`[CollisionHandler] Both pending, message "${trimmed}" is ambiguous - asking user to clarify`);
|
|
371
|
+
await mmClient.createPost(
|
|
372
|
+
post.channel_id,
|
|
373
|
+
`:warning: **Multiple pending requests**\n\nI have both a **question** and a **guest approval request** pending.\n\n` +
|
|
374
|
+
`• To answer the AI question: Reply with a number (e.g., \`1\`) or your answer\n` +
|
|
375
|
+
`• To respond to guest approval: Use \`approve 1\`, \`approve 2\`, \`approve 3\`, or \`deny\`\n\n` +
|
|
376
|
+
`_Use explicit prefixes to avoid confusion._`,
|
|
377
|
+
routeResult.threadRootPostId
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
} else if (hasGuestApproval) {
|
|
382
|
+
const approvalResult = await guestApprovalHandler.handleOwnerReply(
|
|
383
|
+
routeResult.sessionId,
|
|
384
|
+
promptText,
|
|
385
|
+
threadMappingStore,
|
|
386
|
+
post.channel_id
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
if (approvalResult.wasApprovalResponse) {
|
|
390
|
+
if (approvalResult.approved && approvalResult.post) {
|
|
391
|
+
log.info(`[GuestApproval] Processing approved guest message`);
|
|
392
|
+
await handleThreadPrompt({
|
|
393
|
+
sessionId: routeResult.sessionId,
|
|
394
|
+
threadRootPostId: routeResult.threadRootPostId,
|
|
395
|
+
promptText: approvalResult.post.message,
|
|
396
|
+
fileIds: approvalResult.post.file_ids,
|
|
397
|
+
}, userSession, approvalResult.post);
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
log.info(`[GuestApproval] Owner message not an approval response, processing as regular prompt`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Question handler (either only question pending, or collision where we decided to prioritize question)
|
|
405
|
+
if (questionHandler && questionHandler.hasPendingQuestion(routeResult.sessionId)) {
|
|
406
|
+
const verifyResult = await questionHandler.verifyQuestionStillPending(routeResult.sessionId);
|
|
407
|
+
|
|
408
|
+
if (!verifyResult.pending) {
|
|
409
|
+
if (verifyResult.reason === "server_no_longer_pending") {
|
|
410
|
+
const questionInfo = questionHandler.getPendingQuestionInfo(routeResult.sessionId);
|
|
411
|
+
const questionHeader = questionInfo?.request.questions[0]?.header || "Unknown";
|
|
412
|
+
log.warn(`[QuestionHandler] Question "${questionHeader}" is no longer pending on server (expired or already answered)`);
|
|
413
|
+
await mmClient.createPost(
|
|
414
|
+
post.channel_id,
|
|
415
|
+
`:warning: This question has expired or was already answered elsewhere. Your response "${promptText}" was not processed.\n\nThe AI session has likely continued without waiting for your answer.`,
|
|
416
|
+
routeResult.threadRootPostId
|
|
417
|
+
);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const replyResult = await questionHandler.handleUserReply(
|
|
423
|
+
routeResult.sessionId,
|
|
424
|
+
promptText,
|
|
425
|
+
post.channel_id,
|
|
426
|
+
routeResult.threadRootPostId
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
if (replyResult.handled && replyResult.answers && replyResult.requestId) {
|
|
430
|
+
try {
|
|
431
|
+
const ctx = PluginState.activeResponseContexts.get(routeResult.sessionId);
|
|
432
|
+
if (ctx && streamer) {
|
|
433
|
+
const newStreamCtx = await streamer.recreateStreamAtBottom(ctx.streamCtx);
|
|
434
|
+
ctx.streamCtx = newStreamCtx;
|
|
435
|
+
log.debug(`[QuestionHandler] Recreated stream after answer summary, new postId=${newStreamCtx.postId}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const replyUrl = `${opencodeBaseUrl}/question/${replyResult.requestId}/reply`;
|
|
439
|
+
const response = await fetch(replyUrl, {
|
|
440
|
+
method: "POST",
|
|
441
|
+
headers: {
|
|
442
|
+
"Content-Type": "application/json",
|
|
443
|
+
"x-opencode-directory": directory,
|
|
444
|
+
},
|
|
445
|
+
body: JSON.stringify({ answers: replyResult.answers }),
|
|
446
|
+
});
|
|
447
|
+
if (!response.ok) {
|
|
448
|
+
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
449
|
+
}
|
|
450
|
+
log.info(`[QuestionHandler] Submitted answer for question ${replyResult.requestId}`);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
log.error(`[QuestionHandler] Failed to submit answer:`, e);
|
|
453
|
+
await mmClient.createPost(
|
|
454
|
+
post.channel_id,
|
|
455
|
+
`:x: Failed to submit answer: ${e instanceof Error ? e.message : "Unknown error"}`,
|
|
456
|
+
routeResult.threadRootPostId
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (replyResult.handled) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (fileCompletionHandler && fileCompletionHandler.hasFileReferences(promptText)) {
|
|
468
|
+
log.info(`[FileCompletion] Message contains !! file references`);
|
|
469
|
+
|
|
470
|
+
const completionResult = await fileCompletionHandler.processMessage(
|
|
471
|
+
routeResult.sessionId,
|
|
472
|
+
routeResult.threadRootPostId,
|
|
473
|
+
post.channel_id,
|
|
474
|
+
promptText,
|
|
475
|
+
post.user_id,
|
|
476
|
+
routeResult.fileIds
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
if (completionResult.needsDisambiguation) {
|
|
480
|
+
const disambiguationPrompt = fileCompletionHandler.formatDisambiguationPrompt(
|
|
481
|
+
completionResult.unresolvedReferences
|
|
482
|
+
);
|
|
483
|
+
const disambiguationPost = await mmClient.createPost(
|
|
484
|
+
post.channel_id,
|
|
485
|
+
disambiguationPrompt,
|
|
486
|
+
routeResult.threadRootPostId
|
|
487
|
+
);
|
|
488
|
+
fileCompletionHandler.setDisambiguationPostId(
|
|
489
|
+
routeResult.sessionId,
|
|
490
|
+
disambiguationPost.id
|
|
491
|
+
);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (completionResult.resolvedFilePaths.length > 0) {
|
|
496
|
+
await handleThreadPromptWithFiles(
|
|
497
|
+
{
|
|
498
|
+
sessionId: routeResult.sessionId,
|
|
499
|
+
threadRootPostId: routeResult.threadRootPostId,
|
|
500
|
+
promptText: completionResult.processedMessage,
|
|
501
|
+
fileIds: routeResult.fileIds,
|
|
502
|
+
},
|
|
503
|
+
userSession,
|
|
504
|
+
post,
|
|
505
|
+
completionResult.resolvedFilePaths
|
|
506
|
+
);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
await handleThreadPrompt(routeResult, userSession, post);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function convertLegacyRoute(legacyResult: { type: string; command?: any; promptText?: string }, post: Post): InboundRouteResult {
|
|
518
|
+
const { openCodeSessionRegistry } = PluginState;
|
|
519
|
+
|
|
520
|
+
if (legacyResult.type === "command" && legacyResult.command) {
|
|
521
|
+
return { type: "main_dm_command", command: legacyResult.command };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const defaultSession = openCodeSessionRegistry?.getDefault();
|
|
525
|
+
if (!defaultSession) {
|
|
526
|
+
return {
|
|
527
|
+
type: "main_dm_prompt",
|
|
528
|
+
errorMessage: "No OpenCode session available.",
|
|
529
|
+
suggestedAction: "Start an OpenCode session first.",
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
type: "thread_prompt",
|
|
535
|
+
sessionId: defaultSession.id,
|
|
536
|
+
threadRootPostId: "",
|
|
537
|
+
promptText: legacyResult.promptText || post.message,
|
|
538
|
+
fileIds: post.file_ids,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function createNewSessionFromDm(
|
|
543
|
+
userSession: UserSession,
|
|
544
|
+
post: Post
|
|
545
|
+
): Promise<{ sessionId: string; threadRootPostId: string } | null> {
|
|
546
|
+
const { mmClient, threadManager, openCodeSessionRegistry, threadMappingStore } = PluginState;
|
|
547
|
+
if (!mmClient || !threadManager) return null;
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
const result = await client.session.create({
|
|
551
|
+
body: {},
|
|
552
|
+
query: { directory }
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
if (!result.data) {
|
|
556
|
+
throw new Error("Failed to create session - no data returned");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const sessionInfo: OpenCodeSessionInfo = {
|
|
560
|
+
id: result.data.id,
|
|
561
|
+
shortId: result.data.id.substring(0, 8),
|
|
562
|
+
projectName: projectName,
|
|
563
|
+
directory: directory,
|
|
564
|
+
title: result.data.title || `Mattermost DM session`,
|
|
565
|
+
lastUpdated: new Date(),
|
|
566
|
+
isAvailable: true,
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const threadRootId = post.root_id || post.id;
|
|
570
|
+
log.info(`[CreateSession] post.id=${post.id}, post.root_id=${post.root_id}, threadRootId=${threadRootId}, post.channel_id=${post.channel_id}`);
|
|
571
|
+
const mapping = await threadManager.createThread(
|
|
572
|
+
sessionInfo,
|
|
573
|
+
userSession.mattermostUserId,
|
|
574
|
+
userSession.dmChannelId,
|
|
575
|
+
threadRootId,
|
|
576
|
+
post.channel_id,
|
|
577
|
+
userSession.mattermostUsername
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
const approvalPolicy = (post as any)._approvalPolicy as string | undefined;
|
|
581
|
+
if (approvalPolicy && threadMappingStore) {
|
|
582
|
+
const updatedMapping = threadMappingStore.getBySessionId(result.data.id);
|
|
583
|
+
if (updatedMapping) {
|
|
584
|
+
if (approvalPolicy === "approve_all") {
|
|
585
|
+
updatedMapping.approveAllUsers = true;
|
|
586
|
+
log.info(`[CreateSession] Applied approve_all policy to session ${sessionInfo.shortId}`);
|
|
587
|
+
} else if (approvalPolicy === "approve_next") {
|
|
588
|
+
updatedMapping.approveNextMessage = true;
|
|
589
|
+
log.info(`[CreateSession] Applied approve_next policy to session ${sessionInfo.shortId}`);
|
|
590
|
+
}
|
|
591
|
+
threadMappingStore.update(updatedMapping);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
await openCodeSessionRegistry?.refresh();
|
|
596
|
+
|
|
597
|
+
log.info(`[CreateSession] Created new session ${sessionInfo.shortId} for @${userSession.mattermostUsername} in channel ${post.channel_id}`);
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
sessionId: result.data.id,
|
|
601
|
+
threadRootPostId: mapping.threadRootPostId,
|
|
602
|
+
};
|
|
603
|
+
} catch (error) {
|
|
604
|
+
log.error("[CreateSession] Failed:", error);
|
|
605
|
+
const errorThreadRoot = post.root_id || post.id;
|
|
606
|
+
await mmClient.createPost(
|
|
607
|
+
post.channel_id,
|
|
608
|
+
`:x: Failed to create session: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
609
|
+
errorThreadRoot
|
|
610
|
+
);
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function handleThreadPrompt(
|
|
616
|
+
route: { sessionId: string; threadRootPostId: string; promptText: string; fileIds?: string[] },
|
|
617
|
+
userSession: UserSession,
|
|
618
|
+
post: Post
|
|
619
|
+
): Promise<void> {
|
|
620
|
+
const { streamer, notifications, fileHandler, mmClient, threadMappingStore, todoManager } = PluginState;
|
|
621
|
+
if (!streamer || !notifications || !fileHandler || !mmClient) return;
|
|
622
|
+
|
|
623
|
+
if (threadMappingStore && route.threadRootPostId) {
|
|
624
|
+
const mapping = threadMappingStore.getByThreadRootPostId(route.threadRootPostId);
|
|
625
|
+
if (mapping && mapping.status === "orphaned") {
|
|
626
|
+
threadMappingStore.reactivate(route.threadRootPostId);
|
|
627
|
+
log.info(`[ThreadMapping] Reactivated orphaned thread ${route.threadRootPostId} for session ${route.sessionId.substring(0, 8)}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
userSession.isProcessing = true;
|
|
632
|
+
userSession.currentPromptPostId = post.id;
|
|
633
|
+
userSession.lastPrompt = post;
|
|
634
|
+
|
|
635
|
+
let promptText = route.promptText;
|
|
636
|
+
const threadRootPostId = route.threadRootPostId || undefined;
|
|
637
|
+
const targetSessionId = route.sessionId;
|
|
638
|
+
const shortId = targetSessionId.substring(0, 8);
|
|
639
|
+
|
|
640
|
+
const existingMapping = threadMappingStore?.getBySessionId(targetSessionId);
|
|
641
|
+
const targetChannelId = existingMapping?.channelId || existingMapping?.dmChannelId || post.channel_id;
|
|
642
|
+
|
|
643
|
+
const { openCodeSessionRegistry } = PluginState;
|
|
644
|
+
const sessionAvailable = openCodeSessionRegistry?.isAvailable(targetSessionId);
|
|
645
|
+
log.info(`[SessionValidation] Checking session ${shortId}: registry=${!!openCodeSessionRegistry}, available=${sessionAvailable}`);
|
|
646
|
+
|
|
647
|
+
if (openCodeSessionRegistry && !sessionAvailable) {
|
|
648
|
+
log.warn(`[SessionValidation] Session ${shortId} no longer exists in OpenCode`);
|
|
649
|
+
|
|
650
|
+
if (existingMapping && threadMappingStore) {
|
|
651
|
+
existingMapping.status = "ended";
|
|
652
|
+
existingMapping.endedAt = new Date().toISOString();
|
|
653
|
+
threadMappingStore.update(existingMapping);
|
|
654
|
+
log.info(`[SessionValidation] Marked mapping for session ${shortId} as ended`);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
await mmClient.createPost(
|
|
658
|
+
targetChannelId,
|
|
659
|
+
`:warning: **Session No Longer Available**\n\nThe OpenCode session \`${shortId}\` associated with this thread no longer exists. This can happen when OpenCode restarts.\n\n_Start a new conversation to create a new session._`,
|
|
660
|
+
route.threadRootPostId
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
userSession.isProcessing = false;
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const channel = await mmClient.getChannel(post.channel_id);
|
|
668
|
+
const isGroupDm = channel.type === "G";
|
|
669
|
+
const botUser = PluginState.botUser;
|
|
670
|
+
|
|
671
|
+
const { streamCtx, statusIndicator } = await streamer.startStreamWithStatus(
|
|
672
|
+
userSession,
|
|
673
|
+
threadRootPostId,
|
|
674
|
+
"Checking session status...",
|
|
675
|
+
targetChannelId
|
|
676
|
+
);
|
|
677
|
+
userSession.currentResponsePostId = streamCtx.postId;
|
|
678
|
+
|
|
679
|
+
try {
|
|
680
|
+
let sessionIsBusy = false;
|
|
681
|
+
let sessionIsRetrying = false;
|
|
682
|
+
let retryInfo: { attempt?: number; maxAttempts?: number } = {};
|
|
683
|
+
|
|
684
|
+
try {
|
|
685
|
+
const statusResult = await client.session.status();
|
|
686
|
+
const statusMap = statusResult.data as Record<string, { type: string; attempt?: number; maxAttempts?: number }> | undefined;
|
|
687
|
+
|
|
688
|
+
if (statusMap && statusMap[targetSessionId]) {
|
|
689
|
+
const sessionStatus = statusMap[targetSessionId];
|
|
690
|
+
log.debug(`[StatusCheck] Session ${shortId} status: ${sessionStatus.type}`);
|
|
691
|
+
|
|
692
|
+
if (sessionStatus.type === "busy") {
|
|
693
|
+
sessionIsBusy = true;
|
|
694
|
+
} else if (sessionStatus.type === "retry") {
|
|
695
|
+
sessionIsRetrying = true;
|
|
696
|
+
retryInfo = {
|
|
697
|
+
attempt: sessionStatus.attempt,
|
|
698
|
+
maxAttempts: sessionStatus.maxAttempts,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
} catch (e) {
|
|
703
|
+
log.debug(`[StatusCheck] Could not get session status: ${e}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (sessionIsBusy) {
|
|
707
|
+
await statusIndicator.setQueued("Session is busy processing another request", 1);
|
|
708
|
+
} else if (sessionIsRetrying) {
|
|
709
|
+
await statusIndicator.setRetrying(
|
|
710
|
+
retryInfo.attempt || 1,
|
|
711
|
+
retryInfo.maxAttempts || 3,
|
|
712
|
+
"Session is retrying a previous operation",
|
|
713
|
+
5000
|
|
714
|
+
);
|
|
715
|
+
} else {
|
|
716
|
+
await statusIndicator.setConnecting(targetSessionId, shortId);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
let inboundFileParts: Array<{ type: "file"; mime: string; filename: string; url: string }> = [];
|
|
720
|
+
if (route.fileIds && route.fileIds.length > 0) {
|
|
721
|
+
const { fileParts, textFilePaths } = await fileHandler.processInboundAttachmentsAsFileParts(route.fileIds);
|
|
722
|
+
inboundFileParts = fileParts;
|
|
723
|
+
if (textFilePaths.length > 0) {
|
|
724
|
+
promptText += `\n\n[Attached files: ${textFilePaths.join(", ")}]`;
|
|
725
|
+
}
|
|
726
|
+
if (fileParts.length > 0) {
|
|
727
|
+
log.info(`[FileHandler] Sending ${fileParts.length} file(s) as FilePartInput to OpenCode`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
log.info(`Using OpenCode session: ${targetSessionId}`);
|
|
732
|
+
|
|
733
|
+
if (threadMappingStore) {
|
|
734
|
+
const mapping = threadMappingStore.getBySessionId(targetSessionId);
|
|
735
|
+
if (mapping) {
|
|
736
|
+
mapping.lastActivityAt = new Date().toISOString();
|
|
737
|
+
threadMappingStore.update(mapping);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
let sessionTotalCost = 0;
|
|
742
|
+
try {
|
|
743
|
+
const messagesResult = await client.session.messages({ path: { id: targetSessionId } });
|
|
744
|
+
const messages = messagesResult.data || [];
|
|
745
|
+
for (const message of messages) {
|
|
746
|
+
if (message.info.role === "assistant") {
|
|
747
|
+
sessionTotalCost += (message.info as any).cost || 0;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
log.debug(`[CostTracker] Session ${shortId} prior cost: $${sessionTotalCost.toFixed(4)}`);
|
|
751
|
+
} catch (e) {
|
|
752
|
+
log.debug(`[CostTracker] Could not fetch session messages: ${e}`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const responseContext = createEmptyResponseContext(
|
|
756
|
+
targetSessionId,
|
|
757
|
+
userSession,
|
|
758
|
+
streamCtx,
|
|
759
|
+
threadRootPostId,
|
|
760
|
+
sessionTotalCost
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
PluginState.activeResponseContexts.set(targetSessionId, responseContext);
|
|
764
|
+
startResponseTimer(targetSessionId);
|
|
765
|
+
|
|
766
|
+
if (todoManager && threadRootPostId) {
|
|
767
|
+
todoManager.setThreadRoot(targetSessionId, threadRootPostId, targetChannelId);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const replyContext = threadRootPostId
|
|
771
|
+
? `[Reply-To: thread=${threadRootPostId} post=${post.id} channel=${targetChannelId}]`
|
|
772
|
+
: `[Reply-To: post=${post.id} channel=${targetChannelId}]`;
|
|
773
|
+
|
|
774
|
+
let contextPrefix = "";
|
|
775
|
+
let contextFileParts: Array<{ type: "file"; mime: string; filename: string; url: string }> = [];
|
|
776
|
+
if (isGroupDm && threadRootPostId && botUser) {
|
|
777
|
+
try {
|
|
778
|
+
log.info(`[GroupDM] Building thread context for thread ${threadRootPostId}`);
|
|
779
|
+
let threadContext = await buildThreadContext(mmClient, threadRootPostId, post.id, botUser.id, 5);
|
|
780
|
+
|
|
781
|
+
if (threadContext.messages.length > 0) {
|
|
782
|
+
threadContext = await summarizeContextWithHaiku(client, targetSessionId, threadContext);
|
|
783
|
+
contextPrefix = formatContextForPrompt(threadContext, userSession.mattermostUsername || "user");
|
|
784
|
+
log.info(`[GroupDM] Injecting ${threadContext.wasSummarized ? "summarized" : "full"} context (${threadContext.messages.length} messages)`);
|
|
785
|
+
|
|
786
|
+
if (threadContext.allFileIds.length > 0 && fileHandler) {
|
|
787
|
+
log.info(`[GroupDM] Processing ${threadContext.allFileIds.length} file attachment(s) from thread context`);
|
|
788
|
+
const { fileParts: ctxFileParts, textFilePaths: ctxTextPaths } = await fileHandler.processInboundAttachmentsAsFileParts(threadContext.allFileIds);
|
|
789
|
+
contextFileParts = ctxFileParts;
|
|
790
|
+
if (ctxTextPaths.length > 0) {
|
|
791
|
+
contextPrefix += `\n[Context attachments downloaded: ${ctxTextPaths.join(", ")}]`;
|
|
792
|
+
}
|
|
793
|
+
if (ctxFileParts.length > 0) {
|
|
794
|
+
log.info(`[GroupDM] Processed ${ctxFileParts.length} context file(s) as FileParts`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
} catch (e) {
|
|
799
|
+
log.error(`[GroupDM] Failed to build context: ${e}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const promptMessage = `[Mattermost DM from @${userSession.mattermostUsername}]\n${replyContext}\n${contextPrefix}${promptText}`;
|
|
804
|
+
|
|
805
|
+
log.debug(`Injecting prompt into session ${targetSessionId}: "${promptMessage.slice(0, 150)}..."`);
|
|
806
|
+
|
|
807
|
+
await statusIndicator.setProcessing();
|
|
808
|
+
|
|
809
|
+
const mapping = threadMappingStore?.getBySessionId(targetSessionId);
|
|
810
|
+
const selectedModel = mapping?.model;
|
|
811
|
+
|
|
812
|
+
if (selectedModel) {
|
|
813
|
+
log.debug(`[ModelSelection] Using model ${selectedModel.providerID}/${selectedModel.modelID} for session ${shortId}`);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const allFileParts = [...contextFileParts, ...inboundFileParts];
|
|
817
|
+
const promptParts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; filename: string; url: string }> = [
|
|
818
|
+
{ type: "text", text: promptMessage },
|
|
819
|
+
...allFileParts,
|
|
820
|
+
];
|
|
821
|
+
|
|
822
|
+
await client.session.promptAsync({
|
|
823
|
+
path: { id: targetSessionId },
|
|
824
|
+
body: {
|
|
825
|
+
parts: promptParts,
|
|
826
|
+
...(selectedModel && {
|
|
827
|
+
model: {
|
|
828
|
+
providerID: selectedModel.providerID,
|
|
829
|
+
modelID: selectedModel.modelID,
|
|
830
|
+
},
|
|
831
|
+
}),
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
log.info(`Prompt injected into session ${targetSessionId} from @${userSession.mattermostUsername}`);
|
|
836
|
+
|
|
837
|
+
} catch (error) {
|
|
838
|
+
log.error("Error processing message:", error);
|
|
839
|
+
|
|
840
|
+
stopResponseTimer(route.sessionId);
|
|
841
|
+
stopActiveToolTimer(route.sessionId);
|
|
842
|
+
|
|
843
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
844
|
+
await statusIndicator.setError(errorMsg, true);
|
|
845
|
+
|
|
846
|
+
if (notifications && userSession) {
|
|
847
|
+
await notifications.notifyError(userSession, error as Error);
|
|
848
|
+
}
|
|
849
|
+
userSession.isProcessing = false;
|
|
850
|
+
PluginState.activeResponseContexts.delete(route.sessionId);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function handleThreadPromptWithFiles(
|
|
855
|
+
route: { sessionId: string; threadRootPostId: string; promptText: string; fileIds?: string[] },
|
|
856
|
+
userSession: UserSession,
|
|
857
|
+
post: Post,
|
|
858
|
+
resolvedFilePaths: string[]
|
|
859
|
+
): Promise<void> {
|
|
860
|
+
const { fileCompletionHandler, mmClient } = PluginState;
|
|
861
|
+
|
|
862
|
+
if (!fileCompletionHandler || !mmClient || resolvedFilePaths.length === 0) {
|
|
863
|
+
return handleThreadPrompt(route, userSession, post);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
let fileContentSuffix = "";
|
|
867
|
+
const attachedFiles: string[] = [];
|
|
868
|
+
|
|
869
|
+
for (const filePath of resolvedFilePaths) {
|
|
870
|
+
const content = await fileCompletionHandler.readFileContent(filePath);
|
|
871
|
+
if (content !== null) {
|
|
872
|
+
fileContentSuffix += fileCompletionHandler.formatFileContentForPrompt(filePath, content);
|
|
873
|
+
attachedFiles.push(filePath);
|
|
874
|
+
log.info(`[FileCompletion] Attached file content: ${filePath} (${content.length} chars)`);
|
|
875
|
+
} else {
|
|
876
|
+
log.warn(`[FileCompletion] Could not read file: ${filePath}`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (attachedFiles.length > 0) {
|
|
881
|
+
const updatedRoute = {
|
|
882
|
+
...route,
|
|
883
|
+
promptText: route.promptText + fileContentSuffix,
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
log.info(`[FileCompletion] Processing prompt with ${attachedFiles.length} attached file(s)`);
|
|
887
|
+
return handleThreadPrompt(updatedRoute, userSession, post);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
return handleThreadPrompt(route, userSession, post);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const mattermostConnectTool = createConnectTool(connectionContext);
|
|
894
|
+
const mattermostDisconnectTool = createDisconnectTool();
|
|
895
|
+
const mattermostStatusTool = createStatusTool(projectName);
|
|
896
|
+
const mattermostListSessionsTool = createListSessionsTool();
|
|
897
|
+
const mattermostSelectSessionTool = createSelectSessionTool();
|
|
898
|
+
const mattermostCurrentSessionTool = createCurrentSessionTool();
|
|
899
|
+
const mattermostMonitorTool = createMonitorTool({ client, directory, projectName });
|
|
900
|
+
const mattermostUnmonitorTool = createUnmonitorTool(client);
|
|
901
|
+
const mattermostSendFileTool = createSendFileTool();
|
|
902
|
+
|
|
903
|
+
const scheduleContext = { client, directory, projectName };
|
|
904
|
+
const mattermostScheduleAddTool = createScheduleAddTool(scheduleContext);
|
|
905
|
+
const mattermostScheduleListTool = createScheduleListTool();
|
|
906
|
+
const mattermostScheduleRemoveTool = createScheduleRemoveTool();
|
|
907
|
+
const mattermostScheduleEnableTool = createScheduleEnableTool();
|
|
908
|
+
const mattermostScheduleDisableTool = createScheduleDisableTool();
|
|
909
|
+
const mattermostScheduleRunTool = createScheduleRunTool();
|
|
910
|
+
|
|
911
|
+
return {
|
|
912
|
+
tool: {
|
|
913
|
+
mattermost_connect: mattermostConnectTool,
|
|
914
|
+
mattermost_disconnect: mattermostDisconnectTool,
|
|
915
|
+
mattermost_status: mattermostStatusTool,
|
|
916
|
+
mattermost_list_sessions: mattermostListSessionsTool,
|
|
917
|
+
mattermost_select_session: mattermostSelectSessionTool,
|
|
918
|
+
mattermost_current_session: mattermostCurrentSessionTool,
|
|
919
|
+
mattermost_monitor: mattermostMonitorTool,
|
|
920
|
+
mattermost_unmonitor: mattermostUnmonitorTool,
|
|
921
|
+
mattermost_send_file: mattermostSendFileTool,
|
|
922
|
+
mattermost_schedule_add: mattermostScheduleAddTool,
|
|
923
|
+
mattermost_schedule_list: mattermostScheduleListTool,
|
|
924
|
+
mattermost_schedule_remove: mattermostScheduleRemoveTool,
|
|
925
|
+
mattermost_schedule_enable: mattermostScheduleEnableTool,
|
|
926
|
+
mattermost_schedule_disable: mattermostScheduleDisableTool,
|
|
927
|
+
mattermost_schedule_run: mattermostScheduleRunTool,
|
|
928
|
+
},
|
|
929
|
+
|
|
930
|
+
async event({ event }) {
|
|
931
|
+
const eventType = event.type as string;
|
|
932
|
+
|
|
933
|
+
if (eventType === "permission.asked") {
|
|
934
|
+
await handlePermissionAsked(event);
|
|
935
|
+
} else if (eventType === "question.asked") {
|
|
936
|
+
await handleQuestionAsked(event);
|
|
937
|
+
} else if (eventType === "session.idle") {
|
|
938
|
+
await handleSessionIdle(event);
|
|
939
|
+
} else if (eventType === "session.status") {
|
|
940
|
+
await handleSessionStatus(event);
|
|
941
|
+
} else if (eventType === "session.compacted") {
|
|
942
|
+
await handleSessionCompacted(event);
|
|
943
|
+
} else if (eventType === "message.updated") {
|
|
944
|
+
await handleMessageUpdated(event);
|
|
945
|
+
} else if (eventType === "message.part.updated") {
|
|
946
|
+
await handleMessagePartUpdated(event);
|
|
947
|
+
} else if (eventType === "file.edited") {
|
|
948
|
+
await handleFileEdited(event);
|
|
949
|
+
} else if (eventType === "todo.updated") {
|
|
950
|
+
await handleTodoUpdated(event);
|
|
951
|
+
}
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
"tool.execute.before": async (input) => {
|
|
955
|
+
await handleToolExecuteBefore(input);
|
|
956
|
+
},
|
|
957
|
+
|
|
958
|
+
"tool.execute.after": async (input) => {
|
|
959
|
+
await handleToolExecuteAfter(input);
|
|
960
|
+
},
|
|
961
|
+
};
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
export default MattermostControlPlugin;
|