Files
Moltbot/src/cron/isolated-agent/delivery-target.ts
bboyyan d94de5c4a1 fix(cron): normalize topic-qualified target.to in messaging tool suppress check (#29480)
* fix(cron): pass job.delivery.accountId through to delivery target resolution

* fix(cron): normalize topic-qualified target.to in messaging tool suppress check

When a cron job targets a Telegram forum topic (e.g. delivery.to =
"-1003597428309:topic:462"), delivery.to is stripped to the chatId
only by resolveOutboundTarget. However, the agent's message tool may
pass the full topic-qualified address as its target, causing
matchesMessagingToolDeliveryTarget to fail the equality check and not
suppress the tool send.

Strip the :topic:NNN suffix from target.to before comparing so the
suppress check works correctly for topic-bound cron deliveries.
Without this, the agent's message tool fires separately using the
announce session's accountId (often "default"), hitting 403 when
default bot is not in the multi-account target group.

* fix(cron): remove duplicate accountId keys after rebase

---------

Co-authored-by: jaxpkm <jaxpkm@jaxpkmdeMac-mini.local>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 10:32:06 -06:00

216 lines
7.0 KiB
TypeScript

import type { ChannelId } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
loadSessionStore,
resolveAgentMainSessionKey,
resolveStorePath,
} from "../../config/sessions.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import type { OutboundChannel } from "../../infra/outbound/targets.js";
import {
resolveOutboundTarget,
resolveSessionDeliveryTarget,
} from "../../infra/outbound/targets.js";
import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js";
import { buildChannelAccountBindings } from "../../routing/bindings.js";
import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js";
export type DeliveryTargetResolution =
| {
ok: true;
channel: Exclude<OutboundChannel, "none">;
to: string;
accountId?: string;
threadId?: string | number;
mode: "explicit" | "implicit";
}
| {
ok: false;
channel?: Exclude<OutboundChannel, "none">;
to?: string;
accountId?: string;
threadId?: string | number;
mode: "explicit" | "implicit";
error: Error;
};
export async function resolveDeliveryTarget(
cfg: OpenClawConfig,
agentId: string,
jobPayload: {
channel?: "last" | ChannelId;
to?: string;
/** Explicit accountId from job.delivery — overrides session-derived and binding-derived values. */
accountId?: string;
sessionKey?: string;
},
): Promise<DeliveryTargetResolution> {
const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined;
const allowMismatchedLastTo = requestedChannel === "last";
const sessionCfg = cfg.session;
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
const store = loadSessionStore(storePath);
// Look up thread-specific session first (e.g. agent:main:main:thread:1234),
// then fall back to the main session entry.
const threadSessionKey = jobPayload.sessionKey?.trim();
const threadEntry = threadSessionKey ? store[threadSessionKey] : undefined;
const main = threadEntry ?? store[mainSessionKey];
const preliminary = resolveSessionDeliveryTarget({
entry: main,
requestedChannel,
explicitTo,
allowMismatchedLastTo,
});
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
let channelResolutionError: Error | undefined;
if (!preliminary.channel) {
if (preliminary.lastChannel) {
fallbackChannel = preliminary.lastChannel;
} else {
try {
const selection = await resolveMessageChannelSelection({ cfg });
fallbackChannel = selection.channel;
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
channelResolutionError = new Error(
`${detail} Set delivery.channel explicitly or use a main session with a previous channel.`,
);
}
}
}
const resolved = fallbackChannel
? resolveSessionDeliveryTarget({
entry: main,
requestedChannel,
explicitTo,
fallbackChannel,
allowMismatchedLastTo,
mode: preliminary.mode,
})
: preliminary;
const channel = resolved.channel ?? fallbackChannel;
const mode = resolved.mode as "explicit" | "implicit";
let toCandidate = resolved.to;
// Prefer an explicit accountId from the job's delivery config (set via
// --account on cron add/edit). Fall back to the session's lastAccountId,
// then to the agent's bound account from bindings config.
const explicitAccountId =
typeof jobPayload.accountId === "string" && jobPayload.accountId.trim()
? jobPayload.accountId.trim()
: undefined;
let accountId = explicitAccountId ?? resolved.accountId;
if (!accountId && channel) {
const bindings = buildChannelAccountBindings(cfg);
const byAgent = bindings.get(channel);
const boundAccounts = byAgent?.get(normalizeAgentId(agentId));
if (boundAccounts && boundAccounts.length > 0) {
accountId = boundAccounts[0];
}
}
// job.delivery.accountId takes highest precedence — explicitly set by the job author.
if (jobPayload.accountId) {
accountId = jobPayload.accountId;
}
// Carry threadId when it was explicitly set (from :topic: parsing or config)
// or when delivering to the same recipient as the session's last conversation.
// Session-derived threadIds are dropped when the target differs to prevent
// stale thread IDs from leaking to a different chat.
const threadId =
resolved.threadId &&
(resolved.threadIdExplicit || (resolved.to && resolved.to === resolved.lastTo))
? resolved.threadId
: undefined;
if (!channel) {
return {
ok: false,
channel: undefined,
to: undefined,
accountId,
threadId,
mode,
error:
channelResolutionError ??
new Error("Channel is required when delivery.channel=last has no previous channel."),
};
}
if (!toCandidate) {
return {
ok: false,
channel,
to: undefined,
accountId,
threadId,
mode,
error:
channelResolutionError ??
new Error(`No delivery target resolved for channel "${channel}". Set delivery.to.`),
};
}
let allowFromOverride: string[] | undefined;
if (channel === "whatsapp") {
const resolvedAccountId = normalizeAccountId(accountId);
const configuredAllowFromRaw =
resolveWhatsAppAccount({ cfg, accountId: resolvedAccountId }).allowFrom ?? [];
const configuredAllowFrom = configuredAllowFromRaw
.map((entry) => String(entry).trim())
.filter((entry) => entry && entry !== "*")
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
const storeAllowFrom = readChannelAllowFromStoreSync("whatsapp", process.env, resolvedAccountId)
.map((entry) => normalizeWhatsAppTarget(entry))
.filter((entry): entry is string => Boolean(entry));
allowFromOverride = [...new Set([...configuredAllowFrom, ...storeAllowFrom])];
if (mode === "implicit" && allowFromOverride.length > 0) {
const normalizedCurrentTarget = normalizeWhatsAppTarget(toCandidate);
if (!normalizedCurrentTarget || !allowFromOverride.includes(normalizedCurrentTarget)) {
toCandidate = allowFromOverride[0];
}
}
}
const docked = resolveOutboundTarget({
channel,
to: toCandidate,
cfg,
accountId,
mode,
allowFrom: allowFromOverride,
});
if (!docked.ok) {
return {
ok: false,
channel,
to: undefined,
accountId,
threadId,
mode,
error: docked.error,
};
}
return {
ok: true,
channel,
to: docked.to,
accountId,
threadId,
mode,
};
}