feat(channels): add resolve command + defaults

This commit is contained in:
Peter Steinberger
2026-01-18 00:41:57 +00:00
parent b543339373
commit c7ea47e886
60 changed files with 4418 additions and 101 deletions

View File

@@ -9,8 +9,14 @@ import { finalizeInboundContext } from "../../../src/auto-reply/reply/inbound-co
import { resolveCommandAuthorizedFromAuthorizers } from "../../../src/channels/command-gating.js";
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
import { sendMessageZalouser } from "./send.js";
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
import { runZcaStreaming } from "./zca.js";
import type {
CoreConfig,
ResolvedZalouserAccount,
ZcaFriend,
ZcaGroup,
ZcaMessage,
} from "./types.js";
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
export type ZalouserMonitorOptions = {
account: ResolvedZalouserAccount;
@@ -26,6 +32,71 @@ export type ZalouserMonitorResult = {
const ZALOUSER_TEXT_LIMIT = 2000;
function mergeAllowlist(params: {
existing?: Array<string | number>;
additions: string[];
}): string[] {
const seen = new Set<string>();
const merged: string[] = [];
const push = (value: string) => {
const normalized = value.trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
merged.push(normalized);
};
for (const entry of params.existing ?? []) {
push(String(entry));
}
for (const entry of params.additions) {
push(entry);
}
return merged;
}
function summarizeMapping(
label: string,
mapping: string[],
unresolved: string[],
runtime: RuntimeEnv,
) {
const lines: string[] = [];
if (mapping.length > 0) {
const sample = mapping.slice(0, 6);
const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : "";
lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`);
}
if (unresolved.length > 0) {
const sample = unresolved.slice(0, 6);
const suffix =
unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : "";
lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`);
}
if (lines.length > 0) {
runtime.log?.(lines.join("\n"));
}
}
function normalizeZalouserEntry(entry: string): string {
return entry.replace(/^(zalouser|zlu):/i, "").trim();
}
function buildNameIndex<T>(
items: T[],
nameFn: (item: T) => string | undefined,
): Map<string, T[]> {
const index = new Map<string, T[]>();
for (const item of items) {
const name = nameFn(item)?.trim().toLowerCase();
if (!name) continue;
const list = index.get(name) ?? [];
list.push(item);
index.set(name, list);
}
return index;
}
function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
if (deps.shouldLogVerbose()) {
runtime.log(`[zalouser] ${message}`);
@@ -41,6 +112,39 @@ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
});
}
function normalizeGroupSlug(raw?: string | null): string {
const trimmed = raw?.trim().toLowerCase() ?? "";
if (!trimmed) return "";
return trimmed
.replace(/^#/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function isGroupAllowed(params: {
groupId: string;
groupName?: string | null;
groups: Record<string, { allow?: boolean; enabled?: boolean }>;
}): boolean {
const groups = params.groups ?? {};
const keys = Object.keys(groups);
if (keys.length === 0) return false;
const candidates = [
params.groupId,
`group:${params.groupId}`,
params.groupName ?? "",
normalizeGroupSlug(params.groupName ?? ""),
].filter(Boolean);
for (const candidate of candidates) {
const entry = groups[candidate];
if (!entry) continue;
return entry.allow !== false && entry.enabled !== false;
}
const wildcard = groups["*"];
if (wildcard) return wildcard.allow !== false && wildcard.enabled !== false;
return false;
}
function startZcaListener(
runtime: RuntimeEnv,
profile: string,
@@ -106,8 +210,26 @@ async function processMessage(
const isGroup = metadata?.isGroup ?? false;
const senderId = metadata?.fromId ?? threadId;
const senderName = metadata?.senderName ?? "";
const groupName = metadata?.threadName ?? "";
const chatId = threadId;
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
const groups = account.config.groups ?? {};
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
return;
}
if (groupPolicy === "allowlist") {
const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
if (!allowed) {
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
return;
}
}
}
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const rawBody = content.trim();
@@ -194,11 +316,10 @@ async function processMessage(
},
});
const rawBody = content.trim();
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
timestamp: timestamp ? timestamp * 1000 : undefined,
body: rawBody,
});
@@ -301,7 +422,8 @@ async function deliverZalouserReply(params: {
export async function monitorZalouserProvider(
options: ZalouserMonitorOptions,
): Promise<ZalouserMonitorResult> {
const { account, config, abortSignal, statusSink, runtime } = options;
let { account, config } = options;
const { abortSignal, statusSink, runtime } = options;
const deps = await loadCoreChannelDeps();
let stopped = false;
@@ -309,6 +431,92 @@ export async function monitorZalouserProvider(
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let resolveRunning: (() => void) | null = null;
try {
const profile = account.profile;
const allowFromEntries = (account.config.allowFrom ?? [])
.map((entry) => normalizeZalouserEntry(String(entry)))
.filter((entry) => entry && entry !== "*");
if (allowFromEntries.length > 0) {
const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 });
if (result.ok) {
const friends = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
const byName = buildNameIndex(friends, (friend) => friend.displayName);
const additions: string[] = [];
const mapping: string[] = [];
const unresolved: string[] = [];
for (const entry of allowFromEntries) {
if (/^\d+$/.test(entry)) {
additions.push(entry);
continue;
}
const matches = byName.get(entry.toLowerCase()) ?? [];
const match = matches[0];
const id = match?.userId ? String(match.userId) : undefined;
if (id) {
additions.push(id);
mapping.push(`${entry}${id}`);
} else {
unresolved.push(entry);
}
}
const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
account = {
...account,
config: {
...account.config,
allowFrom,
},
};
summarizeMapping("zalouser users", mapping, unresolved, runtime);
} else {
runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`);
}
}
const groupsConfig = account.config.groups ?? {};
const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*");
if (groupKeys.length > 0) {
const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 });
if (result.ok) {
const groups = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
const byName = buildNameIndex(groups, (group) => group.name);
const mapping: string[] = [];
const unresolved: string[] = [];
const nextGroups = { ...groupsConfig };
for (const entry of groupKeys) {
const cleaned = normalizeZalouserEntry(entry);
if (/^\d+$/.test(cleaned)) {
if (!nextGroups[cleaned]) nextGroups[cleaned] = groupsConfig[entry];
mapping.push(`${entry}${cleaned}`);
continue;
}
const matches = byName.get(cleaned.toLowerCase()) ?? [];
const match = matches[0];
const id = match?.groupId ? String(match.groupId) : undefined;
if (id) {
if (!nextGroups[id]) nextGroups[id] = groupsConfig[entry];
mapping.push(`${entry}${id}`);
} else {
unresolved.push(entry);
}
}
account = {
...account,
config: {
...account.config,
groups: nextGroups,
},
};
summarizeMapping("zalouser groups", mapping, unresolved, runtime);
} else {
runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`);
}
}
} catch (err) {
runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`);
}
const stop = () => {
stopped = true;
if (restartTimer) {