mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
a13f4d29ee
commit
de70b00d95
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user