fix: session management and Telegram message formatting
- Fix /new command session creation by checking isNewSession flag - Add timestamp prefix to Telegram messages matching WhatsApp format - Add reply logging for Telegram matching WhatsApp behavior - Move think level token extraction before bodyPrefix to avoid false matches - Update agent specs (claude, gemini, opencode) to send identity on new sessions - Pass sessionIntro in command context for proper agent initialization
This commit is contained in:
parent
08bede3a0b
commit
a67243dd49
@ -39,6 +39,38 @@ describe("agent buildArgs + parseOutput helpers", () => {
|
||||
expect(builtNoIdentity.at(-1)).not.toContain(CLAUDE_IDENTITY_PREFIX);
|
||||
});
|
||||
|
||||
it("claudeSpec prepends identity even when body is empty (e.g. /new)", () => {
|
||||
// This is the /new reset trigger case - body is empty after reset trigger is stripped
|
||||
const argv = ["claude", ""];
|
||||
const built = claudeSpec.buildArgs({
|
||||
argv,
|
||||
bodyIndex: 1,
|
||||
isNewSession: true,
|
||||
sessionId: "sess",
|
||||
sendSystemOnce: true,
|
||||
systemSent: false,
|
||||
identityPrefix: "Custom session intro here",
|
||||
format: "json",
|
||||
});
|
||||
|
||||
// Should still prepend the identity prefix even though body is empty
|
||||
const lastArg = built.at(-1);
|
||||
expect(lastArg).toContain("Custom session intro here");
|
||||
|
||||
// Verify it doesn't prepend identity when systemSent is true
|
||||
const builtAfterSystem = claudeSpec.buildArgs({
|
||||
argv,
|
||||
bodyIndex: 1,
|
||||
isNewSession: false,
|
||||
sessionId: "sess",
|
||||
sendSystemOnce: true,
|
||||
systemSent: true,
|
||||
identityPrefix: "Custom session intro here",
|
||||
format: "json",
|
||||
});
|
||||
expect(builtAfterSystem.at(-1)).toBe("");
|
||||
});
|
||||
|
||||
it("opencodeSpec adds format flag and identity prefix when needed", () => {
|
||||
const argv = ["opencode", "body"];
|
||||
const built = opencodeSpec.buildArgs({
|
||||
|
||||
@ -55,13 +55,12 @@ export const claudeSpec: AgentSpec = {
|
||||
beforeBody.push("-p");
|
||||
}
|
||||
|
||||
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
|
||||
const bodyWithIdentity =
|
||||
shouldPrependIdentity && body
|
||||
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: body;
|
||||
const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
||||
const bodyWithIdentity = shouldPrependIdentity
|
||||
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
: body;
|
||||
|
||||
return [...beforeBody, bodyWithIdentity, ...afterBody];
|
||||
},
|
||||
|
||||
@ -40,7 +40,7 @@ export const geminiSpec: AgentSpec = {
|
||||
}
|
||||
}
|
||||
|
||||
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
|
||||
const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
||||
const bodyWithIdentity =
|
||||
shouldPrependIdentity && body
|
||||
? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body]
|
||||
|
||||
@ -41,7 +41,7 @@ export const opencodeSpec: AgentSpec = {
|
||||
// Identity prefix
|
||||
// Opencode streams text tokens; we still seed an identity so the agent
|
||||
// keeps context on first turn.
|
||||
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
|
||||
const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
||||
const bodyWithIdentity =
|
||||
shouldPrependIdentity && body
|
||||
? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body]
|
||||
|
||||
@ -158,7 +158,8 @@ export const piSpec: AgentSpec = {
|
||||
}
|
||||
// Session defaults
|
||||
// Identity prefix optional; Pi usually doesn't need it, but allow injection
|
||||
if (!(ctx.sendSystemOnce && ctx.systemSent) && argv[ctx.bodyIndex]) {
|
||||
const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
||||
if (shouldPrependIdentity && argv[ctx.bodyIndex]) {
|
||||
const existingBody = argv[ctx.bodyIndex];
|
||||
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody]
|
||||
.filter(Boolean)
|
||||
|
||||
@ -36,6 +36,7 @@ type CommandReplyParams = {
|
||||
isNewSession: boolean;
|
||||
isFirstTurnInSession: boolean;
|
||||
systemSent: boolean;
|
||||
sessionIntro?: string;
|
||||
timeoutMs: number;
|
||||
timeoutSeconds: number;
|
||||
commandRunner: typeof runCommandWithTimeout;
|
||||
@ -288,6 +289,7 @@ export async function runCommandReply(
|
||||
isNewSession,
|
||||
isFirstTurnInSession,
|
||||
systemSent,
|
||||
sessionIntro,
|
||||
timeoutMs,
|
||||
timeoutSeconds,
|
||||
commandRunner,
|
||||
@ -392,7 +394,7 @@ export async function runCommandReply(
|
||||
sessionId: templatingCtx.SessionId,
|
||||
sendSystemOnce,
|
||||
systemSent,
|
||||
identityPrefix: agentCfg.identityPrefix,
|
||||
identityPrefix: agentCfg.identityPrefix ?? sessionIntro,
|
||||
format: agentCfg.format,
|
||||
})
|
||||
: argv;
|
||||
|
||||
@ -471,9 +471,10 @@ export async function getReplyFromConfig(
|
||||
// Optional prefix injected before Body for templating/command prompts.
|
||||
const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
|
||||
const isFirstTurnInSession = isNewSession || !systemSent;
|
||||
const sessionIntroConfig = sessionCfg?.sessionIntro;
|
||||
const sessionIntro =
|
||||
isFirstTurnInSession && sessionCfg?.sessionIntro
|
||||
? applyTemplate(sessionCfg.sessionIntro, sessionCtx)
|
||||
isFirstTurnInSession && sessionIntroConfig
|
||||
? applyTemplate(sessionIntroConfig, sessionCtx)
|
||||
: "";
|
||||
const groupIntro =
|
||||
isFirstTurnInSession && sessionCtx.ChatType === "group"
|
||||
@ -497,7 +498,19 @@ export async function getReplyFromConfig(
|
||||
const bodyPrefix = reply?.bodyPrefix
|
||||
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
||||
: "";
|
||||
const baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
let baseBody = sessionCtx.BodyStripped ?? sessionCtx.Body ?? "";
|
||||
|
||||
// Extract stray think level token from user's message BEFORE adding prefixes
|
||||
// (to avoid removing "Think" from bodyPrefix like "Think deeply whenever asked.")
|
||||
if (!resolvedThinkLevel && baseBody) {
|
||||
const parts = baseBody.split(/\s+/);
|
||||
const maybeLevel = normalizeThinkLevel(parts[0]);
|
||||
if (maybeLevel) {
|
||||
resolvedThinkLevel = maybeLevel;
|
||||
baseBody = parts.slice(1).join(" ").trim();
|
||||
}
|
||||
}
|
||||
|
||||
const abortedHint =
|
||||
reply?.mode === "command" && abortedLastRun
|
||||
? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification."
|
||||
@ -562,22 +575,13 @@ export async function getReplyFromConfig(
|
||||
mediaNote && reply?.mode === "command"
|
||||
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||
: undefined;
|
||||
let commandBody = mediaNote
|
||||
const commandBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim()
|
||||
: prefixedBody;
|
||||
|
||||
// Fallback: if a stray leading level token remains, consume it
|
||||
if (!resolvedThinkLevel && commandBody) {
|
||||
const parts = commandBody.split(/\s+/);
|
||||
const maybeLevel = normalizeThinkLevel(parts[0]);
|
||||
if (maybeLevel) {
|
||||
resolvedThinkLevel = maybeLevel;
|
||||
commandBody = parts.slice(1).join(" ").trim();
|
||||
}
|
||||
}
|
||||
const templatingCtx: TemplateContext = {
|
||||
...sessionCtx,
|
||||
Body: commandBody,
|
||||
@ -629,6 +633,7 @@ export async function getReplyFromConfig(
|
||||
isNewSession,
|
||||
isFirstTurnInSession,
|
||||
systemSent,
|
||||
sessionIntro,
|
||||
timeoutMs,
|
||||
timeoutSeconds,
|
||||
commandRunner,
|
||||
|
||||
@ -126,7 +126,7 @@ describe("runMultiProviderRelay", () => {
|
||||
undefined,
|
||||
mockRuntime,
|
||||
expect.any(AbortSignal),
|
||||
webTuning,
|
||||
{ ...webTuning, suppressStartMessage: true },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -43,10 +43,6 @@ export type WebConfig = {
|
||||
reconnect?: WebReconnectConfig;
|
||||
};
|
||||
|
||||
export type TelegramConfig = {
|
||||
allowFrom?: string[]; // @username or user IDs
|
||||
};
|
||||
|
||||
export type GroupChatConfig = {
|
||||
requireMention?: boolean;
|
||||
mentionPatterns?: string[];
|
||||
@ -90,7 +86,6 @@ export type WarelayConfig = {
|
||||
};
|
||||
};
|
||||
web?: WebConfig;
|
||||
telegram?: TelegramConfig;
|
||||
};
|
||||
|
||||
// New branding path (preferred)
|
||||
|
||||
@ -26,10 +26,8 @@ vi.mock("../env.js", () => ({
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: vi.fn(() => ({
|
||||
telegram: {
|
||||
allowFrom: ["@testuser"],
|
||||
},
|
||||
inbound: {
|
||||
allowFrom: ["@testuser"],
|
||||
reply: {
|
||||
mode: "text",
|
||||
text: "Auto-reply test",
|
||||
@ -207,10 +205,8 @@ describe("monitorTelegramProvider", () => {
|
||||
|
||||
// Set allowFrom filter
|
||||
(loadConfig as Mock).mockReturnValueOnce({
|
||||
telegram: {
|
||||
allowFrom: ["@alloweduser"],
|
||||
},
|
||||
inbound: {
|
||||
allowFrom: ["@alloweduser"],
|
||||
reply: {
|
||||
mode: "text",
|
||||
text: "Auto-reply test",
|
||||
|
||||
@ -22,12 +22,37 @@ import { normalizeAllowFromEntry } from "../utils.js";
|
||||
|
||||
const TELEGRAM_TEXT_LIMIT = 4096; // Telegram's message length limit
|
||||
|
||||
/**
|
||||
* Format timestamp in the same format as WhatsApp relay.
|
||||
* Example: [Dec 5 22:41]
|
||||
*/
|
||||
function formatTimestamp(ts: number, config?: ReturnType<typeof loadConfig>): string {
|
||||
const tsCfg = config?.inbound?.timestampPrefix;
|
||||
const tsEnabled = tsCfg !== false; // default true
|
||||
if (!tsEnabled) return "";
|
||||
const tz = typeof tsCfg === "string" ? tsCfg : "UTC";
|
||||
const date = new Date(ts);
|
||||
try {
|
||||
return `[${date.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: tz })} ${date.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: tz })}] `;
|
||||
} catch {
|
||||
return `[${date.toISOString().slice(5, 16).replace("T", " ")}] `;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ProviderMessage to MsgContext for auto-reply system.
|
||||
*/
|
||||
function providerMessageToContext(message: ProviderMessage): MsgContext {
|
||||
function providerMessageToContext(
|
||||
message: ProviderMessage,
|
||||
config?: ReturnType<typeof loadConfig>,
|
||||
): MsgContext {
|
||||
const timestampPrefix = formatTimestamp(message.timestamp, config);
|
||||
const bodyWithTimestamp = timestampPrefix
|
||||
? `${timestampPrefix}${message.body}`
|
||||
: message.body;
|
||||
|
||||
return {
|
||||
Body: message.body,
|
||||
Body: bodyWithTimestamp,
|
||||
From: message.from,
|
||||
To: message.to,
|
||||
MessageSid: message.id,
|
||||
@ -68,10 +93,13 @@ async function sendReply(
|
||||
media: firstMedia ? [{ type: "image", url: firstMedia }] : undefined,
|
||||
});
|
||||
|
||||
// Log the reply text
|
||||
runtime.log(`↩️ ${firstChunk}`);
|
||||
|
||||
if (isVerbose()) {
|
||||
runtime.log(
|
||||
success(
|
||||
`↩️ Auto-replied to ${replyTo} via Telegram (id ${result.messageId})`,
|
||||
`Auto-replied to ${replyTo} via Telegram (id ${result.messageId})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -85,6 +113,7 @@ async function sendReply(
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
try {
|
||||
await provider.send(replyTo, chunks[i]);
|
||||
runtime.log(`↩️ ${chunks[i]}`);
|
||||
} catch (err) {
|
||||
runtime.error(
|
||||
danger(`Failed to send Telegram reply chunk ${i}: ${String(err)}`),
|
||||
@ -114,11 +143,11 @@ async function handleInboundMessage(
|
||||
provider: Provider,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<void> {
|
||||
const ctx = providerMessageToContext(message);
|
||||
const config = loadConfig();
|
||||
const ctx = providerMessageToContext(message, config);
|
||||
|
||||
// Check allowFrom filter
|
||||
const allowFrom = config.telegram?.allowFrom;
|
||||
const allowFrom = config.inbound?.allowFrom;
|
||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||
if (!allowFrom.includes("*")) {
|
||||
const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram");
|
||||
@ -128,7 +157,7 @@ async function handleInboundMessage(
|
||||
if (!normalizedAllowList.includes(normalizedFrom)) {
|
||||
if (isVerbose()) {
|
||||
logVerbose(
|
||||
`Skipping auto-reply: sender ${message.from} not in telegram.allowFrom list`,
|
||||
`Skipping auto-reply: sender ${message.from} not in allowFrom list`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@ -137,9 +166,9 @@ async function handleInboundMessage(
|
||||
}
|
||||
|
||||
// Log inbound message
|
||||
const timestamp = new Date(message.timestamp).toISOString();
|
||||
const formattedTs = formatTimestamp(message.timestamp, config);
|
||||
runtime.log(
|
||||
`\n[${timestamp}] ${message.from} -> ${message.to}: ${message.body}`,
|
||||
`\n${formattedTs}${message.from} -> ${message.to}: ${message.body}`,
|
||||
);
|
||||
|
||||
// Get reply from config
|
||||
@ -209,7 +238,7 @@ export async function monitorTelegramProvider(
|
||||
apiId: Number.parseInt(env.telegram.apiId, 10),
|
||||
apiHash: env.telegram.apiHash,
|
||||
sessionDir: undefined, // Uses default ~/.warelay/telegram
|
||||
allowFrom: config.telegram?.allowFrom,
|
||||
allowFrom: config.inbound?.allowFrom,
|
||||
verbose,
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user