refactor(doctor): centralize channel capability metadata (#52325)

* refactor(doctor): centralize channel capabilities

* fix(doctor): preserve msteams sender warnings
This commit is contained in:
Vincent Koc 2026-03-22 08:47:16 -07:00 committed by GitHub
parent d3a0a623a3
commit b9e71240ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 122 additions and 29 deletions

View File

@ -113,6 +113,7 @@ Docs: https://docs.openclaw.ai
- Telegram/setup: warn when setup leaves DMs on pairing without an allowlist, and show valid account-scoped remediation commands. (#50710) Thanks @ernestodeoliveira.
- Doctor/Telegram: replace the fresh-install empty group-allowlist false positive with first-run guidance that explains DM pairing approval and the next group setup steps, so new Telegram installs get actionable setup help instead of a broken-config warning. Thanks @vincentkoc.
- Doctor/extensions: keep Matrix DM `allowFrom` repairs on the canonical `dm.allowFrom` path and stop treating Zalouser group sender gating as if it fell back to `allowFrom`, so doctor warnings and `--fix` stay aligned with runtime access control. Thanks @vincentkoc.
- Doctor/refactor: centralize built-in channel doctor semantics in one static capability registry with conservative fallback behavior for unknown/external channels, so future extension changes stop depending on scattered shared string checks. Thanks @vincentkoc.
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { getDoctorChannelCapabilities } from "./channel-capabilities.js";
describe("doctor channel capabilities", () => {
it("returns built-in capability overrides for matrix", () => {
expect(getDoctorChannelCapabilities("matrix")).toEqual({
dmAllowFromMode: "nestedOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
});
});
it("returns hybrid group semantics for zalouser", () => {
expect(getDoctorChannelCapabilities("zalouser")).toEqual({
dmAllowFromMode: "topOnly",
groupModel: "hybrid",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
});
});
it("preserves empty sender allowlist warnings for msteams hybrid routing", () => {
expect(getDoctorChannelCapabilities("msteams")).toEqual({
dmAllowFromMode: "topOnly",
groupModel: "hybrid",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
});
});
it("falls back conservatively for unknown external channels", () => {
expect(getDoctorChannelCapabilities("external-demo")).toEqual({
dmAllowFromMode: "topOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: true,
warnOnEmptyGroupSenderAllowlist: true,
});
});
});

View File

@ -0,0 +1,75 @@
import type { AllowFromMode } from "./shared/allow-from-mode.js";
export type DoctorGroupModel = "sender" | "route" | "hybrid";
export type DoctorChannelCapabilities = {
dmAllowFromMode: AllowFromMode;
groupModel: DoctorGroupModel;
groupAllowFromFallbackToAllowFrom: boolean;
warnOnEmptyGroupSenderAllowlist: boolean;
};
const DEFAULT_DOCTOR_CHANNEL_CAPABILITIES: DoctorChannelCapabilities = {
dmAllowFromMode: "topOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: true,
warnOnEmptyGroupSenderAllowlist: true,
};
const DOCTOR_CHANNEL_CAPABILITIES: Record<string, DoctorChannelCapabilities> = {
discord: {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
},
googlechat: {
dmAllowFromMode: "nestedOnly",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
},
imessage: {
dmAllowFromMode: "topOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
},
irc: {
dmAllowFromMode: "topOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
},
matrix: {
dmAllowFromMode: "nestedOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
},
msteams: {
dmAllowFromMode: "topOnly",
groupModel: "hybrid",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
},
slack: {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
},
zalouser: {
dmAllowFromMode: "topOnly",
groupModel: "hybrid",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
},
};
export function getDoctorChannelCapabilities(channelName?: string): DoctorChannelCapabilities {
if (!channelName) {
return DEFAULT_DOCTOR_CHANNEL_CAPABILITIES;
}
return DOCTOR_CHANNEL_CAPABILITIES[channelName] ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES;
}

View File

@ -1,11 +1,7 @@
import { getDoctorChannelCapabilities } from "../channel-capabilities.js";
export type AllowFromMode = "topOnly" | "topOrNested" | "nestedOnly";
export function resolveAllowFromMode(channelName: string): AllowFromMode {
if (channelName === "googlechat" || channelName === "matrix") {
return "nestedOnly";
}
if (channelName === "discord" || channelName === "slack") {
return "topOrNested";
}
return "topOnly";
return getDoctorChannelCapabilities(channelName).dmAllowFromMode;
}

View File

@ -1,3 +1,4 @@
import { getDoctorChannelCapabilities } from "../channel-capabilities.js";
import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js";
import { hasAllowFromEntries } from "./allowlist.js";
@ -10,31 +11,11 @@ type CollectEmptyAllowlistPolicyWarningsParams = {
};
function usesSenderBasedGroupAllowlist(channelName?: string): boolean {
if (!channelName) {
return true;
}
// These channels enforce group access via channel/space config, not sender-based
// groupAllowFrom lists.
return !(
channelName === "discord" ||
channelName === "slack" ||
channelName === "googlechat" ||
channelName === "zalouser"
);
return getDoctorChannelCapabilities(channelName).warnOnEmptyGroupSenderAllowlist;
}
function allowsGroupAllowFromFallback(channelName?: string): boolean {
if (!channelName) {
return true;
}
// Keep doctor warnings aligned with runtime access semantics.
return !(
channelName === "googlechat" ||
channelName === "imessage" ||
channelName === "matrix" ||
channelName === "msteams" ||
channelName === "irc"
);
return getDoctorChannelCapabilities(channelName).groupAllowFromFallbackToAllowFrom;
}
export function collectEmptyAllowlistPolicyWarningsForAccount(