From de70b00d95c99345fb7a8036d7f3d901f4e2bbf1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 26 Aug 2026 17:58:41 -0700 Subject: [PATCH] feat(buzz): bound bot-to-bot room conversations (#130488) Use the shared per-Gateway bot-pair budget with the latest received signed room roles. Preserve existing sender and mention admission and human traffic. Release note: bound repeated Buzz bot exchanges without adding a channel-specific policy or persistent state. --- docs/channels/buzz.md | 18 ++++++ extensions/buzz/src/directory-state.ts | 10 ++++ extensions/buzz/src/inbound.test.ts | 78 +++++++++++++++++++++++++- extensions/buzz/src/inbound.ts | 19 +++++++ 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/docs/channels/buzz.md b/docs/channels/buzz.md index b60eaedf7836..eb5fe05aa90f 100644 --- a/docs/channels/buzz.md +++ b/docs/channels/buzz.md @@ -339,6 +339,24 @@ routed agent can do after a message is accepted. Treat room messages as untrusted input, and configure that agent's [sandbox and tool policy](/gateway/sandbox-vs-tool-policy-vs-elevated) for the room's trust level. +### Bot conversations + +Authorized room members with the relay-assigned **Bot** role can activate the +agent under the same mention and sender rules. Within each Gateway, OpenClaw +limits repeated exchanges between each bot pair in the same relay and room: +the default budget is 20 accepted messages in 60 seconds, followed by a +60-second cooldown. Changing threads does not reset the budget. Restarting the +Gateway clears this in-memory budget; separate Gateways have separate budgets. +Human messages are unaffected, and suppressed bot turns are logged without +starting an agent run. + +Use the shared `channels.defaults.botLoopProtection` settings to adjust +`maxEventsPerWindow`, `windowSeconds`, or `cooldownSeconds`. Setting +`enabled: false` disables this protection. Bot classification comes from the latest +received relay-signed room roster, never a display name or message content. If an +authorized bot stops replying during a busy exchange, check the suppression log +and allow the cooldown to expire before adjusting the budget. + ## Manual configuration Guided setup is recommended. The equivalent configuration looks like: diff --git a/extensions/buzz/src/directory-state.ts b/extensions/buzz/src/directory-state.ts index 41930a95770a..9a5ffbf7f895 100644 --- a/extensions/buzz/src/directory-state.ts +++ b/extensions/buzz/src/directory-state.ts @@ -237,6 +237,16 @@ export class BuzzDirectoryState { return this.#rooms.get(parseBuzzTarget(roomId))?.archived === true; } + isBotMember(roomId: string, publicKey: string): boolean { + const normalizedRoomId = parseBuzzTarget(roomId); + const membership = this.#memberships.get(normalizedRoomId); + return ( + !this.#rooms.get(normalizedRoomId)?.archived && + membership?.members.has(publicKey) === true && + membership.roles.get(publicKey) === "bot" + ); + } + applyProfileEvent(event: Event): boolean { const profile = parseBuzzDirectoryProfileEvent(event); if ( diff --git a/extensions/buzz/src/inbound.test.ts b/extensions/buzz/src/inbound.test.ts index 3812f3a97758..fbe52cad91e8 100644 --- a/extensions/buzz/src/inbound.test.ts +++ b/extensions/buzz/src/inbound.test.ts @@ -1,4 +1,7 @@ -import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound"; +import { + buildChannelInboundEventContext, + runPreparedInboundReply, +} from "openclaw/plugin-sdk/channel-inbound"; import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime"; // Buzz tests cover inbound room admission, mention gating, and reply delivery. import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers"; @@ -307,6 +310,79 @@ describe("handleBuzzInbound", () => { expect(runtime.channel.inbound.dispatch).not.toHaveBeenCalled(); }); + it.each([ + { role: "bot", enabled: true, dispatches: 1 }, + { role: "member", enabled: true, dispatches: 2 }, + { role: undefined, enabled: true, dispatches: 2 }, + { role: "bot", enabled: false, dispatches: 2 }, + ])( + "bounds current roster role $role with protection enabled=$enabled", + async ({ role, enabled, dispatches }) => { + const runtime = createPluginRuntimeMock(); + const runDispatch = vi.fn(async () => ({ + queuedFinal: false, + counts: { tool: 0, block: 0, final: 1 }, + })); + const recordInboundSession = vi.fn(async () => undefined); + vi.mocked(runtime.channel.inbound.dispatch).mockImplementation(async (params) => + runPreparedInboundReply({ + ...params, + routeSessionKey: params.route.sessionKey, + storePath: "/unused/buzz-bot-loop", + recordInboundSession, + runDispatch, + }), + ); + setBuzzRuntime(runtime); + const bus = createBus(); + bus.directory.replaceMemberships( + new Map([ + [ + ROOM_ID, + { + roomId: ROOM_ID, + createdAt: 1_777_000_000, + eventId: "membership-bot-loop", + publisherPublicKey: OTHER_PUBLIC_KEY, + members: new Set([BOT_PUBLIC_KEY, SENDER_PUBLIC_KEY]), + roles: new Map(role ? [[SENDER_PUBLIC_KEY, role]] : []), + }, + ], + ]), + ); + const account = createAccount({ groups: { [ROOM_ID]: { requireMention: false } } }); + const relayHost = `loop-${role ?? "unknown"}-${enabled}.example.test`; + account.relayUrl = `wss://${relayHost}/`; + const cfg = { + channels: { + defaults: { + botLoopProtection: { + enabled, + maxEventsPerWindow: 1, + windowSeconds: 60, + cooldownSeconds: 60, + }, + }, + }, + } satisfies OpenClawConfig; + for (const [index, id] of ["loop-first", "loop-second"].entries()) { + if (index === 1) { + account.relayUrl = `wss://${relayHost.toUpperCase()}:443/`; + } + await handleBuzzInbound({ + account, + cfg, + bus, + message: createMessage({ id, threadId: id, createdAt: 1_777_000_000 + index * 86_400 }), + ...createLifecycle(), + }); + } + + expect(runDispatch).toHaveBeenCalledTimes(dispatches); + expect(recordInboundSession).toHaveBeenCalledTimes(dispatches); + }, + ); + it.each([ { name: "restricts an otherwise open account to the room allowlist", diff --git a/extensions/buzz/src/inbound.ts b/extensions/buzz/src/inbound.ts index 80b095aaed90..4248f28d9009 100644 --- a/extensions/buzz/src/inbound.ts +++ b/extensions/buzz/src/inbound.ts @@ -1,3 +1,4 @@ +import { normalizeURL } from "nostr-tools/utils"; import { buildChannelInboundEventContext, resolveChannelInboundRouteEnvelope, @@ -157,6 +158,24 @@ export async function handleBuzzInbound(params: { sessionKey: route.sessionKey, }, ctxPayload, + botLoopProtection: bus.directory.isBotMember(channelId, message.senderPubkey) + ? { + // Reciprocal accounts share the relay/room pair budget. Threads and + // sender timestamps must not let a bot reset or evade that budget. + scopeId: `buzz:${normalizeURL(account.relayUrl)}`, + conversationId: channelId, + senderId: message.senderPubkey, + receiverId: bus.publicKey, + eventId: message.id, + defaultsConfig: cfg.channels?.defaults?.botLoopProtection, + defaultEnabled: true, + } + : undefined, + log: (event) => { + if (event.reason === "bot-loop-protection") { + log.warn(`[${account.accountId}] Buzz bot-pair loop suppressed in ${channelId}`); + } + }, delivery: { deliver: async (payload) => { const text =