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.
This commit is contained in:
Peter Steinberger
2026-08-26 17:58:41 -07:00
committed by GitHub
parent a13f4d29ee
commit de70b00d95
4 changed files with 124 additions and 1 deletions
+18
View File
@@ -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:
+10
View File
@@ -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 (
+77 -1
View File
@@ -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",
+19
View File
@@ -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 =