From 2c8ed54ddb604694f20f49d42a663bb2de87c9d6 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Tue, 11 Aug 2026 18:48:22 +0530 Subject: [PATCH] feat(heartbeat): default delivery to the configured owner, never groups (#121988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Fable 5 --- docs/gateway/config-agents.md | 4 +- docs/gateway/configuration-examples.md | 2 +- docs/gateway/configuration.md | 4 +- docs/gateway/heartbeat.md | 28 +- docs/gateway/troubleshooting.md | 2 +- docs/plugins/architecture-internals.md | 2 + docs/plugins/sdk-runtime.md | 2 +- extensions/feishu/src/channel.test.ts | 7 + extensions/feishu/src/channel.ts | 4 +- .../googlechat/src/channel-config.test.ts | 8 + extensions/googlechat/src/channel.ts | 10 + extensions/irc/src/channel.test.ts | 8 + extensions/irc/src/channel.ts | 4 + .../matrix/src/channel.directory.test.ts | 11 + extensions/matrix/src/channel.ts | 4 + extensions/mattermost/src/channel.test.ts | 10 + extensions/mattermost/src/channel.ts | 7 + extensions/msteams/src/channel.test.ts | 15 + extensions/msteams/src/channel.ts | 3 +- extensions/msteams/src/session-route.ts | 32 +- .../nextcloud-talk/src/channel.status.test.ts | 6 + extensions/nextcloud-talk/src/channel.ts | 2 + extensions/nostr/src/channel.test.ts | 11 + extensions/nostr/src/channel.ts | 10 + .../qqbot/src/channel.message-adapter.test.ts | 7 + extensions/qqbot/src/channel.ts | 7 + extensions/sms/src/channel.ts | 2 + extensions/sms/src/session-route.test.ts | 5 + extensions/synology-chat/src/channel.test.ts | 7 + extensions/synology-chat/src/channel.ts | 4 + .../tlon/src/channel.message-adapter.test.ts | 9 + extensions/tlon/src/channel.ts | 4 + extensions/twitch/src/plugin.test.ts | 7 + extensions/twitch/src/plugin.ts | 1 + extensions/zalo/src/channel.directory.test.ts | 5 + extensions/zalo/src/channel.ts | 4 + extensions/zalouser/src/channel.adapters.ts | 7 + extensions/zalouser/src/channel.test.ts | 8 + .../doctor-heartbeat-session-target.test.ts | 6 +- .../doctor-heartbeat-session-target.ts | 16 +- src/commands/status.command-sections.test.ts | 2 +- src/commands/status.command-sections.ts | 2 +- src/commands/status.summary.test.ts | 9 +- src/config/config.plugin-validation.test.ts | 2 +- src/config/schema.test.ts | 2 + src/config/schema.ts | 4 +- src/config/types.agent-defaults.ts | 4 +- src/config/validation.ts | 7 +- src/infra/heartbeat-runner-delivery.ts | 13 +- src/infra/heartbeat-runner-execution.ts | 4 +- ...tbeat-runner.returns-default-unset.test.ts | 68 ++- ...eat-runner.skips-busy-session-lane.test.ts | 16 +- src/infra/heartbeat-summary.ts | 2 +- .../outbound/message-account-selection.ts | 5 +- src/infra/outbound/targets.test-helpers.ts | 3 + src/infra/outbound/targets.test.ts | 442 +++++++++++++++++- src/infra/outbound/targets.ts | 282 +++++++++-- src/plugins/gateway-startup-plugin-config.ts | 2 +- src/status/summary.read-only.test.ts | 61 ++- src/status/summary.ts | 17 +- 60 files changed, 1135 insertions(+), 107 deletions(-) diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index e385456d4d0c..a7d5b5b2ee3b 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -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. diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 1d40ddd3ffbd..f4f30605dd32 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -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", diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index c1fa4008a516..9c250351bbb6 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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` | `` (for example `discord`, `matrix`, `telegram`, or `whatsapp`) + - `target`: `owner` (default operator DM) | `last` (latest conversation, including groups) | `none` (internal only) | `` - `directPolicy`: `allow` (default) or `block` for DM-style heartbeat targets - See [Heartbeat](/gateway/heartbeat) for the full guide. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 03f435091aa6..10311e918d84 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -31,7 +31,7 @@ Troubleshooting: [Automations](/automation/cron-jobs#troubleshooting) Store a tiny checklist in the heartbeat monitor's scratch with `openclaw cron scratch --set "..."`. - 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. - 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 | (core or plugin, e.g. "imessage") - to: "+15551234567", // optional channel-specific override + target: "owner", // default | options: last | none | 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 -- `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 - Optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `:topic:`. + 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 `:topic:`. @@ -301,10 +304,13 @@ Heartbeat configuration is strict: only the fields listed above are accepted. Ac - Heartbeats run in the agent's main session by default (`agent::`), 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. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 239bc2667f8a..02a742cd954a 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -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`. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 18c3f1ca0071..c49686cc0d78 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -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 diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 35c15d380a3d..ffbca6c74465 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -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 diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 246c3041c633..09c7cb5d2512 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -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()); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index a88fc4874fbf..7d3c34225703 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -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 | undefined { @@ -1693,6 +1693,8 @@ export const feishuPlugin: ChannelPlugin normalizeFeishuTarget(raw) ?? undefined, + inferTargetChatType: ({ to }) => + resolveReceiveIdType(to) === "chat_id" ? "group" : "direct", resolveDeliveryTarget: ({ conversationId, parentConversationId }) => { const directId = parseFeishuDirectConversationId(conversationId); if (directId) { diff --git a/extensions/googlechat/src/channel-config.test.ts b/extensions/googlechat/src/channel-config.test.ts index df67361a9ef8..009ff43b6088 100644 --- a/extensions/googlechat/src/channel-config.test.ts +++ b/extensions/googlechat/src/channel-config.test.ts @@ -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: { diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index bbb2598ed535..018270d3220d 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -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) => { diff --git a/extensions/irc/src/channel.test.ts b/extensions/irc/src/channel.test.ts index 832ed01ec049..ed26b865cc9c 100644 --- a/extensions/irc/src/channel.test.ts +++ b/extensions/irc/src/channel.test.ts @@ -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"); + }); +}); diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index d0183f78defe..c397a308459e 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -228,6 +228,10 @@ export const ircPlugin: ChannelPlugin = 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, diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts index f84c59107924..2010b53849b5 100644 --- a/extensions/matrix/src/channel.directory.test.ts +++ b/extensions/matrix/src/channel.directory.test.ts @@ -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) { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 094cae4ba380..fba0dcd1d774 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -436,6 +436,10 @@ export const matrixPlugin: ChannelPlugin = 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 }) => diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 022343b0c464..a30fe1f01b2a 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -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["handleAction"] >; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index a424135ec86c..64503201b74d 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -972,6 +972,13 @@ export const mattermostPlugin: ChannelPlugin = 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(); diff --git a/extensions/msteams/src/channel.test.ts b/extensions/msteams/src/channel.test.ts index d924d815e378..3afcd096bae6 100644 --- a/extensions/msteams/src/channel.test.ts +++ b/extensions/msteams/src/channel.test.ts @@ -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); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 402ddaf602b8..e09b5e66b23c 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -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 inferMSTeamsTargetChatType(to), resolveOutboundSessionRoute: (params) => resolveMSTeamsOutboundSessionRoute(params), targetResolver: { looksLikeId: (raw) => looksLikeMSTeamsTargetId(raw), diff --git a/extensions/msteams/src/session-route.ts b/extensions/msteams/src/session-route.ts index d16956d397d8..a012cfa6892a 100644 --- a/extensions/msteams/src/session-route.ts +++ b/extensions/msteams/src/session-route.ts @@ -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, diff --git a/extensions/nextcloud-talk/src/channel.status.test.ts b/extensions/nextcloud-talk/src/channel.status.test.ts index d313def28aa8..ca142f67d09a 100644 --- a/extensions/nextcloud-talk/src/channel.status.test.ts +++ b/extensions/nextcloud-talk/src/channel.status.test.ts @@ -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?.([ { diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index cde236391a68..7ad05bd69d7f 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -112,6 +112,8 @@ export const nextcloudTalkPlugin: ChannelPlugin = messaging: { targetPrefixes: ["nextcloud-talk", "nc-talk", "nc"], normalizeTarget: normalizeNextcloudTalkMessagingTarget, + inferTargetChatType: ({ to }) => + normalizeNextcloudTalkMessagingTarget(to) ? "group" : undefined, resolveOutboundSessionRoute: (params) => resolveNextcloudTalkOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeNextcloudTalkTargetId, diff --git a/extensions/nostr/src/channel.test.ts b/extensions/nostr/src/channel.test.ts index 8a7eea400a75..e79bcde2cc64 100644 --- a/extensions/nostr/src/channel.test.ts +++ b/extensions/nostr/src/channel.test.ts @@ -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() diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 6b0ca95f12b5..56d55b83732a 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -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({ channelKey: "nostr", resolvePolicy: (account) => account.config.dmPolicy, @@ -156,6 +165,7 @@ export const nostrPlugin: ChannelPlugin = createChatChanne messaging: { targetPrefixes: ["nostr"], normalizeTarget: normalizeNostrTarget, + inferTargetChatType: ({ to }) => inferNostrTargetChatType(to), targetResolver: { looksLikeId: (input, normalized) => { const trimmed = normalized?.trim() || stripNostrTargetPrefix(input); diff --git a/extensions/qqbot/src/channel.message-adapter.test.ts b/extensions/qqbot/src/channel.message-adapter.test.ts index 83c751738dec..9e338d9c569a 100644 --- a/extensions/qqbot/src/channel.message-adapter.test.ts +++ b/extensions/qqbot/src/channel.message-adapter.test.ts @@ -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); }); diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index 20cc68bd3bc6..cf21ff558d4c 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -326,6 +326,13 @@ export const qqbotPlugin: ChannelPlugin = { 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. */ diff --git a/extensions/sms/src/channel.ts b/extensions/sms/src/channel.ts index 190b7174134c..11787675b0e5 100644 --- a/extensions/sms/src/channel.ts +++ b/extensions/sms/src/channel.ts @@ -432,6 +432,8 @@ export const smsPlugin: ChannelPlugin = createChat messaging: { targetPrefixes: ["twilio-sms"], normalizeTarget: (target) => normalizeSmsPhoneNumber(target), + inferTargetChatType: ({ to }) => + looksLikeSmsPhoneNumber(normalizeSmsPhoneNumber(to)) ? "direct" : undefined, resolveOutboundSessionRoute: (params) => resolveSmsOutboundSessionRoute(params), targetResolver: { looksLikeId: looksLikeSmsPhoneNumber, diff --git a/extensions/sms/src/session-route.test.ts b/extensions/sms/src/session-route.test.ts index f2293541f993..ace7f7c309b6 100644 --- a/extensions/sms/src/session-route.test.ts +++ b/extensions/sms/src/session-route.test.ts @@ -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" } }, diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index e89d2c2541f5..ab17ae0f99ae 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -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", ""); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 23ff806cd3ff..bedfc8eea771 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -186,6 +186,9 @@ type SynologyChatPlugin = Omit< messaging: { targetPrefixes?: readonly string[]; normalizeTarget: (target: string) => string | undefined; + inferTargetChatType: NonNullable< + ChannelPlugin["messaging"] + >["inferTargetChatType"]; resolveOutboundSessionRoute: NonNullable< ChannelPlugin["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) { diff --git a/extensions/tlon/src/channel.message-adapter.test.ts b/extensions/tlon/src/channel.message-adapter.test.ts index 4fe71997119f..08c0bbb1e4cd 100644 --- a/extensions/tlon/src/channel.message-adapter.test.ts +++ b/extensions/tlon/src/channel.message-adapter.test.ts @@ -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(); diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index e321534045a0..66a813e94fca 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -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(), diff --git a/extensions/twitch/src/plugin.test.ts b/extensions/twitch/src/plugin.test.ts index bfe68228fb92..926c0d239465 100644 --- a/extensions/twitch/src/plugin.test.ts +++ b/extensions/twitch/src/plugin.test.ts @@ -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"); diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index ab74470567b6..94b7caf94602 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -97,6 +97,7 @@ export const twitchPlugin: ChannelPlugin = chatTypes: ["group"], }, messaging: { + inferTargetChatType: ({ to }) => (normalizeTwitchMessagingTarget(to) ? "group" : undefined), resolveOutboundSessionRoute: ({ cfg, agentId, accountId, target }) => { const channel = normalizeTwitchMessagingTarget(target); if (!channel) { diff --git a/extensions/zalo/src/channel.directory.test.ts b/extensions/zalo/src/channel.directory.test.ts index 8adb32f49454..1c9a1661e9cf 100644 --- a/extensions/zalo/src/channel.directory.test.ts +++ b/extensions/zalo/src/channel.directory.test.ts @@ -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); diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 28a88062a40f..e28fc9dc0a70 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -235,6 +235,10 @@ export const zaloPlugin: ChannelPlugin = 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, diff --git a/extensions/zalouser/src/channel.adapters.ts b/extensions/zalouser/src/channel.adapters.ts index bf92e97b3576..e44b72e1c3eb 100644 --- a/extensions/zalouser/src/channel.adapters.ts +++ b/extensions/zalouser/src/channel.adapters.ts @@ -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[0], ) => resolveZalouserOutboundSessionRoute(params), diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index 9082bab734f1..940cb918f519 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -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 { diff --git a/src/commands/doctor-heartbeat-session-target.test.ts b/src/commands/doctor-heartbeat-session-target.test.ts index 108a3c05d025..55ccab282d92 100644 --- a/src/commands/doctor-heartbeat-session-target.test.ts +++ b/src/commands/doctor-heartbeat-session-target.test.ts @@ -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"); }); }); diff --git a/src/commands/doctor-heartbeat-session-target.ts b/src/commands/doctor-heartbeat-session-target.ts index 8402c405e222..76c9deab39d1 100644 --- a/src/commands/doctor-heartbeat-session-target.ts +++ b/src/commands/doctor-heartbeat-session-target.ts @@ -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"), ); } diff --git a/src/commands/status.command-sections.test.ts b/src/commands/status.command-sections.test.ts index 37fd441e38f6..22925e86a290 100644 --- a/src/commands/status.command-sections.test.ts +++ b/src/commands/status.command-sections.test.ts @@ -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)", ); }); diff --git a/src/commands/status.command-sections.ts b/src/commands/status.command-sections.ts index 8d07d633f046..2cb01e6193e4 100644 --- a/src/commands/status.command-sections.ts +++ b/src/commands/status.command-sections.ts @@ -113,7 +113,7 @@ export function buildStatusHeartbeatValue(params: { summary: Pick { ...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); }); diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index be77553f214d..12cb04052b42 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -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: { diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 93af36fcb889..93709b3c1ac7 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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"); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index 3f3fc969d7b9..01a95f7cb4a5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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; diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 7f9c4122216f..8869426be6ae 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -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; diff --git a/src/config/validation.ts b/src/config/validation.ts index 7b6735945def..2fc94da53ba9 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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)) { diff --git a/src/infra/heartbeat-runner-delivery.ts b/src/infra/heartbeat-runner-delivery.ts index 4e3679d4704c..397ae4e7e5a7 100644 --- a/src/infra/heartbeat-runner-delivery.ts +++ b/src/infra/heartbeat-runner-delivery.ts @@ -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) => { diff --git a/src/infra/heartbeat-runner-execution.ts b/src/infra/heartbeat-runner-execution.ts index f984cd28d03a..cd2485725e28 100644 --- a/src/infra/heartbeat-runner-execution.ts +++ b/src/infra/heartbeat-runner-execution.ts @@ -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, }); } diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index b3edf42bff32..146b35e185c5 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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; diff --git a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts index 22fc95eede2a..3dbdc0f2be1c 100644 --- a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts +++ b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts @@ -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", diff --git a/src/infra/heartbeat-summary.ts b/src/infra/heartbeat-summary.ts index 2cb061fef136..6785b042d9be 100644 --- a/src/infra/heartbeat-summary.ts +++ b/src/infra/heartbeat-summary.ts @@ -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); diff --git a/src/infra/outbound/message-account-selection.ts b/src/infra/outbound/message-account-selection.ts index d54639d94a54..5f74f65db791 100644 --- a/src/infra/outbound/message-account-selection.ts +++ b/src/infra/outbound/message-account-selection.ts @@ -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 { diff --git a/src/infra/outbound/targets.test-helpers.ts b/src/infra/outbound/targets.test-helpers.ts index 5936c08dbb51..c509dd8d220c 100644 --- a/src/infra/outbound/targets.test-helpers.ts +++ b/src/infra/outbound/targets.test-helpers.ts @@ -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)), }); diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index c1a0e92c95b8..eeb9c8b5021e 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -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["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", diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 9c1ee3eec11b..c72a9905398d 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -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 | 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(); + 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,32 +268,37 @@ export function resolveHeartbeatDeliveryTarget(params: { explicitTo: preparedExplicitTo, mode: "heartbeat", }) - : resolveSessionDeliveryTarget({ - entry, - requestedChannel: target === "last" ? "last" : target, - explicitTo: heartbeat?.to, - mode: "heartbeat", - turnSourceChannel: - resolvedTurnSource?.channel && isDeliverableMessageChannel(resolvedTurnSource.channel) - ? resolvedTurnSource.channel - : 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. - turnSourceThreadId: params.turnSource?.threadId, - }); + : ownerRoute + ? resolveSessionDeliveryTarget({ + entry, + requestedChannel: ownerRoute.plugin.id, + explicitTo: ownerSession?.to ?? ownerRoute.ownerId, + explicitThreadId: ownerSession?.threadId, + mode: "heartbeat", + }) + : resolveSessionDeliveryTarget({ + entry, + requestedChannel: target === "last" || ownerTurnSource ? "last" : target, + explicitTo: ownerMode ? undefined : heartbeat?.to, + mode: "heartbeat", + turnSourceChannel: + resolvedTurnSource?.channel && isDeliverableMessageChannel(resolvedTurnSource.channel) + ? resolvedTurnSource.channel + : undefined, + turnSourceTo: resolvedTurnSource?.to, + turnSourceAccountId: resolvedTurnSource?.accountId, + // 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 { 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) { diff --git a/src/plugins/gateway-startup-plugin-config.ts b/src/plugins/gateway-startup-plugin-config.ts index 588b5c11d132..16f59543669f 100644 --- a/src/plugins/gateway-startup-plugin-config.ts +++ b/src/plugins/gateway-startup-plugin-config.ts @@ -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); diff --git a/src/status/summary.read-only.test.ts b/src/status/summary.read-only.test.ts index 451549d6b51e..7fffe8203bba 100644 --- a/src/status/summary.read-only.test.ts +++ b/src/status/summary.read-only.test.ts @@ -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 }); + } + }, + ); }); diff --git a/src/status/summary.ts b/src/status/summary.ts index e6ca9aded216..14c42d2f16c1 100644 --- a/src/status/summary.ts +++ b/src/status/summary.ts @@ -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