mattermost: fix DM media upload for unprefixed user IDs (#29925)

Merged via squash.

Prepared head SHA: 5cffcb072c
Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
Teconomix
2026-03-10 09:52:24 +01:00
committed by GitHub
parent 568b0a22bb
commit 6d0547dc2e
15 changed files with 495 additions and 12 deletions

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,