feat(buzz): support flat automatic replies and typing

Expose opt-in replyToMode off while retaining threaded replies by default. Keep incoming thread/session identity and explicit tool or CLI targets intact. Resolves #120339. Thanks to @Alfridus1 for the report.
This commit is contained in:
Peter Steinberger
2026-08-26 16:58:01 -07:00
parent 57cedf56ef
commit 315f52ab72
17 changed files with 324 additions and 164 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"core": 2358,
"channel": 3648,
"channel": 3649,
"plugin": 4046
}
+3 -3
View File
@@ -1,4 +1,4 @@
a1827ad4b4e37267514b8be68f04e811b350f279197c80e147a5a8202eca8874 config-baseline.json
173e52ecb34783db1303c1df04d87a0304bdf52c2c4494f984834b7012f26213 config-baseline.core.json
91d26e543e7ee19850287801cf8c95fe7c04d9aa9ce0ecd8434e4ae6205b60e4 config-baseline.channel.json
24ab389b31f4b1436a8722529e52dae7d5ec3ebffbaaab7c101e495114db336c config-baseline.json
f67e975229dc70957375558f54d317304e94dc7cc435899aaf21c06271d5ad7e config-baseline.core.json
4c50449cb17b292d590131b88278dd94cfeb775ab3d237caca65c631bef47689 config-baseline.channel.json
580d4bb93216d5a12fc715f3fbbaf5fff7bef548237fc168be7dc1a922407567 config-baseline.plugin.json
+11
View File
@@ -378,6 +378,17 @@ Membership is checked against the latest received roster when context is used.
If the same identity leaves and rejoins before then, its previously authorized
messages can remain in the window; leaving does not erase conversation history.
### Reply placement
Buzz keeps automatic replies threaded by default (`channels.buzz.replyToMode: "all"`).
Set `replyToMode: "off"` to send automatic replies at the top level of the room,
including replies to messages inside existing threads. Typing indicators follow
the same placement, including heartbeat typing.
This changes delivery only: inbound thread context and session identity remain
intact. Explicit message-tool or CLI sends with a thread or reply target still
honor that target. To restore the default, use `"all"` or remove the setting.
## Manual configuration
Guided setup is recommended. The equivalent configuration looks like:
+4
View File
@@ -183,6 +183,10 @@
}
]
},
"replyToMode": {
"type": "string",
"enum": ["off", "all"]
},
"groupPolicy": {
"default": "allowlist",
"type": "string",
+82 -77
View File
@@ -303,87 +303,92 @@ describe("Buzz bus lifecycle", () => {
expect(relayMocks.send).not.toHaveBeenCalled();
});
it("anchors signed threaded replies and typing while preserving top-level replies", async () => {
relayMocks.auth.mockResolvedValue("ok");
const runtime = createPluginRuntimeMock();
setBuzzRuntime(runtime);
const account: ResolvedBuzzAccount = {
accountId: ACCOUNT_ID,
name: "OpenClaw",
enabled: true,
configured: true,
relayUrl: "wss://buzz.example.com",
privateKey: PRIVATE_KEY,
authTag: "",
publicKey: BOT_PUBLIC_KEY,
config: {
groupPolicy: "open",
groups: { [CHANNEL_ID]: { requireMention: false } },
},
};
const bus = await startTestBus({
onMessage: async (message, activeBus, signal, assertCurrent) =>
await handleBuzzInbound({
account,
cfg: {},
bus: activeBus,
message,
signal,
assertCurrent,
historyMap: new Map(),
}),
});
it.each(["all", "off"] as const)(
"signs %s-mode replies and typing without changing inbound threads",
async (replyToMode) => {
relayMocks.auth.mockResolvedValue("ok");
const runtime = createPluginRuntimeMock();
setBuzzRuntime(runtime);
const account: ResolvedBuzzAccount = {
accountId: ACCOUNT_ID,
name: "OpenClaw",
enabled: true,
configured: true,
relayUrl: "wss://buzz.example.com",
privateKey: PRIVATE_KEY,
authTag: "",
publicKey: BOT_PUBLIC_KEY,
config: {
groupPolicy: "open",
replyToMode,
groups: { [CHANNEL_ID]: { requireMention: false } },
},
};
const bus = await startTestBus({
onMessage: async (message, activeBus, signal, assertCurrent) =>
await handleBuzzInbound({
account,
cfg: {},
bus: activeBus,
message,
signal,
assertCurrent,
historyMap: new Map(),
}),
});
try {
const rootId = "a".repeat(64);
const messageSubscription = relayMocks.subscriptions.find((entry) =>
subscriptionIncludesKind(entry, 9),
);
for (const [index, parentId] of ["b".repeat(64), "c".repeat(64), undefined].entries()) {
const inbound = signSenderEvent({
kind: 9,
created_at: 1_700_000_000 + index,
content: `follow-up ${index + 1}`,
tags: [
["h", CHANNEL_ID],
...(parentId
? [
["e", rootId, "", "root"],
["e", parentId, "", "reply"],
]
: []),
],
});
messageSubscription?.handlers.onevent(inbound);
await vi.waitFor(() =>
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(index + 1),
try {
const rootId = "a".repeat(64);
const messageSubscription = relayMocks.subscriptions.find((entry) =>
subscriptionIncludesKind(entry, 9),
);
const dispatch = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[index]?.[0];
await dispatch?.delivery.deliver({ text: `reply ${index + 1}` }, { kind: "final" });
await dispatch?.replyPipeline?.typing?.start();
for (const [index, parentId] of ["b".repeat(64), "c".repeat(64), undefined].entries()) {
const inbound = signSenderEvent({
kind: 9,
created_at: 1_700_000_000 + index,
content: `follow-up ${index + 1}`,
tags: [
["h", CHANNEL_ID],
...(parentId
? [
["e", rootId, "", "root"],
["e", parentId, "", "reply"],
]
: []),
],
});
messageSubscription?.handlers.onevent(inbound);
await vi.waitFor(() =>
expect(runtime.channel.inbound.dispatch).toHaveBeenCalledTimes(index + 1),
);
const dispatch = vi.mocked(runtime.channel.inbound.dispatch).mock.calls[index]?.[0];
expect(dispatch?.ctxPayload.MessageThreadId).toBe(parentId ? rootId : undefined);
await dispatch?.delivery.deliver({ text: `reply ${index + 1}` }, { kind: "final" });
await dispatch?.replyPipeline?.typing?.start();
const published = relayMocks.publish.mock.calls.find(
([event]) => event.kind === 9 && event.content === `reply ${index + 1}`,
)?.[0];
const typing = relayMocks.send.mock.calls
.map(([frame]) => JSON.parse(frame) as [string, Event])
.filter(
([frameType, event]) =>
frameType === "EVENT" && event.kind === BUZZ_TYPING_INDICATOR_KIND,
)[index]?.[1];
const expectedTags = [
["h", CHANNEL_ID],
["e", parentId ? rootId : inbound.id, "", "reply"],
];
expect(published?.tags).toEqual(expectedTags);
expect(typing?.tags).toEqual(expectedTags);
expect(published && verifyEvent(published)).toBe(true);
expect(typing && verifyEvent(typing)).toBe(true);
const published = relayMocks.publish.mock.calls.find(
([event]) => event.kind === 9 && event.content === `reply ${index + 1}`,
)?.[0];
const typing = relayMocks.send.mock.calls
.map(([frame]) => JSON.parse(frame) as [string, Event])
.filter(
([frameType, event]) =>
frameType === "EVENT" && event.kind === BUZZ_TYPING_INDICATOR_KIND,
)[index]?.[1];
const expectedTags = [
["h", CHANNEL_ID],
...(replyToMode === "all" ? [["e", parentId ? rootId : inbound.id, "", "reply"]] : []),
];
expect(published?.tags).toEqual(expectedTags);
expect(typing?.tags).toEqual(expectedTags);
expect(published && verifyEvent(published)).toBe(true);
expect(typing && verifyEvent(typing)).toBe(true);
}
} finally {
await bus.close();
}
} finally {
await bus.close();
}
});
},
);
it("drops typing while the active relay is disconnected", async () => {
relayMocks.auth.mockResolvedValue("ok");
+18
View File
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
import { buzzPlugin } from "./channel.js";
describe("Buzz channel guidance", () => {
it.each([
{ mode: "off", automatic: true, flat: true },
{ mode: "all", automatic: true, flat: false },
{ mode: "off", automatic: false, flat: false },
] as const)(
"routes $mode automatic=$automatic without flattening explicit tools",
({ mode, automatic, flat }) => {
const original = { threadId: "thread-root", replyToId: "requested-parent" };
const transport =
buzzPlugin.threading?.resolveReplyTransport?.({
cfg: {},
...original,
replyToIsExplicit: !automatic,
replyDelivery: automatic ? { replyToMode: mode } : undefined,
}) ?? original;
expect(transport).toEqual(flat ? { threadId: null, replyToId: null } : original);
},
);
it("advertises directory room targets and native mention syntax", () => {
const hints = buzzPlugin.agentPrompt?.messageToolHints?.({} as never) ?? [];
+5
View File
@@ -70,6 +70,11 @@ export const buzzPlugin = createChatChannelPlugin<ResolvedBuzzAccount, BuzzProbe
chatTypes: ["group"],
threads: true,
},
threading: {
// Only automatic replies carry replyDelivery; explicit message-tool targets stay intact.
resolveReplyTransport: ({ replyDelivery }) =>
replyDelivery?.replyToMode === "off" ? { threadId: null, replyToId: null } : null,
},
agentPrompt: {
messageToolHints: () => [
"- Buzz targets: use a configured room UUID, `buzz:<ROOM_UUID>`, or a unique current room name. Use the UUID when room names are ambiguous.",
+25
View File
@@ -85,6 +85,31 @@ describe("BuzzConfigSchema", () => {
).toBe(valid);
}
});
it.each([
["off", true],
["all", true],
["first", false],
["batched", false],
[false, false],
])("validates replyToMode %s in runtime and manifest schemas", (replyToMode, valid) => {
const config = { replyToMode, groupPolicy: "allowlist" };
const manifest: {
channelConfigs: { buzz: { schema: Parameters<typeof validateJsonSchemaValue>[0]["schema"] } };
} = JSON.parse(readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"));
expect(parseBuzzConfig(config).success).toBe(valid);
for (const [name, schema] of [
["runtime", BuzzConfigSchema.schema],
["manifest", manifest.channelConfigs.buzz.schema],
] as const) {
expect(
validateJsonSchemaValue({
cacheKey: `buzz.reply-mode.${name}.${replyToMode}`,
schema,
value: config,
}).ok,
).toBe(valid);
}
});
it.each([
"ws://localhost:3000",
"wss://buzz.example.com/relay",
+1
View File
@@ -22,6 +22,7 @@ const RawBuzzConfigSchema = z
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
responsePrefix: z.string().optional(),
replyToMode: z.enum(["off", "all"]).optional(),
markdown: MarkdownConfigSchema,
relayUrl: z
.string()
+54 -25
View File
@@ -297,6 +297,28 @@ describe("Buzz gateway lifecycle", () => {
});
});
it("preserves an explicit send's thread even when automatic replies are flat", async () => {
const cfg = createBuzzConfig();
const flatCfg = {
...cfg,
channels: { ...cfg.channels, buzz: { ...cfg.channels?.buzz, replyToMode: "off" as const } },
};
await buzzOutboundAdapter.sendText({
cfg: flatCfg,
to: CHANNEL_ID,
text: "explicit thread send",
threadId: "requested-thread",
replyToId: "requested-parent",
});
expect(gatewayMocks.sendBuzzTextOneShot).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "requested-thread",
replyToId: "requested-parent",
text: "explicit thread send",
}),
);
});
it("blocks direct sends before opening a relay when an auth-tag SecretRef is unavailable", async () => {
const cfg = createUnavailableBuzzConfig("authTag");
@@ -473,35 +495,42 @@ describe("Buzz gateway lifecycle", () => {
await expect(lifecycle).resolves.toBeUndefined();
});
it("uses the active bus for heartbeat typing without destabilizing the account", async () => {
const { abortController, cfg, lifecycle } = startTestGateway();
await vi.waitFor(() => expect(gatewayMocks.startBuzzBus).toHaveBeenCalledOnce());
it.each(["all", "off"] as const)(
"uses %s-mode heartbeat typing without destabilizing the account",
async (replyToMode) => {
const { abortController, cfg, lifecycle } = startTestGateway();
await vi.waitFor(() => expect(gatewayMocks.startBuzzBus).toHaveBeenCalledOnce());
const typingCfg = {
...cfg,
channels: { ...cfg.channels, buzz: { ...cfg.channels?.buzz, replyToMode } },
};
await sendBuzzTyping({
cfg,
to: `buzz:${CHANNEL_ID}`,
accountId: "default",
threadId: "root-id",
});
expect(gatewayMocks.busSendTyping).toHaveBeenCalledWith({
channelId: CHANNEL_ID,
threadId: "root-id",
});
gatewayMocks.busSendTyping.mockRejectedValueOnce(new Error("socket closing"));
await expect(
sendBuzzTyping({
cfg,
await sendBuzzTyping({
cfg: typingCfg,
to: `buzz:${CHANNEL_ID}`,
accountId: "default",
}),
).rejects.toThrow("socket closing");
expect(gatewayMocks.startBuzzBus).toHaveBeenCalledOnce();
expect(gatewayMocks.close).not.toHaveBeenCalled();
threadId: "root-id",
});
expect(gatewayMocks.busSendTyping).toHaveBeenCalledWith({
channelId: CHANNEL_ID,
threadId: replyToMode === "off" ? undefined : "root-id",
});
abortController.abort();
await expect(lifecycle).resolves.toBeUndefined();
});
gatewayMocks.busSendTyping.mockRejectedValueOnce(new Error("socket closing"));
await expect(
sendBuzzTyping({
cfg,
to: `buzz:${CHANNEL_ID}`,
accountId: "default",
}),
).rejects.toThrow("socket closing");
expect(gatewayMocks.startBuzzBus).toHaveBeenCalledOnce();
expect(gatewayMocks.close).not.toHaveBeenCalled();
abortController.abort();
await expect(lifecycle).resolves.toBeUndefined();
},
);
it("preserves room activation after a failed initial session", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+5 -1
View File
@@ -290,8 +290,12 @@ export async function sendBuzzTyping(params: {
if (!bus) {
return;
}
const account = resolveBuzzAccount({ cfg: params.cfg, accountId: resolvedAccountId });
await bus.sendTyping({
channelId: parseBuzzTarget(params.to),
threadId: params.threadId == null ? undefined : String(params.threadId),
threadId:
account.config.replyToMode === "off" || params.threadId == null
? undefined
: String(params.threadId),
});
}
+30
View File
@@ -369,6 +369,36 @@ describe("handleBuzzInbound", () => {
expect(params.historyMap.size).toBe(0);
});
it.each([undefined, "all", "off"] as const)(
"uses replyToMode %s for automatic delivery and typing without changing thread context",
async (replyToMode) => {
const runtime = createPluginRuntimeMock();
setBuzzRuntime(runtime);
const bus = createBus();
const account = createAccount();
const config = { ...account.config, replyToMode };
await handleBuzzInbound({
account: { ...account, config },
cfg: {},
bus,
message: createMessage({ threadId: "existing-thread", mentionedPubkeys: [BOT_PUBLIC_KEY] }),
...createLifecycle(),
});
const dispatch = firstDispatch(runtime);
expect(dispatch.ctxPayload.MessageThreadId).toBe("existing-thread");
expect(dispatch.ctxPayload.ReplyToId).toBe("event-1");
await dispatch.delivery.deliver({ text: "response" }, { kind: "final" });
await dispatch.replyPipeline?.typing?.start();
const replyTarget = {
channelId: ROOM_ID,
threadId: replyToMode === "off" ? undefined : "existing-thread",
replyToId: replyToMode === "off" ? undefined : "existing-thread",
};
expect(bus.sendText).toHaveBeenCalledWith({ ...replyTarget, text: "response" });
expect(bus.sendTyping).toHaveBeenCalledWith(replyTarget);
},
);
it("accepts a native Nostr public-key mention", async () => {
const runtime = createPluginRuntimeMock();
setBuzzRuntime(runtime);
+2 -2
View File
@@ -169,8 +169,8 @@ export async function handleBuzzInbound(params: {
});
const replyTarget = {
channelId,
threadId: message.threadId,
replyToId: message.threadId ?? message.id,
threadId: account.config.replyToMode === "off" ? undefined : message.threadId,
replyToId: account.config.replyToMode === "off" ? undefined : (message.threadId ?? message.id),
};
const result = await runtime.channel.inbound.dispatch({
@@ -1,6 +1,6 @@
{
"entries": [
{"name":"@openclaw/buzz","version":"2026.8.1","description":"Connect OpenClaw agents to Buzz rooms","source":"official","kind":"channel","openclaw":{"channelConfigs":{"buzz":{"label":"Buzz","description":"Connect OpenClaw agents to Buzz team rooms.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"responsePrefix":{"type":"string"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"relayUrl":{"allOf":[{"type":"string","format":"uri"},{"type":"string","pattern":"^[wW][sS][sS]?:\\/\\/"}]},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"authTag":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":20},"groups":{"type":"object","propertyNames":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"defaultTo":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}},"channel":{"id":"buzz","configuredState":{"env":{"allOf":["BUZZ_RELAY_URL","BUZZ_PRIVATE_KEY"]}},"label":"Buzz","selectionLabel":"Buzz","docsPath":"/channels/buzz","docsLabel":"buzz","blurb":"Connect OpenClaw agents to Buzz team rooms.","markdownCapable":true,"order":56,"setup":{"fields":[{"key":"relayUrl","kind":"string","cli":{"flags":"--relay-url <url>","description":"Buzz relay WebSocket URL"}},{"key":"privateKey","kind":"string","sensitive":true,"cli":{"flags":"--private-key <key>","description":"Buzz bot Nostr private key"}},{"key":"useEnv","kind":"boolean","cli":{"flags":"--use-env","description":"Use BUZZ_PRIVATE_KEY with the supplied relay URL"},"envVars":["BUZZ_PRIVATE_KEY"]}]}},"install":{"clawhubSpec":"clawhub:@openclaw/buzz","npmSpec":"@openclaw/buzz","defaultChoice":"npm","minHostVersion":">=2026.7.2"}}},
{"name":"@openclaw/buzz","version":"2026.8.1","description":"Connect OpenClaw agents to Buzz rooms","source":"official","kind":"channel","openclaw":{"channelConfigs":{"buzz":{"label":"Buzz","description":"Connect OpenClaw agents to Buzz team rooms.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"responsePrefix":{"type":"string"},"replyToMode":{"type":"string","enum":["off","all"]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"relayUrl":{"allOf":[{"type":"string","format":"uri"},{"type":"string","pattern":"^[wW][sS][sS]?:\\/\\/"}]},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"authTag":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":20},"groups":{"type":"object","propertyNames":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"requireMention":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"defaultTo":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}},"channel":{"id":"buzz","configuredState":{"env":{"allOf":["BUZZ_RELAY_URL","BUZZ_PRIVATE_KEY"]}},"label":"Buzz","selectionLabel":"Buzz","docsPath":"/channels/buzz","docsLabel":"buzz","blurb":"Connect OpenClaw agents to Buzz team rooms.","markdownCapable":true,"order":56,"setup":{"fields":[{"key":"relayUrl","kind":"string","cli":{"flags":"--relay-url <url>","description":"Buzz relay WebSocket URL"}},{"key":"privateKey","kind":"string","sensitive":true,"cli":{"flags":"--private-key <key>","description":"Buzz bot Nostr private key"}},{"key":"useEnv","kind":"boolean","cli":{"flags":"--use-env","description":"Use BUZZ_PRIVATE_KEY with the supplied relay URL"},"envVars":["BUZZ_PRIVATE_KEY"]}]}},"install":{"clawhubSpec":"clawhub:@openclaw/buzz","npmSpec":"@openclaw/buzz","defaultChoice":"npm","minHostVersion":">=2026.7.2"}}},
{"name":"@openclaw/clickclack","version":"2026.8.1","description":"OpenClaw ClickClack channel plugin","source":"official","kind":"channel","openclaw":{"contracts":{"tools":["discussion"]},"channelConfigs":{"clickclack":{"label":"ClickClack","description":"ClickClack channel accounts and group activation policy.","schema":{"type":"object","additionalProperties":true,"properties":{"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0},"windowSeconds":{"type":"integer","exclusiveMinimum":0},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0}}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"groups":{"type":"object","additionalProperties":{"type":"object","additionalProperties":true,"properties":{"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0},"windowSeconds":{"type":"integer","exclusiveMinimum":0},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0}}},"mentionPatterns":{"type":"array","items":{"type":"string"}}}}},"accounts":{"type":"object","additionalProperties":{"type":"object","additionalProperties":true,"properties":{"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0},"windowSeconds":{"type":"integer","exclusiveMinimum":0},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0}}},"mentionPatterns":{"type":"array","items":{"type":"string"}},"groups":{"type":"object","additionalProperties":{"type":"object","additionalProperties":true,"properties":{"requireMention":{"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0},"windowSeconds":{"type":"integer","exclusiveMinimum":0},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0}}},"mentionPatterns":{"type":"array","items":{"type":"string"}}}}}}}}}}}},"channel":{"id":"clickclack","configuredState":{"env":{"anyOf":["CLICKCLACK_BOT_TOKEN"]}},"label":"ClickClack","selectionLabel":"ClickClack","detailLabel":"ClickClack Bot","docsPath":"/channels/clickclack","docsLabel":"clickclack","blurb":"self-hosted chat via first-class ClickClack bot tokens.","systemImage":"bubble.left.and.bubble.right","markdownCapable":true,"preferSessionLookupForAnnounceTarget":true,"order":85,"commands":{"nativeCommandsAutoEnabled":false,"nativeSkillsAutoEnabled":false},"setup":{"fields":[{"key":"code","kind":"string","sensitive":true,"cli":{"flags":"--code <code>","description":"ClickClack one-time setup code or setup URL"}},{"key":"token","kind":"string","sensitive":true,"cli":{"flags":"--token <token>","description":"ClickClack bot token"}},{"key":"tokenFile","kind":"string","sensitive":true,"cli":{"flags":"--token-file <path>","description":"ClickClack bot token file"}},{"key":"baseUrl","kind":"string","cli":{"flags":"--base-url <url>","description":"ClickClack API base URL"}},{"key":"workspace","kind":"string","cli":{"flags":"--workspace <workspace>","description":"ClickClack workspace id, slug, or name"}},{"key":"defaultTo","kind":"string","cli":{"flags":"--default-to <target>","description":"Default ClickClack target"}},{"key":"allowFrom","kind":"string-list","cli":{"flags":"--allow-from <ids>","description":"Allowed ClickClack senders"}},{"key":"agentActivity","kind":"boolean","cli":{"flags":"--agent-activity","description":"Enable ClickClack agent activity"}},{"key":"useEnv","kind":"boolean","cli":{"flags":"--use-env","description":"Use CLICKCLACK_BOT_TOKEN"},"envVars":["CLICKCLACK_BOT_TOKEN"]}]}},"install":{"clawhubSpec":"clawhub:@openclaw/clickclack","npmSpec":"@openclaw/clickclack","defaultChoice":"npm","minHostVersion":">=2026.6.9","allowInvalidConfigRecovery":true}}},
{"name":"@openclaw/discord","version":"2026.8.1","description":"OpenClaw Discord channel plugin for channels, DMs, commands, and app events.","source":"official","kind":"channel","openclaw":{"contracts":{"transcriptSourceProviders":["discord-voice"]},"channel":{"id":"discord","configuredState":{"env":{"anyOf":["DISCORD_BOT_TOKEN"]}},"approvalFlags":["native"],"label":"Discord","selectionLabel":"Discord (Bot API)","detailLabel":"Discord Bot","docsPath":"/channels/discord","docsLabel":"discord","blurb":"very well supported right now.","systemImage":"bubble.left.and.bubble.right","markdownCapable":true,"preferSessionLookupForAnnounceTarget":true,"setup":{"fields":[{"key":"token","kind":"string","sensitive":true,"cli":{"flags":"--token <token>","description":"Discord bot token"}},{"key":"useEnv","kind":"boolean","cli":{"flags":"--use-env","description":"Use DISCORD_BOT_TOKEN"},"envVars":["DISCORD_BOT_TOKEN"]}]},"commands":{"nativeCommandsAutoEnabled":true,"nativeSkillsAutoEnabled":true},"doctorCapabilities":{"dmAllowFromMode":"topOnly","groupModel":"route","groupAllowFromFallbackToAllowFrom":false,"warnOnEmptyGroupSenderAllowlist":false}},"install":{"npmSpec":"@openclaw/discord","defaultChoice":"npm","minHostVersion":">=2026.5.26","allowInvalidConfigRecovery":true}}},
{"name":"@openclaw/feishu","version":"2026.8.1","description":"OpenClaw Feishu/Lark channel plugin for chats and workplace tools (community maintained by @m1heng).","source":"official","kind":"channel","openclaw":{"contracts":{"tools":["feishu_app_scopes","feishu_bitable_create_app","feishu_bitable_create_field","feishu_bitable_create_record","feishu_bitable_get_meta","feishu_bitable_get_record","feishu_bitable_list_fields","feishu_bitable_list_records","feishu_bitable_update_record","feishu_chat","feishu_doc","feishu_drive","feishu_perm","feishu_wiki"]},"channel":{"id":"feishu","configuredState":{"env":{"anyOf":["FEISHU_APP_ID","FEISHU_APP_SECRET","FEISHU_VERIFICATION_TOKEN","FEISHU_ENCRYPT_KEY"]},"specifier":"./configured-state","exportName":"hasConfiguredFeishuChannelState"},"label":"Feishu","selectionLabel":"Feishu/Lark (飞书)","docsPath":"/channels/feishu","docsLabel":"feishu","blurb":"飞书/Lark enterprise messaging with doc/wiki/drive tools.","aliases":["lark"],"order":35,"quickstartAllowFrom":true,"setup":{"fields":[]}},"install":{"npmSpec":"@openclaw/feishu","defaultChoice":"npm","minHostVersion":">=2026.5.29"}}},
@@ -127,16 +127,17 @@ describe("buildReplyPayloads media filter integration", () => {
replyDelivery,
}: ResolveReplyTransportParams) => {
const ambientThreadId = threadId != null ? String(threadId) : undefined;
const resolvedThreadId =
replyDelivery?.chatType === "direct"
? undefined
: replyToIsExplicit
const isFlatDirect =
replyDelivery?.chatType === "direct" && replyDelivery.replyToMode === "off";
const resolvedThreadId = isFlatDirect
? undefined
: replyDelivery
? replyToIsExplicit
? (replyToId ?? ambientThreadId)
: replyDelivery
? (ambientThreadId ?? replyToId ?? undefined)
: (replyToId ?? ambientThreadId);
: (ambientThreadId ?? replyToId ?? undefined)
: (ambientThreadId ?? replyToId);
return {
replyToId: resolvedThreadId,
replyToId: isFlatDirect ? null : resolvedThreadId,
threadId: resolvedThreadId ?? null,
};
},
@@ -745,6 +746,26 @@ describe("buildReplyPayloads media filter integration", () => {
target: {},
expected: [],
},
{
name: "dedupes an all-mode Mattermost DM reply against the same thread",
channel: "mattermost",
text: "same reply",
payload: { replyToId: "post-1", replyToTag: true },
params: { replyToMode: "all", originatingChatType: "direct" },
to: "user:U1",
target: { threadId: "post-1" },
expected: [],
},
{
name: "keeps an all-mode Mattermost DM reply when the tool sent it top-level",
channel: "mattermost",
text: "same reply",
payload: { replyToId: "post-1", replyToTag: true },
params: { replyToMode: "all", originatingChatType: "direct" },
to: "user:U1",
target: {},
expected: ["same reply"],
},
{
name: "dedupes an implicit Mattermost send in the active thread",
channel: "mattermost",
+34 -27
View File
@@ -78,17 +78,18 @@ function resolveSlackThreadTsCandidate(value?: string | number | null): string |
const mattermostThreading: ChannelThreadingAdapter = {
resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => {
const ambientThreadId = threadId != null && threadId !== "" ? String(threadId) : undefined;
const resolvedThreadId =
replyDelivery?.chatType === "direct"
? undefined
: replyToIsExplicit
const ambientThreadId = threadId != null ? String(threadId) : undefined;
const isFlatDirect =
replyDelivery?.chatType === "direct" && replyDelivery.replyToMode === "off";
const resolvedThreadId = isFlatDirect
? undefined
: replyDelivery
? replyToIsExplicit
? (replyToId ?? ambientThreadId)
: replyDelivery
? (ambientThreadId ?? replyToId ?? undefined)
: (replyToId ?? ambientThreadId);
: (ambientThreadId ?? replyToId ?? undefined)
: (ambientThreadId ?? replyToId);
return {
replyToId: replyDelivery?.chatType === "direct" ? null : resolvedThreadId,
replyToId: isFlatDirect ? null : resolvedThreadId,
threadId: resolvedThreadId ?? null,
};
},
@@ -480,25 +481,31 @@ describe("routeReply", () => {
expect(lastDeliveryPayload().replyToId).toBeUndefined();
});
it("honors Mattermost policy that clears direct-message reply targets", async () => {
const res = await routeTestReply({
payload: { text: "hello", replyToId: "post-1" },
channel: "mattermost",
to: "user:U123",
replyDelivery: {
chatType: "direct",
replyToMode: "all",
},
replyKind: "block",
});
it.each([
{ replyToMode: "off" as const, expectedTarget: null },
{ replyToMode: "all" as const, expectedTarget: "post-1" },
])(
"honors Mattermost $replyToMode direct-message reply placement",
async ({ replyToMode, expectedTarget }) => {
const res = await routeTestReply({
payload: { text: "hello", replyToId: "post-1" },
channel: "mattermost",
to: "user:U123",
replyDelivery: {
chatType: "direct",
replyToMode,
},
replyKind: "block",
});
expect(res.ok).toBe(true);
expectLastDeliveryFields({
replyToId: null,
threadId: null,
});
expect(lastDeliveryPayload().replyToId).toBeUndefined();
});
expect(res.ok).toBe(true);
expectLastDeliveryFields({
replyToId: expectedTarget,
threadId: expectedTarget,
});
expect(lastDeliveryPayload().replyToId).toBe(expectedTarget ?? undefined);
},
);
it("preserves explicit Mattermost reply targets over the ambient thread", async () => {
const res = await routeTestReply({
File diff suppressed because one or more lines are too long