refactor(mattermost): trim internal exports (#107062)

This commit is contained in:
Peter Steinberger
2026-07-13 20:27:52 -07:00
committed by GitHub
parent 604eb6af2d
commit c19b7d2823
25 changed files with 750 additions and 670 deletions
@@ -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";
@@ -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"]);
});
@@ -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<string>([normalizeCallbackPath(raw?.callbackPath)]);
const callbackUrl = readTrimmedString(raw?.callbackUrl);
if (callbackUrl) {
@@ -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<Response>;
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,
@@ -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<void>;
};
@@ -9,13 +9,10 @@ import {
buildButtonAttachments,
computeInteractionCallbackUrl,
createMattermostInteractionHandler,
generateInteractionToken,
getInteractionSecret,
resolveInteractionCallbackPath,
resolveInteractionCallbackUrl,
setInteractionCallbackUrl,
setInteractionSecret,
verifyInteractionToken,
} from "./interactions.js";
type ButtonAttachments = ReturnType<typeof buildButtonAttachments>;
@@ -46,6 +43,27 @@ function requireAction(attachments: ButtonAttachments, index = 0): ButtonAction
return action;
}
function generateInteractionToken(context: Record<string, unknown>, 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<string, unknown>,
token: string,
accountId?: string,
): boolean {
return generateInteractionToken(context, accountId) === token;
}
// ── HMAC token management ────────────────────────────────────────────
describe("setInteractionSecret / getInteractionSecret", () => {
@@ -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<string, unknown>,
accountId?: string,
): string {
function generateInteractionToken(context: Record<string, unknown>, 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<string, unknown>,
token: string,
accountId?: string,
@@ -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<ResolvedMattermostAccount, "config" | "streamingMode">,
): boolean {
return (
account.streamingMode !== "off" && resolveChannelStreamingPreviewToolProgress(account.config)
);
}
export function shouldSuppressMattermostDefaultToolProgressMessages(
account: Pick<ResolvedMattermostAccount, "streamingMode">,
): 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<MattermostEventPayload, "broadcast" | "data">,
): string | undefined {
return (
normalizeOptionalString(payload.broadcast?.channel_id) ??
normalizeOptionalString(payload.data?.channel_id)
);
}
@@ -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<typeof createMattermostDraftStream>,
"flush" | "postId" | "clear" | "discardPending" | "seal"
>;
effectiveReplyToId?: string;
resolvePreviewFinalText: (text?: string) => string | undefined;
previewState: MattermostDraftPreviewState;
logVerboseMessage: (message: string) => void;
deliverPayload: (payload: ReplyPayload) => Promise<void>;
// 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<void> {
if (isReasoningReplyPayload(params.payload)) {
return;
}
await deliverWithFinalizableLivePreviewAdapter({
kind: params.info.kind,
payload: params.payload,
adapter: defineFinalizableLivePreviewAdapter<ReplyPayload, string, { message: string }>({
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,
);
},
});
}
@@ -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<void>;
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;
}
}
@@ -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 },
});
@@ -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;
@@ -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<T>(items: readonly T[], predicate: (item: T) => boolean): number {
@@ -21,7 +19,7 @@ function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean):
return count;
}
class FakeWebSocket implements MattermostWebSocketLike {
class FakeWebSocket implements ReturnType<MattermostWebSocketFactory> {
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));
});
});
@@ -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>): 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,
@@ -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<typeof evaluateMattermostMentionGate>[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,
+16 -336
View File
@@ -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<ResolvedMattermostAccount, "config" | "streamingMode">,
): boolean {
return (
account.streamingMode !== "off" && resolveChannelStreamingPreviewToolProgress(account.config)
);
}
export function shouldSuppressMattermostDefaultToolProgressMessages(
account: Pick<ResolvedMattermostAccount, "streamingMode">,
): 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<void>;
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<typeof createMattermostDraftStream> {
const noopAsync = async () => {};
return {
@@ -320,193 +187,6 @@ function createDisabledMattermostDraftStream(): ReturnType<typeof createMattermo
};
}
type MattermostDraftPreviewDeliverParams = {
payload: ReplyPayload;
info: { kind: "tool" | "block" | "final" };
kind: ChatType;
client: MattermostClient;
draftStream: Pick<
ReturnType<typeof createMattermostDraftStream>,
"flush" | "postId" | "clear" | "discardPending" | "seal"
>;
effectiveReplyToId?: string;
resolvePreviewFinalText: (text?: string) => string | undefined;
previewState: MattermostDraftPreviewState;
logVerboseMessage: (message: string) => void;
deliverPayload: (payload: ReplyPayload) => Promise<void>;
// 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<void> {
if (isReasoningReplyPayload(params.payload)) {
return;
}
await deliverWithFinalizableLivePreviewAdapter({
kind: params.info.kind,
payload: params.payload,
adapter: defineFinalizableLivePreviewAdapter<ReplyPayload, string, { message: string }>({
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<MattermostEventPayload, "broadcast" | "data">,
): string | undefined {
return (
normalizeOptionalString(payload.broadcast?.channel_id) ??
normalizeOptionalString(payload.data?.channel_id)
);
}
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
if (mediaList.length === 0) {
return "";
@@ -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" });
});
+5 -63
View File
@@ -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<string, MattermostUser>();
@@ -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,
@@ -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<typeof import("./client.js")>("./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<boolean> {
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<typeof vi.fn>): string {
const message = log.mock.calls[0]?.[0];
return typeof message === "string" ? message : "";
@@ -151,6 +199,8 @@ function firstLogMessage(log: ReturnType<typeof vi.fn>): 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");
@@ -400,7 +400,7 @@ async function fetchCurrentMattermostCommand(params: {
return await lookup;
}
export async function validateMattermostSlashCommandToken(params: {
async function validateMattermostSlashCommandToken(params: {
accountId: string;
client: ReturnType<typeof createMattermostClient>;
registeredCommand: MattermostRegisteredCommand;
@@ -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<void>) | null;
};
function getAccountStates(): Map<string, AccountState> {
const globalStore = globalThis as Record<PropertyKey, unknown>;
const states = globalStore[ACCOUNT_STATES_KEY];
if (!(states instanceof Map)) {
throw new Error("expected Mattermost slash account state map");
}
return states as Map<string, AccountState>;
}
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<typeof vi.fn> }> {
let routeHandler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | undefined;
const warn = vi.fn();
(params.register ?? registerSlashCommandRoute)({
config: { channels: { mattermost: {} } },
logger: { warn },
registerHttpRoute(route: {
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
}) {
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<PropertyKey, unknown>;
const map = globalStore[ACCOUNT_STATES_KEY];
expect(map).toBeInstanceOf(Map);
expect((map as Map<string, unknown>).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)",
);
});
});
@@ -86,7 +86,7 @@ function getSlashAccountStates(): Map<string, SlashCommandAccountState> {
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<void>;
@@ -121,7 +121,7 @@ export function resolveSlashHandlerForToken(token: string): SlashHandlerMatch {
};
}
export function resolveSlashHandlerForCommand(params: {
function resolveSlashHandlerForCommand(params: {
teamId: string;
command: string;
}): SlashHandlerMatch {
@@ -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 () => {
@@ -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<string, MattermostOpaqueTargetResolution["kind"]>();
@@ -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;
}
-31
View File
@@ -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",