diff --git a/CHANGELOG.md b/CHANGELOG.md index c470767b36a..78c57af662f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -480,6 +480,7 @@ Docs: https://docs.openclaw.ai - Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee. - Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh. - Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn. +- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix. ## 2026.3.2 diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index a0b5e505476..effa8f3ab81 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -262,6 +262,7 @@ If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the mai Target format reminders: - Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:`, `user:`) to avoid ambiguity. + Mattermost bare 26-char IDs are resolved **user-first** (DM if user exists, channel otherwise) — use `user:` or `channel:` for deterministic routing. - Telegram topics should use the `:topic:` form (see below). #### Telegram delivery targets (topics / forum threads) diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md index f9417109a77..6a7ee8bb472 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -153,7 +153,14 @@ Use these target formats with `openclaw message send` or cron/webhooks: - `user:` for a DM - `@username` for a DM (resolved via the Mattermost API) -Bare IDs are treated as channels. +Bare opaque IDs (like `64ifufp...`) are **ambiguous** in Mattermost (user ID vs channel ID). + +OpenClaw resolves them **user-first**: + +- If the ID exists as a user (`GET /api/v4/users/` succeeds), OpenClaw sends a **DM** by resolving the direct channel via `/api/v4/channels/direct`. +- Otherwise the ID is treated as a **channel ID**. + +If you need deterministic behavior, always use the explicit prefixes (`user:` / `channel:`). ## Reactions (message tool) diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 4ff847ea30d..b62231ac997 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -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 = { targetResolver: { looksLikeId: looksLikeMattermostTargetId, hint: "", + 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: { diff --git a/extensions/mattermost/src/mattermost/send.test.ts b/extensions/mattermost/src/mattermost/send.test.ts index 41ce2dd283a..cebb82ef7e3 100644 --- a/extensions/mattermost/src/mattermost/send.test.ts +++ b/extensions/mattermost/src/mattermost/send.test.ts @@ -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,6 +15,7 @@ const mockState = vi.hoisted(() => ({ createMattermostPost: vi.fn(), fetchMattermostChannelByName: vi.fn(), fetchMattermostMe: vi.fn(), + fetchMattermostUser: vi.fn(), fetchMattermostUserTeams: vi.fn(), fetchMattermostUserByUsername: vi.fn(), normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""), @@ -34,6 +36,7 @@ vi.mock("./client.js", () => ({ createMattermostPost: mockState.createMattermostPost, fetchMattermostChannelByName: mockState.fetchMattermostChannelByName, fetchMattermostMe: mockState.fetchMattermostMe, + fetchMattermostUser: mockState.fetchMattermostUser, fetchMattermostUserTeams: mockState.fetchMattermostUserTeams, fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername, normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl, @@ -77,9 +80,11 @@ describe("sendMessageMattermost", () => { mockState.createMattermostPost.mockReset(); mockState.fetchMattermostChannelByName.mockReset(); mockState.fetchMattermostMe.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" }); @@ -182,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", () => { @@ -266,3 +326,110 @@ describe("parseMattermostTarget", () => { expect(parseMattermostTarget("Mattermost:QRS")).toEqual({ kind: "user", id: "QRS" }); }); }); + +// Each test uses a unique (token, id) pair to avoid module-level cache collisions. +// userIdResolutionCache and dmChannelCache are module singletons that survive across tests. +// Using unique cache keys per test ensures full isolation without needing a cache reset API. +describe("sendMessageMattermost user-first resolution", () => { + function makeAccount(token: string) { + return { + accountId: "default", + botToken: token, + baseUrl: "https://mattermost.example.com", + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + mockState.createMattermostClient.mockReturnValue({}); + mockState.createMattermostPost.mockResolvedValue({ id: "post-id" }); + mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" }); + mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" }); + }); + + it("resolves unprefixed 26-char id as user and sends via DM channel", async () => { + // Unique token + id to avoid cache pollution from other tests + const userId = "aaaaaa1111111111aaaaaa1111"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-user-dm-t1")); + mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); + + const res = await sendMessageMattermost(userId, "hello"); + + expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); + expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1); + const params = mockState.createMattermostPost.mock.calls[0]?.[1]; + expect(params.channelId).toBe("dm-channel-id"); + expect(res.channelId).toBe("dm-channel-id"); + expect(res.messageId).toBe("post-id"); + }); + + it("falls back to channel id when user lookup returns 404", async () => { + // Unique token + id for this test + const channelId = "bbbbbb2222222222bbbbbb2222"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-404-t2")); + const err = new Error("Mattermost API 404: user not found"); + mockState.fetchMattermostUser.mockRejectedValueOnce(err); + + const res = await sendMessageMattermost(channelId, "hello"); + + expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); + expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled(); + const params = mockState.createMattermostPost.mock.calls[0]?.[1]; + expect(params.channelId).toBe(channelId); + expect(res.channelId).toBe(channelId); + }); + + it("falls back to channel id without caching negative result on transient error", async () => { + // Two unique tokens so each call has its own cache namespace + const userId = "cccccc3333333333cccccc3333"; // 26 chars + const tokenA = "token-transient-t3a"; + const tokenB = "token-transient-t3b"; + const transientErr = new Error("Mattermost API 503: service unavailable"); + + // First call: transient error → fall back to channel id, do NOT cache negative + mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenA)); + mockState.fetchMattermostUser.mockRejectedValueOnce(transientErr); + + const res1 = await sendMessageMattermost(userId, "first"); + expect(res1.channelId).toBe(userId); + + // Second call with a different token (new cache key) → retries user lookup + vi.clearAllMocks(); + mockState.createMattermostClient.mockReturnValue({}); + mockState.createMattermostPost.mockResolvedValue({ id: "post-id-2" }); + mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" }); + mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" }); + mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB)); + mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId }); + + const res2 = await sendMessageMattermost(userId, "second"); + expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1); + expect(res2.channelId).toBe("dm-channel-id"); + }); + + it("does not apply user-first resolution for explicit user: prefix", async () => { + // Unique token + id — explicit user: prefix bypasses probe, goes straight to DM + const userId = "dddddd4444444444dddddd4444"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4")); + + const res = await sendMessageMattermost(`user:${userId}`, "hello"); + + expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannel).toHaveBeenCalledTimes(1); + expect(res.channelId).toBe("dm-channel-id"); + }); + + it("does not apply user-first resolution for explicit channel: prefix", async () => { + // Unique token + id — explicit channel: prefix, no probe, no DM + const chanId = "eeeeee5555555555eeeeee5555"; // 26 chars + mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-chan-t5")); + + const res = await sendMessageMattermost(`channel:${chanId}`, "hello"); + + expect(mockState.fetchMattermostUser).not.toHaveBeenCalled(); + expect(mockState.createMattermostDirectChannel).not.toHaveBeenCalled(); + const params = mockState.createMattermostPost.mock.calls[0]?.[1]; + expect(params.channelId).toBe(chanId); + expect(res.channelId).toBe(chanId); + }); +}); diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index 7af69a65ada..4655dab2f7d 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -19,6 +19,7 @@ import { setInteractionSecret, type MattermostInteractiveButtonInput, } from "./interactions.js"; +import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js"; export type MattermostSendOpts = { cfg?: OpenClawConfig; @@ -50,6 +51,7 @@ type MattermostTarget = const botUserCache = new Map(); const userByNameCache = new Map(); const channelByNameCache = new Map(); +const dmChannelCache = new Map(); const getCore = () => getMattermostRuntime(); @@ -66,12 +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); -} - export function parseMattermostTarget(raw: string): MattermostTarget { const trimmed = raw.trim(); if (!trimmed) { @@ -208,12 +204,18 @@ 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, botToken: params.token, }); const channel = await createMattermostDirectChannel(client, [botUser.id, userId]); + dmChannelCache.set(dmKey, channel.id); return channel.id; } @@ -248,7 +250,18 @@ async function resolveMattermostSendContext( ); } - const target = parseMattermostTarget(to); + const trimmedTo = to?.trim() ?? ""; + 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, diff --git a/extensions/mattermost/src/mattermost/target-resolution.ts b/extensions/mattermost/src/mattermost/target-resolution.ts new file mode 100644 index 00000000000..d3b59a3e696 --- /dev/null +++ b/extensions/mattermost/src/mattermost/target-resolution.ts @@ -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(); + +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 { + 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(); +} diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 22f8e458e79..3bf3c07ddc6 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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; diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index 0965c54d6b9..cfc492abe3b 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -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); diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index 1c27ed08b55..33bd80d4118 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -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, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 8585f1c84aa..1ec5d23c133 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -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, diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index 0169e9c0ba4..d4a8a3466c6 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -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(); } diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 5cd7f78b809..72ccf3e3c55 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -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({ diff --git a/src/infra/outbound/target-resolver.test.ts b/src/infra/outbound/target-resolver.test.ts index bf5bdd7cb8c..643a5c3ed25 100644 --- a/src/infra/outbound/target-resolver.test.ts +++ b/src/infra/outbound/target-resolver.test.ts @@ -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(); + }); }); diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 06bd7d232ca..992206b1566 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -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 { + 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(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,