mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
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:
committed by
GitHub
parent
33e7394861
commit
4f08dcccfd
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ const MattermostAccountSchemaBase = z
|
||||
interactions: z
|
||||
.object({
|
||||
callbackBaseUrl: z.string().optional(),
|
||||
allowedSourceIps: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
155
extensions/mattermost/src/mattermost/model-picker.test.ts
Normal file
155
extensions/mattermost/src/mattermost/model-picker.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
383
extensions/mattermost/src/mattermost/model-picker.ts
Normal file
383
extensions/mattermost/src/mattermost/model-picker.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user