mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
2201 lines
77 KiB
TypeScript
2201 lines
77 KiB
TypeScript
import {
|
|
defineFinalizableLivePreviewAdapter,
|
|
deliverWithFinalizableLivePreviewAdapter,
|
|
} from "openclaw/plugin-sdk/channel-outbound";
|
|
import {
|
|
formatChannelProgressDraftLineForEntry,
|
|
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";
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
normalizeTrimmedStringList,
|
|
uniqueStrings,
|
|
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { getMattermostRuntime } from "../runtime.js";
|
|
import {
|
|
resolveMattermostAccount,
|
|
resolveMattermostReplyToMode,
|
|
type ResolvedMattermostAccount,
|
|
} from "./accounts.js";
|
|
import {
|
|
createMattermostClient,
|
|
fetchMattermostMe,
|
|
normalizeMattermostBaseUrl,
|
|
updateMattermostPost,
|
|
type MattermostClient,
|
|
type MattermostPost,
|
|
type MattermostUser,
|
|
} from "./client.js";
|
|
import { buildMattermostToolStatusText, createMattermostDraftStream } from "./draft-stream.js";
|
|
import {
|
|
computeInteractionCallbackUrl,
|
|
createMattermostInteractionHandler,
|
|
resolveInteractionCallbackPath,
|
|
setInteractionCallbackUrl,
|
|
setInteractionSecret,
|
|
type MattermostInteractionResponse,
|
|
} from "./interactions.js";
|
|
import {
|
|
buildMattermostAllowedModelRefs,
|
|
parseMattermostModelPickerContext,
|
|
renderMattermostModelsPickerView,
|
|
renderMattermostProviderPickerView,
|
|
resolveMattermostModelPickerCurrentModel,
|
|
} from "./model-picker.js";
|
|
import {
|
|
authorizeMattermostCommandInvocation,
|
|
normalizeMattermostAllowEntry,
|
|
resolveMattermostMonitorInboundAccess,
|
|
} from "./monitor-auth.js";
|
|
import {
|
|
evaluateMattermostMentionGate,
|
|
mapMattermostChannelTypeToChatType,
|
|
resolveMattermostTrustedChatKind,
|
|
} from "./monitor-gating.js";
|
|
import {
|
|
formatInboundFromLabel,
|
|
normalizeMention,
|
|
resolveThreadSessionKeys,
|
|
} from "./monitor-helpers.js";
|
|
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
|
|
import { createMattermostMonitorResources, type MattermostMediaInfo } from "./monitor-resources.js";
|
|
import { registerMattermostMonitorSlashCommands } from "./monitor-slash.js";
|
|
import {
|
|
createMattermostConnectOnce,
|
|
type MattermostEventPayload,
|
|
type MattermostWebSocketFactory,
|
|
} from "./monitor-websocket.js";
|
|
import {
|
|
evaluateMattermostNoVisibleReply,
|
|
formatMattermostNoVisibleReplyLog,
|
|
} from "./no-visible-reply-diagnostic.js";
|
|
import { runWithReconnect } from "./reconnect.js";
|
|
import {
|
|
deliverMattermostReplyPayload,
|
|
type MattermostReplyDeliveryOutcome,
|
|
} from "./reply-delivery.js";
|
|
import type {
|
|
ChannelAccountSnapshot,
|
|
ChatType,
|
|
OpenClawConfig,
|
|
ReplyPayload,
|
|
RuntimeEnv,
|
|
} from "./runtime-api.js";
|
|
import {
|
|
buildAgentMediaPayload,
|
|
buildModelsProviderData,
|
|
createChannelHistoryWindow,
|
|
createChannelPairingController,
|
|
createChannelMessageReplyPipeline,
|
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
logInboundDrop,
|
|
logTypingFailure,
|
|
registerPluginHttpRoute,
|
|
resolveAllowlistProviderRuntimeGroupPolicy,
|
|
resolveChannelMediaMaxBytes,
|
|
resolveDefaultGroupPolicy,
|
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
type HistoryEntry,
|
|
} from "./runtime-api.js";
|
|
import { sendMessageMattermost } from "./send.js";
|
|
import { cleanupSlashCommands } from "./slash-commands.js";
|
|
import { deactivateSlashCommands, getSlashCommandState } from "./slash-state.js";
|
|
|
|
export {
|
|
evaluateMattermostMentionGate,
|
|
mapMattermostChannelTypeToChatType,
|
|
resolveMattermostTrustedChatKind,
|
|
} from "./monitor-gating.js";
|
|
export type {
|
|
MattermostMentionGateInput,
|
|
MattermostRequireMentionResolverInput,
|
|
} from "./monitor-gating.js";
|
|
|
|
export type MonitorMattermostOpts = {
|
|
botToken?: string;
|
|
baseUrl?: string;
|
|
accountId?: string;
|
|
config?: OpenClawConfig;
|
|
runtime?: RuntimeEnv;
|
|
abortSignal?: AbortSignal;
|
|
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
|
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 = {
|
|
user_id?: string;
|
|
post_id?: string;
|
|
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 ?? {
|
|
log: console.log,
|
|
error: console.error,
|
|
exit: (code: number): never => {
|
|
throw new Error(`exit ${code}`);
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
function isSystemPost(post: MattermostPost): boolean {
|
|
return normalizeOptionalString(post.type) !== undefined;
|
|
}
|
|
|
|
function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
|
|
if (kind === "direct") {
|
|
return "direct";
|
|
}
|
|
if (kind === "group") {
|
|
return "group";
|
|
}
|
|
return "channel";
|
|
}
|
|
|
|
export function resolveMattermostReplyRootId(params: {
|
|
kind: ChatType;
|
|
threadRootId?: string;
|
|
replyToId?: string;
|
|
}): string | undefined {
|
|
if (params.kind === "direct") {
|
|
return undefined;
|
|
}
|
|
const threadRootId = normalizeOptionalString(params.threadRootId);
|
|
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 shouldClearMattermostDraftPreview(params: {
|
|
finalizedViaPreviewPost: boolean;
|
|
finalReplyDelivered: boolean;
|
|
}): boolean {
|
|
return params.finalReplyDelivered && !params.finalizedViaPreviewPost;
|
|
}
|
|
|
|
export function shouldFinalizeMattermostPreviewAfterDispatch(params: {
|
|
finalCount: number;
|
|
canFinalizeInPlace: boolean;
|
|
}): boolean {
|
|
return params.finalCount === 1 && params.canFinalizeInPlace;
|
|
}
|
|
|
|
type MattermostDraftPreviewState = {
|
|
finalizedViaPreviewPost: boolean;
|
|
};
|
|
|
|
function createDisabledMattermostDraftStream(): ReturnType<typeof createMattermostDraftStream> {
|
|
const noopAsync = async () => {};
|
|
return {
|
|
update: () => {},
|
|
flush: noopAsync,
|
|
postId: () => undefined,
|
|
clear: noopAsync,
|
|
discardPending: noopAsync,
|
|
seal: noopAsync,
|
|
stop: noopAsync,
|
|
forceNewMessage: () => {},
|
|
};
|
|
}
|
|
|
|
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>;
|
|
};
|
|
|
|
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;
|
|
},
|
|
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 {
|
|
if (params.kind === "direct") {
|
|
return undefined;
|
|
}
|
|
const threadRootId = normalizeOptionalString(params.threadRootId);
|
|
if (threadRootId && params.replyToMode !== "off") {
|
|
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,
|
|
parentSessionKey: effectiveReplyToId ? params.baseSessionKey : undefined,
|
|
});
|
|
return {
|
|
effectiveReplyToId,
|
|
sessionKey: threadKeys.sessionKey,
|
|
parentSessionKey: threadKeys.parentSessionKey,
|
|
};
|
|
}
|
|
|
|
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 "";
|
|
}
|
|
if (mediaList.length === 1) {
|
|
const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
|
|
return `<media:${kind}>`;
|
|
}
|
|
const allImages = mediaList.every((media) => media.kind === "image");
|
|
const label = allImages ? "image" : "file";
|
|
const suffix = mediaList.length === 1 ? label : `${label}s`;
|
|
const tag = allImages ? "<media:image>" : "<media:document>";
|
|
return `${tag} (${mediaList.length} ${suffix})`;
|
|
}
|
|
|
|
function buildMattermostWsUrl(baseUrl: string): string {
|
|
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
|
if (!normalized) {
|
|
throw new Error("Mattermost baseUrl is required");
|
|
}
|
|
const wsBase = normalized.replace(/^http/i, "ws");
|
|
return `${wsBase}/api/v4/websocket`;
|
|
}
|
|
|
|
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise<void> {
|
|
const core = getMattermostRuntime();
|
|
const runtime = resolveRuntime(opts);
|
|
const cfg = (opts.config ?? core.config.current()) as OpenClawConfig;
|
|
const account = resolveMattermostAccount({
|
|
cfg,
|
|
accountId: opts.accountId,
|
|
});
|
|
const pairing = createChannelPairingController({
|
|
core,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
});
|
|
const botToken =
|
|
normalizeOptionalString(opts.botToken) ?? normalizeOptionalString(account.botToken);
|
|
if (!botToken) {
|
|
throw new Error(
|
|
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
|
|
);
|
|
}
|
|
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
|
|
if (!baseUrl) {
|
|
throw new Error(
|
|
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
|
|
);
|
|
}
|
|
|
|
const client = createMattermostClient({
|
|
baseUrl,
|
|
botToken,
|
|
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
|
});
|
|
|
|
// Wait for the Mattermost API to accept our bot token before proceeding.
|
|
// When a bot account is disabled and re-enabled, the session is invalidated
|
|
// and API calls return 401 until the account is fully active again. Retrying
|
|
// here (with exponential backoff) keeps the monitor alive and prevents the
|
|
// framework's auto-restart budget from being exhausted.
|
|
let botUser!: MattermostUser;
|
|
await runWithReconnect(
|
|
async () => {
|
|
botUser = await fetchMattermostMe(client);
|
|
},
|
|
{
|
|
abortSignal: opts.abortSignal,
|
|
jitterRatio: 0.2,
|
|
shouldReconnect: ({ outcome }) => outcome === "rejected",
|
|
onError: (err) => {
|
|
runtime.error?.(`mattermost: API auth failed: ${String(err)}`);
|
|
opts.statusSink?.({ lastError: String(err), connected: false });
|
|
},
|
|
onReconnect: (delayMs) => {
|
|
runtime.log?.(`mattermost: API not accessible, retrying in ${Math.round(delayMs / 1000)}s`);
|
|
},
|
|
},
|
|
);
|
|
if (opts.abortSignal?.aborted) {
|
|
return;
|
|
}
|
|
const botUserId = botUser.id;
|
|
const botUsername = normalizeOptionalString(botUser.username);
|
|
runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`);
|
|
await registerMattermostMonitorSlashCommands({
|
|
client,
|
|
cfg,
|
|
runtime,
|
|
account,
|
|
baseUrl,
|
|
botUserId,
|
|
});
|
|
const slashEnabled = getSlashCommandState(account.accountId) != null;
|
|
|
|
// ─── Interactive buttons registration ──────────────────────────────────────
|
|
// Derive a stable HMAC secret from the bot token so CLI and gateway share it.
|
|
setInteractionSecret(account.accountId, botToken);
|
|
|
|
// Register HTTP callback endpoint for interactive button clicks.
|
|
// Mattermost POSTs to this URL when a user clicks a button action.
|
|
const interactionPath = resolveInteractionCallbackPath(account.accountId);
|
|
// Recompute from config on each monitor start so reconnects or config reloads can refresh the
|
|
// cached callback URL for downstream callers such as `message action=send`.
|
|
const callbackUrl = computeInteractionCallbackUrl(account.accountId, {
|
|
gateway: cfg.gateway,
|
|
interactions: account.config.interactions,
|
|
});
|
|
setInteractionCallbackUrl(account.accountId, callbackUrl);
|
|
const allowedInteractionSourceIps = normalizeInteractionSourceIps(
|
|
account.config.interactions?.allowedSourceIps,
|
|
);
|
|
|
|
try {
|
|
const mmHost = new URL(baseUrl).hostname;
|
|
const callbackHost = new URL(callbackUrl).hostname;
|
|
if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) {
|
|
runtime.error?.(
|
|
`mattermost: interactions callbackUrl resolved to ${callbackUrl} (loopback) while baseUrl is ${baseUrl}. This MAY be unreachable depending on your deployment. If button clicks don't work, set channels.mattermost.interactions.callbackBaseUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`,
|
|
);
|
|
}
|
|
if (!isLoopbackHost(callbackHost) && allowedInteractionSourceIps.length === 0) {
|
|
runtime.error?.(
|
|
`mattermost: interactions callbackUrl resolved to ${callbackUrl} without channels.mattermost.interactions.allowedSourceIps. For safety, non-loopback callback sources will be rejected until you allowlist the Mattermost server or trusted ingress IPs.`,
|
|
);
|
|
}
|
|
} catch {
|
|
// URL parse failed; ignore and continue (we will fail naturally if callbacks cannot be delivered).
|
|
}
|
|
|
|
const effectiveInteractionSourceIps =
|
|
allowedInteractionSourceIps.length > 0 ? allowedInteractionSourceIps : ["127.0.0.1", "::1"];
|
|
|
|
const unregisterInteractions = registerPluginHttpRoute({
|
|
path: interactionPath,
|
|
fallbackPath: "/mattermost/interactions/default",
|
|
auth: "plugin",
|
|
handler: createMattermostInteractionHandler({
|
|
client,
|
|
botUserId,
|
|
accountId: account.accountId,
|
|
allowedSourceIps: effectiveInteractionSourceIps,
|
|
trustedProxies: cfg.gateway?.trustedProxies,
|
|
allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true,
|
|
handleInteraction: handleModelPickerInteraction,
|
|
authorizeButtonClick: async ({ payload, post }) => {
|
|
const channelInfo = await resolveChannelInfo(payload.channel_id);
|
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
cfg,
|
|
surface: "mattermost",
|
|
});
|
|
const decision = await authorizeMattermostCommandInvocation({
|
|
account,
|
|
cfg,
|
|
senderId: payload.user_id,
|
|
senderName: payload.user_name ?? "",
|
|
channelId: payload.channel_id,
|
|
channelInfo,
|
|
readStoreAllowFrom: pairing.readAllowFromStore,
|
|
allowTextCommands,
|
|
hasControlCommand: false,
|
|
});
|
|
if (decision.ok) {
|
|
return { ok: true };
|
|
}
|
|
return {
|
|
ok: false,
|
|
response: {
|
|
update: {
|
|
message: post.message ?? "",
|
|
props: post.props ?? undefined,
|
|
},
|
|
ephemeral_text: `OpenClaw ignored this action for ${decision.roomLabel}.`,
|
|
},
|
|
};
|
|
},
|
|
resolveSessionKey: async ({ channelId, userId, post }) => {
|
|
const channelInfo = await resolveChannelInfo(channelId);
|
|
if (!channelInfo?.type) {
|
|
logVerboseMessage(
|
|
`mattermost: drop interaction session event (cannot resolve channel type for ${channelId})`,
|
|
);
|
|
throw new Error("Mattermost channel type could not be resolved");
|
|
}
|
|
const kind = mapMattermostChannelTypeToChatType(channelInfo.type);
|
|
const teamId = channelInfo?.team_id ?? undefined;
|
|
const route = core.channel.routing.resolveAgentRoute({
|
|
cfg,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
teamId,
|
|
peer: {
|
|
kind,
|
|
id: kind === "direct" ? userId : channelId,
|
|
},
|
|
});
|
|
const replyToMode = resolveMattermostReplyToMode(account, kind);
|
|
return resolveMattermostThreadSessionContext({
|
|
baseSessionKey: route.sessionKey,
|
|
kind,
|
|
postId: post.id || undefined,
|
|
replyToMode,
|
|
threadRootId: post.root_id,
|
|
}).sessionKey;
|
|
},
|
|
dispatchButtonClick: async (optsLocal) => {
|
|
const channelInfo = await resolveChannelInfo(optsLocal.channelId);
|
|
if (!channelInfo?.type) {
|
|
logVerboseMessage(
|
|
`mattermost: drop interaction dispatch (cannot resolve channel type for ${optsLocal.channelId})`,
|
|
);
|
|
return;
|
|
}
|
|
const kind = mapMattermostChannelTypeToChatType(channelInfo.type);
|
|
const chatType = channelChatType(kind);
|
|
const teamId = channelInfo?.team_id ?? undefined;
|
|
const channelName = channelInfo?.name ?? undefined;
|
|
const channelDisplay = channelInfo?.display_name ?? channelName ?? optsLocal.channelId;
|
|
const route = core.channel.routing.resolveAgentRoute({
|
|
cfg,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
teamId,
|
|
peer: {
|
|
kind,
|
|
id: kind === "direct" ? optsLocal.userId : optsLocal.channelId,
|
|
},
|
|
});
|
|
const replyToMode = resolveMattermostReplyToMode(account, kind);
|
|
const threadContext = resolveMattermostThreadSessionContext({
|
|
baseSessionKey: route.sessionKey,
|
|
kind,
|
|
postId: optsLocal.post.id || optsLocal.postId,
|
|
replyToMode,
|
|
threadRootId: optsLocal.post.root_id,
|
|
});
|
|
const to =
|
|
kind === "direct" ? `user:${optsLocal.userId}` : `channel:${optsLocal.channelId}`;
|
|
const bodyText = `[Button click: user @${optsLocal.userName} selected "${optsLocal.actionName}"]`;
|
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
Body: bodyText,
|
|
BodyForAgent: bodyText,
|
|
RawBody: bodyText,
|
|
CommandBody: bodyText,
|
|
From:
|
|
kind === "direct"
|
|
? `mattermost:${optsLocal.userId}`
|
|
: kind === "group"
|
|
? `mattermost:group:${optsLocal.channelId}`
|
|
: `mattermost:channel:${optsLocal.channelId}`,
|
|
To: to,
|
|
SessionKey: threadContext.sessionKey,
|
|
ParentSessionKey: threadContext.parentSessionKey,
|
|
AccountId: route.accountId,
|
|
ChatType: chatType,
|
|
ConversationLabel: `mattermost:${optsLocal.userName}`,
|
|
GroupSubject: kind !== "direct" ? channelDisplay : undefined,
|
|
GroupChannel: channelName ? `#${channelName}` : undefined,
|
|
GroupSpace: teamId,
|
|
SenderName: optsLocal.userName,
|
|
SenderId: optsLocal.userId,
|
|
Provider: "mattermost" as const,
|
|
Surface: "mattermost" as const,
|
|
MessageSid: `interaction:${optsLocal.postId}:${optsLocal.actionId}`,
|
|
ReplyToId: threadContext.effectiveReplyToId,
|
|
MessageThreadId: threadContext.effectiveReplyToId,
|
|
WasMentioned: true,
|
|
CommandAuthorized: false,
|
|
OriginatingChannel: "mattermost" as const,
|
|
OriginatingTo: to,
|
|
});
|
|
|
|
const textLimit = core.channel.text.resolveTextChunkLimit(
|
|
cfg,
|
|
"mattermost",
|
|
account.accountId,
|
|
{ fallbackLimit: account.textChunkLimit ?? 4000 },
|
|
);
|
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
cfg,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
});
|
|
const { onModelSelected, typingCallbacks, ...replyPipeline } =
|
|
createChannelMessageReplyPipeline({
|
|
cfg,
|
|
agentId: route.agentId,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
typing: {
|
|
start: () =>
|
|
sendTypingIndicator(optsLocal.channelId, threadContext.effectiveReplyToId),
|
|
onStartError: (err) => {
|
|
logTypingFailure({
|
|
log: (message) => logger.debug?.(message),
|
|
channel: "mattermost",
|
|
target: optsLocal.channelId,
|
|
error: err,
|
|
});
|
|
},
|
|
},
|
|
});
|
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
...replyPipeline,
|
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
deliver: async (payload: ReplyPayload) => {
|
|
await deliverMattermostReplyPayload({
|
|
core,
|
|
cfg,
|
|
payload,
|
|
to,
|
|
accountId: account.accountId,
|
|
agentId: route.agentId,
|
|
replyToId: resolveMattermostReplyRootId({
|
|
kind,
|
|
threadRootId: threadContext.effectiveReplyToId,
|
|
replyToId: payload.replyToId,
|
|
}),
|
|
textLimit,
|
|
tableMode,
|
|
sendMessage: sendMessageMattermost,
|
|
});
|
|
runtime.log?.(`delivered button-click reply to ${to}`);
|
|
},
|
|
onError: (err, info) => {
|
|
runtime.error?.(`mattermost button-click ${info.kind} reply failed: ${String(err)}`);
|
|
},
|
|
onReplyStart: typingCallbacks?.onReplyStart,
|
|
});
|
|
|
|
await core.channel.reply.dispatchReplyFromConfig({
|
|
ctx: ctxPayload,
|
|
cfg,
|
|
dispatcher,
|
|
replyOptions: {
|
|
...replyOptions,
|
|
disableBlockStreaming:
|
|
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
|
onModelSelected,
|
|
},
|
|
});
|
|
markDispatchIdle();
|
|
},
|
|
log: (msg) => runtime.log?.(msg),
|
|
}),
|
|
pluginId: "mattermost",
|
|
source: "mattermost-interactions",
|
|
accountId: account.accountId,
|
|
log: (msg: string) => runtime.log?.(msg),
|
|
});
|
|
|
|
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
|
const logVerboseMessage = (message: string) => {
|
|
if (!core.logging.shouldLogVerbose()) {
|
|
return;
|
|
}
|
|
logger.debug?.(message);
|
|
};
|
|
const mediaMaxBytes =
|
|
resolveChannelMediaMaxBytes({
|
|
cfg,
|
|
resolveChannelLimitMb: () => undefined,
|
|
accountId: account.accountId,
|
|
}) ?? 8 * 1024 * 1024;
|
|
const historyLimit = Math.max(
|
|
0,
|
|
cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
);
|
|
const channelHistories = new Map<string, HistoryEntry[]>();
|
|
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
const { groupPolicy, providerMissingFallbackApplied } =
|
|
resolveAllowlistProviderRuntimeGroupPolicy({
|
|
providerConfigPresent: cfg.channels?.mattermost !== undefined,
|
|
groupPolicy: account.config.groupPolicy,
|
|
defaultGroupPolicy,
|
|
});
|
|
warnMissingProviderGroupPolicyFallbackOnce({
|
|
providerMissingFallbackApplied,
|
|
providerKey: "mattermost",
|
|
accountId: account.accountId,
|
|
log: (message) => logVerboseMessage(message),
|
|
});
|
|
|
|
const {
|
|
resolveMattermostMedia,
|
|
sendTypingIndicator,
|
|
resolveChannelInfo,
|
|
resolveUserInfo,
|
|
updateModelPickerPost,
|
|
} = createMattermostMonitorResources({
|
|
accountId: account.accountId,
|
|
callbackUrl,
|
|
client,
|
|
logger: {
|
|
debug: (message) => logger.debug?.(String(message)),
|
|
},
|
|
mediaMaxBytes,
|
|
saveRemoteMedia: (params) => core.channel.media.saveRemoteMedia(params),
|
|
mediaKindFromMime: (contentType) => core.media.mediaKindFromMime(contentType) as MediaKind,
|
|
});
|
|
|
|
const runModelPickerCommand = async (params: {
|
|
commandText: string;
|
|
commandAuthorized: boolean;
|
|
route: ReturnType<typeof core.channel.routing.resolveAgentRoute>;
|
|
sessionKey: string;
|
|
parentSessionKey?: string;
|
|
channelId: string;
|
|
senderId: string;
|
|
senderName: string;
|
|
kind: ChatType;
|
|
chatType: "direct" | "group" | "channel";
|
|
channelName?: string;
|
|
channelDisplay?: string;
|
|
roomLabel: string;
|
|
teamId?: string;
|
|
postId: string;
|
|
messageSid?: string;
|
|
effectiveReplyToId?: string;
|
|
deliverReplies?: boolean;
|
|
}): Promise<string> => {
|
|
const to = params.kind === "direct" ? `user:${params.senderId}` : `channel:${params.channelId}`;
|
|
const fromLabel =
|
|
params.kind === "direct"
|
|
? `Mattermost DM from ${params.senderName}`
|
|
: `Mattermost message in ${params.roomLabel} from ${params.senderName}`;
|
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
Body: params.commandText,
|
|
BodyForAgent: params.commandText,
|
|
RawBody: params.commandText,
|
|
CommandBody: params.commandText,
|
|
From:
|
|
params.kind === "direct"
|
|
? `mattermost:${params.senderId}`
|
|
: params.kind === "group"
|
|
? `mattermost:group:${params.channelId}`
|
|
: `mattermost:channel:${params.channelId}`,
|
|
To: to,
|
|
SessionKey: params.sessionKey,
|
|
ParentSessionKey: params.parentSessionKey,
|
|
AccountId: params.route.accountId,
|
|
ChatType: params.chatType,
|
|
ConversationLabel: fromLabel,
|
|
GroupSubject:
|
|
params.kind !== "direct" ? params.channelDisplay || params.roomLabel : undefined,
|
|
GroupChannel: params.channelName ? `#${params.channelName}` : undefined,
|
|
GroupSpace: params.teamId,
|
|
SenderName: params.senderName,
|
|
SenderId: params.senderId,
|
|
Provider: "mattermost" as const,
|
|
Surface: "mattermost" as const,
|
|
MessageSid: params.messageSid ?? `interaction:${params.postId}:${Date.now()}`,
|
|
ReplyToId: params.effectiveReplyToId,
|
|
MessageThreadId: params.effectiveReplyToId,
|
|
Timestamp: Date.now(),
|
|
WasMentioned: true,
|
|
CommandAuthorized: params.commandAuthorized,
|
|
CommandSource: "native" as const,
|
|
OriginatingChannel: "mattermost" as const,
|
|
OriginatingTo: to,
|
|
});
|
|
|
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
cfg,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
});
|
|
const textLimit = core.channel.text.resolveTextChunkLimit(
|
|
cfg,
|
|
"mattermost",
|
|
account.accountId,
|
|
{
|
|
fallbackLimit: account.textChunkLimit ?? 4000,
|
|
},
|
|
);
|
|
const shouldDeliverReplies = params.deliverReplies === true;
|
|
const { onModelSelected, typingCallbacks, ...replyPipeline } =
|
|
createChannelMessageReplyPipeline({
|
|
cfg,
|
|
agentId: params.route.agentId,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
typing: shouldDeliverReplies
|
|
? {
|
|
start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId),
|
|
onStartError: (err) => {
|
|
logTypingFailure({
|
|
log: (message) => logger.debug?.(message),
|
|
channel: "mattermost",
|
|
target: params.channelId,
|
|
error: err,
|
|
});
|
|
},
|
|
}
|
|
: undefined,
|
|
});
|
|
const capturedTexts: string[] = [];
|
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
...replyPipeline,
|
|
// Picker-triggered confirmations should stay immediate.
|
|
deliver: async (payload: ReplyPayload) => {
|
|
const trimmedPayload = {
|
|
...payload,
|
|
text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode).trim(),
|
|
};
|
|
|
|
if (!shouldDeliverReplies) {
|
|
if (trimmedPayload.text) {
|
|
capturedTexts.push(trimmedPayload.text);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await deliverMattermostReplyPayload({
|
|
core,
|
|
cfg,
|
|
payload: trimmedPayload,
|
|
to,
|
|
accountId: account.accountId,
|
|
agentId: params.route.agentId,
|
|
replyToId: resolveMattermostReplyRootId({
|
|
kind: params.kind,
|
|
threadRootId: params.effectiveReplyToId,
|
|
replyToId: trimmedPayload.replyToId,
|
|
}),
|
|
textLimit,
|
|
// The picker path already converts and trims text before capture/delivery.
|
|
tableMode: "off",
|
|
sendMessage: sendMessageMattermost,
|
|
});
|
|
},
|
|
onError: (err, info) => {
|
|
runtime.error?.(`mattermost model picker ${info.kind} reply failed: ${String(err)}`);
|
|
},
|
|
onReplyStart: typingCallbacks?.onReplyStart,
|
|
});
|
|
|
|
await core.channel.reply.withReplyDispatcher({
|
|
dispatcher,
|
|
onSettled: () => {
|
|
markDispatchIdle();
|
|
},
|
|
run: () =>
|
|
core.channel.reply.dispatchReplyFromConfig({
|
|
ctx: ctxPayload,
|
|
cfg,
|
|
dispatcher,
|
|
replyOptions: {
|
|
...replyOptions,
|
|
disableBlockStreaming:
|
|
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
|
onModelSelected,
|
|
},
|
|
}),
|
|
});
|
|
|
|
return capturedTexts.join("\n\n").trim();
|
|
};
|
|
|
|
async function handleModelPickerInteraction(params: {
|
|
payload: {
|
|
channel_id: string;
|
|
post_id: string;
|
|
team_id?: string;
|
|
user_id: string;
|
|
};
|
|
userName: string;
|
|
context: Record<string, unknown>;
|
|
post: MattermostPost;
|
|
}): Promise<MattermostInteractionResponse | null> {
|
|
const pickerState = parseMattermostModelPickerContext(params.context);
|
|
if (!pickerState) {
|
|
return null;
|
|
}
|
|
|
|
if (pickerState.ownerUserId !== params.payload.user_id) {
|
|
return {
|
|
ephemeral_text: "Only the person who opened this picker can use it.",
|
|
};
|
|
}
|
|
|
|
const channelInfo = await resolveChannelInfo(params.payload.channel_id);
|
|
const pickerCommandText =
|
|
pickerState.action === "select"
|
|
? `/model ${pickerState.provider}/${pickerState.model}`
|
|
: pickerState.action === "list"
|
|
? `/models ${pickerState.provider}`
|
|
: "/models";
|
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
cfg,
|
|
surface: "mattermost",
|
|
});
|
|
const hasControlCommand = core.channel.text.hasControlCommand(pickerCommandText, cfg);
|
|
const auth = await authorizeMattermostCommandInvocation({
|
|
account,
|
|
cfg,
|
|
senderId: params.payload.user_id,
|
|
senderName: params.userName,
|
|
channelId: params.payload.channel_id,
|
|
channelInfo,
|
|
readStoreAllowFrom: pairing.readAllowFromStore,
|
|
allowTextCommands,
|
|
hasControlCommand,
|
|
});
|
|
if (!auth.ok) {
|
|
if (auth.denyReason === "dm-pairing") {
|
|
const { code } = await pairing.upsertPairingRequest({
|
|
id: params.payload.user_id,
|
|
meta: { name: params.userName },
|
|
});
|
|
return {
|
|
ephemeral_text: core.channel.pairing.buildPairingReply({
|
|
channel: "mattermost",
|
|
idLine: `Your Mattermost user id: ${params.payload.user_id}`,
|
|
code,
|
|
}),
|
|
};
|
|
}
|
|
const denyText =
|
|
auth.denyReason === "unknown-channel"
|
|
? "Temporary error: unable to determine channel type. Please try again."
|
|
: auth.denyReason === "dm-disabled"
|
|
? "This bot is not accepting direct messages."
|
|
: auth.denyReason === "channels-disabled"
|
|
? "Model picker actions are disabled in channels."
|
|
: auth.denyReason === "channel-no-allowlist"
|
|
? "Model picker actions are not configured for this channel."
|
|
: "Unauthorized.";
|
|
return {
|
|
ephemeral_text: denyText,
|
|
};
|
|
}
|
|
const kind = auth.kind;
|
|
const chatType = auth.chatType;
|
|
const teamId = auth.channelInfo.team_id ?? params.payload.team_id ?? undefined;
|
|
const channelName = auth.channelName || undefined;
|
|
const channelDisplay = auth.channelDisplay || auth.channelName || params.payload.channel_id;
|
|
const roomLabel = auth.roomLabel;
|
|
const route = core.channel.routing.resolveAgentRoute({
|
|
cfg,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
teamId,
|
|
peer: {
|
|
kind,
|
|
id: kind === "direct" ? params.payload.user_id : params.payload.channel_id,
|
|
},
|
|
});
|
|
const replyToMode = resolveMattermostReplyToMode(account, kind);
|
|
const threadContext = resolveMattermostThreadSessionContext({
|
|
baseSessionKey: route.sessionKey,
|
|
kind,
|
|
postId: params.post.id || params.payload.post_id,
|
|
replyToMode,
|
|
threadRootId: params.post.root_id,
|
|
});
|
|
const modelSessionRoute = {
|
|
agentId: route.agentId,
|
|
sessionKey: threadContext.sessionKey,
|
|
};
|
|
|
|
const data = await buildModelsProviderData(cfg, route.agentId);
|
|
if (data.providers.length === 0) {
|
|
return await updateModelPickerPost({
|
|
channelId: params.payload.channel_id,
|
|
postId: params.payload.post_id,
|
|
message: "No models available.",
|
|
});
|
|
}
|
|
|
|
if (pickerState.action === "providers" || pickerState.action === "back") {
|
|
const currentModel = resolveMattermostModelPickerCurrentModel({
|
|
cfg,
|
|
route: modelSessionRoute,
|
|
data,
|
|
});
|
|
const view = renderMattermostProviderPickerView({
|
|
ownerUserId: pickerState.ownerUserId,
|
|
data,
|
|
currentModel,
|
|
});
|
|
return await updateModelPickerPost({
|
|
channelId: params.payload.channel_id,
|
|
postId: params.payload.post_id,
|
|
message: view.text,
|
|
buttons: view.buttons,
|
|
});
|
|
}
|
|
|
|
if (pickerState.action === "list") {
|
|
const currentModel = resolveMattermostModelPickerCurrentModel({
|
|
cfg,
|
|
route: modelSessionRoute,
|
|
data,
|
|
});
|
|
const view = renderMattermostModelsPickerView({
|
|
ownerUserId: pickerState.ownerUserId,
|
|
data,
|
|
provider: pickerState.provider,
|
|
page: pickerState.page,
|
|
currentModel,
|
|
});
|
|
return await updateModelPickerPost({
|
|
channelId: params.payload.channel_id,
|
|
postId: params.payload.post_id,
|
|
message: view.text,
|
|
buttons: view.buttons,
|
|
});
|
|
}
|
|
|
|
const targetModelRef = `${pickerState.provider}/${pickerState.model}`;
|
|
if (!buildMattermostAllowedModelRefs(data).has(targetModelRef)) {
|
|
return {
|
|
ephemeral_text: `That model is no longer available: ${targetModelRef}`,
|
|
};
|
|
}
|
|
|
|
void (async () => {
|
|
try {
|
|
await runModelPickerCommand({
|
|
commandText: `/model ${targetModelRef}`,
|
|
commandAuthorized: auth.commandAuthorized,
|
|
route,
|
|
sessionKey: threadContext.sessionKey,
|
|
parentSessionKey: threadContext.parentSessionKey,
|
|
channelId: params.payload.channel_id,
|
|
senderId: params.payload.user_id,
|
|
senderName: params.userName,
|
|
kind,
|
|
chatType,
|
|
channelName,
|
|
channelDisplay,
|
|
roomLabel,
|
|
teamId,
|
|
postId: params.payload.post_id,
|
|
messageSid: buildMattermostModelPickerSelectMessageSid({
|
|
postId: params.payload.post_id,
|
|
provider: pickerState.provider,
|
|
model: pickerState.model,
|
|
}),
|
|
effectiveReplyToId: threadContext.effectiveReplyToId,
|
|
deliverReplies: true,
|
|
});
|
|
const updatedModel = resolveMattermostModelPickerCurrentModel({
|
|
cfg,
|
|
route: modelSessionRoute,
|
|
data,
|
|
skipCache: true,
|
|
});
|
|
const view = renderMattermostModelsPickerView({
|
|
ownerUserId: pickerState.ownerUserId,
|
|
data,
|
|
provider: pickerState.provider,
|
|
page: pickerState.page,
|
|
currentModel: updatedModel,
|
|
});
|
|
|
|
await updateModelPickerPost({
|
|
channelId: params.payload.channel_id,
|
|
postId: params.payload.post_id,
|
|
message: view.text,
|
|
buttons: view.buttons,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(`mattermost model picker select failed: ${String(err)}`);
|
|
}
|
|
})();
|
|
|
|
return {};
|
|
}
|
|
|
|
const handlePost = async (
|
|
post: MattermostPost,
|
|
payload: MattermostEventPayload,
|
|
messageIds?: string[],
|
|
) => {
|
|
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
|
|
if (!channelId) {
|
|
logVerboseMessage("mattermost: drop post (missing channel id)");
|
|
return;
|
|
}
|
|
|
|
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
|
|
if (allMessageIds.length === 0) {
|
|
logVerboseMessage("mattermost: drop post (missing message id)");
|
|
return;
|
|
}
|
|
const replayResult = await processMattermostReplayGuardedPost({
|
|
accountId: account.accountId,
|
|
messageIds: allMessageIds,
|
|
handlePost: async () => {
|
|
const senderId = post.user_id ?? payload.broadcast?.user_id;
|
|
if (!senderId) {
|
|
logVerboseMessage("mattermost: drop post (missing sender id)");
|
|
return;
|
|
}
|
|
if (senderId === botUserId) {
|
|
logVerboseMessage(`mattermost: drop post (self sender=${senderId})`);
|
|
return;
|
|
}
|
|
if (isSystemPost(post)) {
|
|
logVerboseMessage(`mattermost: drop post (system post type=${post.type ?? "unknown"})`);
|
|
return;
|
|
}
|
|
|
|
const channelInfo = await resolveChannelInfo(channelId);
|
|
const channelType =
|
|
normalizeOptionalString(channelInfo?.type) ??
|
|
normalizeOptionalString(payload.data?.channel_type);
|
|
if (!channelType) {
|
|
logVerboseMessage(`mattermost: drop post (cannot resolve channel type for ${channelId})`);
|
|
return;
|
|
}
|
|
const kind = resolveMattermostTrustedChatKind({
|
|
channelType,
|
|
});
|
|
const chatType = channelChatType(kind);
|
|
|
|
const senderName =
|
|
normalizeOptionalString(payload.data?.sender_name) ??
|
|
normalizeOptionalString((await resolveUserInfo(senderId))?.username) ??
|
|
senderId;
|
|
const rawText = normalizeOptionalString(post.message) ?? "";
|
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
cfg,
|
|
surface: "mattermost",
|
|
});
|
|
const isControlCommand =
|
|
allowTextCommands && core.channel.commands.isControlCommandMessage(rawText, cfg);
|
|
const accessDecision = await resolveMattermostMonitorInboundAccess({
|
|
account,
|
|
cfg,
|
|
senderId,
|
|
senderName,
|
|
channelId,
|
|
kind,
|
|
groupPolicy,
|
|
readStoreAllowFrom: pairing.readAllowFromStore,
|
|
allowTextCommands,
|
|
hasControlCommand: isControlCommand,
|
|
eventKind: "message",
|
|
mayPair: true,
|
|
});
|
|
const commandAuthorized = accessDecision.commandAccess.authorized;
|
|
|
|
if (accessDecision.ingress.decision !== "allow") {
|
|
if (kind === "direct") {
|
|
if (accessDecision.ingress.reasonCode === "dm_policy_disabled") {
|
|
logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`);
|
|
return;
|
|
}
|
|
if (accessDecision.ingress.decision === "pairing") {
|
|
const { code, created } = await pairing.upsertPairingRequest({
|
|
id: senderId,
|
|
meta: { name: senderName },
|
|
});
|
|
logVerboseMessage(
|
|
`mattermost: pairing request sender=${senderId} created=${created}`,
|
|
);
|
|
if (created) {
|
|
try {
|
|
await sendMessageMattermost(
|
|
`user:${senderId}`,
|
|
core.channel.pairing.buildPairingReply({
|
|
channel: "mattermost",
|
|
idLine: `Your Mattermost user id: ${senderId}`,
|
|
code,
|
|
}),
|
|
{ cfg, accountId: account.accountId },
|
|
);
|
|
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
|
} catch (err) {
|
|
logVerboseMessage(
|
|
`mattermost: pairing reply failed for ${senderId}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`);
|
|
return;
|
|
}
|
|
if (accessDecision.ingress.reasonCode === "group_policy_disabled") {
|
|
logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)");
|
|
return;
|
|
}
|
|
if (accessDecision.ingress.reasonCode === "group_policy_empty_allowlist") {
|
|
logVerboseMessage("mattermost: drop group message (no group allowlist)");
|
|
return;
|
|
}
|
|
if (accessDecision.ingress.reasonCode === "group_policy_not_allowlisted") {
|
|
logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`);
|
|
return;
|
|
}
|
|
logVerboseMessage(
|
|
`mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.senderAccess.reasonCode})`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (kind !== "direct" && accessDecision.commandAccess.shouldBlockControlCommand) {
|
|
logInboundDrop({
|
|
log: logVerboseMessage,
|
|
channel: "mattermost",
|
|
reason: "control command (unauthorized)",
|
|
target: senderId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
|
|
const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
|
|
const channelDisplay =
|
|
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
|
|
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
|
|
|
const route = core.channel.routing.resolveAgentRoute({
|
|
cfg,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
teamId,
|
|
peer: {
|
|
kind,
|
|
id: kind === "direct" ? senderId : channelId,
|
|
},
|
|
});
|
|
|
|
const baseSessionKey = route.sessionKey;
|
|
const threadRootId = normalizeOptionalString(post.root_id);
|
|
const replyToMode = resolveMattermostReplyToMode(account, kind);
|
|
const threadContext = resolveMattermostThreadSessionContext({
|
|
baseSessionKey,
|
|
kind,
|
|
postId: post.id,
|
|
replyToMode,
|
|
threadRootId,
|
|
});
|
|
const { effectiveReplyToId, sessionKey, parentSessionKey } = threadContext;
|
|
const historyKey = kind === "direct" ? null : sessionKey;
|
|
|
|
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
|
const wasMentioned =
|
|
kind !== "direct" &&
|
|
((botUsername
|
|
? normalizeLowercaseStringOrEmpty(rawText).includes(
|
|
`@${normalizeLowercaseStringOrEmpty(botUsername)}`,
|
|
)
|
|
: false) ||
|
|
core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes));
|
|
const pendingBody =
|
|
rawText ||
|
|
(post.file_ids?.length
|
|
? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
|
|
: "");
|
|
const pendingSender = senderName;
|
|
const recordPendingHistory = () => {
|
|
const trimmed = pendingBody.trim();
|
|
createChannelHistoryWindow({ historyMap: channelHistories }).record({
|
|
limit: historyLimit,
|
|
historyKey: historyKey ?? "",
|
|
entry:
|
|
historyKey && trimmed
|
|
? {
|
|
sender: pendingSender,
|
|
body: trimmed,
|
|
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
|
messageId: post.id ?? undefined,
|
|
}
|
|
: null,
|
|
});
|
|
};
|
|
|
|
const oncharEnabled = account.chatmode === "onchar" && kind !== "direct";
|
|
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
|
|
const oncharResult = oncharEnabled
|
|
? stripOncharPrefix(rawText, oncharPrefixes)
|
|
: { triggered: false, stripped: rawText };
|
|
const oncharTriggered = oncharResult.triggered;
|
|
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
|
|
const mentionDecision = evaluateMattermostMentionGate({
|
|
kind,
|
|
cfg,
|
|
accountId: account.accountId,
|
|
channelId,
|
|
threadRootId,
|
|
requireMentionOverride: account.requireMention,
|
|
resolveRequireMention: core.channel.groups.resolveRequireMention,
|
|
wasMentioned,
|
|
isControlCommand,
|
|
commandAuthorized,
|
|
oncharEnabled,
|
|
oncharTriggered,
|
|
canDetectMention,
|
|
});
|
|
const { shouldRequireMention, shouldBypassMention } = mentionDecision;
|
|
|
|
if (mentionDecision.dropReason === "onchar-not-triggered") {
|
|
logVerboseMessage(
|
|
`mattermost: drop group message (onchar not triggered channel=${channelId} sender=${senderId})`,
|
|
);
|
|
recordPendingHistory();
|
|
return;
|
|
}
|
|
|
|
if (mentionDecision.dropReason === "missing-mention") {
|
|
logVerboseMessage(
|
|
`mattermost: drop group message (missing mention channel=${channelId} sender=${senderId} requireMention=${shouldRequireMention} bypass=${shouldBypassMention} canDetectMention=${canDetectMention})`,
|
|
);
|
|
recordPendingHistory();
|
|
return;
|
|
}
|
|
const mediaList = await resolveMattermostMedia(post.file_ids);
|
|
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
|
|
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
|
|
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
|
|
const bodyText = normalizeMention(baseText, botUsername);
|
|
if (!bodyText) {
|
|
logVerboseMessage(
|
|
`mattermost: drop group message (empty body after normalization channel=${channelId} sender=${senderId})`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
core.channel.activity.record({
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
direction: "inbound",
|
|
});
|
|
|
|
const fromLabel = formatInboundFromLabel({
|
|
isGroup: kind !== "direct",
|
|
groupLabel: channelDisplay || roomLabel,
|
|
groupId: channelId,
|
|
groupFallback: roomLabel || "Channel",
|
|
directLabel: senderName,
|
|
directId: senderId,
|
|
});
|
|
|
|
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
|
const body = core.channel.reply.formatInboundEnvelope({
|
|
channel: "Mattermost",
|
|
from: fromLabel,
|
|
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
|
body: textWithId,
|
|
chatType,
|
|
sender: { name: senderName, id: senderId },
|
|
});
|
|
let combinedBody = body;
|
|
if (historyKey) {
|
|
const channelHistory = createChannelHistoryWindow({ historyMap: channelHistories });
|
|
combinedBody = channelHistory.buildPendingContext({
|
|
historyKey,
|
|
limit: historyLimit,
|
|
currentMessage: combinedBody,
|
|
formatEntry: (entry) =>
|
|
core.channel.reply.formatInboundEnvelope({
|
|
channel: "Mattermost",
|
|
from: fromLabel,
|
|
timestamp: entry.timestamp,
|
|
body: `${entry.body}${
|
|
entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : ""
|
|
}`,
|
|
chatType,
|
|
senderLabel: entry.sender,
|
|
}),
|
|
});
|
|
}
|
|
|
|
const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`;
|
|
const mediaPayload = buildAgentMediaPayload(mediaList);
|
|
const commandBody = rawText.trim();
|
|
const inboundHistory =
|
|
historyKey && historyLimit > 0
|
|
? createChannelHistoryWindow({ historyMap: channelHistories }).buildInboundHistory({
|
|
historyKey,
|
|
limit: historyLimit,
|
|
})
|
|
: undefined;
|
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
Body: combinedBody,
|
|
BodyForAgent: bodyText,
|
|
InboundHistory: inboundHistory,
|
|
RawBody: bodyText,
|
|
CommandBody: commandBody,
|
|
BodyForCommands: commandBody,
|
|
From:
|
|
kind === "direct"
|
|
? `mattermost:${senderId}`
|
|
: kind === "group"
|
|
? `mattermost:group:${channelId}`
|
|
: `mattermost:channel:${channelId}`,
|
|
To: to,
|
|
SessionKey: sessionKey,
|
|
ParentSessionKey: parentSessionKey,
|
|
AccountId: route.accountId,
|
|
ChatType: chatType,
|
|
ConversationLabel: fromLabel,
|
|
GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined,
|
|
GroupChannel: channelName ? `#${channelName}` : undefined,
|
|
GroupSpace: teamId,
|
|
SenderName: senderName,
|
|
SenderId: senderId,
|
|
Provider: "mattermost" as const,
|
|
Surface: "mattermost" as const,
|
|
MessageSid: post.id ?? undefined,
|
|
MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined,
|
|
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
|
|
MessageSidLast:
|
|
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
|
|
ReplyToId: effectiveReplyToId,
|
|
MessageThreadId: effectiveReplyToId,
|
|
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
|
WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined,
|
|
CommandAuthorized: commandAuthorized,
|
|
// Tag typed text-slash control commands (e.g. ` /new`, ` /reset` sent via the regular
|
|
// post path rather than Mattermost's native slash UI) so the explicit-command turn
|
|
// exception in source-reply-delivery-mode.ts surfaces their acknowledgements under
|
|
// message_tool_only delivery modes (e.g. Codex harness DMs). Mirrors iMessage #82642.
|
|
CommandSource: commandAuthorized && isControlCommand ? ("text" as const) : undefined,
|
|
OriginatingChannel: "mattermost" as const,
|
|
OriginatingTo: to,
|
|
...mediaPayload,
|
|
});
|
|
const pinnedMainDmOwner =
|
|
kind === "direct"
|
|
? resolvePinnedMainDmOwnerFromAllowlist({
|
|
dmScope: cfg.session?.dmScope,
|
|
allowFrom: account.config.allowFrom,
|
|
normalizeEntry: normalizeMattermostAllowEntry,
|
|
})
|
|
: null;
|
|
|
|
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
agentId: route.agentId,
|
|
});
|
|
|
|
const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
|
logVerboseMessage(
|
|
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
|
);
|
|
|
|
const textLimit = core.channel.text.resolveTextChunkLimit(
|
|
cfg,
|
|
"mattermost",
|
|
account.accountId,
|
|
{
|
|
fallbackLimit: account.textChunkLimit ?? 4000,
|
|
},
|
|
);
|
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
cfg,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
});
|
|
|
|
const { onModelSelected, typingCallbacks, ...replyPipeline } =
|
|
createChannelMessageReplyPipeline({
|
|
cfg,
|
|
agentId: route.agentId,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
typing: {
|
|
start: () => sendTypingIndicator(channelId, effectiveReplyToId),
|
|
onStartError: (err) => {
|
|
logTypingFailure({
|
|
log: (message) => logger.debug?.(message),
|
|
channel: "mattermost",
|
|
target: channelId,
|
|
error: err,
|
|
});
|
|
},
|
|
},
|
|
});
|
|
const draftPreviewEnabled = account.streamingMode !== "off";
|
|
const draftToolProgressEnabled = shouldUpdateMattermostDraftToolProgress(account);
|
|
const suppressDefaultToolProgressMessages =
|
|
shouldSuppressMattermostDefaultToolProgressMessages(account);
|
|
const draftStream = draftPreviewEnabled
|
|
? createMattermostDraftStream({
|
|
client,
|
|
channelId,
|
|
rootId: effectiveReplyToId,
|
|
throttleMs: 1200,
|
|
log: logVerboseMessage,
|
|
warn: logVerboseMessage,
|
|
})
|
|
: createDisabledMattermostDraftStream();
|
|
let lastPartialText = "";
|
|
const previewState: MattermostDraftPreviewState = {
|
|
finalizedViaPreviewPost: false,
|
|
};
|
|
|
|
const resolvePreviewFinalText = (text?: string) => {
|
|
if (typeof text !== "string") {
|
|
return undefined;
|
|
}
|
|
const formatted = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
const chunkMode = core.channel.text.resolveChunkMode(
|
|
cfg,
|
|
"mattermost",
|
|
account.accountId,
|
|
);
|
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
|
formatted,
|
|
textLimit,
|
|
chunkMode,
|
|
);
|
|
if (!chunks.length && formatted) {
|
|
chunks.push(formatted);
|
|
}
|
|
if (chunks.length != 1) {
|
|
return undefined;
|
|
}
|
|
const trimmed = chunks[0]?.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
lastPartialText &&
|
|
lastPartialText.startsWith(trimmed) &&
|
|
trimmed.length < lastPartialText.length
|
|
) {
|
|
return undefined;
|
|
}
|
|
return trimmed;
|
|
};
|
|
|
|
const updateDraftFromPartial = (text?: string) => {
|
|
const cleaned = text?.trim();
|
|
if (!cleaned) {
|
|
return;
|
|
}
|
|
if (cleaned === lastPartialText) {
|
|
return;
|
|
}
|
|
if (
|
|
lastPartialText &&
|
|
lastPartialText.startsWith(cleaned) &&
|
|
cleaned.length < lastPartialText.length
|
|
) {
|
|
return;
|
|
}
|
|
lastPartialText = cleaned;
|
|
draftStream.update(cleaned);
|
|
};
|
|
|
|
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
|
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
...replyPipeline,
|
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
typingCallbacks,
|
|
deliver: async (payloadEntry: ReplyPayload, info) => {
|
|
await deliverMattermostReplyWithDraftPreview({
|
|
payload: payloadEntry,
|
|
info,
|
|
kind,
|
|
client,
|
|
draftStream,
|
|
effectiveReplyToId,
|
|
resolvePreviewFinalText,
|
|
previewState,
|
|
logVerboseMessage,
|
|
deliverPayload: async (payloadToDeliver) => {
|
|
const outcome = await deliverMattermostReplyPayload({
|
|
core,
|
|
cfg,
|
|
payload: payloadToDeliver,
|
|
to,
|
|
accountId: account.accountId,
|
|
agentId: route.agentId,
|
|
replyToId: resolveMattermostReplyRootId({
|
|
kind,
|
|
threadRootId: effectiveReplyToId,
|
|
replyToId: payloadToDeliver.replyToId,
|
|
}),
|
|
textLimit,
|
|
tableMode,
|
|
sendMessage: sendMessageMattermost,
|
|
});
|
|
const deliveryLog = formatMattermostFinalDeliveryOutcomeLog({
|
|
outcome,
|
|
payload: payloadToDeliver,
|
|
to,
|
|
accountId: account.accountId,
|
|
agentId: route.agentId,
|
|
});
|
|
if (deliveryLog) {
|
|
runtime.log?.(deliveryLog);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
onError: (err, info) => {
|
|
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
|
|
},
|
|
});
|
|
|
|
const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
|
|
route,
|
|
sessionKey: route.sessionKey,
|
|
});
|
|
|
|
let dispatchSettledBeforeStart = false;
|
|
try {
|
|
await core.channel.inbound.run({
|
|
channel: "mattermost",
|
|
accountId: route.accountId,
|
|
raw: post,
|
|
adapter: {
|
|
ingest: () => ({
|
|
id: post.id ?? `${to}:${Date.now()}`,
|
|
timestamp: post.create_at ?? undefined,
|
|
rawText,
|
|
textForAgent: ctxPayload.BodyForAgent,
|
|
textForCommands: ctxPayload.CommandBody,
|
|
raw: post,
|
|
}),
|
|
resolveTurn: () => ({
|
|
channel: "mattermost",
|
|
accountId: route.accountId,
|
|
routeSessionKey: route.sessionKey,
|
|
storePath,
|
|
ctxPayload,
|
|
recordInboundSession: core.channel.session.recordInboundSession,
|
|
record: {
|
|
updateLastRoute:
|
|
kind === "direct"
|
|
? {
|
|
sessionKey: inboundLastRouteSessionKey,
|
|
channel: "mattermost",
|
|
to,
|
|
accountId: route.accountId,
|
|
mainDmOwnerPin:
|
|
inboundLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner
|
|
? {
|
|
ownerRecipient: pinnedMainDmOwner,
|
|
senderRecipient: normalizeMattermostAllowEntry(senderId),
|
|
onSkip: ({
|
|
ownerRecipient,
|
|
senderRecipient,
|
|
}: {
|
|
ownerRecipient: string;
|
|
senderRecipient: string;
|
|
}) => {
|
|
logVerboseMessage(
|
|
`mattermost: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
|
);
|
|
},
|
|
}
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
onRecordError: (err) => {
|
|
logVerboseMessage(
|
|
`mattermost: failed updating session meta id=${post.id ?? "unknown"}: ${String(err)}`,
|
|
);
|
|
},
|
|
},
|
|
history: {
|
|
isGroup: Boolean(historyKey),
|
|
historyKey: historyKey ?? undefined,
|
|
historyMap: channelHistories,
|
|
limit: historyLimit,
|
|
},
|
|
onPreDispatchFailure: async () => {
|
|
dispatchSettledBeforeStart = true;
|
|
await core.channel.reply.settleReplyDispatcher({
|
|
dispatcher,
|
|
onSettled: () => {
|
|
markRunComplete();
|
|
markDispatchIdle();
|
|
},
|
|
});
|
|
},
|
|
runDispatch: () =>
|
|
core.channel.reply.withReplyDispatcher({
|
|
dispatcher,
|
|
onSettled: () => {
|
|
markDispatchIdle();
|
|
},
|
|
run: () =>
|
|
core.channel.reply.dispatchReplyFromConfig({
|
|
ctx: ctxPayload,
|
|
cfg,
|
|
dispatcher,
|
|
replyOptions: {
|
|
...replyOptions,
|
|
disableBlockStreaming: true,
|
|
...(suppressDefaultToolProgressMessages
|
|
? { suppressDefaultToolProgressMessages: true }
|
|
: {}),
|
|
onModelSelected,
|
|
onPartialReply: (payloadResult) => {
|
|
if (account.streamingMode !== "progress") {
|
|
updateDraftFromPartial(payloadResult.text);
|
|
}
|
|
},
|
|
onAssistantMessageStart: () => {
|
|
lastPartialText = "";
|
|
},
|
|
onReasoningEnd: () => {
|
|
lastPartialText = "";
|
|
},
|
|
onReasoningStream: async () => {
|
|
if (!lastPartialText) {
|
|
draftStream.update("Thinking…");
|
|
}
|
|
},
|
|
onToolStart: async (payloadValue) => {
|
|
if (!draftToolProgressEnabled) {
|
|
return;
|
|
}
|
|
draftStream.update(
|
|
buildMattermostToolStatusText({
|
|
...payloadValue,
|
|
config: account.config,
|
|
}),
|
|
);
|
|
},
|
|
onItemEvent: async (payloadLocal) => {
|
|
if (!draftToolProgressEnabled) {
|
|
return;
|
|
}
|
|
const progressText = formatChannelProgressDraftLineForEntry(
|
|
account.config,
|
|
{
|
|
event: "item",
|
|
itemId: payloadLocal.itemId,
|
|
itemKind: payloadLocal.kind,
|
|
title: payloadLocal.title,
|
|
name: payloadLocal.name,
|
|
phase: payloadLocal.phase,
|
|
status: payloadLocal.status,
|
|
summary: payloadLocal.summary,
|
|
progressText: payloadLocal.progressText,
|
|
meta: payloadLocal.meta,
|
|
},
|
|
);
|
|
if (progressText) {
|
|
draftStream.update(progressText);
|
|
}
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
}),
|
|
},
|
|
});
|
|
} finally {
|
|
try {
|
|
await draftStream.stop();
|
|
} catch (err) {
|
|
logVerboseMessage(`mattermost draft preview cleanup failed: ${String(err)}`);
|
|
}
|
|
if (!dispatchSettledBeforeStart) {
|
|
markRunComplete();
|
|
}
|
|
}
|
|
},
|
|
});
|
|
if (replayResult === "duplicate") {
|
|
logVerboseMessage(
|
|
`mattermost: drop post (dedupe account=${account.accountId} ids=${allMessageIds.length})`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleReactionEvent = async (payload: MattermostEventPayload) => {
|
|
const reactionData = payload.data?.reaction;
|
|
if (!reactionData) {
|
|
return;
|
|
}
|
|
let reaction: MattermostReaction | null = null;
|
|
if (typeof reactionData === "string") {
|
|
try {
|
|
reaction = JSON.parse(reactionData) as MattermostReaction;
|
|
} catch {
|
|
return;
|
|
}
|
|
} else if (typeof reactionData === "object") {
|
|
reaction = reactionData as MattermostReaction;
|
|
}
|
|
if (!reaction) {
|
|
return;
|
|
}
|
|
|
|
const userId = reaction.user_id?.trim();
|
|
const postId = reaction.post_id?.trim();
|
|
const emojiName = reaction.emoji_name?.trim();
|
|
if (!userId || !postId || !emojiName) {
|
|
return;
|
|
}
|
|
|
|
// Skip reactions from the bot itself
|
|
if (userId === botUserId) {
|
|
return;
|
|
}
|
|
|
|
const isRemoved = payload.event === "reaction_removed";
|
|
const action = isRemoved ? "removed" : "added";
|
|
|
|
const senderInfo = await resolveUserInfo(userId);
|
|
const senderName = normalizeOptionalString(senderInfo?.username) ?? userId;
|
|
|
|
// Resolve the channel from broadcast or post to route to the correct agent session
|
|
const channelId = resolveMattermostReactionChannelId(payload);
|
|
if (!channelId) {
|
|
// Without a channel id we cannot verify DM/group policies — drop to be safe
|
|
logVerboseMessage(
|
|
`mattermost: drop reaction (no channel_id in broadcast, cannot enforce policy)`,
|
|
);
|
|
return;
|
|
}
|
|
const channelInfo = await resolveChannelInfo(channelId);
|
|
if (!channelInfo?.type) {
|
|
// Cannot determine channel type — drop to avoid policy bypass
|
|
logVerboseMessage(`mattermost: drop reaction (cannot resolve channel type for ${channelId})`);
|
|
return;
|
|
}
|
|
const kind = mapMattermostChannelTypeToChatType(channelInfo.type);
|
|
|
|
// Enforce DM/group policy and allowlist checks (same as normal messages).
|
|
const reactionAccess = await resolveMattermostMonitorInboundAccess({
|
|
account,
|
|
cfg,
|
|
senderId: userId,
|
|
senderName,
|
|
channelId,
|
|
kind,
|
|
groupPolicy,
|
|
readStoreAllowFrom: pairing.readAllowFromStore,
|
|
allowTextCommands: false,
|
|
hasControlCommand: false,
|
|
eventKind: "reaction",
|
|
mayPair: false,
|
|
});
|
|
if (reactionAccess.ingress.decision !== "allow") {
|
|
if (kind === "direct") {
|
|
logVerboseMessage(
|
|
`mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.senderAccess.reasonCode})`,
|
|
);
|
|
} else {
|
|
logVerboseMessage(
|
|
`mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.senderAccess.reasonCode} channel=${channelId})`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const teamId = channelInfo?.team_id ?? undefined;
|
|
const route = core.channel.routing.resolveAgentRoute({
|
|
cfg,
|
|
channel: "mattermost",
|
|
accountId: account.accountId,
|
|
teamId,
|
|
peer: {
|
|
kind,
|
|
id: kind === "direct" ? userId : channelId,
|
|
},
|
|
});
|
|
const sessionKey = route.sessionKey;
|
|
|
|
const eventText = `Mattermost reaction ${action}: :${emojiName}: by @${senderName} on post ${postId} in channel ${channelId}`;
|
|
|
|
core.system.enqueueSystemEvent(eventText, {
|
|
sessionKey,
|
|
contextKey: `mattermost:reaction:${postId}:${emojiName}:${userId}:${action}`,
|
|
});
|
|
|
|
logVerboseMessage(
|
|
`mattermost reaction: ${action} :${emojiName}: by ${senderName} on ${postId}`,
|
|
);
|
|
};
|
|
|
|
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
|
cfg,
|
|
channel: "mattermost",
|
|
});
|
|
const debouncer = core.channel.debounce.createInboundDebouncer<{
|
|
post: MattermostPost;
|
|
payload: MattermostEventPayload;
|
|
}>({
|
|
debounceMs: inboundDebounceMs,
|
|
buildKey: (entry) => {
|
|
const channelId =
|
|
entry.post.channel_id ??
|
|
entry.payload.data?.channel_id ??
|
|
entry.payload.broadcast?.channel_id;
|
|
if (!channelId) {
|
|
return null;
|
|
}
|
|
const threadId = normalizeOptionalString(entry.post.root_id);
|
|
const threadKey = threadId ? `thread:${threadId}` : "channel";
|
|
return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
|
|
},
|
|
shouldDebounce: (entry) => {
|
|
if (entry.post.file_ids && entry.post.file_ids.length > 0) {
|
|
return false;
|
|
}
|
|
const text = normalizeOptionalString(entry.post.message) ?? "";
|
|
if (!text) {
|
|
return false;
|
|
}
|
|
return !core.channel.commands.isControlCommandMessage(text, cfg);
|
|
},
|
|
onFlush: async (entries) => {
|
|
const last = entries.at(-1);
|
|
if (!last) {
|
|
return;
|
|
}
|
|
if (entries.length === 1) {
|
|
await handlePost(last.post, last.payload);
|
|
return;
|
|
}
|
|
const combinedText = entries
|
|
.map((entry) => normalizeOptionalString(entry.post.message) ?? "")
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
const mergedPost: MattermostPost = {
|
|
...last.post,
|
|
message: combinedText,
|
|
file_ids: [],
|
|
};
|
|
const ids = entries.map((entry) => entry.post.id).filter(Boolean);
|
|
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
|
|
},
|
|
onError: (err) => {
|
|
runtime.error?.(`mattermost debounce flush failed: ${String(err)}`);
|
|
},
|
|
});
|
|
|
|
const wsUrl = buildMattermostWsUrl(baseUrl);
|
|
let seq = 1;
|
|
const connectOnce = createMattermostConnectOnce({
|
|
wsUrl,
|
|
botToken,
|
|
abortSignal: opts.abortSignal,
|
|
statusSink: opts.statusSink,
|
|
runtime,
|
|
webSocketFactory: opts.webSocketFactory,
|
|
nextSeq: () => seq++,
|
|
getBotUpdateAt: async () => {
|
|
const me = await fetchMattermostMe(client);
|
|
return me.update_at ?? 0;
|
|
},
|
|
onPosted: async (post, payload) => {
|
|
await debouncer.enqueue({ post, payload });
|
|
},
|
|
onReaction: async (payload) => {
|
|
await handleReactionEvent(payload);
|
|
},
|
|
});
|
|
|
|
let slashShutdownCleanup: Promise<void> | null = null;
|
|
|
|
// Clean up slash commands on shutdown
|
|
if (slashEnabled) {
|
|
const runAbortCleanup = () => {
|
|
if (slashShutdownCleanup) {
|
|
return;
|
|
}
|
|
// Snapshot registered commands before deactivating state.
|
|
// This listener may run concurrently with startup in a new process, so we keep
|
|
// monitor shutdown alive until the remote cleanup completes.
|
|
const commands = getSlashCommandState(account.accountId)?.registeredCommands ?? [];
|
|
// Deactivate state immediately to prevent new local dispatches during teardown.
|
|
deactivateSlashCommands(account.accountId);
|
|
|
|
slashShutdownCleanup = cleanupSlashCommands({
|
|
client,
|
|
commands,
|
|
log: (msg) => runtime.log?.(msg),
|
|
}).catch((err: unknown) => {
|
|
runtime.error?.(`mattermost: slash cleanup failed: ${String(err)}`);
|
|
});
|
|
};
|
|
|
|
if (opts.abortSignal?.aborted) {
|
|
runAbortCleanup();
|
|
} else {
|
|
opts.abortSignal?.addEventListener("abort", runAbortCleanup, { once: true });
|
|
}
|
|
}
|
|
|
|
try {
|
|
await runWithReconnect(connectOnce, {
|
|
abortSignal: opts.abortSignal,
|
|
jitterRatio: 0.2,
|
|
onError: (err) => {
|
|
runtime.error?.(`mattermost connection failed: ${String(err)}`);
|
|
opts.statusSink?.({ lastError: String(err), connected: false });
|
|
},
|
|
onReconnect: (delayMs) => {
|
|
runtime.log?.(`mattermost reconnecting in ${Math.round(delayMs / 1000)}s`);
|
|
},
|
|
});
|
|
} finally {
|
|
unregisterInteractions?.();
|
|
}
|
|
|
|
const slashShutdownCleanupPromise = slashShutdownCleanup;
|
|
if (slashShutdownCleanupPromise) {
|
|
await Promise.resolve(slashShutdownCleanupPromise);
|
|
}
|
|
}
|