mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
refactor(mattermost): trim internal exports (#107062)
This commit is contained in:
committed by
GitHub
parent
604eb6af2d
commit
c19b7d2823
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user