Mattermost: add interactive model picker (#38767)

Merged via squash.

Prepared head SHA: 0883654e88
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
Muhammed Mukhthar CM
2026-03-07 21:45:29 +05:30
committed by GitHub
parent 33e7394861
commit 4f08dcccfd
23 changed files with 1867 additions and 290 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind.
- Config/Compaction safeguard tuning: expose `agents.defaults.compaction.recentTurnsPreserve` and quality-guard retry knobs through the validated config surface and embedded-runner wiring, with regression coverage for real config loading and schema metadata. (#25557) thanks @rodrigouroz.
- iOS/App Store Connect release prep: align iOS bundle identifiers under `ai.openclaw.client`, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman.
- Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
### Breaking

View File

@@ -26,21 +26,14 @@ import {
listMattermostDirectoryGroups,
listMattermostDirectoryPeers,
} from "./mattermost/directory.js";
import {
buildButtonAttachments,
resolveInteractionCallbackUrl,
setInteractionSecret,
} from "./mattermost/interactions.js";
import { monitorMattermostProvider } from "./mattermost/monitor.js";
import { probeMattermost } from "./mattermost/probe.js";
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
import { resolveMattermostSendChannelId, sendMessageMattermost } from "./mattermost/send.js";
import { sendMessageMattermost } from "./mattermost/send.js";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
import { mattermostOnboardingAdapter } from "./onboarding.js";
import { getMattermostRuntime } from "./runtime.js";
const SIGNED_CHANNEL_ID_CONTEXT_KEY = "__openclaw_channel_id";
const mattermostMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const enabledAccounts = listMattermostAccountIds(cfg)
@@ -162,61 +155,14 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
const replyToId = typeof params.replyToId === "string" ? params.replyToId : undefined;
const resolvedAccountId = accountId || undefined;
// Build props with button attachments if buttons are provided
let props: Record<string, unknown> | undefined;
if (params.buttons && Array.isArray(params.buttons)) {
const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId });
if (account.botToken) setInteractionSecret(account.accountId, account.botToken);
const channelId = await resolveMattermostSendChannelId(to, {
cfg,
accountId: account.accountId,
});
const callbackUrl = resolveInteractionCallbackUrl(account.accountId, {
gateway: cfg.gateway,
interactions: account.config.interactions,
});
// Flatten 2D array (rows of buttons) to 1D — core schema sends Array<Array<Button>>
// but Mattermost doesn't have row layout, so we flatten all rows into a single list.
// Also supports 1D arrays for backward compatibility.
const rawButtons = (params.buttons as Array<unknown>).flatMap((item) =>
Array.isArray(item) ? item : [item],
) as Array<Record<string, unknown>>;
const buttons = rawButtons
.map((btn) => ({
id: String(btn.id ?? btn.callback_data ?? ""),
name: String(btn.text ?? btn.name ?? btn.label ?? ""),
style: (btn.style as "default" | "primary" | "danger") ?? "default",
context:
typeof btn.context === "object" && btn.context !== null
? {
...(btn.context as Record<string, unknown>),
[SIGNED_CHANNEL_ID_CONTEXT_KEY]: channelId,
}
: { [SIGNED_CHANNEL_ID_CONTEXT_KEY]: channelId },
}))
.filter((btn) => btn.id && btn.name);
const attachmentText =
typeof params.attachmentText === "string" ? params.attachmentText : undefined;
props = {
attachments: buildButtonAttachments({
callbackUrl,
accountId: account.accountId,
buttons,
text: attachmentText,
}),
};
}
const mediaUrl =
typeof params.media === "string" ? params.media.trim() || undefined : undefined;
const result = await sendMessageMattermost(to, message, {
accountId: resolvedAccountId,
replyToId,
props,
buttons: Array.isArray(params.buttons) ? params.buttons : undefined,
attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined,
mediaUrl,
});

View File

@@ -53,6 +53,7 @@ const MattermostAccountSchemaBase = z
interactions: z
.object({
callbackBaseUrl: z.string().optional(),
allowedSourceIps: z.array(z.string()).optional(),
})
.optional(),
})

View File

@@ -1,5 +1,5 @@
import { type IncomingMessage, type ServerResponse } from "node:http";
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { setMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import type { MattermostClient } from "./client.js";
@@ -109,6 +109,53 @@ describe("generateInteractionToken / verifyInteractionToken", () => {
expect(verifyInteractionToken(reorderedContext, token)).toBe(true);
});
it("verifies nested context regardless of nested key order", () => {
const originalContext = {
action_id: "nested",
payload: {
model: "gpt-5",
meta: {
provider: "openai",
page: 2,
},
},
};
const token = generateInteractionToken(originalContext);
const reorderedContext = {
payload: {
meta: {
page: 2,
provider: "openai",
},
model: "gpt-5",
},
action_id: "nested",
};
expect(verifyInteractionToken(reorderedContext, token)).toBe(true);
});
it("rejects nested context tampering", () => {
const originalContext = {
action_id: "nested",
payload: {
provider: "openai",
model: "gpt-5",
},
};
const token = generateInteractionToken(originalContext);
const tamperedContext = {
action_id: "nested",
payload: {
provider: "anthropic",
model: "gpt-5",
},
};
expect(verifyInteractionToken(tamperedContext, token)).toBe(false);
});
it("scopes tokens per account when account secrets differ", () => {
setInteractionSecret("acct-a", "bot-token-a");
setInteractionSecret("acct-b", "bot-token-b");
@@ -400,12 +447,14 @@ describe("createMattermostInteractionHandler", () => {
method?: string;
body?: unknown;
remoteAddress?: string;
headers?: Record<string, string>;
}): IncomingMessage {
const body = params.body === undefined ? "" : JSON.stringify(params.body);
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const req = {
method: params.method ?? "POST",
headers: params.headers ?? {},
socket: { remoteAddress: params.remoteAddress ?? "203.0.113.10" },
on(event: string, handler: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
@@ -447,7 +496,7 @@ describe("createMattermostInteractionHandler", () => {
return res as unknown as ServerResponse & { headers: Record<string, string>; body: string };
}
it("accepts non-localhost requests when the interaction token is valid", async () => {
it("accepts callback requests from an allowlisted source IP", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const requestLog: Array<{ path: string; method?: string }> = [];
@@ -469,6 +518,7 @@ describe("createMattermostInteractionHandler", () => {
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
allowedSourceIps: ["198.51.100.8"],
});
const req = createReq({
@@ -493,6 +543,80 @@ describe("createMattermostInteractionHandler", () => {
]);
});
it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const handler = createMattermostInteractionHandler({
client: {
request: async (_path: string, init?: { method?: string }) => {
if (init?.method === "PUT") {
return { id: "post-1" };
}
return {
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
},
};
},
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
allowedSourceIps: ["198.51.100.8"],
trustedProxies: ["127.0.0.1"],
});
const req = createReq({
remoteAddress: "127.0.0.1",
headers: { "x-forwarded-for": "198.51.100.8" },
body: {
user_id: "user-1",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
});
it("rejects callback requests from non-allowlisted source IPs", async () => {
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const handler = createMattermostInteractionHandler({
client: {
request: async () => {
throw new Error("should not fetch post for rejected origins");
},
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
allowedSourceIps: ["127.0.0.1"],
});
const req = createReq({
remoteAddress: "198.51.100.8",
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Forbidden origin");
});
it("rejects requests with an invalid interaction token", async () => {
const handler = createMattermostInteractionHandler({
client: {
@@ -610,4 +734,62 @@ describe("createMattermostInteractionHandler", () => {
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Unknown action");
});
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" };
const token = generateInteractionToken(context, "acct");
const requestLog: Array<{ path: string; method?: string }> = [];
const handleInteraction = vi.fn().mockResolvedValue({
ephemeral_text: "Only the original requester can use this picker.",
});
const dispatchButtonClick = vi.fn();
const handler = createMattermostInteractionHandler({
client: {
request: async (path: string, init?: { method?: string }) => {
requestLog.push({ path, method: init?.method });
return {
channel_id: "chan-1",
message: "Choose",
props: {
attachments: [{ actions: [{ id: "mdlprov", name: "Browse providers" }] }],
},
};
},
} as unknown as MattermostClient,
botUserId: "bot",
accountId: "acct",
handleInteraction,
dispatchButtonClick,
});
const req = createReq({
body: {
user_id: "user-2",
user_name: "alice",
channel_id: "chan-1",
post_id: "post-1",
context: { ...context, _token: token },
},
});
const res = createRes();
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toBe(
JSON.stringify({
ephemeral_text: "Only the original requester can use this picker.",
}),
);
expect(requestLog).toEqual([{ path: "/posts/post-1", method: undefined }]);
expect(handleInteraction).toHaveBeenCalledWith(
expect.objectContaining({
actionId: "mdlprov",
actionName: "Browse providers",
originalMessage: "Choose",
userName: "alice",
}),
);
expect(dispatchButtonClick).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,10 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import {
isTrustedProxyAddress,
resolveClientIp,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
import { getMattermostRuntime } from "../runtime.js";
import { updateMattermostPost, type MattermostClient } from "./client.js";
@@ -33,6 +37,16 @@ export type MattermostInteractionResponse = {
ephemeral_text?: string;
};
export type MattermostInteractiveButtonInput = {
id?: string;
callback_data?: string;
text?: string;
name?: string;
label?: string;
style?: "default" | "primary" | "danger";
context?: Record<string, unknown>;
};
// ── Callback URL registry ──────────────────────────────────────────────
const callbackUrls = new Map<string, string>();
@@ -66,6 +80,34 @@ function normalizeCallbackBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, "");
}
function headerValue(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) {
return value[0]?.trim() || undefined;
}
return value?.trim() || undefined;
}
function isAllowedInteractionSource(params: {
req: IncomingMessage;
allowedSourceIps?: string[];
trustedProxies?: string[];
allowRealIpFallback?: boolean;
}): boolean {
const { allowedSourceIps } = params;
if (!allowedSourceIps?.length) {
return true;
}
const clientIp = resolveClientIp({
remoteAddr: params.req.socket?.remoteAddress,
forwardedFor: headerValue(params.req.headers["x-forwarded-for"]),
realIp: headerValue(params.req.headers["x-real-ip"]),
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
});
return isTrustedProxyAddress(clientIp, allowedSourceIps);
}
/**
* Resolve the interaction callback URL for an account.
* Falls back to computing it from interactions.callbackBaseUrl or gateway host config.
@@ -152,13 +194,26 @@ export function getInteractionSecret(accountId?: string): string {
);
}
function canonicalizeInteractionContext(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => canonicalizeInteractionContext(item));
}
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => [key, canonicalizeInteractionContext(entryValue)]);
return Object.fromEntries(entries);
}
return value;
}
export function generateInteractionToken(
context: Record<string, unknown>,
accountId?: string,
): string {
const secret = getInteractionSecret(accountId);
// Sort keys for stable serialization — Mattermost may reorder context keys
const payload = JSON.stringify(context, Object.keys(context).sort());
const payload = JSON.stringify(canonicalizeInteractionContext(context));
return createHmac("sha256", secret).update(payload).digest("hex");
}
@@ -251,6 +306,46 @@ export function buildButtonAttachments(params: {
];
}
export function buildButtonProps(params: {
callbackUrl: string;
accountId?: string;
channelId: string;
buttons: Array<unknown>;
text?: string;
}): Record<string, unknown> | undefined {
const rawButtons = params.buttons.flatMap((item) =>
Array.isArray(item) ? item : [item],
) as MattermostInteractiveButtonInput[];
const buttons = rawButtons
.map((btn) => ({
id: String(btn.id ?? btn.callback_data ?? "").trim(),
name: String(btn.text ?? btn.name ?? btn.label ?? "").trim(),
style: btn.style ?? "default",
context:
typeof btn.context === "object" && btn.context !== null
? {
...btn.context,
[SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId,
}
: { [SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId },
}))
.filter((btn) => btn.id && btn.name);
if (buttons.length === 0) {
return undefined;
}
return {
attachments: buildButtonAttachments({
callbackUrl: params.callbackUrl,
accountId: params.accountId,
buttons,
text: params.text,
}),
};
}
// ── Request body reader ────────────────────────────────────────────────
function readInteractionBody(req: IncomingMessage): Promise<string> {
@@ -292,7 +387,18 @@ export function createMattermostInteractionHandler(params: {
client: MattermostClient;
botUserId: string;
accountId: string;
allowedSourceIps?: string[];
trustedProxies?: string[];
allowRealIpFallback?: boolean;
resolveSessionKey?: (channelId: string, userId: string) => Promise<string>;
handleInteraction?: (opts: {
payload: MattermostInteractionPayload;
userName: string;
actionId: string;
actionName: string;
originalMessage: string;
context: Record<string, unknown>;
}) => Promise<MattermostInteractionResponse | null>;
dispatchButtonClick?: (opts: {
channelId: string;
userId: string;
@@ -316,6 +422,23 @@ export function createMattermostInteractionHandler(params: {
return;
}
if (
!isAllowedInteractionSource({
req,
allowedSourceIps: params.allowedSourceIps,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
})
) {
log?.(
`mattermost interaction: rejected callback source remote=${req.socket?.remoteAddress ?? "?"}`,
);
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Forbidden origin" }));
return;
}
let payload: MattermostInteractionPayload;
try {
const raw = await readInteractionBody(req);
@@ -432,6 +555,31 @@ export function createMattermostInteractionHandler(params: {
`post=${payload.post_id} channel=${payload.channel_id}`,
);
if (params.handleInteraction) {
try {
const response = await params.handleInteraction({
payload,
userName,
actionId,
actionName: clickedButtonName,
originalMessage,
context: contextWithoutToken,
});
if (response !== null) {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(response));
return;
}
} catch (err) {
log?.(`mattermost interaction: custom handler failed: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Interaction handler failed" }));
return;
}
}
// Dispatch as system event so the agent can handle it.
// Wrapped in try/catch — the post update below must still run even if
// system event dispatch fails (e.g. missing sessionKey or channel lookup).

View File

@@ -0,0 +1,155 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import { buildModelsProviderData } from "openclaw/plugin-sdk/mattermost";
import { describe, expect, it } from "vitest";
import {
buildMattermostAllowedModelRefs,
parseMattermostModelPickerContext,
renderMattermostModelSummaryView,
renderMattermostModelsPickerView,
renderMattermostProviderPickerView,
resolveMattermostModelPickerCurrentModel,
resolveMattermostModelPickerEntry,
} from "./model-picker.js";
const data = {
byProvider: new Map<string, Set<string>>([
["anthropic", new Set(["claude-opus-4-5", "claude-sonnet-4-5"])],
["openai", new Set(["gpt-4.1", "gpt-5"])],
]),
providers: ["anthropic", "openai"],
resolvedDefault: {
provider: "anthropic",
model: "claude-opus-4-5",
},
};
describe("Mattermost model picker", () => {
it("resolves bare /model and /models entry points", () => {
expect(resolveMattermostModelPickerEntry("/model")).toEqual({ kind: "summary" });
expect(resolveMattermostModelPickerEntry("/models")).toEqual({ kind: "providers" });
expect(resolveMattermostModelPickerEntry("/models OpenAI")).toEqual({
kind: "models",
provider: "openai",
});
expect(resolveMattermostModelPickerEntry("/model openai/gpt-5")).toBeNull();
});
it("builds the allowed model refs set", () => {
expect(buildMattermostAllowedModelRefs(data)).toEqual(
new Set([
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-5",
"openai/gpt-4.1",
"openai/gpt-5",
]),
);
});
it("renders the summary view with a browse button", () => {
const view = renderMattermostModelSummaryView({
ownerUserId: "user-1",
currentModel: "openai/gpt-5",
});
expect(view.text).toContain("Current: openai/gpt-5");
expect(view.text).toContain("Tap below to browse models");
expect(view.text).toContain("/oc_model <provider/model> to switch");
expect(view.buttons[0]?.[0]?.text).toBe("Browse providers");
});
it("renders providers and models with Telegram-style navigation", () => {
const providersView = renderMattermostProviderPickerView({
ownerUserId: "user-1",
data,
currentModel: "openai/gpt-5",
});
const providerTexts = providersView.buttons.flat().map((button) => button.text);
expect(providerTexts).toContain("anthropic (2)");
expect(providerTexts).toContain("openai (2)");
const modelsView = renderMattermostModelsPickerView({
ownerUserId: "user-1",
data,
provider: "openai",
page: 1,
currentModel: "openai/gpt-5",
});
const modelTexts = modelsView.buttons.flat().map((button) => button.text);
expect(modelsView.text).toContain("Models (openai) - 2 available");
expect(modelTexts).toContain("gpt-5 [current]");
expect(modelTexts).toContain("Back to providers");
});
it("renders unique alphanumeric action ids per button", () => {
const modelsView = renderMattermostModelsPickerView({
ownerUserId: "user-1",
data,
provider: "openai",
page: 1,
currentModel: "openai/gpt-5",
});
const ids = modelsView.buttons.flat().map((button) => button.id);
expect(ids.every((id) => typeof id === "string" && /^[a-z0-9]+$/.test(id))).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
});
it("parses signed picker contexts", () => {
expect(
parseMattermostModelPickerContext({
oc_model_picker: true,
action: "select",
ownerUserId: "user-1",
provider: "openai",
page: 2,
model: "gpt-5",
}),
).toEqual({
action: "select",
ownerUserId: "user-1",
provider: "openai",
page: 2,
model: "gpt-5",
});
expect(parseMattermostModelPickerContext({ action: "select" })).toBeNull();
});
it("falls back to the routed agent default model when no override is stored", async () => {
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-"));
try {
const cfg: OpenClawConfig = {
session: {
store: path.join(testDir, "{agentId}.json"),
},
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
},
list: [
{
id: "support",
model: "openai/gpt-5",
},
],
},
};
const providerData = await buildModelsProviderData(cfg, "support");
expect(
resolveMattermostModelPickerCurrentModel({
cfg,
route: {
agentId: "support",
sessionKey: "agent:support:main",
},
data: providerData,
}),
).toBe("openai/gpt-5");
} finally {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,383 @@
import { createHash } from "node:crypto";
import {
loadSessionStore,
normalizeProviderId,
resolveStorePath,
resolveStoredModelOverride,
type ModelsProviderData,
type OpenClawConfig,
} from "openclaw/plugin-sdk/mattermost";
import type { MattermostInteractiveButtonInput } from "./interactions.js";
const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker";
const MODELS_PAGE_SIZE = 8;
const ACTION_IDS = {
providers: "mdlprov",
list: "mdllist",
select: "mdlsel",
back: "mdlback",
} as const;
export type MattermostModelPickerEntry =
| { kind: "summary" }
| { kind: "providers" }
| { kind: "models"; provider: string };
export type MattermostModelPickerState =
| { action: "providers"; ownerUserId: string }
| { action: "back"; ownerUserId: string }
| { action: "list"; ownerUserId: string; provider: string; page: number }
| { action: "select"; ownerUserId: string; provider: string; page: number; model: string };
export type MattermostModelPickerRenderedView = {
text: string;
buttons: MattermostInteractiveButtonInput[][];
};
function splitModelRef(modelRef?: string | null): { provider: string; model: string } | null {
const trimmed = modelRef?.trim();
if (!trimmed) {
return null;
}
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) {
return null;
}
const provider = normalizeProviderId(trimmed.slice(0, slashIndex));
const model = trimmed.slice(slashIndex + 1).trim();
if (!provider || !model) {
return null;
}
return { provider, model };
}
function normalizePage(value: number | undefined): number {
if (!Number.isFinite(value)) {
return 1;
}
return Math.max(1, Math.floor(value as number));
}
function paginateItems<T>(items: T[], page?: number, pageSize = MODELS_PAGE_SIZE) {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const safePage = Math.max(1, Math.min(normalizePage(page), totalPages));
const start = (safePage - 1) * pageSize;
return {
items: items.slice(start, start + pageSize),
page: safePage,
totalPages,
hasPrev: safePage > 1,
hasNext: safePage < totalPages,
totalItems: items.length,
};
}
function buildContext(state: MattermostModelPickerState): Record<string, unknown> {
return {
[MATTERMOST_MODEL_PICKER_CONTEXT_KEY]: true,
...state,
};
}
function buildButtonId(state: MattermostModelPickerState): string {
const digest = createHash("sha256").update(JSON.stringify(state)).digest("hex").slice(0, 12);
return `${ACTION_IDS[state.action]}${digest}`;
}
function buildButton(params: {
action: MattermostModelPickerState["action"];
ownerUserId: string;
text: string;
provider?: string;
page?: number;
model?: string;
style?: "default" | "primary" | "danger";
}): MattermostInteractiveButtonInput {
const baseState =
params.action === "providers" || params.action === "back"
? {
action: params.action,
ownerUserId: params.ownerUserId,
}
: params.action === "list"
? {
action: "list" as const,
ownerUserId: params.ownerUserId,
provider: normalizeProviderId(params.provider ?? ""),
page: normalizePage(params.page),
}
: {
action: "select" as const,
ownerUserId: params.ownerUserId,
provider: normalizeProviderId(params.provider ?? ""),
page: normalizePage(params.page),
model: String(params.model ?? "").trim(),
};
return {
// Mattermost requires action IDs to be unique within a post.
id: buildButtonId(baseState),
text: params.text,
...(params.style ? { style: params.style } : {}),
context: buildContext(baseState),
};
}
function getProviderModels(data: ModelsProviderData, provider: string): string[] {
return [...(data.byProvider.get(normalizeProviderId(provider)) ?? new Set<string>())].toSorted();
}
function formatCurrentModelLine(currentModel?: string): string {
const parsed = splitModelRef(currentModel);
if (!parsed) {
return "Current: default";
}
return `Current: ${parsed.provider}/${parsed.model}`;
}
export function resolveMattermostModelPickerEntry(
commandText: string,
): MattermostModelPickerEntry | null {
const normalized = commandText.trim().replace(/\s+/g, " ");
if (/^\/model$/i.test(normalized)) {
return { kind: "summary" };
}
if (/^\/models$/i.test(normalized)) {
return { kind: "providers" };
}
const providerMatch = normalized.match(/^\/models\s+(\S+)$/i);
if (!providerMatch?.[1]) {
return null;
}
return {
kind: "models",
provider: normalizeProviderId(providerMatch[1]),
};
}
export function parseMattermostModelPickerContext(
context: Record<string, unknown>,
): MattermostModelPickerState | null {
if (!context || context[MATTERMOST_MODEL_PICKER_CONTEXT_KEY] !== true) {
return null;
}
const ownerUserId = String(context.ownerUserId ?? "").trim();
const action = String(context.action ?? "").trim();
if (!ownerUserId) {
return null;
}
if (action === "providers" || action === "back") {
return { action, ownerUserId };
}
const provider = normalizeProviderId(String(context.provider ?? ""));
const page = Number.parseInt(String(context.page ?? "1"), 10);
if (!provider) {
return null;
}
if (action === "list") {
return {
action,
ownerUserId,
provider,
page: normalizePage(page),
};
}
if (action === "select") {
const model = String(context.model ?? "").trim();
if (!model) {
return null;
}
return {
action,
ownerUserId,
provider,
page: normalizePage(page),
model,
};
}
return null;
}
export function buildMattermostAllowedModelRefs(data: ModelsProviderData): Set<string> {
const refs = new Set<string>();
for (const provider of data.providers) {
for (const model of data.byProvider.get(provider) ?? []) {
refs.add(`${provider}/${model}`);
}
}
return refs;
}
export function resolveMattermostModelPickerCurrentModel(params: {
cfg: OpenClawConfig;
route: { agentId: string; sessionKey: string };
data: ModelsProviderData;
skipCache?: boolean;
}): string {
const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
try {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
const sessionStore = params.skipCache
? loadSessionStore(storePath, { skipCache: true })
: loadSessionStore(storePath);
const sessionEntry = sessionStore[params.route.sessionKey];
const override = resolveStoredModelOverride({
sessionEntry,
sessionStore,
sessionKey: params.route.sessionKey,
});
if (!override?.model) {
return fallback;
}
const provider = (override.provider || params.data.resolvedDefault.provider).trim();
return provider ? `${provider}/${override.model}` : fallback;
} catch {
return fallback;
}
}
export function renderMattermostModelSummaryView(params: {
ownerUserId: string;
currentModel?: string;
}): MattermostModelPickerRenderedView {
return {
text: [
formatCurrentModelLine(params.currentModel),
"",
"Tap below to browse models, or use:",
"/oc_model <provider/model> to switch",
"/oc_model status for details",
].join("\n"),
buttons: [
[
buildButton({
action: "providers",
ownerUserId: params.ownerUserId,
text: "Browse providers",
style: "primary",
}),
],
],
};
}
export function renderMattermostProviderPickerView(params: {
ownerUserId: string;
data: ModelsProviderData;
currentModel?: string;
}): MattermostModelPickerRenderedView {
const currentProvider = splitModelRef(params.currentModel)?.provider;
const rows = params.data.providers.map((provider) => [
buildButton({
action: "list",
ownerUserId: params.ownerUserId,
text: `${provider} (${params.data.byProvider.get(provider)?.size ?? 0})`,
provider,
page: 1,
style: provider === currentProvider ? "primary" : "default",
}),
]);
return {
text: [formatCurrentModelLine(params.currentModel), "", "Select a provider:"].join("\n"),
buttons: rows,
};
}
export function renderMattermostModelsPickerView(params: {
ownerUserId: string;
data: ModelsProviderData;
provider: string;
page?: number;
currentModel?: string;
}): MattermostModelPickerRenderedView {
const provider = normalizeProviderId(params.provider);
const models = getProviderModels(params.data, provider);
const current = splitModelRef(params.currentModel);
if (models.length === 0) {
return {
text: [formatCurrentModelLine(params.currentModel), "", `Unknown provider: ${provider}`].join(
"\n",
),
buttons: [
[
buildButton({
action: "back",
ownerUserId: params.ownerUserId,
text: "Back to providers",
}),
],
],
};
}
const page = paginateItems(models, params.page);
const rows: MattermostInteractiveButtonInput[][] = page.items.map((model) => {
const isCurrent = current?.provider === provider && current.model === model;
return [
buildButton({
action: "select",
ownerUserId: params.ownerUserId,
text: isCurrent ? `${model} [current]` : model,
provider,
model,
page: page.page,
style: isCurrent ? "primary" : "default",
}),
];
});
const navRow: MattermostInteractiveButtonInput[] = [];
if (page.hasPrev) {
navRow.push(
buildButton({
action: "list",
ownerUserId: params.ownerUserId,
text: "Prev",
provider,
page: page.page - 1,
}),
);
}
if (page.hasNext) {
navRow.push(
buildButton({
action: "list",
ownerUserId: params.ownerUserId,
text: "Next",
provider,
page: page.page + 1,
}),
);
}
if (navRow.length > 0) {
rows.push(navRow);
}
rows.push([
buildButton({
action: "back",
ownerUserId: params.ownerUserId,
text: "Back to providers",
}),
]);
return {
text: [
`Models (${provider}) - ${page.totalItems} available`,
formatCurrentModelLine(params.currentModel),
`Page ${page.page}/${page.totalPages}`,
"Select a model to switch immediately.",
].join("\n"),
buttons: rows,
};
}

View File

@@ -1,7 +1,12 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import {
isDangerousNameMatchingEnabled,
resolveAllowlistMatchSimple,
resolveControlCommandGate,
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/mattermost";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostChannel } from "./client.js";
export function normalizeMattermostAllowEntry(entry: string): string {
const trimmed = entry.trim();
@@ -59,3 +64,239 @@ export function isMattermostSenderAllowed(params: {
});
return match.allowed;
}
function mapMattermostChannelKind(channelType?: string | null): "direct" | "group" | "channel" {
const normalized = channelType?.trim().toUpperCase();
if (normalized === "D") {
return "direct";
}
if (normalized === "G" || normalized === "P") {
return "group";
}
return "channel";
}
export type MattermostCommandAuthDecision =
| {
ok: true;
commandAuthorized: boolean;
channelInfo: MattermostChannel;
kind: "direct" | "group" | "channel";
chatType: "direct" | "group" | "channel";
channelName: string;
channelDisplay: string;
roomLabel: string;
}
| {
ok: false;
denyReason:
| "unknown-channel"
| "dm-disabled"
| "dm-pairing"
| "unauthorized"
| "channels-disabled"
| "channel-no-allowlist";
commandAuthorized: false;
channelInfo: MattermostChannel | null;
kind: "direct" | "group" | "channel";
chatType: "direct" | "group" | "channel";
channelName: string;
channelDisplay: string;
roomLabel: string;
};
export function authorizeMattermostCommandInvocation(params: {
account: ResolvedMattermostAccount;
cfg: OpenClawConfig;
senderId: string;
senderName: string;
channelId: string;
channelInfo: MattermostChannel | null;
storeAllowFrom?: Array<string | number> | null;
allowTextCommands: boolean;
hasControlCommand: boolean;
}): MattermostCommandAuthDecision {
const {
account,
cfg,
senderId,
senderName,
channelId,
channelInfo,
storeAllowFrom,
allowTextCommands,
hasControlCommand,
} = params;
if (!channelInfo) {
return {
ok: false,
denyReason: "unknown-channel",
commandAuthorized: false,
channelInfo: null,
kind: "channel",
chatType: "channel",
channelName: "",
channelDisplay: "",
roomLabel: `#${channelId}`,
};
}
const kind = mapMattermostChannelKind(channelInfo.type);
const chatType = kind;
const channelName = channelInfo.name ?? "";
const channelDisplay = channelInfo.display_name ?? channelName;
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []);
const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []);
const normalizedStoreAllowFrom = normalizeMattermostAllowList(storeAllowFrom ?? []);
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({
allowFrom: configAllowFrom,
groupAllowFrom: configGroupAllowFrom,
storeAllowFrom: normalizedStoreAllowFrom,
dmPolicy,
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom;
const commandGroupAllowFrom =
kind === "direct"
? effectiveGroupAllowFrom
: configGroupAllowFrom.length > 0
? configGroupAllowFrom
: configAllowFrom;
const senderAllowedForCommands = isMattermostSenderAllowed({
senderId,
senderName,
allowFrom: commandDmAllowFrom,
allowNameMatching,
});
const groupAllowedForCommands = isMattermostSenderAllowed({
senderId,
senderName,
allowFrom: commandGroupAllowFrom,
allowNameMatching,
});
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: commandGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand: allowTextCommands && hasControlCommand,
});
const commandAuthorized =
kind === "direct"
? dmPolicy === "open" || senderAllowedForCommands
: commandGate.commandAuthorized;
if (kind === "direct") {
if (dmPolicy === "disabled") {
return {
ok: false,
denyReason: "dm-disabled",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (dmPolicy !== "open" && !senderAllowedForCommands) {
return {
ok: false,
denyReason: dmPolicy === "pairing" ? "dm-pairing" : "unauthorized",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
} else {
if (groupPolicy === "disabled") {
return {
ok: false,
denyReason: "channels-disabled",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
return {
ok: false,
denyReason: "channel-no-allowlist",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (!groupAllowedForCommands) {
return {
ok: false,
denyReason: "unauthorized",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
}
if (commandGate.shouldBlock) {
return {
ok: false,
denyReason: "unauthorized",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
}
return {
ok: true,
commandAuthorized,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}

View File

@@ -1,6 +1,20 @@
import { resolveControlCommandGate } from "openclaw/plugin-sdk/mattermost";
import { describe, expect, it } from "vitest";
import { resolveMattermostEffectiveAllowFromLists } from "./monitor-auth.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
authorizeMattermostCommandInvocation,
resolveMattermostEffectiveAllowFromLists,
} from "./monitor-auth.js";
const accountFixture: ResolvedMattermostAccount = {
accountId: "default",
enabled: true,
botToken: "bot-token",
baseUrl: "https://chat.example.com",
botTokenSource: "config",
baseUrlSource: "config",
config: {},
};
describe("mattermost monitor authz", () => {
it("keeps DM allowlist merged with pairing-store entries", () => {
@@ -56,4 +70,74 @@ describe("mattermost monitor authz", () => {
expect(commandGate.commandAuthorized).toBe(false);
});
it("denies group control commands when the sender is outside the allowlist", () => {
const decision = authorizeMattermostCommandInvocation({
account: {
...accountFixture,
config: {
groupPolicy: "allowlist",
allowFrom: ["trusted-user"],
},
},
cfg: {
commands: {
useAccessGroups: true,
},
},
senderId: "attacker",
senderName: "attacker",
channelId: "chan-1",
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
storeAllowFrom: [],
allowTextCommands: true,
hasControlCommand: true,
});
expect(decision).toMatchObject({
ok: false,
denyReason: "unauthorized",
kind: "channel",
});
});
it("authorizes group control commands for allowlisted senders", () => {
const decision = authorizeMattermostCommandInvocation({
account: {
...accountFixture,
config: {
groupPolicy: "allowlist",
allowFrom: ["trusted-user"],
},
},
cfg: {
commands: {
useAccessGroups: true,
},
},
senderId: "trusted-user",
senderName: "trusted-user",
channelId: "chan-1",
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
storeAllowFrom: [],
allowTextCommands: true,
hasControlCommand: true,
});
expect(decision).toMatchObject({
ok: true,
commandAuthorized: true,
kind: "channel",
});
});
});

View File

@@ -7,6 +7,7 @@ import type {
} from "openclaw/plugin-sdk/mattermost";
import {
buildAgentMediaPayload,
buildModelsProviderData,
DM_GROUP_ACCESS_REASON,
createScopedPairingAccess,
createReplyPrefixOptions,
@@ -39,18 +40,32 @@ import {
fetchMattermostUserTeams,
normalizeMattermostBaseUrl,
sendMattermostTyping,
updateMattermostPost,
type MattermostChannel,
type MattermostPost,
type MattermostUser,
} from "./client.js";
import {
buildButtonProps,
computeInteractionCallbackUrl,
createMattermostInteractionHandler,
resolveInteractionCallbackPath,
setInteractionCallbackUrl,
setInteractionSecret,
type MattermostInteractionResponse,
} from "./interactions.js";
import { isMattermostSenderAllowed, normalizeMattermostAllowList } from "./monitor-auth.js";
import {
buildMattermostAllowedModelRefs,
parseMattermostModelPickerContext,
renderMattermostModelsPickerView,
renderMattermostProviderPickerView,
resolveMattermostModelPickerCurrentModel,
} from "./model-picker.js";
import {
authorizeMattermostCommandInvocation,
isMattermostSenderAllowed,
normalizeMattermostAllowList,
} from "./monitor-auth.js";
import {
createDedupeCache,
formatInboundFromLabel,
@@ -106,6 +121,10 @@ function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function normalizeInteractionSourceIps(values?: string[]): string[] {
return (values ?? []).map((value) => value.trim()).filter(Boolean);
}
const recentInboundMessages = createDedupeCache({
ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS,
maxSize: RECENT_MATTERMOST_MESSAGE_MAX,
@@ -463,6 +482,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
interactions: account.config.interactions,
});
setInteractionCallbackUrl(account.accountId, callbackUrl);
const allowedInteractionSourceIps = normalizeInteractionSourceIps(
account.config.interactions?.allowedSourceIps,
);
try {
const mmHost = new URL(baseUrl).hostname;
@@ -472,10 +494,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
`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",
@@ -484,6 +514,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
client,
botUserId,
accountId: account.accountId,
allowedSourceIps: effectiveInteractionSourceIps,
trustedProxies: cfg.gateway?.trustedProxies,
allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true,
handleInteraction: handleModelPickerInteraction,
resolveSessionKey: async (channelId: string, userId: string) => {
const channelInfo = await resolveChannelInfo(channelId);
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
@@ -766,6 +800,394 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
};
const buildModelPickerProps = (
channelId: string,
buttons: Array<unknown>,
): Record<string, unknown> | undefined =>
buildButtonProps({
callbackUrl,
accountId: account.accountId,
channelId,
buttons,
});
const updateModelPickerPost = async (params: {
channelId: string;
postId: string;
message: string;
buttons?: Array<unknown>;
}): Promise<MattermostInteractionResponse> => {
const props = buildModelPickerProps(params.channelId, params.buttons ?? []) ?? {
attachments: [],
};
await updateMattermostPost(client, params.postId, {
message: params.message,
props,
});
return {};
};
const runModelPickerCommand = async (params: {
commandText: string;
commandAuthorized: boolean;
route: ReturnType<typeof core.channel.routing.resolveAgentRoute>;
channelId: string;
senderId: string;
senderName: string;
kind: ChatType;
chatType: "direct" | "group" | "channel";
channelName?: string;
channelDisplay?: string;
roomLabel: string;
teamId?: string;
postId: 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.route.sessionKey,
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: `interaction:${params.postId}:${Date.now()}`,
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 { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: params.route.agentId,
channel: "mattermost",
accountId: account.accountId,
});
const shouldDeliverReplies = params.deliverReplies === true;
const capturedTexts: string[] = [];
const typingCallbacks = shouldDeliverReplies
? createTypingCallbacks({
start: () => sendTypingIndicator(params.channelId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
channel: "mattermost",
target: params.channelId,
error: err,
});
},
})
: undefined;
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...prefixOptions,
// Picker-triggered confirmations should stay immediate.
deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = core.channel.text
.convertMarkdownTables(payload.text ?? "", tableMode)
.trim();
if (!shouldDeliverReplies) {
if (text) {
capturedTexts.push(text);
}
return;
}
if (mediaUrls.length === 0) {
const chunkMode = core.channel.text.resolveChunkMode(
cfg,
"mattermost",
account.accountId,
);
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
for (const chunk of chunks.length > 0 ? chunks : [text]) {
if (!chunk) {
continue;
}
await sendMessageMattermost(to, chunk, {
accountId: account.accountId,
});
}
return;
}
let first = true;
for (const mediaUrl of mediaUrls) {
const caption = first ? text : "";
first = false;
await sendMessageMattermost(to, caption, {
accountId: account.accountId,
mediaUrl,
});
}
},
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>;
}): 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 dmPolicy = account.config.dmPolicy ?? "pairing";
const storeAllowFrom = normalizeMattermostAllowList(
await readStoreAllowFromForDmPolicy({
provider: "mattermost",
accountId: account.accountId,
dmPolicy,
readStore: pairing.readStoreForDmPolicy,
}),
);
const auth = authorizeMattermostCommandInvocation({
account,
cfg,
senderId: params.payload.user_id,
senderName: params.userName,
channelId: params.payload.channel_id,
channelInfo,
storeAllowFrom,
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 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,
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,
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,
channelId: params.payload.channel_id,
senderId: params.payload.user_id,
senderName: params.userName,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
teamId,
postId: params.payload.post_id,
deliverReplies: true,
});
const updatedModel = resolveMattermostModelPickerCurrentModel({
cfg,
route,
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,

View File

@@ -156,6 +156,32 @@ describe("sendMessageMattermost", () => {
}),
);
});
it("builds interactive button props when buttons are provided", async () => {
await sendMessageMattermost("channel:town-square", "Pick a model", {
buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]],
});
expect(mockState.createMattermostPost).toHaveBeenCalledWith(
{},
expect.objectContaining({
channelId: "town-square",
message: "Pick a model",
props: expect.objectContaining({
attachments: expect.arrayContaining([
expect.objectContaining({
actions: expect.arrayContaining([
expect.objectContaining({
id: "mdlprov",
name: "Browse providers",
}),
]),
}),
]),
}),
}),
);
});
});
describe("parseMattermostTarget", () => {

View File

@@ -13,6 +13,12 @@ import {
uploadMattermostFile,
type MattermostUser,
} from "./client.js";
import {
buildButtonProps,
resolveInteractionCallbackUrl,
setInteractionSecret,
type MattermostInteractiveButtonInput,
} from "./interactions.js";
export type MattermostSendOpts = {
cfg?: OpenClawConfig;
@@ -23,6 +29,8 @@ export type MattermostSendOpts = {
mediaLocalRoots?: readonly string[];
replyToId?: string;
props?: Record<string, unknown>;
buttons?: Array<unknown>;
attachmentText?: string;
};
export type MattermostSendResult = {
@@ -30,6 +38,10 @@ export type MattermostSendResult = {
channelId: string;
};
export type MattermostReplyButtons = Array<
MattermostInteractiveButtonInput | MattermostInteractiveButtonInput[]
>;
type MattermostTarget =
| { kind: "channel"; id: string }
| { kind: "channel-name"; name: string }
@@ -272,6 +284,23 @@ export async function sendMessageMattermost(
);
const client = createMattermostClient({ baseUrl, botToken: token });
let props = opts.props;
if (!props && Array.isArray(opts.buttons) && opts.buttons.length > 0) {
setInteractionSecret(accountId, token);
props = buildButtonProps({
callbackUrl: resolveInteractionCallbackUrl(accountId, {
gateway: cfg.gateway,
interactions: resolveMattermostAccount({
cfg,
accountId,
}).config?.interactions,
}),
accountId,
channelId,
buttons: opts.buttons,
text: opts.attachmentText,
});
}
let message = text?.trim() ?? "";
let fileIds: string[] | undefined;
let uploadError: Error | undefined;
@@ -320,7 +349,7 @@ export async function sendMessageMattermost(
message,
rootId: opts.replyToId,
fileIds,
props: opts.props,
props,
});
core.channel.activity.record({

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { MattermostClient } from "./client.js";
import {
DEFAULT_COMMAND_SPECS,
parseSlashCommandPayload,
registerSlashCommands,
resolveCallbackUrl,
@@ -55,9 +56,18 @@ describe("slash-commands", () => {
const triggerMap = new Map<string, string>([["oc_status", "status"]]);
expect(resolveCommandText("oc_status", " ", triggerMap)).toBe("/status");
expect(resolveCommandText("oc_status", " now ", triggerMap)).toBe("/status now");
expect(resolveCommandText("oc_models", " openai ", undefined)).toBe("/models openai");
expect(resolveCommandText("oc_help", "", undefined)).toBe("/help");
});
it("registers both public model slash commands", () => {
expect(
DEFAULT_COMMAND_SPECS.filter(
(spec) => spec.trigger === "oc_model" || spec.trigger === "oc_models",
).map((spec) => spec.trigger),
).toEqual(["oc_model", "oc_models"]);
});
it("normalizes callback path in slash config", () => {
const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" });
expect(config.callbackPath).toBe("/api/channels/mattermost/command");

View File

@@ -141,6 +141,13 @@ export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
autoComplete: true,
autoCompleteHint: "[model-name]",
},
{
trigger: "oc_models",
originalName: "models",
description: "Browse available models",
autoComplete: true,
autoCompleteHint: "[provider]",
},
{
trigger: "oc_new",
originalName: "new",

View File

@@ -6,28 +6,34 @@
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk/mattermost";
import {
buildModelsProviderData,
createReplyPrefixOptions,
createTypingCallbacks,
isDangerousNameMatchingEnabled,
logTypingFailure,
resolveControlCommandGate,
type OpenClawConfig,
type ReplyPayload,
type RuntimeEnv,
} from "openclaw/plugin-sdk/mattermost";
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
import { getMattermostRuntime } from "../runtime.js";
import {
createMattermostClient,
fetchMattermostChannel,
fetchMattermostUser,
normalizeMattermostBaseUrl,
sendMattermostTyping,
type MattermostChannel,
} from "./client.js";
import {
isMattermostSenderAllowed,
renderMattermostModelSummaryView,
renderMattermostModelsPickerView,
renderMattermostProviderPickerView,
resolveMattermostModelPickerCurrentModel,
resolveMattermostModelPickerEntry,
} from "./model-picker.js";
import {
authorizeMattermostCommandInvocation,
normalizeMattermostAllowList,
resolveMattermostEffectiveAllowFromLists,
} from "./monitor-auth.js";
import { sendMessageMattermost } from "./send.js";
import {
@@ -128,29 +134,11 @@ async function authorizeSlashInvocation(params: {
};
}
const channelType = channelInfo.type ?? undefined;
const isDirectMessage = channelType?.toUpperCase() === "D";
const kind: SlashInvocationAuth["kind"] = isDirectMessage
? "direct"
: channelInfo
? channelType?.toUpperCase() === "G"
? "group"
: "channel"
: "channel";
const chatType = kind === "direct" ? "direct" : kind === "group" ? "group" : "channel";
const channelName = channelInfo?.name ?? "";
const channelDisplay = channelInfo?.display_name ?? channelName;
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []);
const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []);
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "mattermost",
});
const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg);
const storeAllowFrom = normalizeMattermostAllowList(
await core.channel.pairing
.readAllowFromStore({
@@ -159,201 +147,61 @@ async function authorizeSlashInvocation(params: {
})
.catch(() => []),
);
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({
allowFrom: configAllowFrom,
groupAllowFrom: configGroupAllowFrom,
storeAllowFrom,
dmPolicy,
});
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
const decision = authorizeMattermostCommandInvocation({
account,
cfg,
surface: "mattermost",
});
const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg);
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom;
const commandGroupAllowFrom =
kind === "direct"
? effectiveGroupAllowFrom
: configGroupAllowFrom.length > 0
? configGroupAllowFrom
: configAllowFrom;
const senderAllowedForCommands = isMattermostSenderAllowed({
senderId,
senderName,
allowFrom: commandDmAllowFrom,
allowNameMatching,
});
const groupAllowedForCommands = isMattermostSenderAllowed({
senderId,
senderName,
allowFrom: commandGroupAllowFrom,
allowNameMatching,
});
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: commandGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
channelId,
channelInfo,
storeAllowFrom,
allowTextCommands,
hasControlCommand,
});
const commandAuthorized =
kind === "direct"
? dmPolicy === "open" || senderAllowedForCommands
: commandGate.commandAuthorized;
// DM policy enforcement
if (kind === "direct") {
if (dmPolicy === "disabled") {
if (!decision.ok) {
if (decision.denyReason === "dm-pairing") {
const { code } = await core.channel.pairing.upsertPairingRequest({
channel: "mattermost",
accountId: account.accountId,
id: senderId,
meta: { name: senderName },
});
return {
ok: false,
...decision,
denyResponse: {
response_type: "ephemeral",
text: "This bot is not accepting direct messages.",
text: core.channel.pairing.buildPairingReply({
channel: "mattermost",
idLine: `Your Mattermost user id: ${senderId}`,
code,
}),
},
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (dmPolicy !== "open" && !senderAllowedForCommands) {
if (dmPolicy === "pairing") {
const { code } = await core.channel.pairing.upsertPairingRequest({
channel: "mattermost",
accountId: account.accountId,
id: senderId,
meta: { name: senderName },
});
return {
ok: false,
denyResponse: {
response_type: "ephemeral",
text: core.channel.pairing.buildPairingReply({
channel: "mattermost",
idLine: `Your Mattermost user id: ${senderId}`,
code,
}),
},
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
return {
ok: false,
denyResponse: {
response_type: "ephemeral",
text: "Unauthorized.",
},
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
} else {
// Group/channel policy enforcement
if (groupPolicy === "disabled") {
return {
ok: false,
denyResponse: {
response_type: "ephemeral",
text: "Slash commands are disabled in channels.",
},
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
return {
ok: false,
denyResponse: {
response_type: "ephemeral",
text: "Slash commands are not configured for this channel (no allowlist).",
},
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (!groupAllowedForCommands) {
return {
ok: false,
denyResponse: {
response_type: "ephemeral",
text: "Unauthorized.",
},
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
}
if (commandGate.shouldBlock) {
return {
ok: false,
denyResponse: {
response_type: "ephemeral",
text: "Unauthorized.",
},
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
const denyText =
decision.denyReason === "unknown-channel"
? "Temporary error: unable to determine channel type. Please try again."
: decision.denyReason === "dm-disabled"
? "This bot is not accepting direct messages."
: decision.denyReason === "channels-disabled"
? "Slash commands are disabled in channels."
: decision.denyReason === "channel-no-allowlist"
? "Slash commands are not configured for this channel (no allowlist)."
: "Unauthorized.";
return {
...decision,
denyResponse: {
response_type: "ephemeral",
text: denyText,
},
};
}
return {
ok: true,
commandAuthorized,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
...decision,
denyResponse: undefined,
};
}
@@ -537,6 +385,48 @@ async function handleSlashCommandAsync(params: {
: `Mattermost message in ${roomLabel} from ${senderName}`;
const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`;
const pickerEntry = resolveMattermostModelPickerEntry(commandText);
if (pickerEntry) {
const data = await buildModelsProviderData(cfg, route.agentId);
if (data.providers.length === 0) {
await sendMessageMattermost(to, "No models available.", {
accountId: account.accountId,
});
return;
}
const currentModel = resolveMattermostModelPickerCurrentModel({
cfg,
route,
data,
});
const view =
pickerEntry.kind === "summary"
? renderMattermostModelSummaryView({
ownerUserId: senderId,
currentModel,
})
: pickerEntry.kind === "providers"
? renderMattermostProviderPickerView({
ownerUserId: senderId,
data,
currentModel,
})
: renderMattermostModelsPickerView({
ownerUserId: senderId,
data,
provider: pickerEntry.provider,
page: 1,
currentModel,
});
await sendMessageMattermost(to, view.text, {
accountId: account.accountId,
buttons: view.buttons,
});
runtime.log?.(`delivered model picker to ${to}`);
return;
}
// Build inbound context — the command text is the body
const ctxPayload = core.channel.reply.finalizeInboundContext({

View File

@@ -73,6 +73,11 @@ export type MattermostAccountConfig = {
interactions?: {
/** External base URL used for Mattermost interaction callbacks. */
callbackBaseUrl?: string;
/**
* IP/CIDR allowlist for callback request sources when Mattermost reaches the gateway
* over a non-loopback path. Keep this narrow to the Mattermost server or trusted ingress.
*/
allowedSourceIps?: string[];
};
};

View File

@@ -1,12 +1,11 @@
import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { resolveModelAuthLabel } from "../../agents/model-auth-label.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import {
buildAllowedModelSet,
buildModelAliasIndex,
normalizeProviderId,
resolveConfiguredModelRef,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -35,11 +34,13 @@ export type ModelsProviderData = {
* Build provider/model data from config and catalog.
* Exported for reuse by callback handlers.
*/
export async function buildModelsProviderData(cfg: OpenClawConfig): Promise<ModelsProviderData> {
const resolvedDefault = resolveConfiguredModelRef({
export async function buildModelsProviderData(
cfg: OpenClawConfig,
agentId?: string,
): Promise<ModelsProviderData> {
const resolvedDefault = resolveDefaultModelForAgent({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
agentId,
});
const catalog = await loadModelCatalog({ config: cfg });
@@ -220,6 +221,7 @@ export async function resolveModelsCommandReply(params: {
commandBodyNormalized: string;
surface?: string;
currentModel?: string;
agentId?: string;
agentDir?: string;
sessionEntry?: SessionEntry;
}): Promise<ReplyPayload | null> {
@@ -231,7 +233,7 @@ export async function resolveModelsCommandReply(params: {
const argText = body.replace(/^\/models\b/i, "").trim();
const { provider, page, pageSize, all } = parseModelsArgs(argText);
const { byProvider, providers } = await buildModelsProviderData(params.cfg);
const { byProvider, providers } = await buildModelsProviderData(params.cfg, params.agentId);
const isTelegram = params.surface === "telegram";
// Provider list (no provider specified)
@@ -386,6 +388,7 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
commandBodyNormalized,
surface: params.ctx.Surface,
currentModel: params.model ? `${params.provider}/${params.model}` : undefined,
agentId: modelsAgentId,
agentDir: modelsAgentDir,
sessionEntry: params.sessionEntry,
});

View File

@@ -907,6 +907,28 @@ describe("/models command", () => {
expect(result.reply?.text).toContain("localai/ultra-chat");
expect(result.reply?.text).not.toContain("Unknown provider");
});
it("threads the routed agent through /models replies", async () => {
const scopedCfg = {
commands: { text: true },
agents: {
defaults: { model: { primary: "anthropic/claude-opus-4-5" } },
list: [{ id: "support", model: "localai/ultra-chat" }],
},
} as unknown as OpenClawConfig;
const params = buildPolicyParams("/models", scopedCfg, {
Provider: "discord",
Surface: "discord",
});
const result = await handleCommands({
...params,
agentId: "support",
sessionKey: "agent:support:main",
});
expect(result.reply?.text).toContain("localai");
});
});
describe("handleCommands plugin commands", () => {

View File

@@ -61,15 +61,17 @@ function renderRecentsViewRows(
}
describe("loadDiscordModelPickerData", () => {
it("reuses buildModelsProviderData as source of truth", async () => {
it("reuses buildModelsProviderData as source of truth with agent scope", async () => {
const expected = createModelsProviderData({ openai: ["gpt-4o"] });
const cfg = {} as OpenClawConfig;
const spy = vi
.spyOn(modelsCommandModule, "buildModelsProviderData")
.mockResolvedValue(expected);
const result = await loadDiscordModelPickerData({} as OpenClawConfig);
const result = await loadDiscordModelPickerData(cfg, "support");
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(cfg, "support");
expect(result).toBe(expected);
});
});

View File

@@ -541,8 +541,11 @@ function buildModelRows(params: {
* Source-of-truth data for Discord picker views. This intentionally reuses the
* same provider/model resolver used by text and Telegram model commands.
*/
export async function loadDiscordModelPickerData(cfg: OpenClawConfig): Promise<ModelsProviderData> {
return buildModelsProviderData(cfg);
export async function loadDiscordModelPickerData(
cfg: OpenClawConfig,
agentId?: string,
): Promise<ModelsProviderData> {
return buildModelsProviderData(cfg, agentId);
}
export function buildDiscordModelPickerCustomId(params: {

View File

@@ -476,13 +476,13 @@ async function replyWithDiscordModelPickerProviders(params: {
threadBindings: ThreadBindingManager;
preferFollowUp: boolean;
}) {
const data = await loadDiscordModelPickerData(params.cfg);
const route = await resolveDiscordModelPickerRoute({
interaction: params.interaction,
cfg: params.cfg,
accountId: params.accountId,
threadBindings: params.threadBindings,
});
const data = await loadDiscordModelPickerData(params.cfg, route.agentId);
const currentModel = resolveDiscordModelPickerCurrentModel({
cfg: params.cfg,
route,
@@ -637,13 +637,13 @@ async function handleDiscordModelPickerInteraction(
return;
}
const pickerData = await loadDiscordModelPickerData(ctx.cfg);
const route = await resolveDiscordModelPickerRoute({
interaction,
cfg: ctx.cfg,
accountId: ctx.accountId,
threadBindings: ctx.threadBindings,
});
const pickerData = await loadDiscordModelPickerData(ctx.cfg, route.agentId);
const currentModelRef = resolveDiscordModelPickerCurrentModel({
cfg: ctx.cfg,
route,

View File

@@ -15,6 +15,12 @@ export type { ChatType } from "../channels/chat-type.js";
export { resolveControlCommandGate } from "../channels/command-gating.js";
export { logInboundDrop, logTypingFailure } from "../channels/logging.js";
export { resolveAllowlistMatchSimple } from "../channels/plugins/allowlist-match.js";
export { normalizeProviderId } from "../agents/model-selection.js";
export {
buildModelsProviderData,
type ModelsProviderData,
} from "../auto-reply/reply/commands-models.js";
export { resolveStoredModelOverride } from "../auto-reply/reply/model-selection.js";
export {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
@@ -44,6 +50,7 @@ export { createReplyPrefixOptions } from "../channels/reply-prefix.js";
export { createTypingCallbacks } from "../channels/typing.js";
export type { OpenClawConfig } from "../config/config.js";
export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
export { loadSessionStore, resolveStorePath } from "../config/sessions.js";
export {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
@@ -65,6 +72,7 @@ export {
} from "../config/zod-schema.core.js";
export { createDedupeCache } from "../infra/dedupe.js";
export { rawDataToString } from "../infra/ws.js";
export { isLoopbackHost, isTrustedProxyAddress, resolveClientIp } from "../gateway/net.js";
export { registerPluginHttpRoute } from "../plugins/http-registry.js";
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export type { PluginRuntime } from "../plugins/runtime/types.js";

View File

@@ -1179,7 +1179,15 @@ export const registerTelegramHandlers = ({
// Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back)
const modelCallback = parseModelCallbackData(data);
if (modelCallback) {
const modelData = await buildModelsProviderData(cfg);
const sessionState = resolveTelegramSessionState({
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
senderId,
});
const modelData = await buildModelsProviderData(cfg, sessionState.agentId);
const { byProvider, providers } = modelData;
const editMessageWithButtons = async (
@@ -1238,14 +1246,15 @@ export const registerTelegramHandlers = ({
const safePage = Math.max(1, Math.min(page, totalPages));
// Resolve current model from session (prefer overrides)
const sessionState = resolveTelegramSessionState({
const currentSessionState = resolveTelegramSessionState({
chatId,
isGroup,
isForum,
messageThreadId,
resolvedThreadId,
senderId,
});
const currentModel = sessionState.model;
const currentModel = currentSessionState.model;
const buttons = buildModelsKeyboard({
provider,
@@ -1259,8 +1268,8 @@ export const registerTelegramHandlers = ({
provider,
total: models.length,
cfg,
agentDir: resolveAgentDir(cfg, sessionState.agentId),
sessionEntry: sessionState.sessionEntry,
agentDir: resolveAgentDir(cfg, currentSessionState.agentId),
sessionEntry: currentSessionState.sessionEntry,
});
await editMessageWithButtons(text, buttons);
return;