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);
|
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", () => {
|
it("opencodeSpec adds format flag and identity prefix when needed", () => {
|
||||||
const argv = ["opencode", "body"];
|
const argv = ["opencode", "body"];
|
||||||
const built = opencodeSpec.buildArgs({
|
const built = opencodeSpec.buildArgs({
|
||||||
|
|||||||
@ -55,13 +55,12 @@ export const claudeSpec: AgentSpec = {
|
|||||||
beforeBody.push("-p");
|
beforeBody.push("-p");
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
|
const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
||||||
const bodyWithIdentity =
|
const bodyWithIdentity = shouldPrependIdentity
|
||||||
shouldPrependIdentity && body
|
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
|
||||||
? [ctx.identityPrefix ?? CLAUDE_IDENTITY_PREFIX, body]
|
.filter(Boolean)
|
||||||
.filter(Boolean)
|
.join("\n\n")
|
||||||
.join("\n\n")
|
: body;
|
||||||
: body;
|
|
||||||
|
|
||||||
return [...beforeBody, bodyWithIdentity, ...afterBody];
|
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 =
|
const bodyWithIdentity =
|
||||||
shouldPrependIdentity && body
|
shouldPrependIdentity && body
|
||||||
? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body]
|
? [ctx.identityPrefix ?? GEMINI_IDENTITY_PREFIX, body]
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export const opencodeSpec: AgentSpec = {
|
|||||||
// Identity prefix
|
// Identity prefix
|
||||||
// Opencode streams text tokens; we still seed an identity so the agent
|
// Opencode streams text tokens; we still seed an identity so the agent
|
||||||
// keeps context on first turn.
|
// keeps context on first turn.
|
||||||
const shouldPrependIdentity = !(ctx.sendSystemOnce && ctx.systemSent);
|
const shouldPrependIdentity = ctx.isNewSession || !(ctx.sendSystemOnce && ctx.systemSent);
|
||||||
const bodyWithIdentity =
|
const bodyWithIdentity =
|
||||||
shouldPrependIdentity && body
|
shouldPrependIdentity && body
|
||||||
? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body]
|
? [ctx.identityPrefix ?? OPENCODE_IDENTITY_PREFIX, body]
|
||||||
|
|||||||
@ -158,7 +158,8 @@ export const piSpec: AgentSpec = {
|
|||||||
}
|
}
|
||||||
// Session defaults
|
// Session defaults
|
||||||
// Identity prefix optional; Pi usually doesn't need it, but allow injection
|
// 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];
|
const existingBody = argv[ctx.bodyIndex];
|
||||||
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody]
|
argv[ctx.bodyIndex] = [ctx.identityPrefix, existingBody]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@ -36,6 +36,7 @@ type CommandReplyParams = {
|
|||||||
isNewSession: boolean;
|
isNewSession: boolean;
|
||||||
isFirstTurnInSession: boolean;
|
isFirstTurnInSession: boolean;
|
||||||
systemSent: boolean;
|
systemSent: boolean;
|
||||||
|
sessionIntro?: string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
timeoutSeconds: number;
|
timeoutSeconds: number;
|
||||||
commandRunner: typeof runCommandWithTimeout;
|
commandRunner: typeof runCommandWithTimeout;
|
||||||
@ -288,6 +289,7 @@ export async function runCommandReply(
|
|||||||
isNewSession,
|
isNewSession,
|
||||||
isFirstTurnInSession,
|
isFirstTurnInSession,
|
||||||
systemSent,
|
systemSent,
|
||||||
|
sessionIntro,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
timeoutSeconds,
|
timeoutSeconds,
|
||||||
commandRunner,
|
commandRunner,
|
||||||
@ -392,7 +394,7 @@ export async function runCommandReply(
|
|||||||
sessionId: templatingCtx.SessionId,
|
sessionId: templatingCtx.SessionId,
|
||||||
sendSystemOnce,
|
sendSystemOnce,
|
||||||
systemSent,
|
systemSent,
|
||||||
identityPrefix: agentCfg.identityPrefix,
|
identityPrefix: agentCfg.identityPrefix ?? sessionIntro,
|
||||||
format: agentCfg.format,
|
format: agentCfg.format,
|
||||||
})
|
})
|
||||||
: argv;
|
: argv;
|
||||||
|
|||||||
@ -471,9 +471,10 @@ export async function getReplyFromConfig(
|
|||||||
// Optional prefix injected before Body for templating/command prompts.
|
// Optional prefix injected before Body for templating/command prompts.
|
||||||
const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
|
const sendSystemOnce = sessionCfg?.sendSystemOnce === true;
|
||||||
const isFirstTurnInSession = isNewSession || !systemSent;
|
const isFirstTurnInSession = isNewSession || !systemSent;
|
||||||
|
const sessionIntroConfig = sessionCfg?.sessionIntro;
|
||||||
const sessionIntro =
|
const sessionIntro =
|
||||||
isFirstTurnInSession && sessionCfg?.sessionIntro
|
isFirstTurnInSession && sessionIntroConfig
|
||||||
? applyTemplate(sessionCfg.sessionIntro, sessionCtx)
|
? applyTemplate(sessionIntroConfig, sessionCtx)
|
||||||
: "";
|
: "";
|
||||||
const groupIntro =
|
const groupIntro =
|
||||||
isFirstTurnInSession && sessionCtx.ChatType === "group"
|
isFirstTurnInSession && sessionCtx.ChatType === "group"
|
||||||
@ -497,7 +498,19 @@ export async function getReplyFromConfig(
|
|||||||
const bodyPrefix = reply?.bodyPrefix
|
const bodyPrefix = reply?.bodyPrefix
|
||||||
? applyTemplate(reply.bodyPrefix, sessionCtx)
|
? 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 =
|
const abortedHint =
|
||||||
reply?.mode === "command" && abortedLastRun
|
reply?.mode === "command" && abortedLastRun
|
||||||
? "Note: The previous agent run was aborted by the user. Resume carefully or ask for clarification."
|
? "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"
|
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."
|
? "To send an image back, add a line like: MEDIA:https://example.com/image.jpg (no spaces). Keep caption in the text body."
|
||||||
: undefined;
|
: undefined;
|
||||||
let commandBody = mediaNote
|
const commandBody = mediaNote
|
||||||
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
? [mediaNote, mediaReplyHint, prefixedBody ?? ""]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
.trim()
|
.trim()
|
||||||
: prefixedBody;
|
: 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 = {
|
const templatingCtx: TemplateContext = {
|
||||||
...sessionCtx,
|
...sessionCtx,
|
||||||
Body: commandBody,
|
Body: commandBody,
|
||||||
@ -629,6 +633,7 @@ export async function getReplyFromConfig(
|
|||||||
isNewSession,
|
isNewSession,
|
||||||
isFirstTurnInSession,
|
isFirstTurnInSession,
|
||||||
systemSent,
|
systemSent,
|
||||||
|
sessionIntro,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
timeoutSeconds,
|
timeoutSeconds,
|
||||||
commandRunner,
|
commandRunner,
|
||||||
|
|||||||
@ -126,7 +126,7 @@ describe("runMultiProviderRelay", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
mockRuntime,
|
mockRuntime,
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
webTuning,
|
{ ...webTuning, suppressStartMessage: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -43,10 +43,6 @@ export type WebConfig = {
|
|||||||
reconnect?: WebReconnectConfig;
|
reconnect?: WebReconnectConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramConfig = {
|
|
||||||
allowFrom?: string[]; // @username or user IDs
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GroupChatConfig = {
|
export type GroupChatConfig = {
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
mentionPatterns?: string[];
|
mentionPatterns?: string[];
|
||||||
@ -90,7 +86,6 @@ export type WarelayConfig = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
web?: WebConfig;
|
web?: WebConfig;
|
||||||
telegram?: TelegramConfig;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// New branding path (preferred)
|
// New branding path (preferred)
|
||||||
|
|||||||
@ -26,10 +26,8 @@ vi.mock("../env.js", () => ({
|
|||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
loadConfig: vi.fn(() => ({
|
loadConfig: vi.fn(() => ({
|
||||||
telegram: {
|
|
||||||
allowFrom: ["@testuser"],
|
|
||||||
},
|
|
||||||
inbound: {
|
inbound: {
|
||||||
|
allowFrom: ["@testuser"],
|
||||||
reply: {
|
reply: {
|
||||||
mode: "text",
|
mode: "text",
|
||||||
text: "Auto-reply test",
|
text: "Auto-reply test",
|
||||||
@ -207,10 +205,8 @@ describe("monitorTelegramProvider", () => {
|
|||||||
|
|
||||||
// Set allowFrom filter
|
// Set allowFrom filter
|
||||||
(loadConfig as Mock).mockReturnValueOnce({
|
(loadConfig as Mock).mockReturnValueOnce({
|
||||||
telegram: {
|
|
||||||
allowFrom: ["@alloweduser"],
|
|
||||||
},
|
|
||||||
inbound: {
|
inbound: {
|
||||||
|
allowFrom: ["@alloweduser"],
|
||||||
reply: {
|
reply: {
|
||||||
mode: "text",
|
mode: "text",
|
||||||
text: "Auto-reply test",
|
text: "Auto-reply test",
|
||||||
|
|||||||
@ -22,12 +22,37 @@ import { normalizeAllowFromEntry } from "../utils.js";
|
|||||||
|
|
||||||
const TELEGRAM_TEXT_LIMIT = 4096; // Telegram's message length limit
|
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.
|
* 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 {
|
return {
|
||||||
Body: message.body,
|
Body: bodyWithTimestamp,
|
||||||
From: message.from,
|
From: message.from,
|
||||||
To: message.to,
|
To: message.to,
|
||||||
MessageSid: message.id,
|
MessageSid: message.id,
|
||||||
@ -68,10 +93,13 @@ async function sendReply(
|
|||||||
media: firstMedia ? [{ type: "image", url: firstMedia }] : undefined,
|
media: firstMedia ? [{ type: "image", url: firstMedia }] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log the reply text
|
||||||
|
runtime.log(`↩️ ${firstChunk}`);
|
||||||
|
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
success(
|
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++) {
|
for (let i = 1; i < chunks.length; i++) {
|
||||||
try {
|
try {
|
||||||
await provider.send(replyTo, chunks[i]);
|
await provider.send(replyTo, chunks[i]);
|
||||||
|
runtime.log(`↩️ ${chunks[i]}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(`Failed to send Telegram reply chunk ${i}: ${String(err)}`),
|
danger(`Failed to send Telegram reply chunk ${i}: ${String(err)}`),
|
||||||
@ -114,11 +143,11 @@ async function handleInboundMessage(
|
|||||||
provider: Provider,
|
provider: Provider,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ctx = providerMessageToContext(message);
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
const ctx = providerMessageToContext(message, config);
|
||||||
|
|
||||||
// Check allowFrom filter
|
// Check allowFrom filter
|
||||||
const allowFrom = config.telegram?.allowFrom;
|
const allowFrom = config.inbound?.allowFrom;
|
||||||
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
if (Array.isArray(allowFrom) && allowFrom.length > 0) {
|
||||||
if (!allowFrom.includes("*")) {
|
if (!allowFrom.includes("*")) {
|
||||||
const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram");
|
const normalizedFrom = normalizeAllowFromEntry(message.from, "telegram");
|
||||||
@ -128,7 +157,7 @@ async function handleInboundMessage(
|
|||||||
if (!normalizedAllowList.includes(normalizedFrom)) {
|
if (!normalizedAllowList.includes(normalizedFrom)) {
|
||||||
if (isVerbose()) {
|
if (isVerbose()) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
`Skipping auto-reply: sender ${message.from} not in telegram.allowFrom list`,
|
`Skipping auto-reply: sender ${message.from} not in allowFrom list`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -137,9 +166,9 @@ async function handleInboundMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log inbound message
|
// Log inbound message
|
||||||
const timestamp = new Date(message.timestamp).toISOString();
|
const formattedTs = formatTimestamp(message.timestamp, config);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`\n[${timestamp}] ${message.from} -> ${message.to}: ${message.body}`,
|
`\n${formattedTs}${message.from} -> ${message.to}: ${message.body}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get reply from config
|
// Get reply from config
|
||||||
@ -209,7 +238,7 @@ export async function monitorTelegramProvider(
|
|||||||
apiId: Number.parseInt(env.telegram.apiId, 10),
|
apiId: Number.parseInt(env.telegram.apiId, 10),
|
||||||
apiHash: env.telegram.apiHash,
|
apiHash: env.telegram.apiHash,
|
||||||
sessionDir: undefined, // Uses default ~/.warelay/telegram
|
sessionDir: undefined, // Uses default ~/.warelay/telegram
|
||||||
allowFrom: config.telegram?.allowFrom,
|
allowFrom: config.inbound?.allowFrom,
|
||||||
verbose,
|
verbose,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user