Merge remote-tracking branch 'origin/main' into feature/agent-avatar-support
This commit is contained in:
@@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
||||||
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
|
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
|
||||||
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
|
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
|
||||||
|
- Models: inherit session model overrides in thread/topic sessions (Telegram topics, Slack/Discord threads). (#1376)
|
||||||
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
||||||
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
||||||
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
|
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
|
||||||
|
|||||||
@@ -706,6 +706,7 @@ export function createExecTool(
|
|||||||
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
|
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
|
||||||
: null;
|
: null;
|
||||||
const elevatedDefaults = defaults?.elevated;
|
const elevatedDefaults = defaults?.elevated;
|
||||||
|
const elevatedAllowed = Boolean(elevatedDefaults?.enabled && elevatedDefaults.allowed);
|
||||||
const elevatedDefaultMode =
|
const elevatedDefaultMode =
|
||||||
elevatedDefaults?.defaultLevel === "full"
|
elevatedDefaults?.defaultLevel === "full"
|
||||||
? "full"
|
? "full"
|
||||||
@@ -714,6 +715,7 @@ export function createExecTool(
|
|||||||
: elevatedDefaults?.defaultLevel === "on"
|
: elevatedDefaults?.defaultLevel === "on"
|
||||||
? "ask"
|
? "ask"
|
||||||
: "off";
|
: "off";
|
||||||
|
const effectiveDefaultMode = elevatedAllowed ? elevatedDefaultMode : "off";
|
||||||
const elevatedMode =
|
const elevatedMode =
|
||||||
typeof params.elevated === "boolean"
|
typeof params.elevated === "boolean"
|
||||||
? params.elevated
|
? params.elevated
|
||||||
@@ -721,7 +723,7 @@ export function createExecTool(
|
|||||||
? "full"
|
? "full"
|
||||||
: "ask"
|
: "ask"
|
||||||
: "off"
|
: "off"
|
||||||
: elevatedDefaultMode;
|
: effectiveDefaultMode;
|
||||||
const elevatedRequested = elevatedMode !== "off";
|
const elevatedRequested = elevatedMode !== "off";
|
||||||
if (elevatedRequested) {
|
if (elevatedRequested) {
|
||||||
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ export async function resolveReplyDirectives(params: {
|
|||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
parentSessionKey: ctx.ParentSessionKey,
|
||||||
storePath,
|
storePath,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
|
|||||||
157
src/auto-reply/reply/model-selection.inherit-parent.test.ts
Normal file
157
src/auto-reply/reply/model-selection.inherit-parent.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { createModelSelectionState } from "./model-selection.js";
|
||||||
|
|
||||||
|
vi.mock("../../agents/model-catalog.js", () => ({
|
||||||
|
loadModelCatalog: vi.fn(async () => [
|
||||||
|
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
|
||||||
|
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
|
||||||
|
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||||
|
]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProvider = "openai";
|
||||||
|
const defaultModel = "gpt-4o-mini";
|
||||||
|
|
||||||
|
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
sessionId: "session-id",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function resolveState(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
sessionEntry: ReturnType<typeof makeEntry>;
|
||||||
|
sessionStore: Record<string, ReturnType<typeof makeEntry>>;
|
||||||
|
sessionKey: string;
|
||||||
|
parentSessionKey?: string;
|
||||||
|
}) {
|
||||||
|
return createModelSelectionState({
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentCfg: params.cfg.agents?.defaults,
|
||||||
|
sessionEntry: params.sessionEntry,
|
||||||
|
sessionStore: params.sessionStore,
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
parentSessionKey: params.parentSessionKey,
|
||||||
|
defaultProvider,
|
||||||
|
defaultModel,
|
||||||
|
provider: defaultProvider,
|
||||||
|
model: defaultModel,
|
||||||
|
hasModelDirective: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createModelSelectionState parent inheritance", () => {
|
||||||
|
it("inherits parent override from explicit parentSessionKey", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const parentKey = "agent:main:discord:channel:C1";
|
||||||
|
const sessionKey = "agent:main:discord:channel:C1:thread:123";
|
||||||
|
const parentEntry = makeEntry({
|
||||||
|
providerOverride: "openai",
|
||||||
|
modelOverride: "gpt-4o",
|
||||||
|
});
|
||||||
|
const sessionEntry = makeEntry();
|
||||||
|
const sessionStore = {
|
||||||
|
[parentKey]: parentEntry,
|
||||||
|
[sessionKey]: sessionEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await resolveState({
|
||||||
|
cfg,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
parentSessionKey: parentKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.provider).toBe("openai");
|
||||||
|
expect(state.model).toBe("gpt-4o");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives parent key from topic session suffix", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const parentKey = "agent:main:telegram:group:123";
|
||||||
|
const sessionKey = "agent:main:telegram:group:123:topic:99";
|
||||||
|
const parentEntry = makeEntry({
|
||||||
|
providerOverride: "openai",
|
||||||
|
modelOverride: "gpt-4o",
|
||||||
|
});
|
||||||
|
const sessionEntry = makeEntry();
|
||||||
|
const sessionStore = {
|
||||||
|
[parentKey]: parentEntry,
|
||||||
|
[sessionKey]: sessionEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await resolveState({
|
||||||
|
cfg,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.provider).toBe("openai");
|
||||||
|
expect(state.model).toBe("gpt-4o");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers child override over parent", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const parentKey = "agent:main:telegram:group:123";
|
||||||
|
const sessionKey = "agent:main:telegram:group:123:topic:99";
|
||||||
|
const parentEntry = makeEntry({
|
||||||
|
providerOverride: "openai",
|
||||||
|
modelOverride: "gpt-4o",
|
||||||
|
});
|
||||||
|
const sessionEntry = makeEntry({
|
||||||
|
providerOverride: "anthropic",
|
||||||
|
modelOverride: "claude-opus-4-5",
|
||||||
|
});
|
||||||
|
const sessionStore = {
|
||||||
|
[parentKey]: parentEntry,
|
||||||
|
[sessionKey]: sessionEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await resolveState({
|
||||||
|
cfg,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.provider).toBe("anthropic");
|
||||||
|
expect(state.model).toBe("claude-opus-4-5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores parent override when disallowed", async () => {
|
||||||
|
const cfg = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
models: {
|
||||||
|
"openai/gpt-4o-mini": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const parentKey = "agent:main:slack:channel:C1";
|
||||||
|
const sessionKey = "agent:main:slack:channel:C1:thread:123";
|
||||||
|
const parentEntry = makeEntry({
|
||||||
|
providerOverride: "anthropic",
|
||||||
|
modelOverride: "claude-opus-4-5",
|
||||||
|
});
|
||||||
|
const sessionEntry = makeEntry();
|
||||||
|
const sessionStore = {
|
||||||
|
[parentKey]: parentEntry,
|
||||||
|
[sessionKey]: sessionEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = await resolveState({
|
||||||
|
cfg,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.provider).toBe(defaultProvider);
|
||||||
|
expect(state.model).toBe(defaultModel);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
|||||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
||||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||||
|
import { resolveThreadParentSessionKey } from "../../sessions/session-key-utils.js";
|
||||||
import type { ThinkLevel } from "./directives.js";
|
import type { ThinkLevel } from "./directives.js";
|
||||||
|
|
||||||
export type ModelDirectiveSelection = {
|
export type ModelDirectiveSelection = {
|
||||||
@@ -78,6 +79,52 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
|
|||||||
return dist;
|
return dist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StoredModelOverride = {
|
||||||
|
provider?: string;
|
||||||
|
model: string;
|
||||||
|
source: "session" | "parent";
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveModelOverrideFromEntry(entry?: SessionEntry): {
|
||||||
|
provider?: string;
|
||||||
|
model: string;
|
||||||
|
} | null {
|
||||||
|
const model = entry?.modelOverride?.trim();
|
||||||
|
if (!model) return null;
|
||||||
|
const provider = entry?.providerOverride?.trim() || undefined;
|
||||||
|
return { provider, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveParentSessionKeyCandidate(params: {
|
||||||
|
sessionKey?: string;
|
||||||
|
parentSessionKey?: string;
|
||||||
|
}): string | null {
|
||||||
|
const explicit = params.parentSessionKey?.trim();
|
||||||
|
if (explicit && explicit !== params.sessionKey) return explicit;
|
||||||
|
const derived = resolveThreadParentSessionKey(params.sessionKey);
|
||||||
|
if (derived && derived !== params.sessionKey) return derived;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStoredModelOverride(params: {
|
||||||
|
sessionEntry?: SessionEntry;
|
||||||
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
|
sessionKey?: string;
|
||||||
|
parentSessionKey?: string;
|
||||||
|
}): StoredModelOverride | null {
|
||||||
|
const direct = resolveModelOverrideFromEntry(params.sessionEntry);
|
||||||
|
if (direct) return { ...direct, source: "session" };
|
||||||
|
const parentKey = resolveParentSessionKeyCandidate({
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
parentSessionKey: params.parentSessionKey,
|
||||||
|
});
|
||||||
|
if (!parentKey || !params.sessionStore) return null;
|
||||||
|
const parentEntry = params.sessionStore[parentKey];
|
||||||
|
const parentOverride = resolveModelOverrideFromEntry(parentEntry);
|
||||||
|
if (!parentOverride) return null;
|
||||||
|
return { ...parentOverride, source: "parent" };
|
||||||
|
}
|
||||||
|
|
||||||
function scoreFuzzyMatch(params: {
|
function scoreFuzzyMatch(params: {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -177,6 +224,7 @@ export async function createModelSelectionState(params: {
|
|||||||
sessionEntry?: SessionEntry;
|
sessionEntry?: SessionEntry;
|
||||||
sessionStore?: Record<string, SessionEntry>;
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
|
parentSessionKey?: string;
|
||||||
storePath?: string;
|
storePath?: string;
|
||||||
defaultProvider: string;
|
defaultProvider: string;
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
@@ -190,6 +238,7 @@ export async function createModelSelectionState(params: {
|
|||||||
sessionEntry,
|
sessionEntry,
|
||||||
sessionStore,
|
sessionStore,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
|
parentSessionKey,
|
||||||
storePath,
|
storePath,
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
@@ -199,7 +248,13 @@ export async function createModelSelectionState(params: {
|
|||||||
let model = params.model;
|
let model = params.model;
|
||||||
|
|
||||||
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
const hasAllowlist = agentCfg?.models && Object.keys(agentCfg.models).length > 0;
|
||||||
const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride);
|
const initialStoredOverride = resolveStoredModelOverride({
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
parentSessionKey,
|
||||||
|
});
|
||||||
|
const hasStoredOverride = Boolean(initialStoredOverride);
|
||||||
const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride;
|
const needsModelCatalog = params.hasModelDirective || hasAllowlist || hasStoredOverride;
|
||||||
|
|
||||||
let allowedModelKeys = new Set<string>();
|
let allowedModelKeys = new Set<string>();
|
||||||
@@ -242,14 +297,18 @@ export async function createModelSelectionState(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedProviderOverride = sessionEntry?.providerOverride?.trim();
|
const storedOverride = resolveStoredModelOverride({
|
||||||
const storedModelOverride = sessionEntry?.modelOverride?.trim();
|
sessionEntry,
|
||||||
if (storedModelOverride) {
|
sessionStore,
|
||||||
const candidateProvider = storedProviderOverride || defaultProvider;
|
sessionKey,
|
||||||
const key = modelKey(candidateProvider, storedModelOverride);
|
parentSessionKey,
|
||||||
|
});
|
||||||
|
if (storedOverride?.model) {
|
||||||
|
const candidateProvider = storedOverride.provider || defaultProvider;
|
||||||
|
const key = modelKey(candidateProvider, storedOverride.model);
|
||||||
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) {
|
||||||
provider = candidateProvider;
|
provider = candidateProvider;
|
||||||
model = storedModelOverride;
|
model = storedOverride.model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,3 +33,21 @@ export function isAcpSessionKey(sessionKey: string | undefined | null): boolean
|
|||||||
const parsed = parseAgentSessionKey(raw);
|
const parsed = parseAgentSessionKey(raw);
|
||||||
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
|
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
|
||||||
|
|
||||||
|
export function resolveThreadParentSessionKey(
|
||||||
|
sessionKey: string | undefined | null,
|
||||||
|
): string | null {
|
||||||
|
const raw = (sessionKey ?? "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const normalized = raw.toLowerCase();
|
||||||
|
let idx = -1;
|
||||||
|
for (const marker of THREAD_SESSION_MARKERS) {
|
||||||
|
const candidate = normalized.lastIndexOf(marker);
|
||||||
|
if (candidate > idx) idx = candidate;
|
||||||
|
}
|
||||||
|
if (idx <= 0) return null;
|
||||||
|
const parent = raw.slice(0, idx).trim();
|
||||||
|
return parent ? parent : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ const getOnHandler = (event: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ORIGINAL_TZ = process.env.TZ;
|
const ORIGINAL_TZ = process.env.TZ;
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
describe("createTelegramBot", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.TZ = "UTC";
|
process.env.TZ = "UTC";
|
||||||
@@ -153,6 +152,11 @@ describe("createTelegramBot", () => {
|
|||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "utc",
|
||||||
|
},
|
||||||
|
},
|
||||||
identity: { name: "Bert" },
|
identity: { name: "Bert" },
|
||||||
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||||
channels: {
|
channels: {
|
||||||
@@ -195,6 +199,11 @@ describe("createTelegramBot", () => {
|
|||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "utc",
|
||||||
|
},
|
||||||
|
},
|
||||||
channels: {
|
channels: {
|
||||||
telegram: {
|
telegram: {
|
||||||
groupPolicy: "open",
|
groupPolicy: "open",
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ describe("createTelegramBot", () => {
|
|||||||
process.env.TZ = "UTC";
|
process.env.TZ = "UTC";
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "utc",
|
||||||
|
},
|
||||||
|
},
|
||||||
channels: {
|
channels: {
|
||||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -149,12 +149,16 @@ const getOnHandler = (event: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ORIGINAL_TZ = process.env.TZ;
|
const ORIGINAL_TZ = process.env.TZ;
|
||||||
|
|
||||||
describe("createTelegramBot", () => {
|
describe("createTelegramBot", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.TZ = "UTC";
|
process.env.TZ = "UTC";
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "utc",
|
||||||
|
},
|
||||||
|
},
|
||||||
channels: {
|
channels: {
|
||||||
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
telegram: { dmPolicy: "open", allowFrom: ["*"] },
|
||||||
},
|
},
|
||||||
@@ -562,106 +566,104 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
||||||
const originalTz = process.env.TZ;
|
|
||||||
process.env.TZ = "UTC";
|
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|
||||||
try {
|
loadConfig.mockReturnValue({
|
||||||
loadConfig.mockReturnValue({
|
agents: {
|
||||||
identity: { name: "Bert" },
|
defaults: {
|
||||||
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
envelopeTimezone: "utc",
|
||||||
channels: {
|
|
||||||
telegram: {
|
|
||||||
groupPolicy: "open",
|
|
||||||
groups: { "*": { requireMention: true } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
identity: { name: "Bert" },
|
||||||
createTelegramBot({ token: "tok" });
|
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
channels: {
|
||||||
|
telegram: {
|
||||||
await handler({
|
groupPolicy: "open",
|
||||||
message: {
|
groups: { "*": { requireMention: true } },
|
||||||
chat: { id: 7, type: "group", title: "Test Group" },
|
|
||||||
text: "bert: introduce yourself",
|
|
||||||
date: 1736380800,
|
|
||||||
message_id: 1,
|
|
||||||
from: { id: 9, first_name: "Ada" },
|
|
||||||
},
|
},
|
||||||
me: { username: "clawdbot_bot" },
|
},
|
||||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
createTelegramBot({ token: "tok" });
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
expectInboundContextContract(payload);
|
|
||||||
expect(payload.WasMentioned).toBe(true);
|
await handler({
|
||||||
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
|
message: {
|
||||||
const timestampPattern = escapeRegExp(expectedTimestamp);
|
chat: { id: 7, type: "group", title: "Test Group" },
|
||||||
expect(payload.Body).toMatch(
|
text: "bert: introduce yourself",
|
||||||
new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
|
date: 1736380800,
|
||||||
);
|
message_id: 1,
|
||||||
expect(payload.SenderName).toBe("Ada");
|
from: { id: 9, first_name: "Ada" },
|
||||||
expect(payload.SenderId).toBe("9");
|
},
|
||||||
} finally {
|
me: { username: "clawdbot_bot" },
|
||||||
process.env.TZ = originalTz;
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
}
|
});
|
||||||
|
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
expectInboundContextContract(payload);
|
||||||
|
expect(payload.WasMentioned).toBe(true);
|
||||||
|
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
|
||||||
|
const timestampPattern = escapeRegExp(expectedTimestamp);
|
||||||
|
expect(payload.Body).toMatch(
|
||||||
|
new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
|
||||||
|
);
|
||||||
|
expect(payload.SenderName).toBe("Ada");
|
||||||
|
expect(payload.SenderId).toBe("9");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes sender identity in group envelope headers", async () => {
|
it("includes sender identity in group envelope headers", async () => {
|
||||||
const originalTz = process.env.TZ;
|
|
||||||
process.env.TZ = "UTC";
|
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|
||||||
try {
|
loadConfig.mockReturnValue({
|
||||||
loadConfig.mockReturnValue({
|
agents: {
|
||||||
channels: {
|
defaults: {
|
||||||
telegram: {
|
envelopeTimezone: "utc",
|
||||||
groupPolicy: "open",
|
|
||||||
groups: { "*": { requireMention: false } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
channels: {
|
||||||
createTelegramBot({ token: "tok" });
|
telegram: {
|
||||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
groupPolicy: "open",
|
||||||
|
groups: { "*": { requireMention: false } },
|
||||||
await handler({
|
|
||||||
message: {
|
|
||||||
chat: { id: 42, type: "group", title: "Ops" },
|
|
||||||
text: "hello",
|
|
||||||
date: 1736380800,
|
|
||||||
message_id: 2,
|
|
||||||
from: {
|
|
||||||
id: 99,
|
|
||||||
first_name: "Ada",
|
|
||||||
last_name: "Lovelace",
|
|
||||||
username: "ada",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
me: { username: "clawdbot_bot" },
|
},
|
||||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
});
|
||||||
});
|
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
createTelegramBot({ token: "tok" });
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
expectInboundContextContract(payload);
|
|
||||||
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
|
await handler({
|
||||||
const timestampPattern = escapeRegExp(expectedTimestamp);
|
message: {
|
||||||
expect(payload.Body).toMatch(
|
chat: { id: 42, type: "group", title: "Ops" },
|
||||||
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
|
text: "hello",
|
||||||
);
|
date: 1736380800,
|
||||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
message_id: 2,
|
||||||
expect(payload.SenderId).toBe("99");
|
from: {
|
||||||
expect(payload.SenderUsername).toBe("ada");
|
id: 99,
|
||||||
} finally {
|
first_name: "Ada",
|
||||||
process.env.TZ = originalTz;
|
last_name: "Lovelace",
|
||||||
}
|
username: "ada",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
me: { username: "clawdbot_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
expectInboundContextContract(payload);
|
||||||
|
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
|
||||||
|
const timestampPattern = escapeRegExp(expectedTimestamp);
|
||||||
|
expect(payload.Body).toMatch(
|
||||||
|
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
|
||||||
|
);
|
||||||
|
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||||
|
expect(payload.SenderId).toBe("99");
|
||||||
|
expect(payload.SenderUsername).toBe("ada");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||||
|
|||||||
@@ -298,6 +298,11 @@ describe("web auto-reply", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setLoadConfigMock(() => ({
|
setLoadConfigMock(() => ({
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
envelopeTimezone: "utc",
|
||||||
|
},
|
||||||
|
},
|
||||||
session: { store: store.storePath },
|
session: { store: store.storePath },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user