* 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>
216 lines
7.0 KiB
TypeScript
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,
|
|
};
|
|
}
|