refactor(telegram): encode conversation binding mode

This commit is contained in:
Peter Steinberger
2026-05-27 03:26:14 +01:00
parent cecb07655a
commit df659d124d
7 changed files with 123 additions and 105 deletions

View File

@@ -146,6 +146,7 @@ Skills own workflows; root owns hard policy and routing.
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
- External boundaries: prefer `zod` or existing schema helpers.
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
- Cross-function state: when valid combos matter, return a closed mode/result shape. Avoid parallel nullable fields or derived booleans that callers must keep in sync; make impossible states unrepresentable.
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.

View File

@@ -63,70 +63,73 @@ function createConfiguredTelegramBinding() {
function createConfiguredTelegramRoute() {
const configuredBinding = createConfiguredTelegramBinding();
return {
configuredBinding: {
conversation: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
compiledBinding: {
channel: "telegram",
accountPattern: "work",
binding: {
type: "acp",
agentId: "codex",
match: {
channel: "telegram",
accountId: "work",
peer: {
kind: "group",
id: "-1001234567890:topic:42",
},
},
},
bindingConversationId: "-1001234567890:topic:42",
target: {
bindingMode: {
kind: "configured",
binding: {
conversation: {
channel: "telegram",
accountId: "work",
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
agentId: "codex",
provider: {
compileConfiguredBinding: () => ({
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
}),
matchInboundConversation: () => ({
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
}),
},
targetFactory: {
driverId: "acp",
materialize: () => ({
record: configuredBinding.record,
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: configuredBinding.record.targetSessionKey,
agentId: configuredBinding.spec.agentId,
compiledBinding: {
channel: "telegram",
accountPattern: "work",
binding: {
type: "acp",
agentId: "codex",
match: {
channel: "telegram",
accountId: "work",
peer: {
kind: "group",
id: "-1001234567890:topic:42",
},
},
}),
},
bindingConversationId: "-1001234567890:topic:42",
target: {
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
agentId: "codex",
provider: {
compileConfiguredBinding: () => ({
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
}),
matchInboundConversation: () => ({
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
}),
},
targetFactory: {
driverId: "acp",
materialize: () => ({
record: configuredBinding.record,
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: configuredBinding.record.targetSessionKey,
agentId: configuredBinding.spec.agentId,
},
}),
},
},
match: {
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
record: configuredBinding.record,
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: configuredBinding.record.targetSessionKey,
agentId: configuredBinding.spec.agentId,
},
},
match: {
conversationId: "-1001234567890:topic:42",
parentConversationId: "-1001234567890",
},
record: configuredBinding.record,
statefulTarget: {
kind: "stateful",
driverId: "acp",
sessionKey: configuredBinding.record.targetSessionKey,
agentId: configuredBinding.spec.agentId,
},
sessionKey: configuredBinding.record.targetSessionKey,
},
configuredBindingSessionKey: configuredBinding.record.targetSessionKey,
route: {
agentId: "codex",
accountId: "work",

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { telegramRouteTestSessionRuntime } from "./bot-message-context.route-test-support.js";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
import type { TelegramConversationBindingMode } from "./conversation-route.js";
const recordInboundSessionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const resolveTelegramConversationRouteMock = vi.hoisted(() => vi.fn());
@@ -32,12 +33,13 @@ function createBoundRoute(params: {
accountId: string;
sessionKey: string;
agentId: string;
pluginOwnedRuntimeBinding?: boolean;
bindingMode?: TelegramConversationBindingMode;
}) {
return {
configuredBinding: null,
configuredBindingSessionKey: "",
pluginOwnedRuntimeBinding: params.pluginOwnedRuntimeBinding ?? false,
bindingMode: params.bindingMode ?? {
kind: "runtime-bound",
sessionKey: params.sessionKey,
},
route: {
accountId: params.accountId,
agentId: params.agentId,
@@ -112,7 +114,7 @@ describe("buildTelegramMessageContext thread binding override", () => {
accountId: "default",
sessionKey: "plugin-binding:openclaw-codex-app-server:session-1",
agentId: "main",
pluginOwnedRuntimeBinding: true,
bindingMode: { kind: "plugin-owned-runtime" },
}),
);

View File

@@ -242,17 +242,16 @@ export const buildTelegramMessageContext = async ({
const freshCfg =
loadFreshConfig?.() ??
(runtime?.getRuntimeConfig ?? (await loadTelegramMessageContextRuntime()).getRuntimeConfig)();
let { route, configuredBinding, configuredBindingSessionKey, pluginOwnedRuntimeBinding } =
resolveTelegramConversationRoute({
cfg: freshCfg,
accountId: account.accountId,
chatId,
isGroup,
resolvedThreadId,
replyThreadId,
senderId,
topicAgentId: topicConfig?.agentId,
});
let { route, bindingMode } = resolveTelegramConversationRoute({
cfg: freshCfg,
accountId: account.accountId,
chatId,
isGroup,
resolvedThreadId,
replyThreadId,
senderId,
topicAgentId: topicConfig?.agentId,
});
const requiresExplicitAccountBinding = (
candidate: ReturnType<typeof resolveTelegramConversationRoute>["route"],
): boolean =>
@@ -372,7 +371,7 @@ export const buildTelegramMessageContext = async ({
}
let initialTypingCueSent = false;
const ensureConfiguredBindingReady = async (): Promise<boolean> => {
if (!configuredBinding) {
if (bindingMode.kind !== "configured") {
return true;
}
const ensureConfiguredBindingRouteReady =
@@ -380,22 +379,22 @@ export const buildTelegramMessageContext = async ({
(await loadTelegramMessageContextRuntime()).ensureConfiguredBindingRouteReady;
const ensured = await ensureConfiguredBindingRouteReady({
cfg: freshCfg,
bindingResolution: configuredBinding,
bindingResolution: bindingMode.binding,
});
if (ensured.ok) {
logVerbose(
`telegram: using configured ACP binding for ${configuredBinding.record.conversation.conversationId} -> ${configuredBindingSessionKey}`,
`telegram: using configured ACP binding for ${bindingMode.binding.record.conversation.conversationId} -> ${bindingMode.sessionKey}`,
);
return true;
}
logVerbose(
`telegram: configured ACP binding unavailable for ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
`telegram: configured ACP binding unavailable for ${bindingMode.binding.record.conversation.conversationId}: ${ensured.error}`,
);
logInboundDrop({
log: logVerbose,
channel: "telegram",
reason: "configured ACP binding unavailable",
target: configuredBinding.record.conversation.conversationId,
target: bindingMode.binding.record.conversation.conversationId,
});
return false;
};
@@ -432,7 +431,7 @@ export const buildTelegramMessageContext = async ({
});
const baseRequireMention = resolveGroupRequireMention(chatId);
const requireMention =
isGroup && pluginOwnedRuntimeBinding
isGroup && bindingMode.kind === "plugin-owned-runtime"
? false
: firstDefined(
topicConfig?.requireMention,

View File

@@ -886,7 +886,7 @@ export const registerTelegramNativeCommands = ({
isForum,
messageThreadId: resolvedThreadId ?? messageThreadId,
});
let { route, configuredBinding } = resolveTelegramConversationRoute({
let { route, bindingMode } = resolveTelegramConversationRoute({
cfg: runtimeCfg,
accountId,
chatId,
@@ -897,14 +897,14 @@ export const registerTelegramNativeCommands = ({
topicAgentId,
});
const nativeCommandRuntime = await loadTelegramNativeCommandRuntime();
if (configuredBinding) {
if (bindingMode.kind === "configured") {
const ensured = await nativeCommandRuntime.ensureConfiguredBindingRouteReady({
cfg: runtimeCfg,
bindingResolution: configuredBinding,
bindingResolution: bindingMode.binding,
});
if (!ensured.ok) {
logVerbose(
`telegram native command: configured ACP binding unavailable for topic ${configuredBinding.record.conversation.conversationId}: ${ensured.error}`,
`telegram native command: configured ACP binding unavailable for topic ${bindingMode.binding.record.conversation.conversationId}: ${ensured.error}`,
);
await withTelegramApiErrorLogging({
operation: "sendMessage",

View File

@@ -156,12 +156,10 @@ describe("resolveTelegramConversationBaseSessionKey", () => {
});
expect(touch).not.toHaveBeenCalled();
expect(result.configuredBinding).toBeNull();
expect(result.configuredBindingSessionKey).toBe("");
expect(result.bindingMode).toEqual({ kind: "none" });
expect(result.route.agentId).toBe("main");
expect(result.route.sessionKey).toBe("agent:main:main");
expect(result.route.matchedBy).toBe("default");
expect(result.pluginOwnedRuntimeBinding).toBe(false);
});
it("detects plugin-owned runtime bindings without replacing the route", () => {
@@ -205,11 +203,9 @@ describe("resolveTelegramConversationBaseSessionKey", () => {
});
expect(touch).toHaveBeenCalledWith("binding-plugin-owned", undefined);
expect(result.configuredBinding).toBeNull();
expect(result.configuredBindingSessionKey).toBe("");
expect(result.bindingMode).toEqual({ kind: "plugin-owned-runtime" });
expect(result.route.agentId).toBe("main");
expect(result.route.sessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:11");
expect(result.route.matchedBy).toBe("default");
expect(result.pluginOwnedRuntimeBinding).toBe(true);
});
});

View File

@@ -20,6 +20,27 @@ import {
resolveTelegramDirectPeerId,
} from "./bot/helpers.js";
type TelegramResolvedRoute = ReturnType<typeof resolveAgentRoute>;
type ConfiguredTelegramBinding = NonNullable<ConfiguredBindingRouteResult["bindingResolution"]>;
export type TelegramConversationBindingMode =
| { kind: "none" }
| {
kind: "configured";
binding: ConfiguredTelegramBinding;
sessionKey: string;
}
| {
kind: "runtime-bound";
sessionKey: string;
}
| { kind: "plugin-owned-runtime" };
export type TelegramConversationRouteResult = {
route: TelegramResolvedRoute;
bindingMode: TelegramConversationBindingMode;
};
export function resolveTelegramConversationRoute(params: {
cfg: OpenClawConfig;
accountId: string;
@@ -29,12 +50,7 @@ export function resolveTelegramConversationRoute(params: {
replyThreadId?: number;
senderId?: string | number | null;
topicAgentId?: string | null;
}): {
route: ReturnType<typeof resolveAgentRoute>;
configuredBinding: ConfiguredBindingRouteResult["bindingResolution"];
configuredBindingSessionKey: string;
pluginOwnedRuntimeBinding: boolean;
} {
}): TelegramConversationRouteResult {
const peerId = params.isGroup
? buildTelegramGroupPeerId(params.chatId, params.resolvedThreadId)
: resolveTelegramDirectPeerId({
@@ -102,9 +118,14 @@ export function resolveTelegramConversationRoute(params: {
parentConversationId: params.isGroup ? String(params.chatId) : undefined,
},
});
let configuredBinding = configuredRoute.bindingResolution;
let configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
route = configuredRoute.route;
let bindingMode: TelegramConversationBindingMode = configuredRoute.bindingResolution
? {
kind: "configured",
binding: configuredRoute.bindingResolution,
sessionKey: configuredRoute.boundSessionKey ?? route.sessionKey,
}
: { kind: "none" };
const runtimeBindingConversationId =
params.replyThreadId != null
@@ -119,12 +140,10 @@ export function resolveTelegramConversationRoute(params: {
},
});
route = runtimeRoute.route;
const pluginOwnedRuntimeBinding = Boolean(
runtimeRoute.bindingRecord && !runtimeRoute.boundSessionKey,
);
if (runtimeRoute.bindingRecord) {
configuredBinding = null;
configuredBindingSessionKey = "";
bindingMode = runtimeRoute.boundSessionKey
? { kind: "runtime-bound", sessionKey: runtimeRoute.boundSessionKey }
: { kind: "plugin-owned-runtime" };
logVerbose(
runtimeRoute.boundSessionKey
? `telegram: routed via bound conversation ${runtimeBindingConversationId} -> ${runtimeRoute.boundSessionKey}`
@@ -134,9 +153,7 @@ export function resolveTelegramConversationRoute(params: {
return {
route,
configuredBinding,
configuredBindingSessionKey,
pluginOwnedRuntimeBinding,
bindingMode,
};
}