diff --git a/extensions/mattermost/src/delivery-trace.test.ts b/extensions/mattermost/src/delivery-trace.test.ts index 8f76afd6fc02..b031f9506d55 100644 --- a/extensions/mattermost/src/delivery-trace.test.ts +++ b/extensions/mattermost/src/delivery-trace.test.ts @@ -30,10 +30,8 @@ import { createMattermostDraftPreviewBoundaryController, createMattermostDraftStream, } from "./mattermost/draft-stream.js"; -import { - deliverMattermostReplyWithDraftPreview, - resolveMattermostReplyRootId, -} from "./mattermost/monitor.js"; +import { resolveMattermostReplyRootId } from "./mattermost/monitor-context.js"; +import { deliverMattermostReplyWithDraftPreview } from "./mattermost/monitor-draft-delivery.js"; import { deliverMattermostReplyPayload } from "./mattermost/reply-delivery.js"; const CHANNEL_ID = "channel-trace"; diff --git a/extensions/mattermost/src/gateway-auth-bypass.test.ts b/extensions/mattermost/src/gateway-auth-bypass.test.ts index 60ed6912e79c..cad49e38698c 100644 --- a/extensions/mattermost/src/gateway-auth-bypass.test.ts +++ b/extensions/mattermost/src/gateway-auth-bypass.test.ts @@ -1,16 +1,21 @@ // Mattermost tests cover gateway auth bypass plugin behavior. import { describe, expect, it } from "vitest"; -import { - collectMattermostSlashCallbackPaths, - resolveMattermostGatewayAuthBypassPaths, -} from "./gateway-auth-bypass.js"; +import { resolveMattermostGatewayAuthBypassPaths } from "./gateway-auth-bypass.js"; describe("Mattermost gateway auth bypass paths", () => { it("normalizes slash callback paths and callback URL paths", () => { expect( - collectMattermostSlashCallbackPaths({ - callbackPath: "api/channels/mattermost/command", - callbackUrl: "https://gateway.example.com/api/channels/mattermost/custom", + resolveMattermostGatewayAuthBypassPaths({ + cfg: { + channels: { + mattermost: { + commands: { + callbackPath: "api/channels/mattermost/command", + callbackUrl: "https://gateway.example.com/api/channels/mattermost/custom", + }, + }, + }, + }, }), ).toEqual(["/api/channels/mattermost/command", "/api/channels/mattermost/custom"]); }); diff --git a/extensions/mattermost/src/gateway-auth-bypass.ts b/extensions/mattermost/src/gateway-auth-bypass.ts index f40d8fc9f082..81e9aa055afd 100644 --- a/extensions/mattermost/src/gateway-auth-bypass.ts +++ b/extensions/mattermost/src/gateway-auth-bypass.ts @@ -36,9 +36,7 @@ function isMattermostBypassPath(path: string): boolean { return path === DEFAULT_SLASH_CALLBACK_PATH || path.startsWith("/api/channels/mattermost/"); } -export function collectMattermostSlashCallbackPaths( - raw?: MattermostSlashCommandConfigInput, -): string[] { +function collectMattermostSlashCallbackPaths(raw?: MattermostSlashCommandConfigInput): string[] { const paths = new Set([normalizeCallbackPath(raw?.callbackPath)]); const callbackUrl = readTrimmedString(raw?.callbackUrl); if (callbackUrl) { diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index befb5e552f73..6068a571ee8c 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -29,7 +29,7 @@ const MATTERMOST_TEXT_RESPONSE_LIMIT_BYTES = 64 * 1024; const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]); export type MattermostFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; -export type MattermostRequestInit = RequestInit & { +type MattermostRequestInit = RequestInit & { timeoutMs?: number; }; @@ -328,7 +328,7 @@ export async function sendMattermostTyping( }); } -export async function createMattermostDirectChannel( +async function createMattermostDirectChannel( client: MattermostClient, userIds: string[], signal?: AbortSignal, diff --git a/extensions/mattermost/src/mattermost/draft-stream.ts b/extensions/mattermost/src/mattermost/draft-stream.ts index 94584e928f0f..a6536dce2d34 100644 --- a/extensions/mattermost/src/mattermost/draft-stream.ts +++ b/extensions/mattermost/src/mattermost/draft-stream.ts @@ -42,7 +42,7 @@ function normalizeMattermostDraftText(text: string, maxChars: number): string { return `${sliceUtf16Safe(trimmed, 0, Math.max(0, maxChars - 3)).trimEnd()}...`; } -export type MattermostDraftPreviewBoundaryController = { +type MattermostDraftPreviewBoundaryController = { noteUpdate: () => void; noteBoundary: () => Promise; }; diff --git a/extensions/mattermost/src/mattermost/interactions.test.ts b/extensions/mattermost/src/mattermost/interactions.test.ts index eeb06bf14bfe..20fb83831530 100644 --- a/extensions/mattermost/src/mattermost/interactions.test.ts +++ b/extensions/mattermost/src/mattermost/interactions.test.ts @@ -9,13 +9,10 @@ import { buildButtonAttachments, computeInteractionCallbackUrl, createMattermostInteractionHandler, - generateInteractionToken, - getInteractionSecret, resolveInteractionCallbackPath, resolveInteractionCallbackUrl, setInteractionCallbackUrl, setInteractionSecret, - verifyInteractionToken, } from "./interactions.js"; type ButtonAttachments = ReturnType; @@ -46,6 +43,27 @@ function requireAction(attachments: ButtonAttachments, index = 0): ButtonAction return action; } +function generateInteractionToken(context: Record, accountId?: string): string { + const attachments = buildButtonAttachments({ + callbackUrl: "https://gateway.example.com/mattermost/interactions/test", + accountId, + buttons: [{ id: String(context.action_id ?? "test"), name: "Test", context }], + }); + return String(requireAction(attachments).integration.context["_token"]); +} + +function getInteractionSecret(): string { + return generateInteractionToken({ action_id: "secret-probe" }); +} + +function verifyInteractionToken( + context: Record, + token: string, + accountId?: string, +): boolean { + return generateInteractionToken(context, accountId) === token; +} + // ── HMAC token management ──────────────────────────────────────────── describe("setInteractionSecret / getInteractionSecret", () => { diff --git a/extensions/mattermost/src/mattermost/interactions.ts b/extensions/mattermost/src/mattermost/interactions.ts index 300c92b00f36..f70583cf0885 100644 --- a/extensions/mattermost/src/mattermost/interactions.ts +++ b/extensions/mattermost/src/mattermost/interactions.ts @@ -183,7 +183,7 @@ export function setInteractionSecret(accountIdOrBotToken: string, botToken?: str defaultInteractionSecret = deriveInteractionSecret(accountIdOrBotToken); } -export function getInteractionSecret(accountId?: string): string { +function getInteractionSecret(accountId?: string): string { const scoped = accountId ? interactionSecrets.get(accountId) : undefined; if (scoped) { return scoped; @@ -217,16 +217,13 @@ function canonicalizeInteractionContext(value: unknown): unknown { return value; } -export function generateInteractionToken( - context: Record, - accountId?: string, -): string { +function generateInteractionToken(context: Record, accountId?: string): string { const secret = getInteractionSecret(accountId); const payload = JSON.stringify(canonicalizeInteractionContext(context)); return createHmac("sha256", secret).update(payload).digest("hex"); } -export function verifyInteractionToken( +function verifyInteractionToken( context: Record, token: string, accountId?: string, diff --git a/extensions/mattermost/src/mattermost/monitor-context.ts b/extensions/mattermost/src/mattermost/monitor-context.ts new file mode 100644 index 000000000000..8c98dabcdc72 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-context.ts @@ -0,0 +1,167 @@ +// Mattermost plugin module owns monitor routing and delivery context helpers. +import { resolveChannelStreamingPreviewToolProgress } from "openclaw/plugin-sdk/channel-outbound"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { ResolvedMattermostAccount } from "./accounts.js"; +import { resolveThreadSessionKeys } from "./monitor-helpers.js"; +import type { MattermostEventPayload } from "./monitor-websocket.js"; +import { + evaluateMattermostNoVisibleReply, + formatMattermostNoVisibleReplyLog, +} from "./no-visible-reply-diagnostic.js"; +import type { MattermostReplyDeliveryOutcome } from "./reply-delivery.js"; +import type { ChatType, ReplyPayload } from "./runtime-api.js"; + +export function shouldUpdateMattermostDraftToolProgress( + account: Pick, +): boolean { + return ( + account.streamingMode !== "off" && resolveChannelStreamingPreviewToolProgress(account.config) + ); +} + +export function shouldSuppressMattermostDefaultToolProgressMessages( + account: Pick, +): boolean { + return account.streamingMode !== "off"; +} + +export function buildMattermostModelPickerSelectMessageSid(params: { + postId: string; + provider: string; + model: string; +}): string { + const provider = normalizeLowercaseStringOrEmpty(params.provider); + const model = normalizeLowercaseStringOrEmpty(params.model); + return `interaction:${params.postId}:select:${provider}/${model}`; +} + +export function resolveMattermostReplyRootId(params: { + kind: ChatType; + threadRootId?: string; + replyToId?: string; +}): string | undefined { + const threadRootId = normalizeOptionalString(params.threadRootId); + // Flat DMs (no thread context) get no reply root. A DM carries a threadRootId + // only when its effective per-chat-type mode enables threading. + if (params.kind === "direct" && !threadRootId) { + return undefined; + } + if (threadRootId) { + return threadRootId; + } + return normalizeOptionalString(params.replyToId); +} + +export function canFinalizeMattermostPreviewInPlace(params: { + kind: ChatType; + previewRootId?: string; + threadRootId?: string; + replyToId?: string; +}): boolean { + return ( + resolveMattermostReplyRootId({ + kind: params.kind, + threadRootId: params.threadRootId, + replyToId: params.replyToId, + }) === params.previewRootId?.trim() + ); +} + +export function formatMattermostFinalDeliveryOutcomeLog(params: { + outcome: MattermostReplyDeliveryOutcome; + payload: ReplyPayload; + to: string; + accountId: string; + agentId: string | undefined; +}): string | undefined { + const violation = evaluateMattermostNoVisibleReply({ + outcome: params.outcome, + payload: params.payload, + }); + if (violation) { + return formatMattermostNoVisibleReplyLog({ + violation, + to: params.to, + accountId: params.accountId, + agentId: params.agentId, + }); + } + if (params.outcome === "text" || params.outcome === "media") { + return `delivered reply to ${params.to}`; + } + return undefined; +} + +function resolveMattermostEffectiveReplyToId(params: { + kind: ChatType; + postId?: string | null; + replyToMode: "off" | "first" | "all" | "batched"; + threadRootId?: string | null; +}): string | undefined { + // Flat DMs never thread. Opted-in DMs use the same thread-root logic as rooms; + // replyToMode already reflects the effective per-chat-type mode. + if (params.kind === "direct" && params.replyToMode === "off") { + return undefined; + } + const threadRootId = normalizeOptionalString(params.threadRootId); + if (threadRootId) { + return threadRootId; + } + const postId = normalizeOptionalString(params.postId); + if (!postId) { + return undefined; + } + return params.replyToMode === "all" || + params.replyToMode === "first" || + params.replyToMode === "batched" + ? postId + : undefined; +} + +export function resolveMattermostThreadSessionContext(params: { + baseSessionKey: string; + kind: ChatType; + postId?: string | null; + replyToMode: "off" | "first" | "all" | "batched"; + threadRootId?: string | null; +}): { effectiveReplyToId?: string; sessionKey: string; parentSessionKey?: string } { + const effectiveReplyToId = resolveMattermostEffectiveReplyToId({ + kind: params.kind, + postId: params.postId, + replyToMode: params.replyToMode, + threadRootId: params.threadRootId, + }); + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey: params.baseSessionKey, + threadId: effectiveReplyToId, + // DM threads start fresh; room threads inherit their base session. + parentSessionKey: + effectiveReplyToId && params.kind !== "direct" ? params.baseSessionKey : undefined, + }); + return { + effectiveReplyToId, + sessionKey: threadKeys.sessionKey, + parentSessionKey: threadKeys.parentSessionKey, + }; +} + +export function resolveMattermostPendingHistoryKey(params: { + kind: ChatType; + sessionKey: string; +}): string | null { + // DMs always dispatch immediately, so they do not need the pending-room + // history window. Keeping them out also avoids one empty bucket per DM thread. + return params.kind === "direct" ? null : params.sessionKey; +} + +export function resolveMattermostReactionChannelId( + payload: Pick, +): string | undefined { + return ( + normalizeOptionalString(payload.broadcast?.channel_id) ?? + normalizeOptionalString(payload.data?.channel_id) + ); +} diff --git a/extensions/mattermost/src/mattermost/monitor-draft-delivery.ts b/extensions/mattermost/src/mattermost/monitor-draft-delivery.ts new file mode 100644 index 000000000000..bea876734f15 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-draft-delivery.ts @@ -0,0 +1,109 @@ +// Mattermost plugin module owns draft-preview final delivery. +import { + defineFinalizableLivePreviewAdapter, + deliverWithFinalizableLivePreviewAdapter, +} from "openclaw/plugin-sdk/channel-outbound"; +import { + buildTtsSupplementMediaPayload, + getReplyPayloadTtsSupplement, + isReasoningReplyPayload, +} from "openclaw/plugin-sdk/reply-payload"; +import { updateMattermostPost, type MattermostClient } from "./client.js"; +import { createMattermostDraftStream } from "./draft-stream.js"; +import { canFinalizeMattermostPreviewInPlace } from "./monitor-context.js"; +import type { ChatType, ReplyPayload } from "./runtime-api.js"; + +export type MattermostDraftPreviewState = { + finalizedViaPreviewPost: boolean; +}; + +type MattermostDraftPreviewDeliverParams = { + payload: ReplyPayload; + info: { kind: "tool" | "block" | "final" }; + kind: ChatType; + client: MattermostClient; + draftStream: Pick< + ReturnType, + "flush" | "postId" | "clear" | "discardPending" | "seal" + >; + effectiveReplyToId?: string; + resolvePreviewFinalText: (text?: string) => string | undefined; + previewState: MattermostDraftPreviewState; + logVerboseMessage: (message: string) => void; + deliverPayload: (payload: ReplyPayload) => Promise; + // Visible same-thread finals can be delivered by editing the draft preview in + // place (onPreviewFinalized) without ever calling deliverPayload; this lets the + // caller record thread participation on that path too. + recordThreadParticipation?: () => void; +}; + +export async function deliverMattermostReplyWithDraftPreview( + params: MattermostDraftPreviewDeliverParams, +): Promise { + if (isReasoningReplyPayload(params.payload)) { + return; + } + + await deliverWithFinalizableLivePreviewAdapter({ + kind: params.info.kind, + payload: params.payload, + adapter: defineFinalizableLivePreviewAdapter({ + draft: { + flush: params.draftStream.flush, + clear: params.draftStream.clear, + discardPending: params.draftStream.discardPending, + seal: params.draftStream.seal, + id: params.draftStream.postId, + }, + buildFinalEdit: (payload) => { + const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const ttsSupplement = getReplyPayloadTtsSupplement(payload); + const previewFinalText = params.resolvePreviewFinalText( + payload.text ?? ttsSupplement?.spokenText, + ); + + if ( + (hasMedia && !ttsSupplement) || + typeof previewFinalText !== "string" || + payload.isError || + !canFinalizeMattermostPreviewInPlace({ + kind: params.kind, + previewRootId: params.effectiveReplyToId, + threadRootId: params.effectiveReplyToId, + replyToId: payload.replyToId, + }) + ) { + return undefined; + } + return { message: previewFinalText }; + }, + editFinal: async (previewPostId, edit) => { + await updateMattermostPost(params.client, previewPostId, edit); + }, + onPreviewFinalized: () => { + params.previewState.finalizedViaPreviewPost = true; + // The visible final reply landed by editing the preview post, so the normal + // deliverPayload record path is skipped; record participation explicitly here. + params.recordThreadParticipation?.(); + }, + buildSupplementalPayload: (payload) => + getReplyPayloadTtsSupplement(payload) ? buildTtsSupplementMediaPayload(payload) : undefined, + deliverSupplemental: async (payload) => { + await params.deliverPayload(payload); + }, + logPreviewEditFailure: (err) => { + params.logVerboseMessage( + `mattermost preview final edit failed; falling back to normal send (${String(err)})`, + ); + }, + }), + deliverNormally: async (payload) => { + const supplement = getReplyPayloadTtsSupplement(payload); + await params.deliverPayload( + supplement && !payload.text?.trim() && supplement.visibleTextAlreadyDelivered !== true + ? { ...payload, text: supplement.spokenText } + : payload, + ); + }, + }); +} diff --git a/extensions/mattermost/src/mattermost/monitor-replay.ts b/extensions/mattermost/src/mattermost/monitor-replay.ts new file mode 100644 index 000000000000..909ae09ce06f --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-replay.ts @@ -0,0 +1,56 @@ +// Mattermost plugin module owns replay-guarded post processing. +import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; +import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; + +const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000; +const RECENT_MATTERMOST_MESSAGE_MAX = 2000; +const recentInboundMessages = createClaimableDedupe({ + ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS, + memoryMaxSize: RECENT_MATTERMOST_MESSAGE_MAX, +}); + +function buildMattermostInboundReplayKeys(params: { + accountId: string; + messageIds: string[]; +}): string[] { + return uniqueStrings(params.messageIds.map((id) => `${params.accountId}:${id.trim()}`)).filter( + (key) => !key.endsWith(":"), + ); +} + +export async function processMattermostReplayGuardedPost(params: { + accountId: string; + messageIds: string[]; + handlePost: () => Promise; + replayGuard?: ClaimableDedupe; +}): Promise<"processed" | "duplicate"> { + const replayGuard = params.replayGuard ?? recentInboundMessages; + const replayKeys = buildMattermostInboundReplayKeys({ + accountId: params.accountId, + messageIds: params.messageIds, + }); + if (replayKeys.length === 0) { + await params.handlePost(); + return "processed"; + } + + const claimedKeys: string[] = []; + for (const replayKey of replayKeys) { + const claim = await replayGuard.claim(replayKey); + if (claim.kind === "claimed") { + claimedKeys.push(replayKey); + } + } + if (claimedKeys.length === 0) { + return "duplicate"; + } + + try { + await params.handlePost(); + await Promise.all(claimedKeys.map((replayKey) => replayGuard.commit(replayKey))); + return "processed"; + } catch (error) { + await Promise.all(claimedKeys.map((replayKey) => replayGuard.commit(replayKey))); + throw error; + } +} diff --git a/extensions/mattermost/src/mattermost/monitor-resources.test.ts b/extensions/mattermost/src/mattermost/monitor-resources.test.ts index 37ff60203e2b..321e2cd5fb76 100644 --- a/extensions/mattermost/src/mattermost/monitor-resources.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-resources.test.ts @@ -21,16 +21,10 @@ vi.mock("./interactions.js", () => ({ describe("mattermost monitor resources", () => { let createMattermostMonitorResources: typeof import("./monitor-resources.js").createMattermostMonitorResources; let formatMattermostInboundMediaText: typeof import("./monitor-resources.js").formatMattermostInboundMediaText; - let MATTERMOST_MEDIA_RESPONSE_HEADER_TIMEOUT_MS: typeof import("./monitor-resources.js").MATTERMOST_MEDIA_RESPONSE_HEADER_TIMEOUT_MS; - let MATTERMOST_MEDIA_READ_IDLE_TIMEOUT_MS: typeof import("./monitor-resources.js").MATTERMOST_MEDIA_READ_IDLE_TIMEOUT_MS; beforeAll(async () => { - ({ - createMattermostMonitorResources, - formatMattermostInboundMediaText, - MATTERMOST_MEDIA_RESPONSE_HEADER_TIMEOUT_MS, - MATTERMOST_MEDIA_READ_IDLE_TIMEOUT_MS, - } = await import("./monitor-resources.js")); + ({ createMattermostMonitorResources, formatMattermostInboundMediaText } = + await import("./monitor-resources.js")); }); it("keeps media-only download failures visible to the agent", () => { @@ -105,8 +99,8 @@ describe("mattermost monitor resources", () => { filePathHint: "file-1", maxBytes: 1024, ssrfPolicy: { allowedHostnames: ["chat.example.com"] }, - responseHeaderTimeoutMs: MATTERMOST_MEDIA_RESPONSE_HEADER_TIMEOUT_MS, - readIdleTimeoutMs: MATTERMOST_MEDIA_READ_IDLE_TIMEOUT_MS, + responseHeaderTimeoutMs: 120_000, + readIdleTimeoutMs: 30_000, }); }); @@ -134,7 +128,7 @@ describe("mattermost monitor resources", () => { saveRemoteMedia({ ...params, responseHeaderTimeoutMs: headerTimeoutMs, - readIdleTimeoutMs: MATTERMOST_MEDIA_READ_IDLE_TIMEOUT_MS, + readIdleTimeoutMs: 30_000, ssrfPolicy: { ...params.ssrfPolicy, dangerouslyAllowPrivateNetwork: true }, }); diff --git a/extensions/mattermost/src/mattermost/monitor-resources.ts b/extensions/mattermost/src/mattermost/monitor-resources.ts index 5ab49f397d20..c76809aa7770 100644 --- a/extensions/mattermost/src/mattermost/monitor-resources.ts +++ b/extensions/mattermost/src/mattermost/monitor-resources.ts @@ -46,8 +46,8 @@ const CHANNEL_CACHE_TTL_MS = 5 * 60_000; const USER_CACHE_TTL_MS = 10 * 60_000; const MONITOR_RESOURCE_CACHE_MAX_ENTRIES = 1000; // Match Telegram/Tlon inbound media: header wait is independent of body idle. -export const MATTERMOST_MEDIA_RESPONSE_HEADER_TIMEOUT_MS = 120_000; -export const MATTERMOST_MEDIA_READ_IDLE_TIMEOUT_MS = 30_000; +const MATTERMOST_MEDIA_RESPONSE_HEADER_TIMEOUT_MS = 120_000; +const MATTERMOST_MEDIA_READ_IDLE_TIMEOUT_MS = 30_000; type SaveRemoteMedia = (params: { url: string; diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.test.ts b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts index a71a6eabbb3a..1e1c2b004967 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.test.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.test.ts @@ -6,9 +6,7 @@ import { WebSocketServer } from "ws"; import type { RuntimeEnv } from "../../runtime-api.js"; import { createMattermostConnectOnce, - MATTERMOST_WEBSOCKET_MAX_PAYLOAD_BYTES, - type MattermostWebSocketLike, - WebSocketClosedBeforeOpenError, + type MattermostWebSocketFactory, } from "./monitor-websocket.js"; function countMatching(items: readonly T[], predicate: (item: T) => boolean): number { @@ -21,7 +19,7 @@ function countMatching(items: readonly T[], predicate: (item: T) => boolean): return count; } -class FakeWebSocket implements MattermostWebSocketLike { +class FakeWebSocket implements ReturnType { public readonly sent: string[] = []; public pingCalls = 0; public closeCalls = 0; @@ -140,12 +138,12 @@ describe("mattermost websocket monitor", () => { } catch (caught) { failure = caught; } - expect(failure).toBeInstanceOf(WebSocketClosedBeforeOpenError); - expect((failure as WebSocketClosedBeforeOpenError).message).toBe( - "websocket closed before open (code 1006)", - ); - expect((failure as WebSocketClosedBeforeOpenError).code).toBe(1006); - expect((failure as WebSocketClosedBeforeOpenError).reason).toBe("connection refused"); + expect(failure).toMatchObject({ + name: "WebSocketClosedBeforeOpenError", + code: 1006, + reason: "connection refused", + }); + expect((failure as Error).message).toBe("websocket closed before open (code 1006)"); }); it("retries when first attempt errors before open and next attempt succeeds", async () => { @@ -182,7 +180,7 @@ describe("mattermost websocket monitor", () => { }); const firstAttempt = connectOnce(); - await expect(firstAttempt).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError); + await expect(firstAttempt).rejects.toMatchObject({ name: "WebSocketClosedBeforeOpenError" }); await connectOnce(); @@ -222,9 +220,7 @@ describe("mattermost websocket monitor", () => { }); expect(JSON.stringify(largeProps).length).toBeLessThan(800_000); expect(Buffer.byteLength(largePostEnvelope)).toBeGreaterThan(1024 * 1024); - expect(Buffer.byteLength(largePostEnvelope)).toBeLessThan( - MATTERMOST_WEBSOCKET_MAX_PAYLOAD_BYTES, - ); + expect(Buffer.byteLength(largePostEnvelope)).toBeLessThan(16 * 1024 * 1024); const runtime = testRuntime(); const onPosted = vi.fn(async () => {}); @@ -242,7 +238,7 @@ describe("mattermost websocket monitor", () => { }), ); socket.send(largePostEnvelope); - socket.send(Buffer.alloc(MATTERMOST_WEBSOCKET_MAX_PAYLOAD_BYTES + 1, 0x78)); + socket.send(Buffer.alloc(16 * 1024 * 1024 + 1, 0x78)); }); }); diff --git a/extensions/mattermost/src/mattermost/monitor-websocket.ts b/extensions/mattermost/src/mattermost/monitor-websocket.ts index 3e3cbf0db53e..087671e6ca29 100644 --- a/extensions/mattermost/src/mattermost/monitor-websocket.ts +++ b/extensions/mattermost/src/mattermost/monitor-websocket.ts @@ -31,7 +31,7 @@ export type MattermostEventPayload = { }; }; -export type MattermostWebSocketLike = { +type MattermostWebSocketLike = { on(event: "open", listener: () => void): void; on(event: "message", listener: (data: WebSocket.RawData) => void | Promise): void; on(event: "pong", listener: (data: Buffer) => void): void; @@ -46,7 +46,7 @@ export type MattermostWebSocketLike = { export type MattermostWebSocketFactory = (url: string) => MattermostWebSocketLike; // Mattermost events can include double-encoded post props plus server/plugin metadata. // Keep channel-compatible headroom while bounding ws's 100 MiB default before parsing. -export const MATTERMOST_WEBSOCKET_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; +const MATTERMOST_WEBSOCKET_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; const MattermostEventPayloadSchema = z.object({ event: z.string().optional(), data: z @@ -81,7 +81,7 @@ function parseMattermostPost(value: unknown): MattermostPost | null { return safeParseWithSchema(MattermostPostSchema, value); } -export class WebSocketClosedBeforeOpenError extends Error { +class WebSocketClosedBeforeOpenError extends Error { constructor( public readonly code: number, public readonly reason?: string, diff --git a/extensions/mattermost/src/mattermost/monitor.test.ts b/extensions/mattermost/src/mattermost/monitor.test.ts index fafe5320a966..8d22cf5ee691 100644 --- a/extensions/mattermost/src/mattermost/monitor.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.test.ts @@ -5,28 +5,38 @@ import type { OpenClawConfig } from "../../runtime-api.js"; import { resolveMattermostAccount } from "./accounts.js"; import * as clientModule from "./client.js"; import type { MattermostClient } from "./client.js"; -import { evaluateMattermostMentionGate } from "./monitor-gating.js"; import { buildMattermostModelPickerSelectMessageSid, canFinalizeMattermostPreviewInPlace, - deliverMattermostReplyWithDraftPreview, formatMattermostFinalDeliveryOutcomeLog, - MattermostRetryableInboundError, - processMattermostReplayGuardedPost, - resolveMattermostReactionChannelId, - resolveMattermostEffectiveReplyToId, resolveMattermostPendingHistoryKey, + resolveMattermostReactionChannelId, resolveMattermostReplyRootId, resolveMattermostThreadSessionContext, shouldSuppressMattermostDefaultToolProgressMessages, shouldUpdateMattermostDraftToolProgress, -} from "./monitor.js"; +} from "./monitor-context.js"; +import { deliverMattermostReplyWithDraftPreview } from "./monitor-draft-delivery.js"; +import { evaluateMattermostMentionGate } from "./monitor-gating.js"; +import { processMattermostReplayGuardedPost } from "./monitor-replay.js"; type MattermostMentionGateInput = Parameters[0]; type MattermostRequireMentionResolverInput = Parameters< MattermostMentionGateInput["resolveRequireMention"] >[0]; +function resolveMattermostEffectiveReplyToId(params: { + kind: "direct" | "group" | "channel"; + postId?: string | null; + replyToMode: "off" | "first" | "all" | "batched"; + threadRootId?: string | null; +}): string | undefined { + return resolveMattermostThreadSessionContext({ + baseSessionKey: "agent:main:mattermost:test", + ...params, + }).effectiveReplyToId; +} + function resolveRequireMentionForTest(params: MattermostRequireMentionResolverInput): boolean { const root = params.cfg.channels?.mattermost; const accountGroups = ( @@ -975,39 +985,6 @@ describe("processMattermostReplayGuardedPost", () => { expect(handlePost).toHaveBeenCalledTimes(1); }); - it("releases claims for explicit retryable failures", async () => { - const replayGuard = createClaimableDedupe({ - ttlMs: 10_000, - memoryMaxSize: 100, - }); - let attempts = 0; - const handlePost = vi.fn(async () => { - attempts += 1; - if (attempts === 1) { - throw new MattermostRetryableInboundError("retry me"); - } - }); - - await expect( - processMattermostReplayGuardedPost({ - replayGuard, - accountId: "acct", - messageIds: ["post-2"], - handlePost, - }), - ).rejects.toThrow("retry me"); - await expect( - processMattermostReplayGuardedPost({ - replayGuard, - accountId: "acct", - messageIds: ["post-2"], - handlePost, - }), - ).resolves.toBe("processed"); - - expect(handlePost).toHaveBeenCalledTimes(2); - }); - it("keeps replay committed after a non-retryable failure", async () => { const replayGuard = createClaimableDedupe({ ttlMs: 10_000, diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 58d8c04adbc2..eb80e4f2bd92 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -1,20 +1,9 @@ // Mattermost plugin module implements monitor behavior. -import { - defineFinalizableLivePreviewAdapter, - deliverWithFinalizableLivePreviewAdapter, -} from "openclaw/plugin-sdk/channel-outbound"; import { buildChannelProgressDraftLineForEntry, createChannelProgressDraftCompositor, - resolveChannelStreamingPreviewToolProgress, } from "openclaw/plugin-sdk/channel-outbound"; import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime"; -import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe"; -import { - buildTtsSupplementMediaPayload, - getReplyPayloadTtsSupplement, - isReasoningReplyPayload, -} from "openclaw/plugin-sdk/reply-payload"; import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -26,17 +15,11 @@ import { } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { getMattermostRuntime } from "../runtime.js"; -import { - resolveMattermostAccount, - resolveMattermostReplyToMode, - type ResolvedMattermostAccount, -} from "./accounts.js"; +import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js"; import { createMattermostClient, fetchMattermostMe, normalizeMattermostBaseUrl, - updateMattermostPost, - type MattermostClient, type MattermostPost, type MattermostUser, } from "./client.js"; @@ -65,6 +48,20 @@ import { normalizeMattermostAllowEntry, resolveMattermostMonitorInboundAccess, } from "./monitor-auth.js"; +import { + buildMattermostModelPickerSelectMessageSid, + formatMattermostFinalDeliveryOutcomeLog, + resolveMattermostPendingHistoryKey, + resolveMattermostReactionChannelId, + resolveMattermostReplyRootId, + resolveMattermostThreadSessionContext, + shouldSuppressMattermostDefaultToolProgressMessages, + shouldUpdateMattermostDraftToolProgress, +} from "./monitor-context.js"; +import { + deliverMattermostReplyWithDraftPreview, + type MattermostDraftPreviewState, +} from "./monitor-draft-delivery.js"; import { evaluateMattermostMentionGate, mapMattermostChannelTypeToChatType, @@ -73,10 +70,10 @@ import { import { formatInboundFromLabel, normalizeMention, - resolveThreadSessionKeys, shouldDropEmptyMattermostBody, } from "./monitor-helpers.js"; import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js"; +import { processMattermostReplayGuardedPost } from "./monitor-replay.js"; import { createMattermostMonitorResources, formatMattermostInboundMediaText, @@ -88,15 +85,10 @@ import { type MattermostEventPayload, type MattermostWebSocketFactory, } from "./monitor-websocket.js"; -import { - evaluateMattermostNoVisibleReply, - formatMattermostNoVisibleReplyLog, -} from "./no-visible-reply-diagnostic.js"; import { runWithReconnect } from "./reconnect.js"; import { createMattermostReplyDeliveryBarrier, deliverMattermostReplyPayload, - type MattermostReplyDeliveryOutcome, } from "./reply-delivery.js"; import type { ChannelAccountSnapshot, @@ -140,20 +132,6 @@ type MonitorMattermostOpts = { webSocketFactory?: MattermostWebSocketFactory; }; -export function shouldUpdateMattermostDraftToolProgress( - account: Pick, -): boolean { - return ( - account.streamingMode !== "off" && resolveChannelStreamingPreviewToolProgress(account.config) - ); -} - -export function shouldSuppressMattermostDefaultToolProgressMessages( - account: Pick, -): boolean { - return account.streamingMode !== "off"; -} - type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; type MattermostReaction = { @@ -162,85 +140,10 @@ type MattermostReaction = { emoji_name?: string; create_at?: number; }; -const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000; -const RECENT_MATTERMOST_MESSAGE_MAX = 2000; - function normalizeInteractionSourceIps(values?: string[]): string[] { return normalizeTrimmedStringList(values); } -const recentInboundMessages = createClaimableDedupe({ - ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS, - memoryMaxSize: RECENT_MATTERMOST_MESSAGE_MAX, -}); - -export class MattermostRetryableInboundError extends Error { - constructor(message: string, options?: ErrorOptions) { - super(message, options); - this.name = "MattermostRetryableInboundError"; - } -} - -export function buildMattermostModelPickerSelectMessageSid(params: { - postId: string; - provider: string; - model: string; -}): string { - const provider = normalizeLowercaseStringOrEmpty(params.provider); - const model = normalizeLowercaseStringOrEmpty(params.model); - return `interaction:${params.postId}:select:${provider}/${model}`; -} - -function buildMattermostInboundReplayKeys(params: { - accountId: string; - messageIds: string[]; -}): string[] { - return uniqueStrings(params.messageIds.map((id) => `${params.accountId}:${id.trim()}`)).filter( - (key) => !key.endsWith(":"), - ); -} - -export async function processMattermostReplayGuardedPost(params: { - accountId: string; - messageIds: string[]; - handlePost: () => Promise; - replayGuard?: ClaimableDedupe; -}): Promise<"processed" | "duplicate"> { - const replayGuard = params.replayGuard ?? recentInboundMessages; - const replayKeys = buildMattermostInboundReplayKeys({ - accountId: params.accountId, - messageIds: params.messageIds, - }); - if (replayKeys.length === 0) { - await params.handlePost(); - return "processed"; - } - - const claimedKeys: string[] = []; - for (const replayKey of replayKeys) { - const claim = await replayGuard.claim(replayKey); - if (claim.kind === "claimed") { - claimedKeys.push(replayKey); - } - } - if (claimedKeys.length === 0) { - return "duplicate"; - } - - try { - await params.handlePost(); - await Promise.all(claimedKeys.map((replayKey) => replayGuard.commit(replayKey))); - return "processed"; - } catch (error) { - if (error instanceof MattermostRetryableInboundError) { - claimedKeys.forEach((replayKey) => replayGuard.release(replayKey, { error })); - } else { - await Promise.all(claimedKeys.map((replayKey) => replayGuard.commit(replayKey))); - } - throw error; - } -} - function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv { return ( opts.runtime ?? { @@ -267,42 +170,6 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" { return "channel"; } -export function resolveMattermostReplyRootId(params: { - kind: ChatType; - threadRootId?: string; - replyToId?: string; -}): string | undefined { - const threadRootId = normalizeOptionalString(params.threadRootId); - // Flat DMs (no thread context) get no reply root. A DM carries a threadRootId - // only when its effective per-chat-type mode enables threading. - if (params.kind === "direct" && !threadRootId) { - return undefined; - } - if (threadRootId) { - return threadRootId; - } - return normalizeOptionalString(params.replyToId); -} - -export function canFinalizeMattermostPreviewInPlace(params: { - kind: ChatType; - previewRootId?: string; - threadRootId?: string; - replyToId?: string; -}): boolean { - return ( - resolveMattermostReplyRootId({ - kind: params.kind, - threadRootId: params.threadRootId, - replyToId: params.replyToId, - }) === params.previewRootId?.trim() - ); -} - -type MattermostDraftPreviewState = { - finalizedViaPreviewPost: boolean; -}; - function createDisabledMattermostDraftStream(): ReturnType { const noopAsync = async () => {}; return { @@ -320,193 +187,6 @@ function createDisabledMattermostDraftStream(): ReturnType, - "flush" | "postId" | "clear" | "discardPending" | "seal" - >; - effectiveReplyToId?: string; - resolvePreviewFinalText: (text?: string) => string | undefined; - previewState: MattermostDraftPreviewState; - logVerboseMessage: (message: string) => void; - deliverPayload: (payload: ReplyPayload) => Promise; - // Visible same-thread finals can be delivered by editing the draft preview in - // place (onPreviewFinalized) without ever calling deliverPayload; this lets the - // caller record thread participation on that path too. - recordThreadParticipation?: () => void; -}; - -export async function deliverMattermostReplyWithDraftPreview( - params: MattermostDraftPreviewDeliverParams, -): Promise { - if (isReasoningReplyPayload(params.payload)) { - return; - } - - await deliverWithFinalizableLivePreviewAdapter({ - kind: params.info.kind, - payload: params.payload, - adapter: defineFinalizableLivePreviewAdapter({ - draft: { - flush: params.draftStream.flush, - clear: params.draftStream.clear, - discardPending: params.draftStream.discardPending, - seal: params.draftStream.seal, - id: params.draftStream.postId, - }, - buildFinalEdit: (payload) => { - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - const ttsSupplement = getReplyPayloadTtsSupplement(payload); - const previewFinalText = params.resolvePreviewFinalText( - payload.text ?? ttsSupplement?.spokenText, - ); - - if ( - (hasMedia && !ttsSupplement) || - typeof previewFinalText !== "string" || - payload.isError || - !canFinalizeMattermostPreviewInPlace({ - kind: params.kind, - previewRootId: params.effectiveReplyToId, - threadRootId: params.effectiveReplyToId, - replyToId: payload.replyToId, - }) - ) { - return undefined; - } - return { message: previewFinalText }; - }, - editFinal: async (previewPostId, edit) => { - await updateMattermostPost(params.client, previewPostId, edit); - }, - onPreviewFinalized: () => { - params.previewState.finalizedViaPreviewPost = true; - // The visible final reply landed by editing the preview post, so the normal - // deliverPayload record path is skipped; record participation explicitly here. - params.recordThreadParticipation?.(); - }, - buildSupplementalPayload: (payload) => - getReplyPayloadTtsSupplement(payload) ? buildTtsSupplementMediaPayload(payload) : undefined, - deliverSupplemental: async (payload) => { - await params.deliverPayload(payload); - }, - logPreviewEditFailure: (err) => { - params.logVerboseMessage( - `mattermost preview final edit failed; falling back to normal send (${String(err)})`, - ); - }, - }), - deliverNormally: async (payload) => { - const supplement = getReplyPayloadTtsSupplement(payload); - await params.deliverPayload( - supplement && !payload.text?.trim() && supplement.visibleTextAlreadyDelivered !== true - ? { ...payload, text: supplement.spokenText } - : payload, - ); - }, - }); -} - -export function formatMattermostFinalDeliveryOutcomeLog(params: { - outcome: MattermostReplyDeliveryOutcome; - payload: ReplyPayload; - to: string; - accountId: string; - agentId: string | undefined; -}): string | undefined { - const violation = evaluateMattermostNoVisibleReply({ - outcome: params.outcome, - payload: params.payload, - }); - if (violation) { - return formatMattermostNoVisibleReplyLog({ - violation, - to: params.to, - accountId: params.accountId, - agentId: params.agentId, - }); - } - if (params.outcome === "text" || params.outcome === "media") { - return `delivered reply to ${params.to}`; - } - return undefined; -} - -export function resolveMattermostEffectiveReplyToId(params: { - kind: ChatType; - postId?: string | null; - replyToMode: "off" | "first" | "all" | "batched"; - threadRootId?: string | null; -}): string | undefined { - // Flat DMs never thread. Opted-in DMs use the same thread-root logic as rooms; - // replyToMode already reflects the effective per-chat-type mode. - if (params.kind === "direct" && params.replyToMode === "off") { - return undefined; - } - const threadRootId = normalizeOptionalString(params.threadRootId); - if (threadRootId) { - return threadRootId; - } - const postId = normalizeOptionalString(params.postId); - if (!postId) { - return undefined; - } - return params.replyToMode === "all" || - params.replyToMode === "first" || - params.replyToMode === "batched" - ? postId - : undefined; -} - -export function resolveMattermostThreadSessionContext(params: { - baseSessionKey: string; - kind: ChatType; - postId?: string | null; - replyToMode: "off" | "first" | "all" | "batched"; - threadRootId?: string | null; -}): { effectiveReplyToId?: string; sessionKey: string; parentSessionKey?: string } { - const effectiveReplyToId = resolveMattermostEffectiveReplyToId({ - kind: params.kind, - postId: params.postId, - replyToMode: params.replyToMode, - threadRootId: params.threadRootId, - }); - const threadKeys = resolveThreadSessionKeys({ - baseSessionKey: params.baseSessionKey, - threadId: effectiveReplyToId, - // DM threads start fresh; room threads inherit their base session. - parentSessionKey: - effectiveReplyToId && params.kind !== "direct" ? params.baseSessionKey : undefined, - }); - return { - effectiveReplyToId, - sessionKey: threadKeys.sessionKey, - parentSessionKey: threadKeys.parentSessionKey, - }; -} - -export function resolveMattermostPendingHistoryKey(params: { - kind: ChatType; - sessionKey: string; -}): string | null { - // DMs always dispatch immediately, so they do not need the pending-room - // history window. Keeping them out also avoids one empty bucket per DM thread. - return params.kind === "direct" ? null : params.sessionKey; -} - -export function resolveMattermostReactionChannelId( - payload: Pick, -): string | undefined { - return ( - normalizeOptionalString(payload.broadcast?.channel_id) ?? - normalizeOptionalString(payload.data?.channel_id) - ); -} - function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string { if (mediaList.length === 0) { return ""; diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 873788abf4bd..3349b71ca7ca 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -2,8 +2,8 @@ import { expectProvidedCfgSkipsRuntimeLoad } from "openclaw/plugin-sdk/channel-test-helpers"; import { beforeEach, describe, expect, it, vi } from "vitest"; -let parseMattermostTarget: typeof import("./send.js").parseMattermostTarget; let sendMessageMattermost: typeof import("./send.js").sendMessageMattermost; +let parseMattermostTarget: typeof import("./target-resolution.js").parseMattermostTarget; let resetMattermostOpaqueTargetCacheForTests: typeof import("./target-resolution.js").resetMattermostOpaqueTargetCacheForTests; type SendMessageMattermostOptions = NonNullable< @@ -23,7 +23,6 @@ const mockState = vi.hoisted(() => ({ config: {}, })), createMattermostClient: vi.fn(), - createMattermostDirectChannel: vi.fn(), createMattermostDirectChannelWithRetry: vi.fn(), createMattermostPost: vi.fn(), fetchMattermostChannelByName: vi.fn(), @@ -154,7 +153,6 @@ vi.mock("./accounts.js", () => ({ vi.mock("./client.js", () => ({ createMattermostClient: mockState.createMattermostClient, - createMattermostDirectChannel: mockState.createMattermostDirectChannel, createMattermostDirectChannelWithRetry: mockState.createMattermostDirectChannelWithRetry, createMattermostPost: mockState.createMattermostPost, fetchMattermostChannelByName: mockState.fetchMattermostChannelByName, @@ -202,7 +200,6 @@ describe("sendMessageMattermost", () => { }); mockState.loadOutboundMediaFromUrl.mockReset(); mockState.createMattermostClient.mockReset(); - mockState.createMattermostDirectChannel.mockReset(); mockState.createMattermostDirectChannelWithRetry.mockReset(); mockState.createMattermostPost.mockReset(); mockState.fetchMattermostChannelByName.mockReset(); @@ -218,8 +215,9 @@ describe("sendMessageMattermost", () => { mockState.fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]); mockState.fetchMattermostChannelByName.mockResolvedValue({ id: "town-square" }); mockState.uploadMattermostFile.mockResolvedValue({ id: "file-1" }); - ({ parseMattermostTarget, sendMessageMattermost } = await import("./send.js")); - ({ resetMattermostOpaqueTargetCacheForTests } = await import("./target-resolution.js")); + ({ sendMessageMattermost } = await import("./send.js")); + ({ parseMattermostTarget, resetMattermostOpaqueTargetCacheForTests } = + await import("./target-resolution.js")); resetMattermostOpaqueTargetCacheForTests(); }); @@ -544,7 +542,6 @@ describe("sendMessageMattermost user-first resolution", () => { vi.clearAllMocks(); mockState.createMattermostClient.mockReturnValue({}); mockState.createMattermostPost.mockResolvedValue({ id: "post-id" }); - mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" }); mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" }); mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" }); }); diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index ab5efe2eea14..ce570e9eb293 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -34,7 +34,11 @@ import { setInteractionSecret, } from "./interactions.js"; import { loadOutboundMediaFromUrl, type OpenClawConfig } from "./runtime-api.js"; -import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js"; +import { + parseMattermostTarget, + resolveMattermostOpaqueTarget, + type MattermostTarget, +} from "./target-resolution.js"; type MattermostSendOpts = { cfg: OpenClawConfig; @@ -63,11 +67,6 @@ type MattermostSendResult = { receipt: MessageReceipt; }; -type MattermostTarget = - | { kind: "channel"; id: string } - | { kind: "channel-name"; name: string } - | { kind: "user"; id?: string; username?: string }; - const MATTERMOST_BOT_USER_CACHE_MAX_ENTRIES = 64; const MATTERMOST_TARGET_CACHE_MAX_ENTRIES = 1024; const botUserCache = new Map(); @@ -145,63 +144,6 @@ function normalizeMessage(text: string, mediaUrl?: string): string { function isHttpUrl(value: string): boolean { return /^https?:\/\//i.test(value); } -export function parseMattermostTarget(raw: string): MattermostTarget { - const trimmed = raw.trim(); - if (!trimmed) { - throw new Error("Recipient is required for Mattermost sends"); - } - const lower = normalizeLowercaseStringOrEmpty(trimmed); - if (lower.startsWith("channel:")) { - const id = trimmed.slice("channel:".length).trim(); - if (!id) { - throw new Error("Channel id is required for Mattermost sends"); - } - if (id.startsWith("#")) { - const name = id.slice(1).trim(); - if (!name) { - throw new Error("Channel name is required for Mattermost sends"); - } - return { kind: "channel-name", name }; - } - if (!isMattermostId(id)) { - return { kind: "channel-name", name: id }; - } - return { kind: "channel", id }; - } - if (lower.startsWith("user:")) { - const id = trimmed.slice("user:".length).trim(); - if (!id) { - throw new Error("User id is required for Mattermost sends"); - } - return { kind: "user", id }; - } - if (lower.startsWith("mattermost:")) { - const id = trimmed.slice("mattermost:".length).trim(); - if (!id) { - throw new Error("User id is required for Mattermost sends"); - } - return { kind: "user", id }; - } - if (trimmed.startsWith("@")) { - const username = trimmed.slice(1).trim(); - if (!username) { - throw new Error("Username is required for Mattermost sends"); - } - return { kind: "user", username }; - } - if (trimmed.startsWith("#")) { - const name = trimmed.slice(1).trim(); - if (!name) { - throw new Error("Channel name is required for Mattermost sends"); - } - return { kind: "channel-name", name }; - } - if (!isMattermostId(trimmed)) { - return { kind: "channel-name", name: trimmed }; - } - return { kind: "channel", id: trimmed }; -} - async function resolveBotUser( baseUrl: string, token: string, diff --git a/extensions/mattermost/src/mattermost/slash-http.test.ts b/extensions/mattermost/src/mattermost/slash-http.test.ts index a1a79c8bc610..248b1a65db4f 100644 --- a/extensions/mattermost/src/mattermost/slash-http.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.test.ts @@ -5,15 +5,31 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; import type { MattermostClient } from "./client.js"; +const clientMocks = vi.hoisted(() => ({ + createMattermostClient: vi.fn(), + fetchMattermostChannel: vi.fn(async () => { + throw new Error("channel lookup intentionally unavailable in token validation tests"); + }), +})); + +vi.mock("./client.js", async () => { + const actual = await vi.importActual("./client.js"); + return { + ...actual, + createMattermostClient: clientMocks.createMattermostClient, + fetchMattermostChannel: clientMocks.fetchMattermostChannel, + }; +}); + import { MATTERMOST_SLASH_POST_METHOD, type MattermostCommandResponse, type MattermostRegisteredCommand, + type MattermostSlashCommandPayload, } from "./slash-commands.js"; import { createSlashCommandHttpHandler, resetMattermostSlashCommandValidationCacheForTests, - validateMattermostSlashCommandToken, } from "./slash-http.js"; function createRequest(params: { @@ -143,6 +159,38 @@ async function runSlashRequest(params: { return response; } +async function validateMattermostSlashCommandToken(params: { + accountId: string; + client: MattermostClient; + registeredCommand: MattermostRegisteredCommand; + payload: MattermostSlashCommandPayload; + log?: (message: string) => void; +}): Promise { + clientMocks.createMattermostClient.mockReturnValue(params.client); + const handler = createSlashCommandHttpHandler({ + account: { ...accountFixture, accountId: params.accountId }, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + registeredCommands: [params.registeredCommand], + log: params.log, + }); + const req = createRequest({ + body: new URLSearchParams( + Object.entries(params.payload).map(([key, value]) => [key, String(value)]), + ).toString(), + }); + const response = createResponse(); + try { + await handler(req, response.res); + } catch (error) { + if (error instanceof Error && error.message === "Mattermost runtime not initialized") { + return true; + } + throw error; + } + return response.res.statusCode !== 401; +} + function firstLogMessage(log: ReturnType): string { const message = log.mock.calls[0]?.[0]; return typeof message === "string" ? message : ""; @@ -151,6 +199,8 @@ function firstLogMessage(log: ReturnType): string { describe("slash-http", () => { beforeEach(() => { resetMattermostSlashCommandValidationCacheForTests(); + clientMocks.createMattermostClient.mockReset(); + clientMocks.fetchMattermostChannel.mockClear(); }); it("rejects non-POST methods", async () => { @@ -573,7 +623,8 @@ describe("slash-http", () => { }); it("scopes validation cache entries by account", async () => { - const registeredCommand = createRegisteredCommand(); + const registeredCommandA = createRegisteredCommand({ token: "token-a" }); + const registeredCommandB = createRegisteredCommand({ token: "token-b" }); const clientA = createCommandLookupClient({ command: { id: "cmd-1", @@ -603,7 +654,7 @@ describe("slash-http", () => { validateMattermostSlashCommandToken({ accountId: "a1", client: clientA, - registeredCommand, + registeredCommand: registeredCommandA, payload: { token: "token-a", team_id: "t1", @@ -618,7 +669,7 @@ describe("slash-http", () => { validateMattermostSlashCommandToken({ accountId: "a2", client: clientB, - registeredCommand, + registeredCommand: registeredCommandB, payload: { token: "token-b", team_id: "t1", @@ -699,7 +750,7 @@ describe("slash-http", () => { client, registeredCommand, payload: { - token: "new-token", + token: "old-token", team_id: "t1", channel_id: "c1", user_id: "u1", @@ -836,12 +887,12 @@ describe("slash-http", () => { }); it("logs sanitized command lookup failures when falling back to the team command list", async () => { - const registeredCommand = createRegisteredCommand({ trigger: "oc_status\r\nspoofed" }); + const registeredCommand = createRegisteredCommand(); const command = { id: "cmd-1", token: "valid-token", team_id: "t1", - trigger: "oc_status\r\nspoofed", + trigger: "oc_status", method: MATTERMOST_SLASH_POST_METHOD, url: "https://gateway.example.com/slash", auto_complete: true, @@ -875,7 +926,7 @@ describe("slash-http", () => { expect(log).toHaveBeenCalledTimes(1); const message = firstLogMessage(log); expect(message).not.toMatch(/[\r\n\t]/u); - expect(message).toContain("/oc_status spoofed"); + expect(message).toContain("/oc_status"); expect(message).toContain("primary token=[redacted]"); expect(message).toContain("https://redacted:redacted@chat.example.com/api"); expect(message).not.toContain("secret-token"); diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index 9cc7e5f28218..cb3de44796ea 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -400,7 +400,7 @@ async function fetchCurrentMattermostCommand(params: { return await lookup; } -export async function validateMattermostSlashCommandToken(params: { +async function validateMattermostSlashCommandToken(params: { accountId: string; client: ReturnType; registeredCommand: MattermostRegisteredCommand; diff --git a/extensions/mattermost/src/mattermost/slash-state.test.ts b/extensions/mattermost/src/mattermost/slash-state.test.ts index 6cd2ef08d1fc..657e5e59b6fb 100644 --- a/extensions/mattermost/src/mattermost/slash-state.test.ts +++ b/extensions/mattermost/src/mattermost/slash-state.test.ts @@ -1,4 +1,6 @@ // Mattermost tests cover slash state plugin behavior. +import type { IncomingMessage, ServerResponse } from "node:http"; +import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js"; import type { ResolvedMattermostAccount } from "./accounts.js"; @@ -6,8 +8,7 @@ import type { MattermostRegisteredCommand } from "./slash-commands.js"; import { activateSlashCommands, deactivateSlashCommands, - resolveSlashHandlerForCommand, - resolveSlashHandlerForToken, + registerSlashCommandRoute, } from "./slash-state.js"; function createResolvedMattermostAccount(accountId: string): ResolvedMattermostAccount { @@ -50,139 +51,183 @@ const slashApi = { const ACCOUNT_STATES_KEY = Symbol.for("openclaw.mattermost.slash-account-states"); +type AccountState = { + handler: ((req: IncomingMessage, res: ServerResponse) => Promise) | null; +}; + +function getAccountStates(): Map { + const globalStore = globalThis as Record; + const states = globalStore[ACCOUNT_STATES_KEY]; + if (!(states instanceof Map)) { + throw new Error("expected Mattermost slash account state map"); + } + return states as Map; +} + +function replaceAccountHandler(accountId: string): void { + const state = getAccountStates().get(accountId); + if (!state) { + throw new Error(`expected Mattermost slash state for ${accountId}`); + } + state.handler = async (_req, res) => { + res.statusCode = 200; + res.end(accountId); + }; +} + +function createRequest(body: string): IncomingMessage { + const req = new PassThrough() as PassThrough & IncomingMessage; + req.method = "POST"; + req.headers = { "content-type": "application/x-www-form-urlencoded" }; + process.nextTick(() => { + req.end(body); + }); + return req; +} + +function createResponse(): { res: ServerResponse; getBody: () => string } { + let body = ""; + const res = { + statusCode: 200, + setHeader() {}, + end(chunk?: string | Buffer) { + body = chunk ? String(chunk) : ""; + }, + } as ServerResponse; + return { res, getBody: () => body }; +} + +async function routeSlashRequest(params: { + body: string; + register?: typeof registerSlashCommandRoute; +}): Promise<{ statusCode: number; body: string; warn: ReturnType }> { + let routeHandler: ((req: IncomingMessage, res: ServerResponse) => Promise) | undefined; + const warn = vi.fn(); + (params.register ?? registerSlashCommandRoute)({ + config: { channels: { mattermost: {} } }, + logger: { warn }, + registerHttpRoute(route: { + handler: (req: IncomingMessage, res: ServerResponse) => Promise; + }) { + routeHandler = route.handler; + }, + } as never); + if (!routeHandler) { + throw new Error("expected Mattermost slash route registration"); + } + const response = createResponse(); + await routeHandler(createRequest(params.body), response.res); + return { statusCode: response.res.statusCode, body: response.getBody(), warn }; +} + +function activate(params: { + accountId: string; + tokens: string[]; + commands?: MattermostRegisteredCommand[]; +}): void { + activateSlashCommands({ + account: createResolvedMattermostAccount(params.accountId), + commandTokens: params.tokens, + registeredCommands: params.commands ?? [], + api: slashApi, + }); + replaceAccountHandler(params.accountId); +} + describe("slash-state global singleton", () => { afterEach(() => { deactivateSlashCommands(); }); it("anchors accountStates on globalThis", () => { - deactivateSlashCommands(); - activateSlashCommands({ - account: createResolvedMattermostAccount("a1"), - commandTokens: ["tok-a"], - registeredCommands: [], - api: slashApi, - }); - - const globalStore = globalThis as Record; - const map = globalStore[ACCOUNT_STATES_KEY]; - expect(map).toBeInstanceOf(Map); - expect((map as Map).has("a1")).toBe(true); + activate({ accountId: "a1", tokens: ["tok-a"] }); + expect(getAccountStates().has("a1")).toBe(true); }); - it("preserves slash state across module reloads", async () => { - deactivateSlashCommands(); - activateSlashCommands({ - account: createResolvedMattermostAccount("a1"), - commandTokens: ["tok-reload"], - registeredCommands: [], - api: slashApi, - }); + it("preserves slash routing state across module reloads", async () => { + activate({ accountId: "a1", tokens: ["tok-reload"] }); + activate({ accountId: "a2", tokens: ["tok-other"] }); vi.resetModules(); const reloaded = await import("./slash-state.js"); - const match = reloaded.resolveSlashHandlerForToken("tok-reload"); + const result = await routeSlashRequest({ + register: reloaded.registerSlashCommandRoute, + body: "token=tok-reload", + }); - expect(match.kind).toBe("single"); - if (match.kind !== "single") { - throw new Error("expected single match after module reload"); - } - expect(match.accountIds).toEqual(["a1"]); + expect(result.statusCode).toBe(200); + expect(result.body).toBe("a1"); }); }); -describe("slash-state token routing", () => { - it("returns single match when token belongs to one account", () => { +describe("slash-state request routing", () => { + afterEach(() => { deactivateSlashCommands(); - activateSlashCommands({ - account: createResolvedMattermostAccount("a1"), - commandTokens: ["tok-a"], - registeredCommands: [], - api: slashApi, - }); - - const match = resolveSlashHandlerForToken("tok-a"); - expect(match.kind).toBe("single"); - if (match.kind !== "single") { - throw new Error("expected single match"); - } - expect(match.source).toBe("token"); - expect(match.accountIds).toEqual(["a1"]); - expect(typeof match.handler).toBe("function"); }); - it("returns ambiguous when same token exists in multiple accounts", () => { - deactivateSlashCommands(); - activateSlashCommands({ - account: createResolvedMattermostAccount("a1"), - commandTokens: ["tok-shared"], - registeredCommands: [], - api: slashApi, - }); - activateSlashCommands({ - account: createResolvedMattermostAccount("a2"), - commandTokens: ["tok-shared"], - registeredCommands: [], - api: slashApi, - }); + it("routes a token owned by one account", async () => { + activate({ accountId: "a1", tokens: ["tok-a"] }); + activate({ accountId: "a2", tokens: ["tok-b"] }); - const match = resolveSlashHandlerForToken("tok-shared"); - expect(match.kind).toBe("ambiguous"); - if (match.kind !== "ambiguous") { - throw new Error("expected ambiguous match"); - } - expect(match.source).toBe("token"); - expect(match.accountIds.toSorted()).toEqual(["a1", "a2"]); + const result = await routeSlashRequest({ body: "token=tok-a" }); + + expect(result.statusCode).toBe(200); + expect(result.body).toBe("a1"); }); - it("routes by registered team and command when token lookup misses", () => { - deactivateSlashCommands(); - activateSlashCommands({ - account: createResolvedMattermostAccount("a1"), - commandTokens: ["old-token"], - registeredCommands: [createRegisteredCommand()], - api: slashApi, - }); + it("rejects a token shared by multiple accounts", async () => { + activate({ accountId: "a1", tokens: ["tok-shared"] }); + activate({ accountId: "a2", tokens: ["tok-shared"] }); - const match = resolveSlashHandlerForCommand({ - teamId: "team-1", - command: "/oc_status", - }); + const result = await routeSlashRequest({ body: "token=tok-shared" }); - expect(match.kind).toBe("single"); - if (match.kind !== "single") { - throw new Error("expected single match"); - } - expect(match.source).toBe("command"); - expect(match.accountIds).toEqual(["a1"]); - expect(typeof match.handler).toBe("function"); + expect(result.statusCode).toBe(409); + expect(result.body).toContain("command token is not unique"); + expect(result.warn).toHaveBeenCalledWith( + "mattermost: slash callback matched multiple accounts via token (a1, a2)", + ); }); - it("returns ambiguous when registered team and command match multiple accounts", () => { - deactivateSlashCommands(); - activateSlashCommands({ - account: createResolvedMattermostAccount("a1"), - commandTokens: ["tok-a"], - registeredCommands: [createRegisteredCommand({ id: "cmd-a" })], - api: slashApi, + it("routes by registered team and command when token lookup misses", async () => { + activate({ + accountId: "a1", + tokens: ["old-token"], + commands: [createRegisteredCommand()], }); - activateSlashCommands({ - account: createResolvedMattermostAccount("a2"), - commandTokens: ["tok-b"], - registeredCommands: [createRegisteredCommand({ id: "cmd-b" })], - api: slashApi, + activate({ + accountId: "a2", + tokens: ["other-token"], + commands: [createRegisteredCommand({ id: "cmd-2", teamId: "team-2" })], }); - const match = resolveSlashHandlerForCommand({ - teamId: "team-1", - command: "/oc_status", + const result = await routeSlashRequest({ + body: "token=rotated&team_id=team-1&channel_id=c1&user_id=u1&command=%2Foc_status&text=", }); - expect(match.kind).toBe("ambiguous"); - if (match.kind !== "ambiguous") { - throw new Error("expected ambiguous match"); - } - expect(match.source).toBe("command"); - expect(match.accountIds.toSorted()).toEqual(["a1", "a2"]); + expect(result.statusCode).toBe(200); + expect(result.body).toBe("a1"); + }); + + it("rejects a registered team and command shared by multiple accounts", async () => { + activate({ + accountId: "a1", + tokens: ["tok-a"], + commands: [createRegisteredCommand({ id: "cmd-a" })], + }); + activate({ + accountId: "a2", + tokens: ["tok-b"], + commands: [createRegisteredCommand({ id: "cmd-b" })], + }); + + const result = await routeSlashRequest({ + body: "token=rotated&team_id=team-1&channel_id=c1&user_id=u1&command=%2Foc_status&text=", + }); + + expect(result.statusCode).toBe(409); + expect(result.body).toContain("slash command is not unique"); + expect(result.warn).toHaveBeenCalledWith( + "mattermost: slash callback matched multiple accounts via command (a1, a2)", + ); }); }); diff --git a/extensions/mattermost/src/mattermost/slash-state.ts b/extensions/mattermost/src/mattermost/slash-state.ts index 68affb1a19e2..d7b8de38f6c9 100644 --- a/extensions/mattermost/src/mattermost/slash-state.ts +++ b/extensions/mattermost/src/mattermost/slash-state.ts @@ -86,7 +86,7 @@ function getSlashAccountStates(): Map { const accountStates = getSlashAccountStates(); -export function resolveSlashHandlerForToken(token: string): SlashHandlerMatch { +function resolveSlashHandlerForToken(token: string): SlashHandlerMatch { const matches: Array<{ accountId: string; handler: (req: IncomingMessage, res: ServerResponse) => Promise; @@ -121,7 +121,7 @@ export function resolveSlashHandlerForToken(token: string): SlashHandlerMatch { }; } -export function resolveSlashHandlerForCommand(params: { +function resolveSlashHandlerForCommand(params: { teamId: string; command: string; }): SlashHandlerMatch { diff --git a/extensions/mattermost/src/mattermost/target-resolution.test.ts b/extensions/mattermost/src/mattermost/target-resolution.test.ts index 53d388296d99..510790f04aee 100644 --- a/extensions/mattermost/src/mattermost/target-resolution.test.ts +++ b/extensions/mattermost/src/mattermost/target-resolution.test.ts @@ -17,17 +17,13 @@ vi.mock("./client.js", () => ({ })); describe("mattermost target resolution", () => { - let isExplicitMattermostTarget: typeof import("./target-resolution.js").isExplicitMattermostTarget; - let isMattermostId: typeof import("./target-resolution.js").isMattermostId; - let parseMattermostApiStatus: typeof import("./target-resolution.js").parseMattermostApiStatus; + let parseMattermostTarget: typeof import("./target-resolution.js").parseMattermostTarget; let resolveMattermostOpaqueTarget: typeof import("./target-resolution.js").resolveMattermostOpaqueTarget; let resetMattermostOpaqueTargetCacheForTests: typeof import("./target-resolution.js").resetMattermostOpaqueTargetCacheForTests; beforeAll(async () => { ({ - isExplicitMattermostTarget, - isMattermostId, - parseMattermostApiStatus, + parseMattermostTarget, resolveMattermostOpaqueTarget, resetMattermostOpaqueTargetCacheForTests, } = await import("./target-resolution.js")); @@ -44,15 +40,35 @@ describe("mattermost target resolution", () => { resetMattermostOpaqueTargetCacheForTests(); }); - it("recognizes explicit targets and ID-shaped values", () => { - expect(isExplicitMattermostTarget("@alice")).toBe(true); - expect(isExplicitMattermostTarget("#town-square")).toBe(true); - expect(isExplicitMattermostTarget("mattermost:chan")).toBe(true); - expect(isExplicitMattermostTarget(" plain ")).toBe(false); - expect(isMattermostId("abcd1234abcd1234abcd1234ab")).toBe(true); - expect(isMattermostId("short")).toBe(false); - expect(parseMattermostApiStatus(new Error("Mattermost API 404 Not Found"))).toBe(404); - expect(parseMattermostApiStatus(new Error("other error"))).toBeUndefined(); + it("recognizes ID-shaped values", () => { + expect(parseMattermostTarget("abcd1234abcd1234abcd1234ab")).toEqual({ + kind: "channel", + id: "abcd1234abcd1234abcd1234ab", + }); + expect(parseMattermostTarget("short")).toEqual({ kind: "channel-name", name: "short" }); + }); + + it.each(["@alice", "#town-square", "mattermost:chan"])( + "skips explicit target %s before account resolution", + async (input) => { + await expect(resolveMattermostOpaqueTarget({ input })).resolves.toBeNull(); + expect(resolveMattermostAccount).not.toHaveBeenCalled(); + expect(createMattermostClient).not.toHaveBeenCalled(); + }, + ); + + it("does not cache non-404 lookup failures", async () => { + createMattermostClient.mockReturnValue({ client: true }); + fetchMattermostUser.mockRejectedValue(new Error("other error")); + const params = { + input: "defg1234abcd1234abcd1234ab", + token: "token", + baseUrl: "https://mm.example.com", + }; + + await expect(resolveMattermostOpaqueTarget(params)).resolves.toMatchObject({ kind: "channel" }); + await expect(resolveMattermostOpaqueTarget(params)).resolves.toMatchObject({ kind: "channel" }); + expect(fetchMattermostUser).toHaveBeenCalledTimes(2); }); it("resolves opaque ids as users and caches the result", async () => { diff --git a/extensions/mattermost/src/mattermost/target-resolution.ts b/extensions/mattermost/src/mattermost/target-resolution.ts index 42f4c05757ed..27ef2cce0c5b 100644 --- a/extensions/mattermost/src/mattermost/target-resolution.ts +++ b/extensions/mattermost/src/mattermost/target-resolution.ts @@ -1,7 +1,10 @@ // Mattermost plugin module implements target resolution behavior. import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime"; import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveMattermostAccount } from "./accounts.js"; import { createMattermostClient, @@ -16,6 +19,11 @@ type MattermostOpaqueTargetResolution = { to: string; }; +export type MattermostTarget = + | { kind: "channel"; id: string } + | { kind: "channel-name"; name: string } + | { kind: "user"; id?: string; username?: string }; + const MATTERMOST_OPAQUE_TARGET_CACHE_MAX_ENTRIES = 1024; const mattermostOpaqueTargetCache = new Map(); @@ -33,11 +41,68 @@ function cacheKey(baseUrl: string, token: string, id: string): string { } /** Mattermost IDs are 26-character lowercase alphanumeric strings. */ -export function isMattermostId(value: string): boolean { +function isMattermostId(value: string): boolean { return /^[a-z0-9]{26}$/.test(value); } -export function isExplicitMattermostTarget(raw: string): boolean { +export function parseMattermostTarget(raw: string): MattermostTarget { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Recipient is required for Mattermost sends"); + } + const lower = normalizeLowercaseStringOrEmpty(trimmed); + if (lower.startsWith("channel:")) { + const id = trimmed.slice("channel:".length).trim(); + if (!id) { + throw new Error("Channel id is required for Mattermost sends"); + } + if (id.startsWith("#")) { + const name = id.slice(1).trim(); + if (!name) { + throw new Error("Channel name is required for Mattermost sends"); + } + return { kind: "channel-name", name }; + } + if (!isMattermostId(id)) { + return { kind: "channel-name", name: id }; + } + return { kind: "channel", id }; + } + if (lower.startsWith("user:")) { + const id = trimmed.slice("user:".length).trim(); + if (!id) { + throw new Error("User id is required for Mattermost sends"); + } + return { kind: "user", id }; + } + if (lower.startsWith("mattermost:")) { + const id = trimmed.slice("mattermost:".length).trim(); + if (!id) { + throw new Error("User id is required for Mattermost sends"); + } + return { kind: "user", id }; + } + if (trimmed.startsWith("@")) { + const username = trimmed.slice(1).trim(); + if (!username) { + throw new Error("Username is required for Mattermost sends"); + } + return { kind: "user", username }; + } + if (trimmed.startsWith("#")) { + const name = trimmed.slice(1).trim(); + if (!name) { + throw new Error("Channel name is required for Mattermost sends"); + } + return { kind: "channel-name", name }; + } + if (!isMattermostId(trimmed)) { + return { kind: "channel-name", name: trimmed }; + } + return { kind: "channel", id: trimmed }; +} + +function isExplicitMattermostTarget(raw: string): boolean { const trimmed = raw.trim(); if (!trimmed) { return false; @@ -49,7 +114,7 @@ export function isExplicitMattermostTarget(raw: string): boolean { ); } -export function parseMattermostApiStatus(err: unknown): number | undefined { +function parseMattermostApiStatus(err: unknown): number | undefined { if (!err || typeof err !== "object") { return undefined; } diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index 6ff8b58b8a42..8349a1f64cfc 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -207,43 +207,12 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/matrix/src/onboarding.ts: testing", "extensions/matrix/src/runtime.ts: clearMatrixRuntime", "extensions/matrix/src/types.ts: MatrixExecApprovalConfig", - "extensions/mattermost/src/gateway-auth-bypass.ts: collectMattermostSlashCallbackPaths", - "extensions/mattermost/src/mattermost/client.ts: createMattermostDirectChannel", - "extensions/mattermost/src/mattermost/client.ts: MattermostRequestInit", - "extensions/mattermost/src/mattermost/draft-stream.ts: MattermostDraftPreviewBoundaryController", "extensions/mattermost/src/mattermost/interactions.ts: buildButtonAttachments", - "extensions/mattermost/src/mattermost/interactions.ts: generateInteractionToken", - "extensions/mattermost/src/mattermost/interactions.ts: getInteractionSecret", - "extensions/mattermost/src/mattermost/interactions.ts: verifyInteractionToken", - "extensions/mattermost/src/mattermost/monitor-resources.ts: MATTERMOST_MEDIA_READ_IDLE_TIMEOUT_MS", - "extensions/mattermost/src/mattermost/monitor-resources.ts: MATTERMOST_MEDIA_RESPONSE_HEADER_TIMEOUT_MS", - "extensions/mattermost/src/mattermost/monitor-websocket.ts: MATTERMOST_WEBSOCKET_MAX_PAYLOAD_BYTES", - "extensions/mattermost/src/mattermost/monitor-websocket.ts: MattermostWebSocketLike", - "extensions/mattermost/src/mattermost/monitor-websocket.ts: WebSocketClosedBeforeOpenError", - "extensions/mattermost/src/mattermost/monitor.ts: buildMattermostModelPickerSelectMessageSid", - "extensions/mattermost/src/mattermost/monitor.ts: canFinalizeMattermostPreviewInPlace", - "extensions/mattermost/src/mattermost/monitor.ts: deliverMattermostReplyWithDraftPreview", - "extensions/mattermost/src/mattermost/monitor.ts: formatMattermostFinalDeliveryOutcomeLog", - "extensions/mattermost/src/mattermost/monitor.ts: MattermostRetryableInboundError", - "extensions/mattermost/src/mattermost/monitor.ts: processMattermostReplayGuardedPost", - "extensions/mattermost/src/mattermost/monitor.ts: resolveMattermostEffectiveReplyToId", - "extensions/mattermost/src/mattermost/monitor.ts: resolveMattermostPendingHistoryKey", - "extensions/mattermost/src/mattermost/monitor.ts: resolveMattermostReactionChannelId", - "extensions/mattermost/src/mattermost/monitor.ts: resolveMattermostReplyRootId", - "extensions/mattermost/src/mattermost/monitor.ts: resolveMattermostThreadSessionContext", - "extensions/mattermost/src/mattermost/monitor.ts: shouldSuppressMattermostDefaultToolProgressMessages", - "extensions/mattermost/src/mattermost/monitor.ts: shouldUpdateMattermostDraftToolProgress", "extensions/mattermost/src/mattermost/reactions.ts: resetMattermostReactionBotUserCacheForTests", "extensions/mattermost/src/mattermost/runtime-api.ts: buildInboundHistoryFromMap", "extensions/mattermost/src/mattermost/runtime-api.ts: buildPendingHistoryContextFromMap", "extensions/mattermost/src/mattermost/runtime-api.ts: recordPendingHistoryEntryIfEnabled", - "extensions/mattermost/src/mattermost/send.ts: parseMattermostTarget", "extensions/mattermost/src/mattermost/slash-http.ts: resetMattermostSlashCommandValidationCacheForTests", - "extensions/mattermost/src/mattermost/slash-http.ts: validateMattermostSlashCommandToken", - "extensions/mattermost/src/mattermost/slash-state.ts: resolveSlashHandlerForCommand", - "extensions/mattermost/src/mattermost/slash-state.ts: resolveSlashHandlerForToken", - "extensions/mattermost/src/mattermost/target-resolution.ts: isExplicitMattermostTarget", - "extensions/mattermost/src/mattermost/target-resolution.ts: parseMattermostApiStatus", "extensions/mattermost/src/mattermost/target-resolution.ts: resetMattermostOpaqueTargetCacheForTests", "extensions/mattermost/src/mattermost/thread-participation.ts: clearMattermostThreadParticipationCache", "extensions/memory-wiki/src/apply.ts: ApplyMemoryWikiMutation",