diff --git a/src/infra/outbound/target-resolver.test.ts b/src/infra/outbound/target-resolver.test.ts index 9ecbbdd57328..12139cead5c2 100644 --- a/src/infra/outbound/target-resolver.test.ts +++ b/src/infra/outbound/target-resolver.test.ts @@ -130,7 +130,7 @@ describe("resolveMessagingTarget (directory fallback)", () => { expect(mocks.listGroupsLive).toHaveBeenCalledTimes(1); }); - it("rejects plugin-reserved literal targets before directory lookup", async () => { + it("preserves configured directory entries before rejecting reserved literal targets", async () => { mocks.getChannelPlugin.mockReturnValue({ ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), directory: { @@ -162,13 +162,47 @@ describe("resolveMessagingTarget (directory fallback)", () => { input: "current", }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.target.to).toBe("-1002458651455"); + expect(result.target.source).toBe("directory"); + } + expect(mocks.listGroups).toHaveBeenCalled(); + expect(mocks.resolveTarget).not.toHaveBeenCalled(); + }); + + it("rejects reserved literal targets after directory miss", async () => { + mocks.getChannelPlugin.mockReturnValue({ + ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), + directory: { + listPeers: mocks.listPeers, + listPeersLive: mocks.listPeersLive, + listGroups: mocks.listGroups, + listGroupsLive: mocks.listGroupsLive, + }, + messaging: { + targetResolver: { + reservedLiterals: ["current", "self", "this", "me"], + hint: "", + resolveTarget: mocks.resolveTarget, + }, + }, + }); + mocks.listGroups.mockResolvedValue([]); + mocks.listGroupsLive.mockResolvedValue([]); + + const result = await resolveMessagingTarget({ + cfg, + channel: "telegram", + input: "current", + }); + expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.message).toContain('Reserved target "current"'); expect(result.error.message).toContain("Telegram"); } - expect(mocks.listGroups).not.toHaveBeenCalled(); - expect(mocks.listGroupsLive).not.toHaveBeenCalled(); + expect(mocks.listGroups).toHaveBeenCalled(); expect(mocks.resolveTarget).not.toHaveBeenCalled(); }); diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 4aea73a3caa2..c84060009f6b 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -400,10 +400,6 @@ export async function resolveMessagingTarget(params: { const plugin = params.plugin ?? getChannelPlugin(params.channel); const providerLabel = plugin?.meta?.label ?? params.channel; const hint = plugin?.messaging?.targetResolver?.hint; - const reservedLiteral = resolveReservedTargetLiteral({ raw, plugin }); - if (reservedLiteral) { - return { ok: false, error: reservedTargetLiteralError(providerLabel, reservedLiteral, hint) }; - } const kind = detectTargetKind(params.channel, raw, params.preferredKind, plugin); const normalizedInput = resolveNormalizedTargetInput(params.channel, raw, plugin); const normalized = normalizedInput?.normalized ?? raw; @@ -483,6 +479,13 @@ export async function resolveMessagingTarget(params: { candidates: match.entries, }; } + // Directory miss: reject reserved literals before falling back to plugin + // resolution, so a bare reserved word without a matching directory entry + // does not accidentally resolve to a public channel or incorrect target. + const reservedLiteral = resolveReservedTargetLiteral({ raw, plugin }); + if (reservedLiteral) { + return { ok: false, error: reservedTargetLiteralError(providerLabel, reservedLiteral, hint) }; + } const resolvedFallbackTarget = asResolvedMessagingTarget( await maybeResolvePluginMessagingTarget({ cfg: params.cfg,