feat(heartbeat): default delivery to the configured owner, never groups (#121988)

Unset heartbeat.target now resolves "owner": elected heartbeat notifications deliver to the operator's DM resolved from commands.ownerAllowFrom or the channel allowFrom (first concrete entry; wildcards and channel-scoped wildcards excluded; configured owners exhausted across channels before any channel-local fallback). Delivery requires the channel's own classifier to positively prove a direct destination — every bundled messaging plugin now ships an inferTargetChatType contract — and unproven or group-shaped destinations fail closed to the visible no-route state. The first implicitly-routed delivery carries a one-line self-explanation naming the target: "none" opt-out. Explicit target "last" remains as the follow-the-conversation opt-in. Refines the unreleased #121892 default before it ships; refs #121880.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ayaan Zaidi
2026-08-11 18:48:22 +05:30
committed by GitHub
parent 8862cc46b3
commit 2c8ed54ddb
60 changed files with 1135 additions and 107 deletions
+3 -1
View File
@@ -532,7 +532,7 @@ Periodic heartbeat runs.
activeHours: { start: "08:00", end: "24:00" },
model: "openai/gpt-5.4-mini",
session: "main",
target: "last", // default: last | options: none | whatsapp | telegram | discord | ...
target: "owner", // default | options: last | none | whatsapp | telegram | discord | ...
directPolicy: "allow", // allow (default) | block
to: "+15555550123",
accountId: "ops-bot",
@@ -552,6 +552,8 @@ Periodic heartbeat runs.
- The heartbeat object is strict. Its supported fields are `every`, `activeHours`, `model`, `session`, `target`, `directPolicy`, `to`, `accountId`, `prompt`, `timeoutSeconds`, `lightContext`, and `isolatedSession`.
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
- `target`: `owner` (default) sends only to a direct-message identity from `commands.ownerAllowFrom` or channel `allowFrom`. `last` explicitly follows the latest conversation, including groups. `none` keeps results internal.
- `to`: used only with an explicit channel target. `owner` and an unset target ignore it.
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and skip workspace bootstrap files. Monitor scratch is injected by the heartbeat runner either way.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.
- Busy deferral is automatic: scheduled heartbeats wait for main/cron activity, same-agent active runs, and target-session work. Immediate and manual wakes bypass only the broad same-agent active-run precheck.
+1 -1
View File
@@ -251,7 +251,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
heartbeat: {
every: "30m",
model: "anthropic/claude-sonnet-4-6",
target: "last",
target: "whatsapp",
directPolicy: "allow", // allow (default) | block
to: "+15555550123",
prompt: "HEARTBEAT",
+2 -2
View File
@@ -390,7 +390,7 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`.
defaults: {
heartbeat: {
every: "30m",
target: "last",
target: "owner",
},
},
},
@@ -398,7 +398,7 @@ candidate contains a redacted secret placeholder such as `***` or `[redacted]`.
```
- `every`: duration string (`30m`, `2h`). Set `0m` to disable. Default: `30m`.
- `target`: `last` | `none` | `<channel-id>` (for example `discord`, `matrix`, `telegram`, or `whatsapp`)
- `target`: `owner` (default operator DM) | `last` (latest conversation, including groups) | `none` (internal only) | `<channel-id>`
- `directPolicy`: `allow` (default) or `block` for DM-style heartbeat targets
- See [Heartbeat](/gateway/heartbeat) for the full guide.
+17 -11
View File
@@ -31,7 +31,7 @@ Troubleshooting: [Automations](/automation/cron-jobs#troubleshooting)
Store a tiny checklist in the heartbeat monitor's scratch with `openclaw cron scratch <jobId> --set "..."`.
</Step>
<Step title="Decide where heartbeat messages should go">
Heartbeat messages go to the last conversation by default. On a fresh install, message your bot once to establish that route.
Heartbeat alerts go to the operator's direct message by default. Set `commands.ownerAllowFrom` or a concrete channel `allowFrom`; wildcard-only allowlists do not identify an owner.
</Step>
<Step title="Optional tuning">
- Use lightweight bootstrap context if heartbeat runs only need the monitor scratch.
@@ -45,11 +45,14 @@ Example config:
```json5
{
commands: {
ownerAllowFrom: ["telegram:123456789"],
},
agents: {
defaults: {
heartbeat: {
every: "30m",
target: "last", // default: deliver to the last conversation
target: "owner", // default: operator DM from ownerAllowFrom or channel allowFrom
directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
lightContext: true, // optional: skip workspace bootstrap files for heartbeat runs
isolatedSession: true, // optional: fresh session each run (no conversation history)
@@ -63,7 +66,7 @@ Example config:
## Defaults
- Interval: `30m`. Applying Anthropic provider defaults bumps this to `1h` when the resolved auth mode is OAuth/token (including Claude CLI reuse), but only while `heartbeat.every` is unset. Set `agents.defaults.heartbeat.every` or per-agent `agents.entries.*.heartbeat.every`; use `0m` to disable.
- Delivery target: `last`. Until the agent has a last conversation route, polls skip with `reason=no-route`; message the bot once or set an explicit `target` and `to`. Set `target: "none"` for internal-only runs.
- Delivery target: `owner`. OpenClaw uses the first concrete `commands.ownerAllowFrom` entry, then channel `allowFrom`, and never sends this route to a group. Without a resolvable owner DM, ambient polls skip with `reason=no-route`. Set `target: "last"` to follow the most recent conversation, including groups, or `target: "none"` for internal-only runs.
- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Follow the heartbeat monitor scratch context when provided. Recurring tasks are automations; create or change their schedules with the automations tool, not heartbeat scratch. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
- Timeout: unset heartbeat turns use `agents.defaults.timeoutSeconds` when set. Otherwise, they use the heartbeat cadence capped at 600 seconds. Set `agents.defaults.heartbeat.timeoutSeconds` or per-agent `agents.entries.*.heartbeat.timeoutSeconds` for longer heartbeat work.
- The heartbeat prompt is sent **verbatim** as the user message. The system prompt automatically includes a "Heartbeats" section when cadence is enabled for the default agent; that guidance has no separate heartbeat toggle.
@@ -119,8 +122,7 @@ Outside heartbeats, stray `HEARTBEAT_OK` at the start/end of a message is stripp
model: "anthropic/claude-opus-4-6",
lightContext: false, // default: false; true skips workspace bootstrap files for heartbeat runs
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
target: "last", // default: last | options: none | <channel id> (core or plugin, e.g. "imessage")
to: "+15551234567", // optional channel-specific override
target: "owner", // default | options: last | none | <channel id>
accountId: "ops-bot", // optional multi-account channel id
prompt: "Follow the heartbeat monitor scratch context when provided. Recurring tasks are automations; create or change their schedules with the automations tool, not heartbeat scratch. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
},
@@ -149,7 +151,7 @@ Example: two agents, only the second agent runs heartbeats.
defaults: {
heartbeat: {
every: "30m",
target: "last", // default: deliver to the last conversation
target: "owner", // default: operator DM
},
},
entries: {
@@ -178,7 +180,7 @@ Restrict heartbeats to business hours in a specific timezone:
defaults: {
heartbeat: {
every: "30m",
target: "last", // default: deliver to the last conversation
target: "owner", // default: operator DM
activeHours: {
start: "09:00",
end: "22:00",
@@ -255,7 +257,8 @@ Use `accountId` to target a specific account on multi-account channels like Tele
</ParamField>
<ParamField path="target" type="string">
- `last` (default): deliver to the last used external channel.
- `owner` (default): deliver to the first resolvable operator DM from `commands.ownerAllowFrom`, then channel `allowFrom`. This route never resolves to a group or channel.
- `last`: explicitly follow the last used external conversation, including groups and channels.
- explicit channel: any configured channel or plugin id, for example `discord`, `matrix`, `telegram`, or `whatsapp`.
- `none`: run the heartbeat for internal state only; **do not deliver** externally.
@@ -265,7 +268,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele
</ParamField>
<ParamField path="to" type="string">
Optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `<chatId>:topic:<messageThreadId>`.
Recipient for an explicit channel target (for example, E.164 for WhatsApp or a Telegram chat id). `owner` and an unset target ignore `to`. For Telegram topics/threads, use `<chatId>:topic:<messageThreadId>`.
</ParamField>
<ParamField path="accountId" type="string">
@@ -301,10 +304,13 @@ Heartbeat configuration is strict: only the fields listed above are accepted. Ac
<Accordion title="Session and target routing">
- Heartbeats run in the agent's main session by default (`agent:<id>:<mainKey>`), or `global` when `session.scope = "global"`. Set `session` to override to a specific channel session (Discord/WhatsApp/etc.).
- `session` only affects the run context; delivery is controlled by `target` and `to`.
- To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session.
- The default `owner` target chooses an explicitly configured owner identity. It reuses the exact account/thread only when the session's last route is a direct chat to that owner.
- A wake that carries a channel and recipient uses that named origin before owner discovery. This event destination can be a group because it is explicit, not inferred.
- To deliver to a specific channel/recipient, set a channel `target` plus `to`. `target: "last"` is an explicit opt-in to the last external conversation, including groups.
- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn.
- Scheduled heartbeats are skipped and retried later when the main queue or automation work is busy, any reply or embedded run for the same agent is active, or the resolved target session has active or queued work. Immediate and manual wakes bypass only the broad same-agent active-run precheck.
- If `target: "last"` has no external destination yet, the poll is skipped as `reason=no-route` before the agent runs. Message the bot once to establish a route, or set an explicit `target` and `to`.
- If `owner` has no concrete, DM-capable owner or configured channel, the poll is skipped as `reason=no-route` before the agent runs. Explicit `last` also skips when the session has no external route.
- The first alert delivered by the implicit `owner` default explains periodic checks and how to choose `target: "none"`. Later alerts omit that line.
</Accordion>
<Accordion title="Visibility and skip behavior">
+1 -1
View File
@@ -764,7 +764,7 @@ Look for:
- `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors.
- `heartbeat skipped` with `reason=quiet-hours` → outside active hours window.
- `heartbeat skipped` with `reason=empty-heartbeat-file` → heartbeat monitor scratch only contains blank, comment, header, fence, or empty-checklist scaffolding, so OpenClaw skips the model call.
- `heartbeat skipped` with `reason=no-route` → the default `target: "last"` has no conversation route yet; message the bot once or configure an explicit heartbeat target.
- `heartbeat skipped` with `reason=no-route` → the default `owner` target has no concrete owner in `commands.ownerAllowFrom` or channel `allowFrom`, the owner cannot resolve to a DM, or no channel is configured. Explicit `last` also needs a session conversation route.
- `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target.
- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style destination while `agents.defaults.heartbeat.directPolicy` (or per-agent override) is set to `block`.
+2
View File
@@ -753,6 +753,8 @@ outbound host generic and use the messaging adapter surface for provider rules:
- `messaging.inferTargetChatType({ to })` decides whether a normalized target
should be treated as `direct`, `group`, or `channel` before directory lookup.
Implicit owner heartbeat delivery requires this direct classification; without
it, Gateway status reports `waiting for route`.
- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
input should skip straight to id-like resolution instead of directory search.
- `messaging.targetResolver.reservedLiterals` lists bare words that are
+1 -1
View File
@@ -735,7 +735,7 @@ two-party event loops that do not go through the shared inbound reply runner.
const hint = api.runtime.system.formatNativeDependencyHint(pkg);
```
`runHeartbeatOnce(...)` runs a single heartbeat cycle immediately, bypassing the normal coalesce timer. Delivery defaults to the last active conversation; pass `{ heartbeat: { target: "none" } }` for an internal-only run.
`runHeartbeatOnce(...)` runs a single heartbeat cycle immediately, bypassing the normal coalesce timer. Delivery defaults to the configured operator DM (`commands.ownerAllowFrom`, then channel `allowFrom`); pass `{ heartbeat: { target: "none" } }` for an internal-only run.
`runCommandWithTimeout(...)` returns captured `stdout` and `stderr`, optional
truncation counts, `code`, `signal`, `killed`, `termination`, and
+7
View File
@@ -5,6 +5,13 @@ import type { OpenClawConfig } from "../runtime-api.js";
import { feishuPlugin } from "./channel.js";
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
describe("feishu target classification", () => {
it("distinguishes users from chats", () => {
expect(feishuPlugin.messaging?.inferTargetChatType?.({ to: "ou_owner" })).toBe("direct");
expect(feishuPlugin.messaging?.inferTargetChatType?.({ to: "oc_group" })).toBe("group");
});
});
const probeFeishuMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const addReactionFeishuMock = vi.hoisted(() => vi.fn());
+3 -1
View File
@@ -110,7 +110,7 @@ import { resolveFeishuSessionConversation } from "./session-conversation.js";
import { resolveFeishuOutboundSessionRoute } from "./session-route.js";
import { feishuSetupContract } from "./setup-core.js";
import { feishuSetupWizard, runFeishuLogin } from "./setup-surface.js";
import { looksLikeFeishuId, normalizeFeishuTarget } from "./targets.js";
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
import type { FeishuConfig, FeishuProbeResult, ResolvedFeishuAccount } from "./types.js";
function readFeishuMediaParam(params: Record<string, unknown>): string | undefined {
@@ -1693,6 +1693,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
messaging: {
targetPrefixes: ["feishu", "lark"],
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
inferTargetChatType: ({ to }) =>
resolveReceiveIdType(to) === "chat_id" ? "group" : "direct",
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
const directId = parseFeishuDirectConversationId(conversationId);
if (directId) {
@@ -25,6 +25,14 @@ describe("googlechatPlugin config adapter", () => {
expect(googlechatPlugin.capabilities?.reactions).toBeUndefined();
});
it("classifies Google Chat users as direct and spaces as groups", () => {
const inferTargetChatType = googlechatPlugin.messaging?.inferTargetChatType;
expect(inferTargetChatType?.({ to: "users/abc" })).toBe("direct");
expect(inferTargetChatType?.({ to: "spaces/xyz" })).toBe("group");
expect(inferTargetChatType?.({ to: "unknown" })).toBeUndefined();
});
it("does not advertise user-auth-only actions", () => {
const cfg = {
channels: {
+10
View File
@@ -75,6 +75,16 @@ export const googlechatPlugin = createChatChannelPlugin({
targetPrefixes: ["googlechat", "google-chat", "gchat"],
targetIdComparison: "case-sensitive",
normalizeTarget: normalizeGoogleChatTarget,
inferTargetChatType: ({ to }) => {
const target = normalizeGoogleChatTarget(to);
if (!target) {
return undefined;
}
if (isGoogleChatUserTarget(target)) {
return "direct";
}
return isGoogleChatSpaceTarget(target) ? "group" : undefined;
},
resolveOutboundSessionRoute: (params) => resolveGoogleChatOutboundSessionRoute(params),
targetResolver: {
looksLikeId: (raw, normalized) => {
+8
View File
@@ -1,5 +1,6 @@
// Irc tests cover channel plugin behavior.
import { describe, expect, it } from "vitest";
import { ircPlugin } from "./channel.js";
import { ircOutboundBaseAdapter } from "./outbound-base.js";
describe("irc outbound chunking", () => {
@@ -10,3 +11,10 @@ describe("irc outbound chunking", () => {
expect(ircOutboundBaseAdapter.textChunkLimit).toBe(350);
});
});
describe("irc target classification", () => {
it("distinguishes nicknames from channels", () => {
expect(ircPlugin.messaging?.inferTargetChatType?.({ to: "alice" })).toBe("direct");
expect(ircPlugin.messaging?.inferTargetChatType?.({ to: "#operators" })).toBe("group");
});
});
+4
View File
@@ -228,6 +228,10 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChat
messaging: {
targetPrefixes: ["irc"],
normalizeTarget: normalizeIrcMessagingTarget,
inferTargetChatType: ({ to }) => {
const target = normalizeIrcMessagingTarget(to);
return target ? (isChannelTarget(target) ? "group" : "direct") : undefined;
},
resolveOutboundSessionRoute: (params) => resolveIrcOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeIrcTargetId,
@@ -10,6 +10,17 @@ import type { MatrixSetupInput } from "./setup-config.js";
import { installMatrixTestRuntime } from "./test-runtime.js";
import type { CoreConfig } from "./types.js";
describe("matrix target classification", () => {
it("distinguishes users from rooms", () => {
expect(matrixPlugin.messaging?.inferTargetChatType?.({ to: "@owner:example.org" })).toBe(
"direct",
);
expect(matrixPlugin.messaging?.inferTargetChatType?.({ to: "!room:example.org" })).toBe(
"channel",
);
});
});
function requireMatrixDirectory() {
const directory = matrixPlugin.directory;
if (!directory?.listPeers || !directory.listGroups) {
+4
View File
@@ -436,6 +436,10 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
targetPrefixes: ["matrix"],
targetIdComparison: "case-sensitive",
normalizeTarget: normalizeMatrixMessagingTarget,
inferTargetChatType: ({ to }) => {
const target = resolveMatrixTargetIdentity(to);
return target ? (target.kind === "user" ? "direct" : "channel") : undefined;
},
resolveInboundConversation: ({ to, conversationId, threadId }) =>
resolveMatrixInboundConversation({ to, conversationId, threadId }),
resolveDeliveryTarget: ({ conversationId, parentConversationId }) =>
+10
View File
@@ -31,6 +31,16 @@ import {
withMockedGlobalFetch,
} from "./mattermost/reactions.test-helpers.js";
describe("mattermost target classification", () => {
it("requires an explicit user namespace for direct targets", () => {
expect(mattermostPlugin.messaging?.inferTargetChatType?.({ to: "user:owner" })).toBe("direct");
expect(mattermostPlugin.messaging?.inferTargetChatType?.({ to: "channel:operators" })).toBe(
"channel",
);
expect(mattermostPlugin.messaging?.inferTargetChatType?.({ to: "ambiguous" })).toBeUndefined();
});
});
type MattermostHandleAction = NonNullable<
NonNullable<typeof mattermostPlugin.actions>["handleAction"]
>;
+7
View File
@@ -972,6 +972,13 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
targetIdComparison: "case-sensitive",
defaultMarkdownTableMode: "off",
normalizeTarget: normalizeMattermostMessagingTarget,
inferTargetChatType: ({ to }) => {
const target = normalizeMattermostMessagingTarget(to);
if (!target) {
return undefined;
}
return target.startsWith("user:") || target.startsWith("@") ? "direct" : "channel";
},
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
const parent = parentConversationId?.trim();
const child = conversationId.trim();
+15
View File
@@ -19,6 +19,21 @@ function createConfiguredMSTeamsCfg(): OpenClawConfig {
}
describe("msteamsPlugin", () => {
it("distinguishes users from channel and group conversations", () => {
const infer = msteamsPlugin.messaging?.inferTargetChatType;
const ownerId = "00000000-0000-0000-0000-000000000001";
expect(infer?.({ to: ownerId })).toBe("direct");
expect(infer?.({ to: "19:channel@thread.tacv2" })).toBe("channel");
expect(infer?.({ to: "19:group@thread.v2" })).toBe("group");
expect(
msteamsPlugin.messaging?.resolveOutboundSessionRoute?.({
cfg: {},
agentId: "main",
target: ownerId,
}),
).toMatchObject({ chatType: "direct" });
});
it("shares account and metadata contracts with the lightweight setup plugin", () => {
expect(msteamsSetupPlugin.meta).toEqual(msteamsPlugin.meta);
+2 -1
View File
@@ -71,7 +71,7 @@ import {
resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
import { resolveMSTeamsOutboundSessionRoute } from "./session-route.js";
import { inferMSTeamsTargetChatType, resolveMSTeamsOutboundSessionRoute } from "./session-route.js";
import { msteamsSetupContract } from "./setup-core.js";
import { msteamsSetupWizard } from "./setup-surface.js";
import { resolveMSTeamsCredentials } from "./token.js";
@@ -464,6 +464,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
targetPrefixes: ["msteams", "teams"],
directTargetStyle: "user-prefixed",
normalizeTarget: normalizeMSTeamsMessagingTarget,
inferTargetChatType: ({ to }) => inferMSTeamsTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params),
targetResolver: {
looksLikeId: (raw) => looksLikeMSTeamsTargetId(raw),
+27 -5
View File
@@ -9,20 +9,43 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer
import { extractMSTeamsConversationMessageId, normalizeMSTeamsConversationId } from "./inbound.js";
import { resolveMSTeamsRouteSessionKey } from "./monitor-handler/thread-session.js";
export function inferMSTeamsTargetChatType(
raw: string,
): "direct" | "group" | "channel" | undefined {
const target = stripChannelTargetPrefix(raw, "msteams", "teams");
if (!target) {
return undefined;
}
const lower = normalizeLowercaseStringOrEmpty(target);
const rawId = stripTargetKindPrefix(target);
if (!rawId) {
return undefined;
}
const conversationId = normalizeMSTeamsConversationId(rawId);
if (lower.startsWith("user:") || /^[0-9a-f-]{16,}$/i.test(conversationId)) {
return "direct";
}
if (/@thread\.tacv2/i.test(conversationId)) {
return "channel";
}
return /^19:.+@thread\.(?:skype|v2)$/i.test(conversationId) ? "group" : undefined;
}
export function resolveMSTeamsOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
const trimmed = stripChannelTargetPrefix(params.target, "msteams", "teams");
if (!trimmed) {
return null;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
const isUser = lower.startsWith("user:");
const resolvedKind = params.resolvedTarget?.kind;
const targetChatType = inferMSTeamsTargetChatType(trimmed);
const isUser = resolvedKind === "user" || targetChatType === "direct";
const rawId = stripTargetKindPrefix(trimmed);
if (!rawId) {
return null;
}
const conversationId = normalizeMSTeamsConversationId(rawId);
const isChannel = !isUser && /@thread\.tacv2/i.test(conversationId);
const isChannel = !isUser && targetChatType === "channel";
const embeddedThreadId = extractMSTeamsConversationMessageId(rawId);
const explicitThreadId = params.threadId ?? params.replyToId;
const channelThreadId =
@@ -30,12 +53,11 @@ export function resolveMSTeamsOutboundSessionRoute(params: ChannelOutboundSessio
(explicitThreadId !== undefined && explicitThreadId !== null
? String(explicitThreadId)
: undefined);
const resolvedKind = params.resolvedTarget?.kind;
const isCanonicalUserId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
conversationId,
);
const recipientSessionExact =
((isUser || resolvedKind === "user") && isCanonicalUserId) ||
(isUser && isCanonicalUserId) ||
(isChannel ? channelThreadId !== undefined : resolvedKind === "group");
const route = buildChannelOutboundSessionRoute({
cfg: params.cfg,
@@ -3,6 +3,12 @@ import { describe, expect, it } from "vitest";
import { nextcloudTalkPlugin } from "./channel.js";
describe("nextcloud-talk channel status", () => {
it("classifies room tokens as groups", () => {
expect(nextcloudTalkPlugin.messaging?.inferTargetChatType?.({ to: "room:abcdefgh" })).toBe(
"group",
);
});
it("surfaces missing response feature probes as config issues", () => {
const issues = nextcloudTalkPlugin.status?.collectStatusIssues?.([
{
+2
View File
@@ -112,6 +112,8 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
messaging: {
targetPrefixes: ["nextcloud-talk", "nc-talk", "nc"],
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
inferTargetChatType: ({ to }) =>
normalizeNextcloudTalkMessagingTarget(to) ? "group" : undefined,
resolveOutboundSessionRoute: (params) => resolveNextcloudTalkOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeNextcloudTalkTargetId,
+11
View File
@@ -19,6 +19,17 @@ import {
} from "./test-fixtures.js";
import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
describe("nostr target classification", () => {
it("accepts only valid direct-message public keys", () => {
expect(nostrPlugin.messaging?.inferTargetChatType?.({ to: TEST_HEX_PUBLIC_KEY })).toBe(
"direct",
);
expect(
nostrPlugin.messaging?.inferTargetChatType?.({ to: "not-a-public-key" }),
).toBeUndefined();
});
});
function normalizeNostrTestEntry(entry: string): string {
return entry
.trim()
+10
View File
@@ -57,6 +57,15 @@ function normalizeNostrTarget(target: string): string {
}
}
function inferNostrTargetChatType(target: string): "direct" | undefined {
try {
normalizePubkey(stripNostrTargetPrefix(target));
return "direct";
} catch {
return undefined;
}
}
const resolveNostrDmPolicy = createScopedDmSecurityResolver<ResolvedNostrAccount>({
channelKey: "nostr",
resolvePolicy: (account) => account.config.dmPolicy,
@@ -156,6 +165,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = createChatChanne
messaging: {
targetPrefixes: ["nostr"],
normalizeTarget: normalizeNostrTarget,
inferTargetChatType: ({ to }) => inferNostrTargetChatType(to),
targetResolver: {
looksLikeId: (input, normalized) => {
const trimmed = normalized?.trim() || stripNostrTargetPrefix(input);
@@ -5,6 +5,13 @@ import { describe, expect, it, vi } from "vitest";
import { qqbotPlugin } from "./channel.js";
describe("qqbotPlugin metadata", () => {
it("distinguishes c2c targets from shared targets", () => {
const infer = qqbotPlugin.messaging?.inferTargetChatType;
expect(infer?.({ to: "qqbot:c2c:owner" })).toBe("direct");
expect(infer?.({ to: "qqbot:group:operators" })).toBe("group");
expect(infer?.({ to: "qqbot:channel:alerts" })).toBe("group");
});
it("opts announce delivery into persisted session lookup", () => {
expect(qqbotPlugin.meta.preferSessionLookupForAnnounceTarget).toBe(true);
});
+7
View File
@@ -326,6 +326,13 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
targetPrefixes: ["qqbot"],
/** Normalize common QQ Bot target formats into the canonical qqbot:... form. */
normalizeTarget: coreNormalizeTarget,
inferTargetChatType: ({ to }) => {
try {
return parseTarget(to).type === "c2c" ? "direct" : "group";
} catch {
return undefined;
}
},
resolveOutboundSessionRoute: (params) => resolveQQBotOutboundSessionRoute(params),
targetResolver: {
/** Return true when the id looks like a QQ Bot target. */
+2
View File
@@ -432,6 +432,8 @@ export const smsPlugin: ChannelPlugin<ResolvedSmsAccount, SmsProbe> = createChat
messaging: {
targetPrefixes: ["twilio-sms"],
normalizeTarget: (target) => normalizeSmsPhoneNumber(target),
inferTargetChatType: ({ to }) =>
looksLikeSmsPhoneNumber(normalizeSmsPhoneNumber(to)) ? "direct" : undefined,
resolveOutboundSessionRoute: (params) => resolveSmsOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeSmsPhoneNumber,
+5
View File
@@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest";
import { smsPlugin } from "./channel.js";
describe("SMS outbound session routing", () => {
it("classifies valid phone numbers as direct", () => {
expect(smsPlugin.messaging?.inferTargetChatType?.({ to: "+1 (555) 123-4567" })).toBe("direct");
expect(smsPlugin.messaging?.inferTargetChatType?.({ to: "not-a-phone" })).toBeUndefined();
});
it("uses the canonical inbound phone session", async () => {
const route = await smsPlugin.messaging?.resolveOutboundSessionRoute?.({
cfg: { session: { dmScope: "per-channel-peer" } },
@@ -53,6 +53,13 @@ vi.mock("./webhook-handler.js", () => ({
const { synologyChatPlugin } = await import("./channel.js");
const getSynologyChatSetupStatus = createPluginSetupWizardStatus(synologyChatPlugin);
describe("synology chat target classification", () => {
it("accepts numeric chat user ids as direct", () => {
expect(synologyChatPlugin.messaging?.inferTargetChatType?.({ to: "42" })).toBe("direct");
expect(synologyChatPlugin.messaging?.inferTargetChatType?.({ to: "room" })).toBeUndefined();
});
});
describe("createSynologyChatPlugin", () => {
beforeEach(() => {
vi.stubEnv("SYNOLOGY_CHAT_TOKEN", "");
+4
View File
@@ -186,6 +186,9 @@ type SynologyChatPlugin = Omit<
messaging: {
targetPrefixes?: readonly string[];
normalizeTarget: (target: string) => string | undefined;
inferTargetChatType: NonNullable<
ChannelPlugin<ResolvedSynologyChatAccount>["messaging"]
>["inferTargetChatType"];
resolveOutboundSessionRoute: NonNullable<
ChannelPlugin<ResolvedSynologyChatAccount>["messaging"]
>["resolveOutboundSessionRoute"];
@@ -357,6 +360,7 @@ function createSynologyChatPlugin(): SynologyChatPlugin {
messaging: {
targetPrefixes: ["synology-chat", "synology_chat", "synology"],
normalizeTarget: normalizeSynologyChatTarget,
inferTargetChatType: ({ to }) => (normalizeSynologyChatTarget(to) ? "direct" : undefined),
resolveOutboundSessionRoute: ({ agentId, accountId, target }) => {
const chatUserId = normalizeSynologyChatTarget(target);
if (!chatUserId) {
@@ -27,6 +27,15 @@ const cfg = {
},
} as OpenClawConfig;
describe("tlon target classification", () => {
it("distinguishes ships from group nests", () => {
expect(tlonPlugin.messaging?.inferTargetChatType?.({ to: "~sampel-palnet" })).toBe("direct");
expect(
tlonPlugin.messaging?.inferTargetChatType?.({ to: "chat/~sampel-palnet/operators" }),
).toBe("group");
});
});
describe("tlon channel message adapter", () => {
beforeEach(() => {
mocks.sendText.mockReset();
+4
View File
@@ -142,6 +142,10 @@ export const tlonPlugin = createChatChannelPlugin({
}
return parsed.nest;
},
inferTargetChatType: ({ to }) => {
const target = parseTlonTarget(to);
return target ? (target.kind === "dm" ? "direct" : "group") : undefined;
},
targetResolver: {
looksLikeId: (target) => Boolean(parseTlonTarget(target)),
hint: formatTargetHint(),
+7
View File
@@ -5,6 +5,13 @@ import { twitchPlugin } from "./plugin.js";
import { twitchSetupPlugin } from "./setup-surface.js";
describe("twitchPlugin pairing", () => {
it("classifies only channel targets as groups", () => {
expect(twitchPlugin.messaging?.inferTargetChatType?.({ to: "twitch:openclaw" })).toBe("group");
expect(
twitchPlugin.messaging?.inferTargetChatType?.({ to: "twitch:user:operator" }),
).toBeUndefined();
});
it("normalizes trimmed twitch user prefixes in allow entries", () => {
expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" twitch:user:123456 ")).toBe("123456");
expect(twitchPlugin.pairing?.normalizeAllowEntry?.(" user789012 ")).toBe("789012");
+1
View File
@@ -97,6 +97,7 @@ export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
chatTypes: ["group"],
},
messaging: {
inferTargetChatType: ({ to }) => (normalizeTwitchMessagingTarget(to) ? "group" : undefined),
resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => {
const channel = normalizeTwitchMessagingTarget(target);
if (!channel) {
@@ -8,6 +8,11 @@ import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import { zaloPlugin } from "./channel.js";
describe("zalo directory", () => {
it("distinguishes user ids from group ids", () => {
expect(zaloPlugin.messaging?.inferTargetChatType?.({ to: "user:123" })).toBe("direct");
expect(zaloPlugin.messaging?.inferTargetChatType?.({ to: "group:456" })).toBe("group");
});
const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
const directory = expectDirectorySurface(zaloPlugin.directory);
+4
View File
@@ -235,6 +235,10 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
messaging: {
targetPrefixes: ["zalo", "zl"],
normalizeTarget: normalizeZaloMessagingTarget,
inferTargetChatType: ({ to }) => {
const target = normalizeZaloMessagingTarget(to);
return target ? (/^group:/i.test(target) ? "group" : "direct") : undefined;
},
resolveOutboundSessionRoute: (params) => resolveZaloOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeZaloChatId,
@@ -490,6 +490,13 @@ export const zalouserOutboundAdapter = {
export const zalouserMessagingAdapter = {
targetPrefixes: ["zalouser", "zlu"],
normalizeTarget: (raw: string) => normalizeZalouserTarget(raw),
inferTargetChatType: ({ to }: { to: string }) => {
try {
return parseZalouserOutboundTarget(to).isGroup ? ("group" as const) : ("direct" as const);
} catch {
return undefined;
}
},
resolveOutboundSessionRoute: (
params: Parameters<typeof resolveZalouserOutboundSessionRoute>[0],
) => resolveZalouserOutboundSessionRoute(params),
+8
View File
@@ -6,11 +6,19 @@ import {
zalouserAuthAdapter,
zalouserGroupsAdapter,
zalouserMessageActions,
zalouserMessagingAdapter,
zalouserOutboundAdapter,
zalouserPairingTextAdapter,
zalouserResolverAdapter,
zalouserSecurityAdapter,
} from "./channel.adapters.js";
describe("zalouser target classification", () => {
it("distinguishes users from groups", () => {
expect(zalouserMessagingAdapter.inferTargetChatType({ to: "user:123" })).toBe("direct");
expect(zalouserMessagingAdapter.inferTargetChatType({ to: "group:456" })).toBe("group");
});
});
import { setZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
import {
@@ -154,10 +154,12 @@ describe("describeHeartbeatSessionTargetIssues", () => {
expect(warnings[0]).toContain("resolved to agent:ops:slack:channel:c123");
});
it("warns when the default last target has no configured session route", () => {
it("warns when the default owner target has no configured owner route", () => {
const cfg = cfgWithSession("slack:channel:c123", null);
writeStore(cfg, {});
expect(describeHeartbeatSessionTargetIssues(cfg)[0]).toContain('reason="no-route"');
const warning = describeHeartbeatSessionTargetIssues(cfg)[0];
expect(warning).toContain('reason="no-route"');
expect(warning).toContain("set commands.ownerAllowFrom or a channel allowFrom");
});
});
@@ -26,10 +26,7 @@ function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) {
function resolveHeartbeatConfig(cfg: OpenClawConfig, agentId: string): HeartbeatConfig | undefined {
const defaults = cfg.agents?.defaults?.heartbeat;
const overrides = resolveAgentConfig(cfg, agentId)?.heartbeat;
if (!defaults && !overrides) {
return overrides;
}
return { ...defaults, ...overrides };
return defaults || overrides ? { ...defaults, ...overrides } : undefined;
}
function listHeartbeatDoctorAgents(cfg: OpenClawConfig) {
@@ -132,15 +129,20 @@ export function describeHeartbeatSessionTargetIssues(cfg: OpenClawConfig): strin
if (entry) {
continue;
}
const missingRouteOutcome =
deliveryWithoutSession.reason === "no-route"
const ownerTarget = target === undefined || target === "owner";
const missingRouteOutcome = ownerTarget
? ` Heartbeats will skip with reason="no-route" until a configured owner resolves to a direct message.`
: deliveryWithoutSession.reason === "no-route"
? ` Heartbeats will skip with reason="no-route" until that session has a delivery route.`
: ` Heartbeats will run but resolve delivery to channel="none"/reason="no-target", so replies are dropped.`;
const fix = ownerTarget
? ` Fix: set commands.ownerAllowFrom or a channel allowFrom to a direct-message owner, set heartbeat.target="none", or choose an explicit heartbeat target.`
: ` Fix: point heartbeat.session at a session the agent actually owns, set heartbeat.target="none" to suppress delivery, or remove the heartbeat.session field to fall back to the agent main session.`;
warnings.push(
[
`- Agent ${agentId} heartbeat.session pins ${configuredSession} (resolved to ${canonicalSession}) but that session has no entry in ${storePath}.`,
missingRouteOutcome,
` Fix: point heartbeat.session at a session the agent actually owns, set heartbeat.target="none" to suppress delivery, or remove the heartbeat.session field to fall back to the agent main session.`,
fix,
].join("\n"),
);
}
+1 -1
View File
@@ -35,7 +35,7 @@ describe("status.command-sections", () => {
},
}),
).toBe(
"30m (main; waiting for delivery route — message your bot once, or set heartbeat.target)",
"30m (main; waiting for delivery route — set commands.ownerAllowFrom or channel allowFrom, or heartbeat.target)",
);
});
+1 -1
View File
@@ -113,7 +113,7 @@ export function buildStatusHeartbeatValue(params: { summary: Pick<SummaryLike, "
return `disabled (${agent.agentId})`;
}
if (agent.waitingForRoute) {
return `${agent.every} (${agent.agentId}; waiting for delivery route — message your bot once, or set heartbeat.target)`;
return `${agent.every} (${agent.agentId}; waiting for delivery route — set commands.ownerAllowFrom or channel allowFrom, or heartbeat.target)`;
}
return `${agent.every} (${agent.agentId})`;
})
+4 -5
View File
@@ -332,11 +332,10 @@ describe("getStatusSummary", () => {
...emptyKeys.map((sessionKey) => ({ sessionKey, entry: {} })),
]);
const summary = await getStatusSummary(
heartbeatSession === undefined
? undefined
: { config: { agents: { defaults: { heartbeat: { session: heartbeatSession } } } } },
);
const config = {
agents: { defaults: { heartbeat: { target: "last", session: heartbeatSession } } },
};
const summary = await getStatusSummary({ config });
expect(summary.heartbeat.agents[0]?.waitingForRoute).toBe(waitingForRoute);
});
+1 -1
View File
@@ -2263,7 +2263,7 @@ describe("config plugin validation", () => {
it("accepts known plugin ids and valid channel/heartbeat enums", () => {
const res = validateInSuite({
agents: {
defaults: { heartbeat: { target: "last", directPolicy: "block" } },
defaults: { heartbeat: { target: "owner", directPolicy: "block" } },
list: [{ id: "openclaw", heartbeat: { directPolicy: "allow" } }],
},
channels: {
+2
View File
@@ -627,7 +627,9 @@ describe("config schema", () => {
const defaultsHint = res.uiHints["agents.defaults.heartbeat.target"];
const entryHint = res.uiHints["agents.entries.*.heartbeat.target"];
expect(defaultsHint?.help).toContain("imessage");
expect(defaultsHint?.help).toContain("owner");
expect(defaultsHint?.help).toContain("last");
expect(defaultsHint?.placeholder).toBe("owner");
expect(entryHint?.help).toContain("imessage");
});
+2 -2
View File
@@ -380,14 +380,14 @@ function applyHeartbeatTargetHints(
const next: ConfigUiHints = { ...hints };
const channelList = listHeartbeatTargetChannels(channels);
const channelHelp = channelList.length ? ` Known channels: ${channelList.join(", ")}.` : "";
const help = `Delivery target ("last", "none", or a channel id).${channelHelp}`;
const help = `Delivery target ("owner", "last", "none", or a channel id).${channelHelp}`;
const paths = ["agents.defaults.heartbeat.target", "agents.entries.*.heartbeat.target"];
for (const path of paths) {
const current = next[path] ?? {};
next[path] = {
...current,
help: current.help ?? help,
placeholder: current.placeholder ?? "last",
placeholder: current.placeholder ?? "owner",
};
}
return next;
+2 -2
View File
@@ -295,11 +295,11 @@ export type AgentDefaultsConfig = {
model?: string;
/** Session key for heartbeat runs ("main" or explicit session key). */
session?: string;
/** Delivery target ("last", "none", or a channel id). Default: "last". */
/** Delivery target. Default "owner" uses explicit ownerAllowFrom/allowFrom; "last" may follow groups. */
target?: string;
/** Direct/DM delivery policy. Default: "allow". */
directPolicy?: "allow" | "block";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). Supports :topic:NNN suffix for Telegram topics. */
/** Explicit channel destination; ignored for target "owner" or an unset target. */
to?: string;
/** Optional account id for multi-account channels. */
accountId?: string;
+6 -1
View File
@@ -581,7 +581,12 @@ function validateConfigObjectWithPluginsBase(
return;
}
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
if (normalized === "last" || normalized === "none" || normalizeBundledChannelId(trimmed)) {
if (
normalized === "owner" ||
normalized === "last" ||
normalized === "none" ||
normalizeBundledChannelId(trimmed)
) {
return;
}
if (!heartbeatChannelIds.has(normalized)) {
+10 -3
View File
@@ -41,6 +41,9 @@ const CLEARED_PENDING_FINAL_DELIVERY_FIELDS = {
pendingFinalDelivery: undefined,
} as const;
const FIRST_HEARTBEAT_ALERT_PREAMBLE =
'First heartbeat alert: your bot runs periodic background checks and messages you only when something needs attention. Set agents.defaults.heartbeat.target: "none" to keep these internal.';
// Clear pending-final only when this run produced it: the agent run stamps
// createdAt during the run, so createdAt >= run start means we own it. An older
// final (e.g. one a message_tool_only run never refreshed) must keep its recovery path.
@@ -335,7 +338,11 @@ export async function finalizeHeartbeatOutcome(params: {
return { status: "ran", durationMs: Date.now() - startedAt };
}
const previewText = normalized.text;
const deliveryText =
delivery.implicitDefaultRoute && prevHeartbeatAt === undefined
? `${FIRST_HEARTBEAT_ALERT_PREAMBLE}\n${normalized.text}`
: normalized.text;
const previewText = deliveryText;
if (delivery.channel === "none" || !delivery.to) {
emitHeartbeatEvent({
status: "skipped",
@@ -401,7 +408,7 @@ export async function finalizeHeartbeatOutcome(params: {
payloads: [
copyReplyPayloadMetadata(replyPayload ?? {}, {
...replyPayload,
text: normalized.text,
text: deliveryText,
mediaUrls,
}),
],
@@ -413,7 +420,7 @@ export async function finalizeHeartbeatOutcome(params: {
}
const visibleSendSucceeded = send.status === "sent";
if (visibleSendSucceeded) {
const hasHeartbeatText = Boolean(normalized.text.trim());
const hasHeartbeatText = Boolean(deliveryText.trim());
await patchSessionEntry(
{ storePath, sessionKey },
(current, context) => {
+2 -2
View File
@@ -432,12 +432,12 @@ export async function prepareHeartbeatRunStage(wake: ReadyHeartbeatWake) {
if (delivery.reason === "unknown-account") {
log.warn("heartbeat: unknown accountId", {
accountId: delivery.accountId ?? heartbeatAccountId ?? null,
target: heartbeat?.target ?? "last",
target: heartbeat?.target ?? "owner",
});
} else if (heartbeatAccountId) {
log.info("heartbeat: using explicit accountId", {
accountId: delivery.accountId ?? heartbeatAccountId,
target: heartbeat?.target ?? "last",
target: heartbeat?.target ?? "owner",
channel: delivery.channel,
});
}
@@ -265,6 +265,12 @@ beforeAll(async () => {
allowFrom,
}),
},
messaging: {
inferTargetChatType: ({ to }) => {
const target = normalizeWhatsAppTargetForTest(to);
return target ? (isWhatsAppGroupJidForTest(target) ? "group" : "direct") : undefined;
},
},
});
whatsappPlugin.config = {
...whatsappPlugin.config,
@@ -352,8 +358,8 @@ afterAll(async () => {
});
describe("resolveHeartbeatIntervalMs", () => {
it("reports last as the default delivery target", () => {
expect(resolveHeartbeatSummaryForAgent({}).target).toBe("last");
it("reports owner as the default delivery target", () => {
expect(resolveHeartbeatSummaryForAgent({}).target).toBe("owner");
});
it("reports the merged per-agent heartbeat session", () => {
@@ -492,12 +498,15 @@ describe("resolveHeartbeatDeliveryTarget", () => {
},
},
{
name: "target defaults to last when unset",
cfg: {},
name: "target defaults to owner when unset",
cfg: {
commands: { ownerAllowFrom: ["+15555550166"] },
channels: { whatsapp: { allowFrom: ["+15555550166"] } },
},
entry: entryWithDelivery("whatsapp", "120363401234567890@g.us"),
expected: {
channel: "whatsapp",
to: "120363401234567890@g.us",
to: "+15555550166",
accountId: undefined,
lastChannel: "whatsapp",
lastAccountId: undefined,
@@ -627,7 +636,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
},
];
for (const { cfg, entry, name, expected } of cases) {
expect(resolveHeartbeatDeliveryTarget({ cfg, entry }), name).toEqual(expected);
expect(resolveHeartbeatDeliveryTarget({ cfg, entry }), name).toMatchObject(expected);
}
});
@@ -713,6 +722,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
).toEqual({
channel: "whatsapp",
to: "120363401234567890@g.us",
chatType: "group",
accountId: undefined,
lastChannel: "whatsapp",
lastAccountId: undefined,
@@ -991,10 +1001,11 @@ describe("runHeartbeatOnce", () => {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: { every: "5m" },
heartbeat: { every: "5m", target: "owner" },
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
commands: { ownerAllowFrom: ["+15555550166"] },
channels: { whatsapp: { allowFrom: ["+15555550166"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
@@ -1022,7 +1033,7 @@ describe("runHeartbeatOnce", () => {
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
expectWhatsAppSendCall(sendWhatsApp, 0, {
to: "120363401234567890@g.us",
to: "+15555550166",
text: "Final alert",
});
} finally {
@@ -1030,6 +1041,41 @@ describe("runHeartbeatOnce", () => {
}
});
it("prepends the first heartbeat alert only once for the implicit owner default", async () => {
const tmpDir = await createCaseDir("hb-owner-preamble");
const storePath = path.join(tmpDir, "sessions.json");
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: tmpDir, heartbeat: { every: "5m" } } },
commands: { ownerAllowFrom: ["+15555550166"] },
channels: { whatsapp: { allowFrom: ["+15555550166"] } },
session: { store: storePath },
};
await seedWhatsAppSession(storePath, resolveMainSessionKey(cfg));
const replySpy = vi
.fn()
.mockResolvedValueOnce({ text: "First alert" })
.mockResolvedValueOnce({ text: "Second alert" });
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" });
await runHeartbeatOnce({
cfg,
deps: createHeartbeatDeps(sendWhatsApp, { nowMs: 1, getReplyFromConfig: replySpy }),
});
await runHeartbeatOnce({
cfg,
deps: createHeartbeatDeps(sendWhatsApp, { nowMs: 2, getReplyFromConfig: replySpy }),
});
expectWhatsAppSendCall(sendWhatsApp, 0, {
to: "+15555550166",
text: 'First heartbeat alert: your bot runs periodic background checks and messages you only when something needs attention. Set agents.defaults.heartbeat.target: "none" to keep these internal.\nFirst alert',
});
expectWhatsAppSendCall(sendWhatsApp, 1, {
to: "+15555550166",
text: "Second alert",
});
});
it("uses per-agent heartbeat overrides and session keys", async () => {
const tmpDir = await createCaseDir("hb-agent-overrides");
const storePath = path.join(tmpDir, "sessions.json");
@@ -1763,7 +1809,9 @@ describe("runHeartbeatOnce", () => {
storePath: customCronStore,
});
const cfg = {
agents: { defaults: { workspace: workspaceDir, heartbeat: { every: "5m" } } },
agents: {
defaults: { workspace: workspaceDir, heartbeat: { every: "5m", target: "last" } },
},
cron: { store: customCronStore },
session: { store: storePath },
} as unknown as OpenClawConfig;
@@ -55,7 +55,7 @@ function createHeartbeatTelegramConfig(storePath: string): OpenClawConfig {
session: { store: storePath },
agents: {
defaults: {
heartbeat: { every: "30m" },
heartbeat: { every: "30m", target: "last" },
model: { primary: "test/model" },
},
},
@@ -300,7 +300,7 @@ describe("heartbeat runner skips when target session lane is busy", () => {
// lane variants exercised below.
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
const cfg = createHeartbeatTelegramConfig(storePath);
cfg.agents!.defaults!.heartbeat = { every: "30m" };
cfg.agents!.defaults!.heartbeat = { every: "30m", target: "last" };
await seedHeartbeatTelegramSession(storePath, cfg);
const result = await runHeartbeat(
@@ -320,7 +320,7 @@ describe("heartbeat runner skips when target session lane is busy", () => {
it("runs despite work in this agent's nested session lane", async () => {
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
const cfg = createHeartbeatTelegramConfig(storePath);
cfg.agents!.defaults!.heartbeat = { every: "30m" };
cfg.agents!.defaults!.heartbeat = { every: "30m", target: "last" };
await seedHeartbeatTelegramSession(storePath, cfg);
const nestedSessionLane = resolveNestedAgentLaneForSession("agent:main:telegram:123");
@@ -343,7 +343,7 @@ describe("heartbeat runner skips when target session lane is busy", () => {
// different agent must not block this agent's heartbeat.
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
const cfg = createHeartbeatTelegramConfig(storePath);
cfg.agents!.defaults!.heartbeat = { every: "30m" };
cfg.agents!.defaults!.heartbeat = { every: "30m", target: "last" };
await seedHeartbeatTelegramSession(storePath, cfg);
const nestedSessionLane = resolveNestedAgentLaneForSession("agent:other:telegram:123");
@@ -515,7 +515,11 @@ describe("heartbeat runner skips when target session lane is busy", () => {
it("returns requests-in-flight when an isolated heartbeat reply run is still active", async () => {
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
const cfg = createHeartbeatTelegramConfig(storePath);
cfg.agents!.defaults!.heartbeat = { every: "30m", isolatedSession: true };
cfg.agents!.defaults!.heartbeat = {
every: "30m",
target: "last",
isolatedSession: true,
};
const baseSessionKey = await seedHeartbeatTelegramSession(storePath, cfg);
const isolatedSessionKey = `${baseSessionKey}:heartbeat`;
const operation = createReplyOperation({
@@ -566,7 +570,7 @@ describe("heartbeat runner skips when target session lane is busy", () => {
await withTempHeartbeatSandbox(async ({ storePath, replySpy }) => {
const cfg = createHeartbeatTelegramConfig(storePath);
cfg.session = { store: storePath };
cfg.agents!.defaults!.heartbeat = { every: "30m" };
cfg.agents!.defaults!.heartbeat = { every: "30m", target: "last" };
await seedHeartbeatTelegramSession(storePath, cfg, {
lastProvider: "heartbeat",
lastTo: "heartbeat",
+1 -1
View File
@@ -31,7 +31,7 @@ export type HeartbeatSummary = {
ackMaxChars: number;
};
const DEFAULT_HEARTBEAT_TARGET = "last";
const DEFAULT_HEARTBEAT_TARGET = "owner";
function hasExplicitHeartbeatAgents(cfg: OpenClawConfig) {
const list = listAgentEntries(cfg);
@@ -107,7 +107,8 @@ export function validateExplicitMessageAccountSelection(params: {
return accountId;
}
function isPotentialBroadcastChannel(params: {
/** Selects configured, enabled, deliverable plugins without bootstrap or config mutation. */
export function isPotentialConfiguredMessageChannel(params: {
cfg: OpenClawConfig;
plugin: ChannelPlugin;
}): params is { cfg: OpenClawConfig; plugin: ChannelPlugin & { id: ChannelId } } {
@@ -156,7 +157,7 @@ export function resolveMessageBroadcastAccountPlan(params: {
}
const candidatePlugins = listChannelPlugins().filter((plugin) =>
isPotentialBroadcastChannel({ cfg: params.cfg, plugin }),
isPotentialConfiguredMessageChannel({ cfg: params.cfg, plugin }),
);
const secretChannels = candidatePlugins.flatMap((plugin) => {
try {
@@ -193,6 +193,9 @@ export function createGenericTargetTestPlugin(
},
messaging: {
targetPrefixes: [String(id)],
// Owner-route tests need positive direct classification; syntax alone
// never admits an implicit owner destination.
inferTargetChatType: ({ to }) => (/^user:/i.test(to) ? "direct" : undefined),
},
resolveDefaultTo: ({ cfg }) => readTestDefaultTo(cfg, String(id)),
});
+440 -2
View File
@@ -1,6 +1,7 @@
// Covers outbound direct target resolution, heartbeat target derivation,
// heartbeat sender context, and route-aware heartbeat refinements.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js";
@@ -8,6 +9,7 @@ import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import { normalizeLegacySessionEntryDelivery } from "../state-migrations.legacy-session-store.js";
import {
hasResolvableHeartbeatOwnerRoute,
resolveHeartbeatDeliveryTarget as resolveCanonicalHeartbeatDeliveryTarget,
resolveHeartbeatDeliveryTargetWithSessionRoute as resolveCanonicalHeartbeatDeliveryTargetWithSessionRoute,
resolveOutboundTarget,
@@ -75,6 +77,34 @@ async function resolveHeartbeatDeliveryTargetWithSessionRoute(
});
}
function createOwnerAllowlistTargetTestPlugin(params: {
id: ChannelPlugin["id"];
label: string;
ownerId: string;
inferTargetChatType?: NonNullable<ChannelPlugin["messaging"]>["inferTargetChatType"];
}): ChannelPlugin {
const plugin = createTestChannelPlugin({
id: params.id,
label: params.label,
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }) =>
to
? { ok: true as const, to: to.trim() }
: { ok: false as const, error: new Error("target required") },
},
messaging: {
...(params.inferTargetChatType ? { inferTargetChatType: params.inferTargetChatType } : {}),
// Real channel plugins declare their id as a target prefix; prefixed
// configured-owner entries rely on it to bind to the right channel.
targetPrefixes: [String(params.id)],
targetResolver: { looksLikeId: () => true },
},
});
plugin.config = { ...plugin.config, resolveAllowFrom: () => [params.ownerId] };
return plugin;
}
vi.mock("./channel-resolution.js", () => ({
normalizeDeliverableOutboundChannel: mocks.normalizeDeliverableOutboundChannel,
resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin,
@@ -86,7 +116,9 @@ beforeEach(() => {
mocks.normalizeDeliverableOutboundChannel.mockReset();
mocks.normalizeDeliverableOutboundChannel.mockImplementation((value?: string | null) => {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : undefined;
return ["alpha", "beta", "forum", "telegram"].includes(String(normalized))
return ["alpha", "beta", "forum", "googlechat", "telegram", "whatsapp"].includes(
String(normalized),
)
? normalized
: undefined;
});
@@ -576,7 +608,7 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.reason).toBe("target-none");
});
it("delivers to the last session route with unset heartbeat config", () => {
it("delivers to the last session route when explicitly configured", () => {
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
@@ -585,11 +617,217 @@ describe("resolveSessionDeliveryTarget", () => {
lastChannel: "alpha",
lastTo: "chat:one",
},
heartbeat: { target: "last" },
});
expect(resolved.channel).toBe("alpha");
expect(resolved.to).toBe("chat:one");
});
it("never reuses a group route for implicit owner delivery", () => {
const forum = createForumTargetTestPlugin();
forum.config = {
...forum.config,
resolveAllowFrom: () => ["dm:operator"],
};
setActivePluginRegistry(createTargetsTestRegistry([forum]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: { channels: { forum: { allowFrom: ["dm:operator"] } } } as OpenClawConfig,
entry: {
sessionId: "sess-owner-group",
updatedAt: 1,
lastChannel: "forum",
lastTo: "room:ops",
chatType: "group",
},
});
expect(resolved.channel).toBe("forum");
expect(resolved.to).toBe("dm:operator");
expect(resolved.chatType).toBe("direct");
});
it("prefers commands.ownerAllowFrom over channel allowFrom", () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
alpha.config = { ...alpha.config, resolveAllowFrom: () => ["user:channel-owner"] };
setActivePluginRegistry(createTargetsTestRegistry([alpha]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {
commands: { ownerAllowFrom: ["user:global-owner"] },
channels: { alpha: { allowFrom: ["user:channel-owner"] } },
} as OpenClawConfig,
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({
channel: "alpha",
to: "user:global-owner",
chatType: "direct",
});
});
it("uses the first owner entry compatible with a configured channel", () => {
const telegram = createOwnerAllowlistTargetTestPlugin({
id: "telegram",
label: "Telegram",
ownerId: "789",
inferTargetChatType: ({ to }) => (/^\d+$/.test(to) ? "direct" : undefined),
});
setActivePluginRegistry(createTargetsTestRegistry([telegram]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {
commands: { ownerAllowFrom: ["discord:123", "456"] },
channels: { telegram: { allowFrom: ["789"] } },
} as OpenClawConfig,
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "telegram", to: "456", chatType: "direct" });
});
it("falls back to the channel allowFrom owner", () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
alpha.config = { ...alpha.config, resolveAllowFrom: () => ["", "*", "user:channel-owner"] };
setActivePluginRegistry(createTargetsTestRegistry([alpha]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: { channels: { alpha: { allowFrom: ["user:channel-owner"] } } } as OpenClawConfig,
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({
channel: "alpha",
to: "user:channel-owner",
chatType: "direct",
});
});
it("reports no route for wildcard-only owner allowlists", () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
alpha.config = { ...alpha.config, resolveAllowFrom: () => ["", "*"] };
setActivePluginRegistry(createTargetsTestRegistry([alpha]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {
commands: { ownerAllowFrom: ["", "*"] },
channels: { alpha: { allowFrom: ["*"] } },
} as OpenClawConfig,
});
expect(resolved).toMatchObject({ channel: "none", reason: "no-route" });
});
it("reports no route for channel-scoped wildcard owner allowlists", () => {
const telegram = createOwnerAllowlistTargetTestPlugin({
id: "telegram",
label: "Telegram",
ownerId: "telegram:*",
inferTargetChatType: () => "direct",
});
setActivePluginRegistry(createTargetsTestRegistry([telegram]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {
commands: { ownerAllowFrom: ["telegram:*"] },
channels: { telegram: { allowFrom: ["telegram:*"] } },
} as OpenClawConfig,
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "none", reason: "no-route" });
});
it("picks the first configured channel in deterministic registry order", () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
alpha.config = { ...alpha.config, resolveAllowFrom: () => ["user:alpha-owner"] };
const beta = createGenericTargetTestPlugin("beta", "Beta");
beta.config = { ...beta.config, resolveAllowFrom: () => ["user:beta-owner"] };
setActivePluginRegistry(createTargetsTestRegistry([beta, alpha]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {
channels: {
alpha: { allowFrom: ["user:alpha-owner"] },
beta: { allowFrom: ["user:beta-owner"] },
},
} as OpenClawConfig,
});
expect(resolved).toMatchObject({ channel: "alpha", to: "user:alpha-owner" });
});
it("reuses an exact direct owner route with its account and thread", () => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
setActivePluginRegistry(createTargetsTestRegistry([alpha]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: { commands: { ownerAllowFrom: ["alpha:user:owner"] } },
entry: {
sessionId: "sess-owner-direct",
updatedAt: 1,
lastChannel: "alpha",
lastTo: "user:owner",
lastAccountId: "work",
lastThreadId: "thread-7",
chatType: "direct",
},
});
expect(resolved).toMatchObject({
channel: "alpha",
to: "user:owner",
accountId: "work",
threadId: "thread-7",
chatType: "direct",
});
});
it("rejects an owner id that resolves to a group", () => {
const forum = createForumTargetTestPlugin();
forum.config = { ...forum.config, resolveAllowFrom: () => ["room:operators"] };
setActivePluginRegistry(createTargetsTestRegistry([forum]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: { channels: { forum: { allowFrom: ["room:operators"] } } } as OpenClawConfig,
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "none", reason: "no-route" });
});
it.each([undefined, "owner"])(
"uses a turn-source origin before owner discovery for target %s",
(target) => {
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
heartbeat: target ? { target } : undefined,
turnSource: { channel: "beta", to: "group:event", threadId: "77" },
});
expect(resolved).toMatchObject({
channel: "beta",
to: "group:event",
threadId: "77",
});
},
);
it.each([undefined, "owner"])("ignores heartbeat.to for target %s", (target) => {
const alpha = createGenericTargetTestPlugin("alpha", "Alpha");
alpha.config = { ...alpha.config, resolveAllowFrom: () => ["user:owner"] };
setActivePluginRegistry(createTargetsTestRegistry([alpha]));
const heartbeat = { ...(target ? { target } : {}), to: "group:wrong" };
const resolved = resolveHeartbeatDeliveryTarget({
cfg: { channels: { alpha: { allowFrom: ["user:owner"] } } } as OpenClawConfig,
heartbeat,
});
expect(resolved).toMatchObject({ channel: "alpha", to: "user:owner" });
});
it("reports no route when unset heartbeat config has no session route", () => {
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
@@ -1105,6 +1343,206 @@ describe("resolveSessionDeliveryTarget", () => {
expect(resolved.chatType).toBe("group");
});
it("rejects an owner destination whose canonical session route is a group", async () => {
const alpha = createTestChannelPlugin({
id: "alpha",
label: "Alpha",
outbound: {
deliveryMode: "direct",
resolveTarget: ({ to }) =>
to
? { ok: true as const, to: to.trim() }
: { ok: false as const, error: new Error("target required") },
},
messaging: {
inferTargetChatType: () => "direct",
targetResolver: {
resolveTarget: async ({ normalized }) => ({
to: normalized,
kind: "user",
source: "directory",
}),
},
resolveOutboundSessionRoute: ({ target }) => ({
sessionKey: `main:alpha:group:${target}`,
baseSessionKey: `main:alpha:group:${target}`,
peer: { kind: "group", id: target },
chatType: "group",
from: `alpha:group:${target}`,
to: target,
}),
},
});
alpha.config = { ...alpha.config, resolveAllowFrom: () => ["operator"] };
setActivePluginRegistry(createTargetsTestRegistry([alpha]));
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: { channels: { alpha: { allowFrom: ["operator"] } } } as OpenClawConfig,
agentId: "main",
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "none", reason: "no-route" });
});
it("delivers a Google Chat user allowlist entry to its owner route", async () => {
const googlechat = createOwnerAllowlistTargetTestPlugin({
id: "googlechat",
label: "Google Chat",
ownerId: "users/abc",
inferTargetChatType: ({ to }) => (to.startsWith("users/") ? "direct" : undefined),
});
setActivePluginRegistry(createTargetsTestRegistry([googlechat]));
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: { channels: { googlechat: { allowFrom: ["users/abc"] } } } as OpenClawConfig,
agentId: "main",
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "googlechat", to: "users/abc" });
});
it("rejects a Google Chat space allowlist entry as an owner route", async () => {
const googlechat = createOwnerAllowlistTargetTestPlugin({
id: "googlechat",
label: "Google Chat",
ownerId: "spaces/xyz",
inferTargetChatType: ({ to }) => (to.startsWith("spaces/") ? "group" : undefined),
});
setActivePluginRegistry(createTargetsTestRegistry([googlechat]));
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: { channels: { googlechat: { allowFrom: ["spaces/xyz"] } } } as OpenClawConfig,
agentId: "main",
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "none", reason: "no-route" });
});
it.each(["@shared", "user:shared"])(
"rejects classifier-proven group owner id %s",
async (ownerId) => {
const telegram = createOwnerAllowlistTargetTestPlugin({
id: "telegram",
label: "Telegram",
ownerId,
inferTargetChatType: () => "group",
});
setActivePluginRegistry(createTargetsTestRegistry([telegram]));
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: { channels: { telegram: { allowFrom: [ownerId] } } } as OpenClawConfig,
agentId: "main",
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "none", reason: "no-route" });
},
);
it("rejects an unclassified plugin owner id", async () => {
const external = createOwnerAllowlistTargetTestPlugin({
id: "external-channel",
label: "External",
ownerId: "opaque-owner-id",
});
setActivePluginRegistry(createTargetsTestRegistry([external]));
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {
channels: { "external-channel": { allowFrom: ["opaque-owner-id"] } },
} as OpenClawConfig,
agentId: "main",
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "none", reason: "no-route" });
});
it("rejects a user-prefixed owner id on a classifier-less plugin", async () => {
const external = createOwnerAllowlistTargetTestPlugin({
id: "external-channel",
label: "External",
ownerId: "user:shared",
});
setActivePluginRegistry(createTargetsTestRegistry([external]));
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg: {} as OpenClawConfig,
agentId: "main",
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "none", reason: "no-route" });
});
it("prefers a prefixed configured owner on a later channel over session-channel allowFrom", () => {
const slack = createOwnerAllowlistTargetTestPlugin({
id: "slack",
label: "Slack",
ownerId: "user:slack-local",
inferTargetChatType: ({ to }) => (/^user:/i.test(to) ? "direct" : undefined),
});
const telegram = createOwnerAllowlistTargetTestPlugin({
id: "telegram",
label: "Telegram",
ownerId: "999",
inferTargetChatType: ({ to }) => (/^\d+$/.test(to) ? "direct" : undefined),
});
setActivePluginRegistry(createTargetsTestRegistry([slack, telegram]));
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {
commands: { ownerAllowFrom: ["telegram:456"] },
channels: {
slack: { allowFrom: ["user:slack-local"] },
telegram: { allowFrom: ["999"] },
},
} as OpenClawConfig,
entry: {
sessionId: "sess-slack-first",
updatedAt: 1,
lastChannel: "slack",
lastTo: "user:someone",
chatType: "direct",
},
heartbeat: { target: "owner" },
});
// Precedence and channel binding are under test; the passthrough fixture
// resolveTarget keeps the raw prefixed form (stripping is covered elsewhere).
expect(resolved).toMatchObject({ channel: "telegram", to: "telegram:456" });
});
it("delivers a classifier-proven WhatsApp E.164 owner route", async () => {
const inferTargetChatType = vi.fn(({ to }: { to: string }) =>
/^\+\d+$/.test(to) ? ("direct" as const) : undefined,
);
const whatsapp = createOwnerAllowlistTargetTestPlugin({
id: "whatsapp",
label: "WhatsApp",
ownerId: "+15555550166",
inferTargetChatType,
});
setActivePluginRegistry(createTargetsTestRegistry([whatsapp]));
const cfg = {
channels: { whatsapp: { allowFrom: ["+15555550166"] } },
} as OpenClawConfig;
expect(hasResolvableHeartbeatOwnerRoute({ cfg })).toBe(true);
const resolved = await resolveHeartbeatDeliveryTargetWithSessionRoute({
cfg,
agentId: "main",
heartbeat: { target: "owner" },
});
expect(resolved).toMatchObject({ channel: "whatsapp", to: "+15555550166" });
expect(inferTargetChatType).toHaveBeenCalledWith({ to: "+15555550166" });
});
it("uses an activation-aware external plugin when canonicalizing heartbeat routes", async () => {
const external = createTestChannelPlugin({
id: "external-channel",
+240 -20
View File
@@ -2,6 +2,7 @@
// sender context, and session-route aware heartbeat refinements.
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.core.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
@@ -22,6 +23,11 @@ import {
normalizeDeliverableOutboundChannel,
resolveOutboundChannelPlugin,
} from "./channel-resolution.js";
import {
resolveTargetPrefixedChannel,
stripTargetProviderPrefix,
} from "./channel-target-prefix.js";
import { isPotentialConfiguredMessageChannel } from "./message-account-selection.js";
import { resolveOutboundSessionRoute } from "./outbound-session.js";
import { isReservedTargetLiteralError } from "./target-errors.js";
import { resolveChannelTarget, type ResolvedMessagingTarget } from "./target-resolver.js";
@@ -40,6 +46,7 @@ type OutboundTarget = {
threadId?: string | number;
lastChannel?: string;
lastAccountId?: string;
implicitDefaultRoute?: true;
};
/** Sender identity context used when a heartbeat needs channel-compatible metadata. */
@@ -89,7 +96,104 @@ export function resolveOutboundTarget(params: {
);
}
/** Resolves the heartbeat delivery destination from config, session state, and turn source. */
function concreteAllowFromEntries(entries: Array<string | number> | null | undefined): string[] {
return mapAllowFromEntries(entries)
.map((entry) => entry.trim())
.filter((entry) => entry && entry !== "*" && !entry.endsWith(":*"));
}
function ownerIdMatchesRoute(plugin: ChannelPlugin, ownerId: string, routeTo: string): boolean {
const normalize = (value: string) => {
const prefixedChannel = resolveTargetPrefixedChannel(value);
return prefixedChannel === plugin.id
? stripTargetProviderPrefix(value, plugin.id, ...(plugin.messaging?.targetPrefixes ?? []))
: value.trim();
};
return normalize(ownerId) === normalize(routeTo);
}
function resolveHeartbeatOwnerRoute(params: {
cfg: OpenClawConfig;
entry?: SessionEntry;
heartbeat?: AgentDefaultsConfig["heartbeat"];
}): { plugin: ChannelPlugin; ownerId: string; reuseSessionRoute: boolean } | undefined {
const session = deliveryContextFromSession(params.entry);
const plugins: ChannelPlugin[] = [];
const seen = new Set<string>();
const add = (plugin: ChannelPlugin | undefined) => {
if (plugin && isDeliverableMessageChannel(plugin.id) && !seen.has(plugin.id)) {
seen.add(plugin.id);
plugins.push(plugin);
}
};
if (session?.channel) {
add(resolveOutboundChannelPlugin({ channel: session.channel, cfg: params.cfg }));
}
for (const plugin of listChannelPlugins()) {
if (isPotentialConfiguredMessageChannel({ cfg: params.cfg, plugin })) {
add(plugin);
}
}
const buildRoute = (plugin: ChannelPlugin, ownerId: string) => ({
plugin,
ownerId,
reuseSessionRoute:
session?.channel === plugin.id &&
Boolean(session.to) &&
normalizeChatType(params.entry?.chatType) === "direct" &&
ownerIdMatchesRoute(plugin, ownerId, session.to ?? ""),
});
// commands.ownerAllowFrom is the documented higher-priority owner identity:
// exhaust it across every eligible channel before any channel-local
// allowFrom fallback, or a session channel's fallback shadows a prefixed
// configured owner on a later channel.
const configuredOwners = concreteAllowFromEntries(params.cfg.commands?.ownerAllowFrom);
for (const plugin of plugins) {
const configuredOwner = configuredOwners.find((ownerId) => {
const prefixedChannel = resolveTargetPrefixedChannel(ownerId);
return (
(!prefixedChannel || prefixedChannel === plugin.id) &&
isPositivelyDirectHeartbeatOwnerTarget({ plugin, to: ownerId })
);
});
if (configuredOwner) {
return buildRoute(plugin, configuredOwner);
}
}
for (const plugin of plugins) {
const ownerId = concreteAllowFromEntries(
plugin.config.resolveAllowFrom?.({
cfg: params.cfg,
accountId:
params.heartbeat?.accountId ??
(session?.channel === plugin.id ? session.accountId : undefined),
}),
)[0];
if (ownerId) {
return buildRoute(plugin, ownerId);
}
}
return undefined;
}
/** Read-only owner-route probe for status/doctor surfaces. Unproven targets fail closed. */
export function hasResolvableHeartbeatOwnerRoute(params: {
cfg: OpenClawConfig;
entry?: SessionEntry;
heartbeat?: AgentDefaultsConfig["heartbeat"];
}): boolean {
const delivery = resolveHeartbeatDeliveryTarget({
...params,
heartbeat: { ...params.heartbeat, target: "owner" },
});
return delivery.channel !== "none" && Boolean(delivery.to);
}
/**
* Resolves heartbeat delivery. Owner/unset ignores `to`; only explicit channels consume it.
*/
export function resolveHeartbeatDeliveryTarget(params: {
cfg: OpenClawConfig;
entry?: SessionEntry;
@@ -99,11 +203,11 @@ export function resolveHeartbeatDeliveryTarget(params: {
const { cfg, entry } = params;
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
const rawTarget = heartbeat?.target;
// Unset delivers to the last conversation; only explicit "none" opts out.
let target = rawTarget === undefined ? "last" : "none";
const implicitDefaultRoute = rawTarget === undefined;
let target = implicitDefaultRoute ? "owner" : "none";
let preparedExplicitPlugin: ChannelPlugin | undefined;
let preparedExplicitTo: string | undefined;
if (rawTarget === "none" || rawTarget === "last") {
if (rawTarget === "none" || rawTarget === "last" || rawTarget === "owner") {
target = rawTarget;
} else if (typeof rawTarget === "string") {
const normalized = normalizeDeliverableOutboundChannel(rawTarget);
@@ -134,10 +238,27 @@ export function resolveHeartbeatDeliveryTarget(params: {
});
}
const ownerMode = target === "owner";
const ownerTurnSource = ownerMode && hasDeliverableHeartbeatTurnSource(params.turnSource);
const resolvedTurnSource =
target === "last"
target === "last" || ownerTurnSource
? mergeDeliveryContext(params.turnSource, deliveryContextFromSession(entry))
: undefined;
const ownerRoute =
ownerMode && !ownerTurnSource
? resolveHeartbeatOwnerRoute({ cfg, entry, heartbeat })
: undefined;
if (ownerMode && !ownerTurnSource && !ownerRoute) {
const base = resolveSessionDeliveryTarget({ entry });
return buildNoHeartbeatDeliveryTarget({
reason: "no-route",
lastChannel: base.lastChannel,
lastAccountId: base.lastAccountId,
});
}
const ownerSession = ownerRoute?.reuseSessionRoute
? deliveryContextFromSession(entry)
: undefined;
const resolvedTarget =
preparedExplicitPlugin && preparedExplicitTo
@@ -147,10 +268,18 @@ export function resolveHeartbeatDeliveryTarget(params: {
explicitTo: preparedExplicitTo,
mode: "heartbeat",
})
: ownerRoute
? resolveSessionDeliveryTarget({
entry,
requestedChannel: ownerRoute.plugin.id,
explicitTo: ownerSession?.to ?? ownerRoute.ownerId,
explicitThreadId: ownerSession?.threadId,
mode: "heartbeat",
})
: resolveSessionDeliveryTarget({
entry,
requestedChannel: target === "last" ? "last" : target,
explicitTo: heartbeat?.to,
requestedChannel: target === "last" || ownerTurnSource ? "last" : target,
explicitTo: ownerMode ? undefined : heartbeat?.to,
mode: "heartbeat",
turnSourceChannel:
resolvedTurnSource?.channel && isDeliverableMessageChannel(resolvedTurnSource.channel)
@@ -158,21 +287,18 @@ export function resolveHeartbeatDeliveryTarget(params: {
: undefined,
turnSourceTo: resolvedTurnSource?.to,
turnSourceAccountId: resolvedTurnSource?.accountId,
// Only pass threadId from an explicit turn source (e.g., restart sentinel's
// delivery context). Do NOT fall back to session-stored threadId here —
// heartbeat mode intentionally drops inherited thread IDs to avoid replying
// in stale threads (e.g., Slack thread_ts). The sentinel's delivery context
// carries the correct topic/thread ID when present.
// Explicit wake origins own their thread. Session-only threads stay dropped;
// reusing one could post a later heartbeat into a stale conversation.
turnSourceThreadId: params.turnSource?.threadId,
});
const heartbeatAccountId = heartbeat?.accountId?.trim();
const heartbeatAccountId = ownerTurnSource ? undefined : heartbeat?.accountId?.trim();
// Use explicit accountId from heartbeat config if provided, otherwise fall back to session
let effectiveAccountId = heartbeatAccountId || resolvedTarget.accountId;
if (!resolvedTarget.channel || !resolvedTarget.to) {
return buildNoHeartbeatDeliveryTarget({
reason: target === "last" ? "no-route" : "no-target",
reason: target === "last" || ownerMode ? "no-route" : "no-target",
accountId: effectiveAccountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
@@ -183,6 +309,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
// through account validation, target policy, and allow-from comparison.
const plugin =
preparedExplicitPlugin ??
ownerRoute?.plugin ??
resolveOutboundChannelPlugin({
channel: resolvedTarget.channel,
cfg,
@@ -199,7 +326,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
);
if (!normalizedAccountIds.has(normalizedAccountId)) {
return buildNoHeartbeatDeliveryTarget({
reason: "unknown-account",
reason: ownerMode ? "no-route" : "unknown-account",
accountId: normalizedAccountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
@@ -214,6 +341,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
target: {
channel: resolvedTarget.channel,
to: resolvedTarget.to,
allowFrom: ownerRoute ? [ownerRoute.ownerId] : undefined,
cfg,
accountId: effectiveAccountId,
mode: "heartbeat",
@@ -221,7 +349,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
});
if (!resolved?.ok) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-target",
reason: ownerMode ? "no-route" : "no-target",
accountId: effectiveAccountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
@@ -229,7 +357,9 @@ export function resolveHeartbeatDeliveryTarget(params: {
}
const sessionChatTypeHint =
target === "last" && !heartbeat?.to ? normalizeChatType(entry?.chatType) : undefined;
(target === "last" && !heartbeat?.to) || ownerRoute?.reuseSessionRoute
? normalizeChatType(entry?.chatType)
: undefined;
const deliveryChatType = resolveHeartbeatDeliveryChatType({
channel: resolvedTarget.channel,
to: resolved.to,
@@ -244,6 +374,22 @@ export function resolveHeartbeatDeliveryTarget(params: {
lastAccountId: resolvedTarget.lastAccountId,
});
}
if (
ownerMode &&
!ownerTurnSource &&
!isPositivelyDirectHeartbeatOwnerTarget({
plugin,
to: resolved.to,
chatType: deliveryChatType,
})
) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-route",
accountId: effectiveAccountId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
});
}
let reason: string | undefined;
if (plugin?.config.resolveAllowFrom) {
@@ -285,9 +431,36 @@ export function resolveHeartbeatDeliveryTarget(params: {
threadId: resolvedTarget.threadId ?? inheritedHeartbeatThreadId,
lastChannel: resolvedTarget.lastChannel,
lastAccountId: resolvedTarget.lastAccountId,
...(implicitDefaultRoute ? { implicitDefaultRoute: true as const } : {}),
};
}
function isPositivelyDirectHeartbeatOwnerTarget(params: {
plugin?: ChannelPlugin;
to: string;
chatType?: ChatType;
}): boolean {
const to = params.plugin
? stripTargetProviderPrefix(
params.to,
params.plugin.id,
...(params.plugin.messaging?.targetPrefixes ?? []),
)
: params.to.trim();
const chatType =
normalizeChatType(params.chatType) ?? params.plugin?.messaging?.inferTargetChatType?.({ to });
// Implicit delivery must prove a direct destination via the channel's own
// classifier; syntax alone (even `user:`) never admits, so unclassified
// shapes fail closed and operator alerts cannot escape into a shared chat.
return chatType === "direct";
}
function hasDeliverableHeartbeatTurnSource(turnSource: DeliveryContext | undefined): boolean {
return Boolean(
turnSource?.channel && isDeliverableMessageChannel(turnSource.channel) && turnSource.to?.trim(),
);
}
function buildNoHeartbeatDeliveryTarget(params: {
reason: string;
accountId?: string;
@@ -314,6 +487,9 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
}): Promise<OutboundTarget> {
const delivery = resolveHeartbeatDeliveryTarget(params);
const heartbeat = params.heartbeat ?? params.cfg.agents?.defaults?.heartbeat;
const ownerRouteMustBeDirect =
(heartbeat?.target === undefined || heartbeat.target === "owner") &&
!hasDeliverableHeartbeatTurnSource(params.turnSource);
if (delivery.channel === "none" || !delivery.to) {
return delivery;
}
@@ -324,6 +500,21 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
allowBootstrap: true,
});
const resolveSessionRoute = plugin?.messaging?.resolveOutboundSessionRoute;
if (
ownerRouteMustBeDirect &&
!isPositivelyDirectHeartbeatOwnerTarget({
plugin,
to: deliveryTo,
chatType: delivery.chatType,
})
) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-route",
accountId: delivery.accountId,
lastChannel: delivery.lastChannel,
lastAccountId: delivery.lastAccountId,
});
}
if (!resolveSessionRoute && !plugin?.messaging?.targetResolver) {
return delivery;
}
@@ -347,7 +538,7 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
routeResolvedTarget = targetResolution.target;
} else if (targetResolution && isReservedTargetLiteralError(targetResolution.error)) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-target",
reason: ownerRouteMustBeDirect ? "no-route" : "no-target",
accountId: delivery.accountId,
lastChannel: delivery.lastChannel,
lastAccountId: delivery.lastAccountId,
@@ -361,6 +552,20 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
lastAccountId: delivery.lastAccountId,
});
}
if (
ownerRouteMustBeDirect &&
!isPositivelyDirectHeartbeatOwnerTarget({
plugin,
to: routeResolvedTarget?.to ?? deliveryTo,
})
) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-route",
accountId: delivery.accountId,
lastChannel: delivery.lastChannel,
lastAccountId: delivery.lastAccountId,
});
}
if (!resolveSessionRoute) {
return delivery;
}
@@ -392,6 +597,21 @@ export async function resolveHeartbeatDeliveryTargetWithSessionRoute(params: {
lastAccountId: delivery.lastAccountId,
});
}
if (
ownerRouteMustBeDirect &&
!isPositivelyDirectHeartbeatOwnerTarget({
plugin,
to: route.to,
chatType: normalizeChatType(route.chatType),
})
) {
return buildNoHeartbeatDeliveryTarget({
reason: "no-route",
accountId: delivery.accountId,
lastChannel: delivery.lastChannel,
lastAccountId: delivery.lastAccountId,
});
}
return {
...delivery,
to: route.to,
@@ -486,8 +706,8 @@ function resolveHeartbeatSenderId(params: {
provider && lastTo ? `${provider}:${lastTo}` : undefined,
].filter((val): val is string => Boolean(val?.trim()));
const allowList = mapAllowFromEntries(allowFrom).filter((entry) => entry && entry !== "*");
if (allowFrom.includes("*")) {
const allowList = concreteAllowFromEntries(allowFrom);
if (mapAllowFromEntries(allowFrom).some((entry) => entry.trim() === "*")) {
return candidates[0] ?? "heartbeat";
}
if (candidates.length > 0 && allowList.length > 0) {
+1 -1
View File
@@ -380,7 +380,7 @@ function collectValidationHeartbeatTargetChannelIds(config: OpenClawConfig): str
return;
}
const normalized = normalizeOptionalLowercaseString(target);
if (!normalized || normalized === "last" || normalized === "none") {
if (!normalized || normalized === "owner" || normalized === "last" || normalized === "none") {
return;
}
channelIds.push(normalized);
+60 -1
View File
@@ -1,10 +1,44 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import {
createDirectOutboundTestAdapter,
createOutboundTestPlugin,
createTestRegistry,
} from "../test-utils/channel-plugins.js";
import { getStatusSummary } from "./summary.js";
describe("getStatusSummary read-only session access", () => {
const previousRegistry = getActivePluginRegistry();
beforeEach(() => {
const telegram = createOutboundTestPlugin({
id: "telegram",
outbound: createDirectOutboundTestAdapter({ channel: "telegram" }),
messaging: {
targetPrefixes: ["telegram"],
inferTargetChatType: ({ to }) => {
return /^(?:telegram:)?\d+$/.test(to) ? "direct" : undefined;
},
},
});
telegram.config = {
...telegram.config,
resolveAllowFrom: ({ cfg }) => cfg.channels?.telegram?.allowFrom ?? [],
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", plugin: telegram, source: "test" }]),
);
});
afterAll(() => {
if (previousRegistry) {
setActivePluginRegistry(previousRegistry);
}
});
it("does not create the heartbeat session database while checking its route", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-status-heartbeat-"));
const databasePath = path.join(tempDir, "openclaw-agent.sqlite");
@@ -21,4 +55,29 @@ describe("getStatusSummary read-only session access", () => {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it.each([undefined, "owner"])(
"resolves the configured owner DM without writing session state for target %s",
async (target) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-status-owner-"));
const databasePath = path.join(tempDir, "openclaw-agent.sqlite");
try {
const summary = await getStatusSummary({
includeChannelSummary: false,
config: {
...(target ? { agents: { defaults: { heartbeat: { target } } } } : {}),
commands: { ownerAllowFrom: ["telegram:123"] },
channels: { telegram: { allowFrom: ["123"] } },
session: { store: databasePath },
},
});
expect(summary.heartbeat.agents[0]?.waitingForRoute).toBe(false);
expect(fs.existsSync(databasePath)).toBe(false);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
},
);
});
+15 -2
View File
@@ -2,6 +2,7 @@
// It aggregates sessions, tasks, heartbeat, channel summary, and model/runtime metadata.
import { normalizeLowercaseStringOrEmpty as normalizeStatusModelPart } from "@openclaw/normalization-core/string-coerce";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { areRuntimeModelRefsEquivalent } from "../agents/model-runtime-aliases.js";
import { getRuntimeConfig, projectConfigOntoRuntimeSourceSnapshot } from "../config/config.js";
@@ -24,6 +25,7 @@ import type { OpenClawConfig } from "../config/types.js";
import { listGatewayAgentsBasic } from "../gateway/agent-list.js";
import { resolveHeartbeatSessionKey } from "../infra/heartbeat-runner-session.js";
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js";
import { hasResolvableHeartbeatOwnerRoute } from "../infra/outbound/targets.js";
import { peekSystemEvents } from "../infra/system-events.js";
import {
listActiveDegradedPlugins,
@@ -344,13 +346,24 @@ export async function getStatusSummary(
sessionKey: heartbeatSession.sessionKey,
})?.entry;
const route = deliveryContextFromSession(entry);
const heartbeat = {
...cfg.agents?.defaults?.heartbeat,
...resolveAgentConfig(cfg, agent.id)?.heartbeat,
};
// Owner status uses the runner's synchronous stage-1 decision.
// The shared probe requires positive direct proof before reporting ready.
const hasDeliveryRoute =
summary.target === "last"
? Boolean(route?.channel && route.to)
: summary.target === "owner"
? hasResolvableHeartbeatOwnerRoute({ cfg, entry, heartbeat })
: true;
return {
agentId: agent.id,
enabled: summary.enabled,
every: summary.every,
everyMs: summary.everyMs,
waitingForRoute:
summary.enabled && summary.target === "last" && (!route?.channel || !route.to),
waitingForRoute: summary.enabled && !hasDeliveryRoute,
} satisfies HeartbeatStatus;
});
const channelSummary = needsChannelPlugins