fix(channels): honor paired DM policy before publish routing (#1314)

* fix(channels): honor paired DM policy before publish

* fix(channels): clear advanced allowlist updates

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
This commit is contained in:
Duc Nguyenandntduc authored and GitHub committed 2026-07-01 10:54:34 +07:00
1 parent c02fb660bc
commit d9a15be86c
3 files changed
+108 -21

No files matched your search

@@ -0,0 +1,36 @@
import { flattenConfig, unflattenConfig } from "@/lib/config-flatten";
import { normalizeReasoningDeliveryConfig } from "../reasoning-delivery-config";
export const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention", "mention_mode"]);
function isAdvancedConfigKey(key: string): boolean {
return !ESSENTIAL_CONFIG_KEYS.has(key) && key !== "groups" && !key.startsWith("groups.");
}
export function deriveAdvancedInitialValues(config: Record<string, unknown> | null | undefined): Record<string, unknown> {
const normalized = normalizeReasoningDeliveryConfig({ ...((config ?? {}) as Record<string, unknown>) });
const flat = flattenConfig(normalized);
// Only keep advanced keys (exclude essential + group overrides).
return Object.fromEntries(
Object.entries(flat).filter(([k]) => isAdvancedConfigKey(k)),
);
}
export function buildAdvancedConfigUpdate(
existingConfig: Record<string, unknown> | null | undefined,
values: Record<string, unknown>,
): Record<string, unknown> {
const normalized = normalizeReasoningDeliveryConfig({ ...((existingConfig ?? {}) as Record<string, unknown>) });
const flat = flattenConfig(normalized);
for (const [key, value] of Object.entries(values)) {
if (!isAdvancedConfigKey(key)) continue;
if (value === undefined || value === "" || value === null) {
delete flat[key];
continue;
}
flat[key] = value;
}
return normalizeReasoningDeliveryConfig(unflattenConfig(flat));
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { buildAdvancedConfigUpdate, deriveAdvancedInitialValues } from "./channel-advanced-config";
describe("channel advanced config payload", () => {
it("removes a stale allow_from entry when the allowed users picker is cleared", () => {
const existing = {
dm_policy: "pairing",
group_policy: "open",
allow_from: ["195835936795841454"],
groups: {
"-100123": { allow_from: ["group-user"] },
},
};
const values = deriveAdvancedInitialValues(existing);
const next = buildAdvancedConfigUpdate(existing, { ...values, allow_from: undefined });
expect(next).not.toHaveProperty("allow_from");
expect(next.dm_policy).toBe("pairing");
expect(next.group_policy).toBe("open");
expect(next.groups).toEqual(existing.groups);
});
it("persists an explicit empty allow_from array when a tags field emits one", () => {
const existing = {
allow_from: ["195835936795841454"],
proxy: "http://localhost:8080",
};
const next = buildAdvancedConfigUpdate(existing, {
allow_from: [],
proxy: "",
});
expect(next.allow_from).toEqual([]);
expect(next).not.toHaveProperty("proxy");
});
it("flattens nested delivery config for editing and writes it back nested", () => {
const existing = {
chat_behavior: {
final_split: {
enabled: "inherit",
},
},
};
const values = deriveAdvancedInitialValues(existing);
expect(values["chat_behavior.final_split.enabled"]).toBe("inherit");
const next = buildAdvancedConfigUpdate(existing, {
...values,
"chat_behavior.final_split.enabled": "off",
});
expect(next).toEqual({
chat_behavior: {
final_split: {
enabled: "off",
},
},
});
});
});
@@ -11,7 +11,11 @@ import {
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import { ChannelFields } from "../channel-fields";
import { configSchema } from "../channel-schemas";
import { normalizeReasoningDeliveryConfig } from "../reasoning-delivery-config";
import {
buildAdvancedConfigUpdate,
deriveAdvancedInitialValues,
ESSENTIAL_CONFIG_KEYS,
} from "./channel-advanced-config";
import type { ChannelInstanceData } from "@/types/channel";
interface ChannelAdvancedDialogProps {
@@ -21,8 +25,6 @@ interface ChannelAdvancedDialogProps {
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
}
const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention", "mention_mode"]);
const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]);
const LIMITS_KEYS = new Set(["history_limit", "media_max_mb", "text_chunk_limit"]);
const STREAMING_KEYS = new Set(["dm_stream", "group_stream", "draft_transport", "reasoning_delivery", "native_stream", "debounce_delay", "thread_ttl"]);
@@ -41,14 +43,6 @@ function getAdvancedFields(channelType: string) {
};
}
function deriveInitialValues(instance: ChannelInstanceData): Record<string, unknown> {
const config = normalizeReasoningDeliveryConfig((instance.config ?? {}) as Record<string, unknown>);
// Only keep advanced keys (exclude essential + groups)
return Object.fromEntries(
Object.entries(config).filter(([k]) => !ESSENTIAL_CONFIG_KEYS.has(k) && k !== "groups"),
);
}
export function ChannelAdvancedDialog({
open,
onOpenChange,
@@ -58,14 +52,13 @@ export function ChannelAdvancedDialog({
const { t } = useTranslation("channels");
const groups = getAdvancedFields(instance.channel_type);
const [values, setValues] = useState<Record<string, unknown>>(() => deriveInitialValues(instance));
const [values, setValues] = useState<Record<string, unknown>>(() => deriveAdvancedInitialValues(instance.config));
const [saving, setSaving] = useState(false);
// Re-sync local state when dialog opens
useEffect(() => {
if (!open) return;
setValues(deriveInitialValues(instance));
setValues(deriveAdvancedInitialValues(instance.config));
}, [open, instance]);
const handleChange = useCallback((key: string, value: unknown) => {
@@ -75,13 +68,7 @@ export function ChannelAdvancedDialog({
const handleSave = async () => {
setSaving(true);
try {
const existingConfig = (instance.config ?? {}) as Record<string, unknown>;
const cleanAdvanced = Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
// Merge: preserve essential keys and groups from existing, overwrite advanced keys
const merged = normalizeReasoningDeliveryConfig({ ...existingConfig, ...cleanAdvanced });
await onUpdate({ config: merged });
await onUpdate({ config: buildAdvancedConfigUpdate(instance.config, values) });
onOpenChange(false);
} catch { // toast shown by hook
} finally {