diff --git a/CHANGELOG.md b/CHANGELOG.md index faa93275f..4b86f1696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan. - **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`. - **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups. +- **BREAKING:** drop legacy target normalization helpers; use outbound target normalization and resolver flows. ### Changes - Tools: improve `web_fetch` extraction using Readability (with fallback). diff --git a/src/agents/pi-embedded-messaging.ts b/src/agents/pi-embedded-messaging.ts index d48e0b627..5aae66fd4 100644 --- a/src/agents/pi-embedded-messaging.ts +++ b/src/agents/pi-embedded-messaging.ts @@ -31,12 +31,3 @@ export function isMessagingToolSendAction( if (!plugin?.actions?.extractToolSend) return false; return Boolean(plugin.actions.extractToolSend({ args })?.to); } - -export function normalizeTargetForProvider(provider: string, raw?: string): string | undefined { - if (!raw) return undefined; - const providerId = normalizeChannelId(provider); - const plugin = providerId ? getChannelPlugin(providerId) : undefined; - const normalized = - plugin?.messaging?.normalizeTarget?.(raw) ?? (raw.trim().toLowerCase() || undefined); - return normalized || undefined; -} diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 5299e1dd4..158138972 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -1,6 +1,7 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import { truncateUtf16Safe } from "../utils.js"; -import { type MessagingToolSend, normalizeTargetForProvider } from "./pi-embedded-messaging.js"; +import { type MessagingToolSend } from "./pi-embedded-messaging.js"; +import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js"; const TOOL_RESULT_MAX_CHARS = 8000; diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 31303e23a..edfdc1cbc 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -1,5 +1,5 @@ import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; -import { normalizeTargetForProvider } from "../../agents/pi-embedded-messaging.js"; +import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; import type { ReplyToMode } from "../../config/types.js"; import type { OriginatingChannelType } from "../templating.js"; diff --git a/src/infra/outbound/channel-target.ts b/src/infra/outbound/channel-target.ts index fc53dda34..dae736abf 100644 --- a/src/infra/outbound/channel-target.ts +++ b/src/infra/outbound/channel-target.ts @@ -6,10 +6,6 @@ export const CHANNEL_TARGET_DESCRIPTION = export const CHANNEL_TARGETS_DESCRIPTION = "Recipient/channel targets (same format as --target); accepts ids or names when the directory is available."; -export function normalizeChannelTargetInput(raw: string): string { - return raw.trim(); -} - export function applyTargetToParams(params: { action: string; args: Record; diff --git a/src/infra/outbound/directory-cache.ts b/src/infra/outbound/directory-cache.ts index f2dd4f1f0..f1b6c43c2 100644 --- a/src/infra/outbound/directory-cache.ts +++ b/src/infra/outbound/directory-cache.ts @@ -11,10 +11,12 @@ export type DirectoryCacheKey = { accountId?: string | null; kind: ChannelDirectoryEntryKind; source: "cache" | "live"; + signature?: string | null; }; export function buildDirectoryCacheKey(key: DirectoryCacheKey): string { - return `${key.channel}:${key.accountId ?? "default"}:${key.kind}:${key.source}`; + const signature = key.signature ?? "default"; + return `${key.channel}:${key.accountId ?? "default"}:${key.kind}:${key.source}:${signature}`; } export class DirectoryCache { diff --git a/src/infra/outbound/outbound-policy.ts b/src/infra/outbound/outbound-policy.ts index 19961729f..9c0ecb027 100644 --- a/src/infra/outbound/outbound-policy.ts +++ b/src/infra/outbound/outbound-policy.ts @@ -1,4 +1,4 @@ -import { normalizeTargetForProvider } from "../../agents/pi-embedded-messaging.js"; +import { normalizeTargetForProvider } from "./target-normalization.js"; import type { ChannelId, ChannelMessageActionName, diff --git a/src/infra/outbound/target-errors.ts b/src/infra/outbound/target-errors.ts index 5064b0967..14caf79c3 100644 --- a/src/infra/outbound/target-errors.ts +++ b/src/infra/outbound/target-errors.ts @@ -1,8 +1,28 @@ export function missingTargetMessage(provider: string, hint?: string): string { - const suffix = hint ? ` ${hint}` : ""; - return `Delivering to ${provider} requires target${suffix}`; + return `Delivering to ${provider} requires target${formatHint(hint)}`; } export function missingTargetError(provider: string, hint?: string): Error { return new Error(missingTargetMessage(provider, hint)); } + +export function ambiguousTargetMessage(provider: string, raw: string, hint?: string): string { + return `Ambiguous target "${raw}" for ${provider}. Provide a unique name or an explicit id.${formatHint(hint, true)}`; +} + +export function ambiguousTargetError(provider: string, raw: string, hint?: string): Error { + return new Error(ambiguousTargetMessage(provider, raw, hint)); +} + +export function unknownTargetMessage(provider: string, raw: string, hint?: string): string { + return `Unknown target "${raw}" for ${provider}.${formatHint(hint, true)}`; +} + +export function unknownTargetError(provider: string, raw: string, hint?: string): Error { + return new Error(unknownTargetMessage(provider, raw, hint)); +} + +function formatHint(hint?: string, withLabel = false): string { + if (!hint) return ""; + return withLabel ? ` Hint: ${hint}` : ` ${hint}`; +} diff --git a/src/infra/outbound/target-normalization.ts b/src/infra/outbound/target-normalization.ts new file mode 100644 index 000000000..93bd79dba --- /dev/null +++ b/src/infra/outbound/target-normalization.ts @@ -0,0 +1,31 @@ +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import type { ChannelId } from "../../channels/plugins/types.js"; + +export function normalizeChannelTargetInput(raw: string): string { + return raw.trim(); +} + +export function normalizeTargetForProvider(provider: string, raw?: string): string | undefined { + if (!raw) return undefined; + const providerId = normalizeChannelId(provider); + const plugin = providerId ? getChannelPlugin(providerId) : undefined; + const normalized = + plugin?.messaging?.normalizeTarget?.(raw) ?? (raw.trim().toLowerCase() || undefined); + return normalized || undefined; +} + +export function buildTargetResolverSignature(channel: ChannelId): string { + const plugin = getChannelPlugin(channel); + const hint = plugin?.messaging?.targetHint ?? ""; + const looksLike = plugin?.messaging?.looksLikeTargetId; + const source = looksLike ? looksLike.toString() : ""; + return hashSignature(`${hint}|${source}`); +} + +function hashSignature(value: string): string { + let hash = 5381; + for (let i = 0; i < value.length; i += 1) { + hash = ((hash << 5) + hash) ^ value.charCodeAt(i); + } + return (hash >>> 0).toString(36); +} diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index 760e64791..14df9dd4c 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -1,4 +1,3 @@ -import { normalizeTargetForProvider } from "../../agents/pi-embedded-messaging.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelDirectoryEntry, @@ -7,8 +6,13 @@ import type { } from "../../channels/plugins/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; -import { normalizeChannelTargetInput } from "./channel-target.js"; import { buildDirectoryCacheKey, DirectoryCache } from "./directory-cache.js"; +import { + buildTargetResolverSignature, + normalizeChannelTargetInput, + normalizeTargetForProvider, +} from "./target-normalization.js"; +import { ambiguousTargetError, unknownTargetError } from "./target-errors.js"; export type TargetResolveKind = ChannelDirectoryEntryKind | "channel"; @@ -223,11 +227,13 @@ async function getDirectoryEntries(params: { runtime?: RuntimeEnv; preferLiveOnMiss?: boolean; }): Promise { + const signature = buildTargetResolverSignature(params.channel); const cacheKey = buildDirectoryCacheKey({ channel: params.channel, accountId: params.accountId, kind: params.kind, source: "cache", + signature, }); const cached = directoryCache.get(cacheKey, params.cfg); if (cached) return cached; @@ -249,6 +255,7 @@ async function getDirectoryEntries(params: { accountId: params.accountId, kind: params.kind, source: "live", + signature, }); const liveEntries = await listDirectoryEntries({ cfg: params.cfg, @@ -276,6 +283,9 @@ export async function resolveMessagingTarget(params: { if (!raw) { return { ok: false, error: new Error("Target is required") }; } + const plugin = getChannelPlugin(params.channel); + const providerLabel = plugin?.meta?.label ?? params.channel; + const hint = plugin?.messaging?.targetHint; const kind = detectTargetKind(raw, params.preferredKind); const normalized = normalizeTargetForProvider(params.channel, raw) ?? raw; if (looksLikeTargetId({ channel: params.channel, raw, normalized })) { @@ -316,13 +326,13 @@ export async function resolveMessagingTarget(params: { if (match.kind === "ambiguous") { return { ok: false, - error: new Error(`Ambiguous target "${raw}". Provide a unique name or an explicit id.`), + error: ambiguousTargetError(providerLabel, raw, hint), candidates: match.entries, }; } return { ok: false, - error: new Error(`Unknown target "${raw}" for ${params.channel}.`), + error: unknownTargetError(providerLabel, raw, hint), }; }