mattermost: fix DM media upload for unprefixed user IDs (#29925)
Merged via squash. Prepared head SHA: 5cffcb072cc82394fe4c93d6c1c0c520325180b7 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:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## 2026.3.2
|
||||||
|
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the mai
|
|||||||
Target format reminders:
|
Target format reminders:
|
||||||
|
|
||||||
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
|
||||||
|
Mattermost bare 26-char IDs are resolved **user-first** (DM if user exists, channel otherwise) — use `user:<id>` or `channel:<id>` for deterministic routing.
|
||||||
- Telegram topics should use the `:topic:` form (see below).
|
- Telegram topics should use the `:topic:` form (see below).
|
||||||
|
|
||||||
#### Telegram delivery targets (topics / forum threads)
|
#### Telegram delivery targets (topics / forum threads)
|
||||||
|
|||||||
@@ -153,7 +153,14 @@ Use these target formats with `openclaw message send` or cron/webhooks:
|
|||||||
- `user:<id>` for a DM
|
- `user:<id>` for a DM
|
||||||
- `@username` for a DM (resolved via the Mattermost API)
|
- `@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/<id>` 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:<id>` / `channel:<id>`).
|
||||||
|
|
||||||
## Reactions (message tool)
|
## Reactions (message tool)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
|||||||
import { probeMattermost } from "./mattermost/probe.js";
|
import { probeMattermost } from "./mattermost/probe.js";
|
||||||
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
||||||
import { sendMessageMattermost } from "./mattermost/send.js";
|
import { sendMessageMattermost } from "./mattermost/send.js";
|
||||||
|
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
|
||||||
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
||||||
import { mattermostOnboardingAdapter } from "./onboarding.js";
|
import { mattermostOnboardingAdapter } from "./onboarding.js";
|
||||||
import { getMattermostRuntime } from "./runtime.js";
|
import { getMattermostRuntime } from "./runtime.js";
|
||||||
@@ -340,6 +341,21 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: looksLikeMattermostTargetId,
|
looksLikeId: looksLikeMattermostTargetId,
|
||||||
hint: "<channelId|user:ID|channel:ID>",
|
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: {
|
outbound: {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { parseMattermostTarget, sendMessageMattermost } from "./send.js";
|
import { parseMattermostTarget, sendMessageMattermost } from "./send.js";
|
||||||
|
import { resetMattermostOpaqueTargetCacheForTests } from "./target-resolution.js";
|
||||||
|
|
||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
loadConfig: vi.fn(() => ({})),
|
loadConfig: vi.fn(() => ({})),
|
||||||
@@ -14,6 +15,7 @@ const mockState = vi.hoisted(() => ({
|
|||||||
createMattermostPost: vi.fn(),
|
createMattermostPost: vi.fn(),
|
||||||
fetchMattermostChannelByName: vi.fn(),
|
fetchMattermostChannelByName: vi.fn(),
|
||||||
fetchMattermostMe: vi.fn(),
|
fetchMattermostMe: vi.fn(),
|
||||||
|
fetchMattermostUser: vi.fn(),
|
||||||
fetchMattermostUserTeams: vi.fn(),
|
fetchMattermostUserTeams: vi.fn(),
|
||||||
fetchMattermostUserByUsername: vi.fn(),
|
fetchMattermostUserByUsername: vi.fn(),
|
||||||
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
|
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
|
||||||
@@ -34,6 +36,7 @@ vi.mock("./client.js", () => ({
|
|||||||
createMattermostPost: mockState.createMattermostPost,
|
createMattermostPost: mockState.createMattermostPost,
|
||||||
fetchMattermostChannelByName: mockState.fetchMattermostChannelByName,
|
fetchMattermostChannelByName: mockState.fetchMattermostChannelByName,
|
||||||
fetchMattermostMe: mockState.fetchMattermostMe,
|
fetchMattermostMe: mockState.fetchMattermostMe,
|
||||||
|
fetchMattermostUser: mockState.fetchMattermostUser,
|
||||||
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
|
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
|
||||||
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
|
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
|
||||||
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
|
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
|
||||||
@@ -77,9 +80,11 @@ describe("sendMessageMattermost", () => {
|
|||||||
mockState.createMattermostPost.mockReset();
|
mockState.createMattermostPost.mockReset();
|
||||||
mockState.fetchMattermostChannelByName.mockReset();
|
mockState.fetchMattermostChannelByName.mockReset();
|
||||||
mockState.fetchMattermostMe.mockReset();
|
mockState.fetchMattermostMe.mockReset();
|
||||||
|
mockState.fetchMattermostUser.mockReset();
|
||||||
mockState.fetchMattermostUserTeams.mockReset();
|
mockState.fetchMattermostUserTeams.mockReset();
|
||||||
mockState.fetchMattermostUserByUsername.mockReset();
|
mockState.fetchMattermostUserByUsername.mockReset();
|
||||||
mockState.uploadMattermostFile.mockReset();
|
mockState.uploadMattermostFile.mockReset();
|
||||||
|
resetMattermostOpaqueTargetCacheForTests();
|
||||||
mockState.createMattermostClient.mockReturnValue({});
|
mockState.createMattermostClient.mockReturnValue({});
|
||||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
|
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
|
||||||
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" });
|
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", () => {
|
describe("parseMattermostTarget", () => {
|
||||||
@@ -266,3 +326,110 @@ describe("parseMattermostTarget", () => {
|
|||||||
expect(parseMattermostTarget("Mattermost:QRS")).toEqual({ kind: "user", id: "QRS" });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
setInteractionSecret,
|
setInteractionSecret,
|
||||||
type MattermostInteractiveButtonInput,
|
type MattermostInteractiveButtonInput,
|
||||||
} from "./interactions.js";
|
} from "./interactions.js";
|
||||||
|
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
|
||||||
|
|
||||||
export type MattermostSendOpts = {
|
export type MattermostSendOpts = {
|
||||||
cfg?: OpenClawConfig;
|
cfg?: OpenClawConfig;
|
||||||
@@ -50,6 +51,7 @@ type MattermostTarget =
|
|||||||
const botUserCache = new Map<string, MattermostUser>();
|
const botUserCache = new Map<string, MattermostUser>();
|
||||||
const userByNameCache = new Map<string, MattermostUser>();
|
const userByNameCache = new Map<string, MattermostUser>();
|
||||||
const channelByNameCache = new Map<string, string>();
|
const channelByNameCache = new Map<string, string>();
|
||||||
|
const dmChannelCache = new Map<string, string>();
|
||||||
|
|
||||||
const getCore = () => getMattermostRuntime();
|
const getCore = () => getMattermostRuntime();
|
||||||
|
|
||||||
@@ -66,12 +68,6 @@ function normalizeMessage(text: string, mediaUrl?: string): string {
|
|||||||
function isHttpUrl(value: string): boolean {
|
function isHttpUrl(value: string): boolean {
|
||||||
return /^https?:\/\//i.test(value);
|
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 {
|
export function parseMattermostTarget(raw: string): MattermostTarget {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -208,12 +204,18 @@ async function resolveTargetChannelId(params: {
|
|||||||
token: params.token,
|
token: params.token,
|
||||||
username: params.target.username ?? "",
|
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 botUser = await resolveBotUser(params.baseUrl, params.token);
|
||||||
const client = createMattermostClient({
|
const client = createMattermostClient({
|
||||||
baseUrl: params.baseUrl,
|
baseUrl: params.baseUrl,
|
||||||
botToken: params.token,
|
botToken: params.token,
|
||||||
});
|
});
|
||||||
const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
|
const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
|
||||||
|
dmChannelCache.set(dmKey, channel.id);
|
||||||
return 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({
|
const channelId = await resolveTargetChannelId({
|
||||||
target,
|
target,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
|||||||
97
extensions/mattermost/src/mattermost/target-resolution.ts
Normal file
97
extensions/mattermost/src/mattermost/target-resolution.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -288,6 +288,18 @@ export type ChannelMessagingAdapter = {
|
|||||||
targetResolver?: {
|
targetResolver?: {
|
||||||
looksLikeId?: (raw: string, normalized?: string) => boolean;
|
looksLikeId?: (raw: string, normalized?: string) => boolean;
|
||||||
hint?: string;
|
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: {
|
formatTargetDisplay?: (params: {
|
||||||
target: string;
|
target: string;
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ vi.mock("../../infra/outbound/channel-selection.js", () => ({
|
|||||||
.mockResolvedValue({ channel: "telegram", configured: ["telegram"] }),
|
.mockResolvedValue({ channel: "telegram", configured: ["telegram"] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../infra/outbound/target-resolver.js", () => ({
|
||||||
|
maybeResolveIdLikeTarget: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../pairing/pairing-store.js", () => ({
|
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||||
readChannelAllowFromStoreSync: vi.fn(() => []),
|
readChannelAllowFromStoreSync: vi.fn(() => []),
|
||||||
}));
|
}));
|
||||||
@@ -23,6 +27,7 @@ vi.mock("../../web/accounts.js", () => ({
|
|||||||
|
|
||||||
import { loadSessionStore } from "../../config/sessions.js";
|
import { loadSessionStore } from "../../config/sessions.js";
|
||||||
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.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 { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
|
||||||
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||||
import { resolveDeliveryTarget } from "./delivery-target.js";
|
import { resolveDeliveryTarget } from "./delivery-target.js";
|
||||||
@@ -152,6 +157,30 @@ describe("resolveDeliveryTarget", () => {
|
|||||||
expect(result.accountId).toBeUndefined();
|
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 () => {
|
it("selects correct binding when multiple agents have bindings", async () => {
|
||||||
setMainSessionEntry(undefined);
|
setMainSessionEntry(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.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 type { OutboundChannel } from "../../infra/outbound/targets.js";
|
||||||
import {
|
import {
|
||||||
resolveOutboundTarget,
|
resolveOutboundTarget,
|
||||||
@@ -190,10 +191,16 @@ export async function resolveDeliveryTarget(
|
|||||||
error: docked.error,
|
error: docked.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const idLikeTarget = await maybeResolveIdLikeTarget({
|
||||||
|
cfg,
|
||||||
|
channel,
|
||||||
|
input: docked.to,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
channel,
|
channel,
|
||||||
to: docked.to,
|
to: idLikeTarget?.to ?? docked.to,
|
||||||
accountId,
|
accountId,
|
||||||
threadId,
|
threadId,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../../infra/outbound/outbound-session.js";
|
} from "../../infra/outbound/outbound-session.js";
|
||||||
import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js";
|
import { normalizeReplyPayloadsForDelivery } from "../../infra/outbound/payloads.js";
|
||||||
import { buildOutboundSessionContext } from "../../infra/outbound/session-context.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 { resolveOutboundTarget } from "../../infra/outbound/targets.js";
|
||||||
import { normalizePollInput } from "../../polls.js";
|
import { normalizePollInput } from "../../polls.js";
|
||||||
import {
|
import {
|
||||||
@@ -194,6 +195,13 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
meta: { channel },
|
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 outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : undefined;
|
||||||
const mirrorPayloads = normalizeReplyPayloadsForDelivery([
|
const mirrorPayloads = normalizeReplyPayloadsForDelivery([
|
||||||
{ text: message, mediaUrl, mediaUrls },
|
{ text: message, mediaUrl, mediaUrls },
|
||||||
@@ -225,7 +233,8 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
channel,
|
channel,
|
||||||
agentId: effectiveAgentId,
|
agentId: effectiveAgentId,
|
||||||
accountId,
|
accountId,
|
||||||
target: resolved.to,
|
target: deliveryTarget,
|
||||||
|
resolvedTarget: idLikeTarget,
|
||||||
threadId,
|
threadId,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
@@ -246,7 +255,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
|||||||
const results = await deliverOutboundPayloads({
|
const results = await deliverOutboundPayloads({
|
||||||
cfg,
|
cfg,
|
||||||
channel: outboundChannel,
|
channel: outboundChannel,
|
||||||
to: resolved.to,
|
to: deliveryTarget,
|
||||||
accountId,
|
accountId,
|
||||||
payloads: [{ text: message, mediaUrl, mediaUrls }],
|
payloads: [{ text: message, mediaUrl, mediaUrls }],
|
||||||
session: outboundSession,
|
session: outboundSession,
|
||||||
|
|||||||
@@ -583,7 +583,12 @@ function resolveMattermostSession(
|
|||||||
}
|
}
|
||||||
trimmed = trimmed.replace(/^mattermost:/i, "").trim();
|
trimmed = trimmed.replace(/^mattermost:/i, "").trim();
|
||||||
const lower = trimmed.toLowerCase();
|
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("@")) {
|
if (trimmed.startsWith("@")) {
|
||||||
trimmed = trimmed.slice(1).trim();
|
trimmed = trimmed.slice(1).trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
it("rejects bare numeric Discord targets when the caller has no kind hint", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
resolveOutboundSessionRoute({
|
resolveOutboundSessionRoute({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { resetDirectoryCache, resolveMessagingTarget } from "./target-resolver.j
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
listGroups: vi.fn(),
|
listGroups: vi.fn(),
|
||||||
listGroupsLive: vi.fn(),
|
listGroupsLive: vi.fn(),
|
||||||
|
resolveTarget: vi.fn(),
|
||||||
getChannelPlugin: vi.fn(),
|
getChannelPlugin: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ describe("resolveMessagingTarget (directory fallback)", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.listGroups.mockClear();
|
mocks.listGroups.mockClear();
|
||||||
mocks.listGroupsLive.mockClear();
|
mocks.listGroupsLive.mockClear();
|
||||||
|
mocks.resolveTarget.mockClear();
|
||||||
mocks.getChannelPlugin.mockClear();
|
mocks.getChannelPlugin.mockClear();
|
||||||
resetDirectoryCache();
|
resetDirectoryCache();
|
||||||
mocks.getChannelPlugin.mockReturnValue({
|
mocks.getChannelPlugin.mockReturnValue({
|
||||||
@@ -27,6 +29,11 @@ describe("resolveMessagingTarget (directory fallback)", () => {
|
|||||||
listGroups: mocks.listGroups,
|
listGroups: mocks.listGroups,
|
||||||
listGroupsLive: mocks.listGroupsLive,
|
listGroupsLive: mocks.listGroupsLive,
|
||||||
},
|
},
|
||||||
|
messaging: {
|
||||||
|
targetResolver: {
|
||||||
|
resolveTarget: mocks.resolveTarget,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,4 +82,43 @@ describe("resolveMessagingTarget (directory fallback)", () => {
|
|||||||
expect(mocks.listGroups).not.toHaveBeenCalled();
|
expect(mocks.listGroups).not.toHaveBeenCalled();
|
||||||
expect(mocks.listGroupsLive).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,44 @@ export async function resolveChannelTarget(params: {
|
|||||||
return resolveMessagingTarget(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 CACHE_TTL_MS = 30 * 60 * 1000;
|
||||||
const directoryCache = new DirectoryCache<ChannelDirectoryEntry[]>(CACHE_TTL_MS);
|
const directoryCache = new DirectoryCache<ChannelDirectoryEntry[]>(CACHE_TTL_MS);
|
||||||
|
|
||||||
@@ -388,6 +426,19 @@ export async function resolveMessagingTarget(params: {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if (looksLikeTargetId()) {
|
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({
|
return buildNormalizedResolveResult({
|
||||||
channel: params.channel,
|
channel: params.channel,
|
||||||
raw,
|
raw,
|
||||||
|
|||||||
Reference in New Issue
Block a user