Mattermost: align bare ID DM routing

This commit is contained in:
Muhammed Mukhthar CM
2026-03-08 17:46:49 +00:00
parent b8b640ecef
commit 9759aebaed
12 changed files with 370 additions and 96 deletions

View File

@@ -35,6 +35,7 @@ import { monitorMattermostProvider } from "./mattermost/monitor.js";
import { probeMattermost } from "./mattermost/probe.js";
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
import { sendMessageMattermost } from "./mattermost/send.js";
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
import { mattermostOnboardingAdapter } from "./onboarding.js";
import { getMattermostRuntime } from "./runtime.js";
@@ -340,6 +341,21 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
targetResolver: {
looksLikeId: looksLikeMattermostTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ cfg, accountId, input }) => {
const resolved = await resolveMattermostOpaqueTarget({
input,
cfg,
accountId,
});
if (!resolved) {
return null;
}
return {
to: resolved.to,
kind: resolved.kind,
source: "directory",
};
},
},
},
outbound: {

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parseMattermostTarget, sendMessageMattermost } from "./send.js";
import { resetMattermostOpaqueTargetCacheForTests } from "./target-resolution.js";
const mockState = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
@@ -14,8 +15,8 @@ const mockState = vi.hoisted(() => ({
createMattermostPost: vi.fn(),
fetchMattermostChannelByName: vi.fn(),
fetchMattermostMe: vi.fn(),
fetchMattermostUserTeams: vi.fn(),
fetchMattermostUser: vi.fn(),
fetchMattermostUserTeams: vi.fn(),
fetchMattermostUserByUsername: vi.fn(),
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
uploadMattermostFile: vi.fn(),
@@ -35,8 +36,8 @@ vi.mock("./client.js", () => ({
createMattermostPost: mockState.createMattermostPost,
fetchMattermostChannelByName: mockState.fetchMattermostChannelByName,
fetchMattermostMe: mockState.fetchMattermostMe,
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
fetchMattermostUser: mockState.fetchMattermostUser,
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
uploadMattermostFile: mockState.uploadMattermostFile,
@@ -79,10 +80,11 @@ describe("sendMessageMattermost", () => {
mockState.createMattermostPost.mockReset();
mockState.fetchMattermostChannelByName.mockReset();
mockState.fetchMattermostMe.mockReset();
mockState.fetchMattermostUserTeams.mockReset();
mockState.fetchMattermostUser.mockReset();
mockState.fetchMattermostUserTeams.mockReset();
mockState.fetchMattermostUserByUsername.mockReset();
mockState.uploadMattermostFile.mockReset();
resetMattermostOpaqueTargetCacheForTests();
mockState.createMattermostClient.mockReturnValue({});
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" });
@@ -185,6 +187,61 @@ describe("sendMessageMattermost", () => {
}),
);
});
it("resolves a bare Mattermost user id as a DM target before upload", async () => {
const userId = "dthcxgoxhifn3pwh65cut3ud3w";
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
mockState.createMattermostDirectChannel.mockResolvedValueOnce({ id: "dm-channel-1" });
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: Buffer.from("media-bytes"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const result = await sendMessageMattermost(userId, "hello", {
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
});
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId);
expect(mockState.createMattermostDirectChannel).toHaveBeenCalledWith({}, ["bot-user", userId]);
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
{},
expect.objectContaining({
channelId: "dm-channel-1",
}),
);
expect(result.channelId).toBe("dm-channel-1");
});
it("falls back to a channel target when bare Mattermost id is not a user", async () => {
const channelId = "aaaaaaaaaaaaaaaaaaaaaaaaaa";
mockState.fetchMattermostUser.mockRejectedValueOnce(
new Error("Mattermost API 404 Not Found: user not found"),
);
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: Buffer.from("media-bytes"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const result = await sendMessageMattermost(channelId, "hello", {
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
});
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId);
expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled();
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
{},
expect.objectContaining({
channelId,
}),
);
expect(result.channelId).toBe(channelId);
});
});
describe("parseMattermostTarget", () => {

View File

@@ -7,7 +7,6 @@ import {
createMattermostPost,
fetchMattermostChannelByName,
fetchMattermostMe,
fetchMattermostUser,
fetchMattermostUserByUsername,
fetchMattermostUserTeams,
normalizeMattermostBaseUrl,
@@ -20,6 +19,7 @@ import {
setInteractionSecret,
type MattermostInteractiveButtonInput,
} from "./interactions.js";
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
export type MattermostSendOpts = {
cfg?: OpenClawConfig;
@@ -51,11 +51,6 @@ type MattermostTarget =
const botUserCache = new Map<string, MattermostUser>();
const userByNameCache = new Map<string, MattermostUser>();
const channelByNameCache = new Map<string, string>();
// Cache for ambiguous, unprefixed IDs:
// - whether an opaque id resolved as a user
// - DM channel ids per user
const userIdResolutionCache = new Map<string, boolean>();
const dmChannelCache = new Map<string, string>();
const getCore = () => getMattermostRuntime();
@@ -73,44 +68,6 @@ function normalizeMessage(text: string, mediaUrl?: string): string {
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
function isMattermostId(value: string): boolean {
return /^[a-z0-9]{26}$/.test(value);
}
/** Returns true when the target has an explicit prefix (user:, channel:, mattermost:, @). */
function isExplicitMattermostTarget(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(channel|user|mattermost):/i.test(trimmed)) {
return true;
}
if (trimmed.startsWith("@")) {
return true;
}
return false;
}
/**
* Extract Mattermost HTTP status from an error message.
* Returns undefined for non-API errors or when parsing fails.
*/
function parseMattermostApiStatus(err: unknown): number | undefined {
if (!err || typeof err !== "object") {
return undefined;
}
const msg = "message" in err ? String((err as { message?: unknown }).message ?? "") : "";
const m = /Mattermost API (\d{3})\b/.exec(msg);
if (!m) {
return undefined;
}
const code = Number(m[1]);
return Number.isFinite(code) ? code : undefined;
}
export function parseMattermostTarget(raw: string): MattermostTarget {
const trimmed = raw.trim();
if (!trimmed) {
@@ -247,13 +204,11 @@ async function resolveTargetChannelId(params: {
token: params.token,
username: params.target.username ?? "",
});
const dmKey = `${cacheKey(params.baseUrl, params.token)}::dm::${userId}`;
const cachedDm = dmChannelCache.get(dmKey);
if (cachedDm) {
return cachedDm;
}
const botUser = await resolveBotUser(params.baseUrl, params.token);
const client = createMattermostClient({
baseUrl: params.baseUrl,
@@ -296,49 +251,17 @@ async function resolveMattermostSendContext(
}
const trimmedTo = to?.trim() ?? "";
// User-first resolution for ambiguous, unprefixed 26-char Mattermost IDs.
// A bare 26-char ID is ambiguous: it could be a user ID or a channel ID.
// We probe the users API first; on 404 we fall back to treating it as a channel ID.
// Negative results are only cached for confirmed 404s to avoid poisoning the cache
// on transient errors (429, 5xx, network failures).
let target: MattermostTarget;
if (!isExplicitMattermostTarget(trimmedTo) && isMattermostId(trimmedTo)) {
const key = `${cacheKey(baseUrl, token)}::isUser::${trimmedTo}`;
const cachedResolution = userIdResolutionCache.get(key);
if (cachedResolution === true) {
target = { kind: "user", id: trimmedTo };
} else if (cachedResolution === false) {
target = { kind: "channel", id: trimmedTo };
} else {
const client = createMattermostClient({ baseUrl, botToken: token });
try {
await fetchMattermostUser(client, trimmedTo);
userIdResolutionCache.set(key, true);
target = { kind: "user", id: trimmedTo };
} catch (err) {
const status = parseMattermostApiStatus(err);
// Only cache negative resolution for confirmed not-found.
// For transient errors (429/5xx/network), avoid poisoning the cache.
if (status === 404) {
userIdResolutionCache.set(key, false);
} else {
if (core.logging.shouldLogVerbose()) {
const logger = core.logging.getChildLogger({ module: "mattermost" });
logger.debug?.(
`mattermost send: could not resolve ambiguous id as user (status=${status ?? "unknown"}); falling back to channel id`,
);
}
}
target = { kind: "channel", id: trimmedTo };
}
}
} else {
target = parseMattermostTarget(trimmedTo);
}
const opaqueTarget = await resolveMattermostOpaqueTarget({
input: trimmedTo,
token,
baseUrl,
});
const target =
opaqueTarget?.kind === "user"
? { kind: "user" as const, id: opaqueTarget.id }
: opaqueTarget?.kind === "channel"
? { kind: "channel" as const, id: opaqueTarget.id }
: parseMattermostTarget(trimmedTo);
const channelId = await resolveTargetChannelId({
target,
baseUrl,

View File

@@ -0,0 +1,97 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostUser,
normalizeMattermostBaseUrl,
} from "./client.js";
export type MattermostOpaqueTargetResolution = {
kind: "user" | "channel";
id: string;
to: string;
};
const mattermostOpaqueTargetCache = new Map<string, boolean>();
function cacheKey(baseUrl: string, token: string, id: string): string {
return `${baseUrl}::${token}::${id}`;
}
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
export function isMattermostId(value: string): boolean {
return /^[a-z0-9]{26}$/.test(value);
}
export function isExplicitMattermostTarget(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
return (
/^(channel|user|mattermost):/i.test(trimmed) ||
trimmed.startsWith("@") ||
trimmed.startsWith("#")
);
}
export function parseMattermostApiStatus(err: unknown): number | undefined {
if (!err || typeof err !== "object") {
return undefined;
}
const msg = "message" in err ? String((err as { message?: unknown }).message ?? "") : "";
const match = /Mattermost API (\d{3})\b/.exec(msg);
if (!match) {
return undefined;
}
const code = Number(match[1]);
return Number.isFinite(code) ? code : undefined;
}
export async function resolveMattermostOpaqueTarget(params: {
input: string;
cfg?: OpenClawConfig;
accountId?: string | null;
token?: string;
baseUrl?: string;
}): Promise<MattermostOpaqueTargetResolution | null> {
const input = params.input.trim();
if (!input || isExplicitMattermostTarget(input) || !isMattermostId(input)) {
return null;
}
const account =
params.cfg && (!params.token || !params.baseUrl)
? resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId })
: null;
const token = params.token?.trim() || account?.botToken?.trim();
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl ?? account?.baseUrl);
if (!token || !baseUrl) {
return null;
}
const key = cacheKey(baseUrl, token, input);
const cached = mattermostOpaqueTargetCache.get(key);
if (cached === true) {
return { kind: "user", id: input, to: `user:${input}` };
}
if (cached === false) {
return { kind: "channel", id: input, to: `channel:${input}` };
}
const client = createMattermostClient({ baseUrl, botToken: token });
try {
await fetchMattermostUser(client, input);
mattermostOpaqueTargetCache.set(key, true);
return { kind: "user", id: input, to: `user:${input}` };
} catch (err) {
if (parseMattermostApiStatus(err) === 404) {
mattermostOpaqueTargetCache.set(key, false);
}
return { kind: "channel", id: input, to: `channel:${input}` };
}
}
export function resetMattermostOpaqueTargetCacheForTests(): void {
mattermostOpaqueTargetCache.clear();
}

View File

@@ -288,6 +288,18 @@ export type ChannelMessagingAdapter = {
targetResolver?: {
looksLikeId?: (raw: string, normalized?: string) => boolean;
hint?: string;
resolveTarget?: (params: {
cfg: OpenClawConfig;
accountId?: string | null;
input: string;
normalized: string;
preferredKind?: ChannelDirectoryEntryKind | "channel";
}) => Promise<{
to: string;
kind: ChannelDirectoryEntryKind | "channel";
display?: string;
source?: "normalized" | "directory";
} | null>;
};
formatTargetDisplay?: (params: {
target: string;

View File

@@ -13,6 +13,10 @@ vi.mock("../../infra/outbound/channel-selection.js", () => ({
.mockResolvedValue({ channel: "telegram", configured: ["telegram"] }),
}));
vi.mock("../../infra/outbound/target-resolver.js", () => ({
maybeResolveIdLikeTarget: vi.fn(),
}));
vi.mock("../../pairing/pairing-store.js", () => ({
readChannelAllowFromStoreSync: vi.fn(() => []),
}));
@@ -23,6 +27,7 @@ vi.mock("../../web/accounts.js", () => ({
import { loadSessionStore } from "../../config/sessions.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { resolveDeliveryTarget } from "./delivery-target.js";
@@ -152,6 +157,30 @@ describe("resolveDeliveryTarget", () => {
expect(result.accountId).toBeUndefined();
});
it("applies id-like target normalization before returning delivery targets", async () => {
setMainSessionEntry(undefined);
vi.mocked(maybeResolveIdLikeTarget).mockClear();
vi.mocked(maybeResolveIdLikeTarget).mockResolvedValueOnce({
to: "user:123456789",
kind: "user",
source: "directory",
});
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
channel: "telegram",
to: "123456789",
});
expect(result.ok).toBe(true);
expect(result.to).toBe("user:123456789");
expect(maybeResolveIdLikeTarget).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
input: "123456789",
}),
);
});
it("selects correct binding when multiple agents have bindings", async () => {
setMainSessionEntry(undefined);

View File

@@ -6,6 +6,7 @@ import {
resolveStorePath,
} from "../../config/sessions.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
import type { OutboundChannel } from "../../infra/outbound/targets.js";
import {
resolveOutboundTarget,
@@ -190,10 +191,16 @@ export async function resolveDeliveryTarget(
error: docked.error,
};
}
const idLikeTarget = await maybeResolveIdLikeTarget({
cfg,
channel,
input: docked.to,
accountId,
});
return {
ok: true,
channel,
to: docked.to,
to: idLikeTarget?.to ?? docked.to,
accountId,
threadId,
mode,

View File

@@ -11,6 +11,7 @@ import {
} from "../../infra/outbound/outbound-session.js";
import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js";
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js";
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
import { normalizePollInput } from "../../polls.js";
import {
@@ -194,6 +195,13 @@ export const sendHandlers: GatewayRequestHandlers = {
meta: { channel },
};
}
const idLikeTarget = await maybeResolveIdLikeTarget({
cfg,
channel,
input: resolved.to,
accountId,
});
const deliveryTarget = idLikeTarget?.to ?? resolved.to;
const outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : undefined;
const mirrorPayloads = normalizeReplyPayloadsForDelivery([
{ text: message, mediaUrl, mediaUrls },
@@ -225,7 +233,8 @@ export const sendHandlers: GatewayRequestHandlers = {
channel,
agentId: effectiveAgentId,
accountId,
target: resolved.to,
target: deliveryTarget,
resolvedTarget: idLikeTarget,
threadId,
})
: null;
@@ -246,7 +255,7 @@ export const sendHandlers: GatewayRequestHandlers = {
const results = await deliverOutboundPayloads({
cfg,
channel: outboundChannel,
to: resolved.to,
to: deliveryTarget,
accountId,
payloads: [{ text: message, mediaUrl, mediaUrls }],
session: outboundSession,

View File

@@ -583,7 +583,12 @@ function resolveMattermostSession(
}
trimmed = trimmed.replace(/^mattermost:/i, "").trim();
const lower = trimmed.toLowerCase();
const isUser = lower.startsWith("user:") || trimmed.startsWith("@");
const resolvedKind = params.resolvedTarget?.kind;
const isUser =
resolvedKind === "user" ||
(resolvedKind !== "channel" &&
resolvedKind !== "group" &&
(lower.startsWith("user:") || trimmed.startsWith("@")));
if (trimmed.startsWith("@")) {
trimmed = trimmed.slice(1).trim();
}

View File

@@ -1142,6 +1142,28 @@ describe("resolveOutboundSessionRoute", () => {
});
});
it("uses resolved Mattermost user targets to route bare ids as DMs", async () => {
const userId = "dthcxgoxhifn3pwh65cut3ud3w";
const route = await resolveOutboundSessionRoute({
cfg: { session: { dmScope: "per-channel-peer" } } as OpenClawConfig,
channel: "mattermost",
agentId: "main",
target: userId,
resolvedTarget: {
to: `user:${userId}`,
kind: "user",
source: "directory",
},
});
expect(route).toMatchObject({
sessionKey: `agent:main:mattermost:direct:${userId}`,
from: `mattermost:${userId}`,
to: `user:${userId}`,
chatType: "direct",
});
});
it("rejects bare numeric Discord targets when the caller has no kind hint", async () => {
await expect(
resolveOutboundSessionRoute({

View File

@@ -6,6 +6,7 @@ import { resetDirectoryCache, resolveMessagingTarget } from "./target-resolver.j
const mocks = vi.hoisted(() => ({
listGroups: vi.fn(),
listGroupsLive: vi.fn(),
resolveTarget: vi.fn(),
getChannelPlugin: vi.fn(),
}));
@@ -20,6 +21,7 @@ describe("resolveMessagingTarget (directory fallback)", () => {
beforeEach(() => {
mocks.listGroups.mockClear();
mocks.listGroupsLive.mockClear();
mocks.resolveTarget.mockClear();
mocks.getChannelPlugin.mockClear();
resetDirectoryCache();
mocks.getChannelPlugin.mockReturnValue({
@@ -27,6 +29,11 @@ describe("resolveMessagingTarget (directory fallback)", () => {
listGroups: mocks.listGroups,
listGroupsLive: mocks.listGroupsLive,
},
messaging: {
targetResolver: {
resolveTarget: mocks.resolveTarget,
},
},
});
});
@@ -75,4 +82,43 @@ describe("resolveMessagingTarget (directory fallback)", () => {
expect(mocks.listGroups).not.toHaveBeenCalled();
expect(mocks.listGroupsLive).not.toHaveBeenCalled();
});
it("lets plugins override id-like target resolution before falling back to raw ids", async () => {
mocks.getChannelPlugin.mockReturnValue({
messaging: {
targetResolver: {
looksLikeId: () => true,
resolveTarget: mocks.resolveTarget,
},
},
});
mocks.resolveTarget.mockResolvedValue({
to: "user:dm-user-id",
kind: "user",
source: "directory",
});
const result = await resolveMessagingTarget({
cfg,
channel: "mattermost",
input: "dthcxgoxhifn3pwh65cut3ud3w",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.target).toEqual({
to: "user:dm-user-id",
kind: "user",
source: "directory",
display: undefined,
});
}
expect(mocks.resolveTarget).toHaveBeenCalledWith(
expect.objectContaining({
input: "dthcxgoxhifn3pwh65cut3ud3w",
}),
);
expect(mocks.listGroups).not.toHaveBeenCalled();
expect(mocks.listGroupsLive).not.toHaveBeenCalled();
});
});

View File

@@ -40,6 +40,44 @@ export async function resolveChannelTarget(params: {
return resolveMessagingTarget(params);
}
export async function maybeResolveIdLikeTarget(params: {
cfg: OpenClawConfig;
channel: ChannelId;
input: string;
accountId?: string | null;
preferredKind?: TargetResolveKind;
}): Promise<ResolvedMessagingTarget | undefined> {
const raw = normalizeChannelTargetInput(params.input);
if (!raw) {
return undefined;
}
const plugin = getChannelPlugin(params.channel);
const resolver = plugin?.messaging?.targetResolver;
if (!resolver?.resolveTarget) {
return undefined;
}
const normalized = normalizeTargetForProvider(params.channel, raw) ?? raw;
if (resolver.looksLikeId && !resolver.looksLikeId(raw, normalized)) {
return undefined;
}
const resolved = await resolver.resolveTarget({
cfg: params.cfg,
accountId: params.accountId,
input: raw,
normalized,
preferredKind: params.preferredKind,
});
if (!resolved) {
return undefined;
}
return {
to: resolved.to,
kind: resolved.kind,
display: resolved.display,
source: resolved.source ?? "normalized",
};
}
const CACHE_TTL_MS = 30 * 60 * 1000;
const directoryCache = new DirectoryCache<ChannelDirectoryEntry[]>(CACHE_TTL_MS);
@@ -388,6 +426,19 @@ export async function resolveMessagingTarget(params: {
return false;
};
if (looksLikeTargetId()) {
const resolvedIdLikeTarget = await maybeResolveIdLikeTarget({
cfg: params.cfg,
channel: params.channel,
input: raw,
accountId: params.accountId,
preferredKind: params.preferredKind,
});
if (resolvedIdLikeTarget) {
return {
ok: true,
target: resolvedIdLikeTarget,
};
}
return buildNormalizedResolveResult({
channel: params.channel,
raw,