diff --git a/CHANGELOG.md b/CHANGELOG.md index 22d6474f6..68540eb61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ Docs: https://docs.clawd.bot -## 2026.1.17 (Unreleased) +## 2026.1.17-2 (Unreleased) + +### Changes + +### Fixes + +## 2026.1.17-1 ### Changes - Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko. @@ -10,14 +16,17 @@ Docs: https://docs.clawd.bot - macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg. - CLI: surface update availability in `clawdbot status`. - CLI: add `clawdbot memory status --deep/--index` probes. +- CLI: add playful update completion quips. ### Fixes - Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos. - Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight. - CLI: add WSL2/systemd unavailable hints in daemon status/doctor output. +- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied. - Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh. - Memory: probe sqlite-vec availability in `clawdbot memory status`. - Memory: split embedding batches to avoid OpenAI token limits during indexing. +- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko. ## 2026.1.16-2 diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 23958d18d..609c48b4e 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/copilot-proxy", - "version": "2026.1.17", + "version": "2026.1.17-1", "type": "module", "description": "Clawdbot Copilot Proxy provider plugin", "clawdbot": { diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json index ab392774d..9bffea31e 100644 --- a/extensions/google-antigravity-auth/package.json +++ b/extensions/google-antigravity-auth/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/google-antigravity-auth", - "version": "2026.1.17", + "version": "2026.1.17-1", "type": "module", "description": "Clawdbot Google Antigravity OAuth provider plugin", "clawdbot": { diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json index 051f8e44a..f2ecfb92c 100644 --- a/extensions/google-gemini-cli-auth/package.json +++ b/extensions/google-gemini-cli-auth/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/google-gemini-cli-auth", - "version": "2026.1.17", + "version": "2026.1.17-1", "type": "module", "description": "Clawdbot Gemini CLI OAuth provider plugin", "clawdbot": { diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md index 5c1135dae..ab2c543cd 100644 --- a/extensions/matrix/CHANGELOG.md +++ b/extensions/matrix/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.17-1 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.17 ### Changes diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index d17e43c72..c58f85761 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/matrix", - "version": "2026.1.17", + "version": "2026.1.17-1", "type": "module", "description": "Clawdbot Matrix channel plugin", "clawdbot": { diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md index 7eb30a489..71cf588b5 100644 --- a/extensions/msteams/CHANGELOG.md +++ b/extensions/msteams/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.17-1 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.17 ### Changes diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 8b1681df9..77609c5da 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/msteams", - "version": "2026.1.17", + "version": "2026.1.17-1", "type": "module", "description": "Clawdbot Microsoft Teams channel plugin", "clawdbot": { diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index be7141903..50d7fa468 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.17-1 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.17 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index abd02484a..aaa43a369 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/voice-call", - "version": "2026.1.17", + "version": "2026.1.17-1", "type": "module", "description": "Clawdbot voice-call plugin", "dependencies": { diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 707fd1f80..c3ad49f03 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.17-1 + +### Changes +- Version alignment with core Clawdbot release numbers. + ## 2026.1.17 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 72be9cb8f..3723fb86b 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/zalo", - "version": "2026.1.17", + "version": "2026.1.17-1", "type": "module", "description": "Clawdbot Zalo channel plugin", "clawdbot": { diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 54456d694..f70b496d3 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@clawdbot/zalouser", - "version": "2026.1.17", + "version": "2026.1.17-1", "type": "module", "description": "Clawdbot Zalo Personal Account plugin via zca-cli", "dependencies": { diff --git a/package.json b/package.json index c65bb8f3b..088771eb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawdbot", - "version": "2026.1.17", + "version": "2026.1.17-1", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", diff --git a/src/cli/memory-cli.test.ts b/src/cli/memory-cli.test.ts index 5397878f2..cc5628658 100644 --- a/src/cli/memory-cli.test.ts +++ b/src/cli/memory-cli.test.ts @@ -167,7 +167,9 @@ describe("memory cli", () => { registerMemoryCli(program); await program.parseAsync(["memory", "status", "--index"], { from: "user" }); - expect(sync).toHaveBeenCalledWith({ reason: "cli" }); + expect(sync).toHaveBeenCalledWith( + expect.objectContaining({ reason: "cli", progress: expect.any(Function) }), + ); expect(probeEmbeddingAvailability).toHaveBeenCalled(); expect(close).toHaveBeenCalled(); }); diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 9861a60ae..c1321de1a 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; +import { withProgress, withProgressTotals } from "./progress.js"; import { getMemorySearchManager } from "../memory/index.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; @@ -46,18 +47,44 @@ export function registerMemoryCli(program: Command) { return; } try { - await manager.probeVectorAvailability(); const deep = Boolean(opts.deep || opts.index); - const embeddingProbe = deep ? await manager.probeEmbeddingAvailability() : undefined; + let embeddingProbe: Awaited> | undefined; let indexError: string | undefined; - if (opts.index) { - try { - await manager.sync({ reason: "cli" }); - } catch (err) { - indexError = err instanceof Error ? err.message : String(err); - defaultRuntime.error(`Memory index failed: ${indexError}`); - process.exitCode = 1; + if (deep) { + await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => { + progress.setLabel("Probing vector…"); + await manager.probeVectorAvailability(); + progress.tick(); + progress.setLabel("Probing embeddings…"); + embeddingProbe = await manager.probeEmbeddingAvailability(); + progress.tick(); + }); + if (opts.index) { + await withProgressTotals( + { label: "Indexing memory…", total: 0 }, + async (update, progress) => { + try { + await manager.sync({ + reason: "cli", + progress: (syncUpdate) => { + update({ + completed: syncUpdate.completed, + total: syncUpdate.total, + label: syncUpdate.label, + }); + if (syncUpdate.label) progress.setLabel(syncUpdate.label); + }, + }); + } catch (err) { + indexError = err instanceof Error ? err.message : String(err); + defaultRuntime.error(`Memory index failed: ${indexError}`); + process.exitCode = 1; + } + }, + ); } + } else { + await manager.probeVectorAvailability(); } const status = manager.status(); if (opts.json) { @@ -76,6 +103,10 @@ export function registerMemoryCli(program: Command) { ); return; } + if (opts.index) { + const line = indexError ? `Memory index failed: ${indexError}` : "Memory index complete."; + defaultRuntime.log(line); + } const rich = isRich(); const heading = (text: string) => colorize(rich, theme.heading, text); const muted = (text: string) => colorize(rich, theme.muted, text); diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index d713327f9..02ef3a360 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -72,7 +72,7 @@ export async function maybeInstallDaemon(params: { } if (shouldInstall) { - let installError: unknown | null = null; + let installError: string | null = null; await withProgress( { label: "Gateway daemon", indeterminate: true, delayMs: 0 }, async (progress) => { @@ -128,13 +128,13 @@ export async function maybeInstallDaemon(params: { }); progress.setLabel("Gateway daemon installed."); } catch (err) { - installError = err; + installError = err instanceof Error ? err.message : String(err); progress.setLabel("Gateway daemon install failed."); } }, ); if (installError) { - note(`Gateway daemon install failed: ${String(installError)}`, "Gateway"); + note("Gateway daemon install failed: " + installError, "Gateway"); if (process.platform === "win32") { note( "Tip: rerun from an elevated PowerShell (Start → type PowerShell → right-click → Run as administrator) or skip daemon install.", diff --git a/src/gateway/ws-log.test.ts b/src/gateway/ws-log.test.ts index 037ccf782..5a748c38e 100644 --- a/src/gateway/ws-log.test.ts +++ b/src/gateway/ws-log.test.ts @@ -29,12 +29,15 @@ describe("gateway ws log helpers", () => { test("summarizeAgentEventForWsLog extracts useful fields", () => { const summary = summarizeAgentEventForWsLog({ runId: "12345678-1234-1234-1234-123456789abc", + sessionKey: "agent:main:main", stream: "assistant", seq: 2, data: { text: "hello world", mediaUrls: ["a", "b"] }, }); expect(summary).toMatchObject({ + agent: "main", run: "12345678…9abc", + session: "main", stream: "assistant", aseq: 2, text: "hello world", diff --git a/src/gateway/ws-log.ts b/src/gateway/ws-log.ts index b95632614..27f86c61a 100644 --- a/src/gateway/ws-log.ts +++ b/src/gateway/ws-log.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import { isVerbose } from "../globals.js"; +import { parseAgentSessionKey } from "../routing/session-key.js"; import { createSubsystemLogger, shouldLogSubsystemToConsole } from "../logging.js"; import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js"; import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js"; @@ -88,11 +89,21 @@ export function summarizeAgentEventForWsLog(payload: unknown): Record) : undefined; const extra: Record = {}; if (runId) extra.run = shortId(runId); + if (sessionKey) { + const parsed = parseAgentSessionKey(sessionKey); + if (parsed) { + extra.agent = parsed.agentId; + extra.session = parsed.rest; + } else { + extra.session = sessionKey; + } + } if (stream) extra.stream = stream; if (seq !== undefined) extra.aseq = seq; diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index 08f44a09e..c11dff8ab 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -51,8 +51,14 @@ export function resetAgentRunContextForTest() { export function emitAgentEvent(event: Omit) { const nextSeq = (seqByRun.get(event.runId) ?? 0) + 1; seqByRun.set(event.runId, nextSeq); + const context = runContextById.get(event.runId); + const sessionKey = + typeof event.sessionKey === "string" && event.sessionKey.trim() + ? event.sessionKey + : context?.sessionKey; const enriched: AgentEventPayload = { ...event, + sessionKey, seq: nextSeq, ts: Date.now(), }; diff --git a/src/logging/console-capture.test.ts b/src/logging/console-capture.test.ts index fe10a6da4..312fb189a 100644 --- a/src/logging/console-capture.test.ts +++ b/src/logging/console-capture.test.ts @@ -92,6 +92,17 @@ describe("enableConsoleCapture", () => { vi.useRealTimers(); }); + it("suppresses discord EventQueue slow listener duplicates", () => { + setLoggerOverride({ level: "info", file: tempLogPath() }); + const warn = vi.fn(); + console.warn = warn; + enableConsoleCapture(); + console.warn( + "[EventQueue] Slow listener detected: DiscordMessageListener took 12.3 seconds for event MESSAGE_CREATE", + ); + expect(warn).not.toHaveBeenCalled(); + }); + it("does not double-prefix timestamps", () => { setLoggerOverride({ level: "info", file: tempLogPath() }); const warn = vi.fn(); diff --git a/src/logging/console.ts b/src/logging/console.ts index 2cd649d15..92f4a4235 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -90,7 +90,16 @@ const SUPPRESSED_CONSOLE_PREFIXES = [ function shouldSuppressConsoleMessage(message: string): boolean { if (isVerbose()) return false; - return SUPPRESSED_CONSOLE_PREFIXES.some((prefix) => message.startsWith(prefix)); + if (SUPPRESSED_CONSOLE_PREFIXES.some((prefix) => message.startsWith(prefix))) { + return true; + } + if ( + message.startsWith("[EventQueue] Slow listener detected") && + message.includes("DiscordMessageListener") + ) { + return true; + } + return false; } function isEpipeError(err: unknown): boolean { diff --git a/src/logging/subsystem.ts b/src/logging/subsystem.ts index c709b3913..578b0ac1c 100644 --- a/src/logging/subsystem.ts +++ b/src/logging/subsystem.ts @@ -148,7 +148,15 @@ function formatConsoleLine(opts: { ? color.gray : color.cyan; const displayMessage = stripRedundantSubsystemPrefixForConsole(opts.message, displaySubsystem); - const time = opts.style === "pretty" ? color.gray(new Date().toISOString().slice(11, 19)) : ""; + const time = (() => { + if (opts.style === "pretty") { + return color.gray(new Date().toISOString().slice(11, 19)); + } + if (loggingState.consoleTimestampPrefix) { + return color.gray(new Date().toISOString()); + } + return ""; + })(); const prefixToken = prefixColor(prefix); const head = [time, prefixToken].filter(Boolean).join(" "); return `${head} ${levelColor(displayMessage)}`; diff --git a/src/macos/gateway-daemon.ts b/src/macos/gateway-daemon.ts index 606c33954..44a2c7a39 100644 --- a/src/macos/gateway-daemon.ts +++ b/src/macos/gateway-daemon.ts @@ -46,14 +46,18 @@ async function main() { { setGatewayWsLogStyle }, { setVerbose }, { defaultRuntime }, + { enableConsoleCapture, setConsoleTimestampPrefix }, ] = await Promise.all([ import("../config/config.js"), import("../gateway/server.js"), import("../gateway/ws-logging.js"), import("../globals.js"), import("../runtime.js"), + import("../logging.js"), ]); + enableConsoleCapture(); + setConsoleTimestampPrefix(true); setVerbose(hasFlag(args, "--verbose")); const wsLogRaw = (hasFlag(args, "--compact") ? "compact" : argValue(args, "--ws-log")) as diff --git a/src/memory/manager.embedding-batches.test.ts b/src/memory/manager.embedding-batches.test.ts index f5f77aff4..9fe711389 100644 --- a/src/memory/manager.embedding-batches.test.ts +++ b/src/memory/manager.embedding-batches.test.ts @@ -109,4 +109,44 @@ describe("memory embedding batches", () => { expect(embedBatch.mock.calls.length).toBe(1); }); + + it("reports sync progress totals", async () => { + const line = "c".repeat(120); + const content = Array.from({ length: 20 }, () => line).join("\n"); + await fs.writeFile(path.join(workspaceDir, "memory", "2026-01-05.md"), content); + + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath }, + chunking: { tokens: 200, overlap: 0 }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + query: { minScore: 0 }, + }, + }, + list: [{ id: "main", default: true }], + }, + }; + + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + expect(result.manager).not.toBeNull(); + if (!result.manager) throw new Error("manager missing"); + manager = result.manager; + const updates: Array<{ completed: number; total: number; label?: string }> = []; + await manager.sync({ + force: true, + progress: (update) => { + updates.push(update); + }, + }); + + expect(updates.length).toBeGreaterThan(0); + const last = updates[updates.length - 1]; + expect(last?.total).toBeGreaterThan(0); + expect(last?.completed).toBe(last?.total); + }); }); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index ed979f0d5..18987a6d3 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -60,12 +60,24 @@ type SessionFileEntry = { content: string; }; +type MemorySyncProgressUpdate = { + completed: number; + total: number; + label?: string; +}; + +type MemorySyncProgressState = { + completed: number; + total: number; + report: (update: MemorySyncProgressUpdate) => void; +}; + const META_KEY = "memory_index_meta_v1"; const SNIPPET_MAX_CHARS = 700; const VECTOR_TABLE = "chunks_vec"; const SESSION_DIRTY_DEBOUNCE_MS = 5000; const EMBEDDING_BATCH_MAX_TOKENS = 8000; -const EMBEDDING_APPROX_CHARS_PER_TOKEN = 2; +const EMBEDDING_APPROX_CHARS_PER_TOKEN = 1; const log = createSubsystemLogger("memory"); @@ -258,7 +270,11 @@ export class MemoryIndexManager { })); } - async sync(params?: { reason?: string; force?: boolean }): Promise { + async sync(params?: { + reason?: string; + force?: boolean; + progress?: (update: MemorySyncProgressUpdate) => void; + }): Promise { if (this.syncing) return this.syncing; this.syncing = this.runSync(params).finally(() => { this.syncing = null; @@ -650,21 +666,46 @@ export class MemoryIndexManager { return this.sessionsDirty || needsFullReindex; } - private async syncMemoryFiles(params: { needsFullReindex: boolean }) { + private async syncMemoryFiles(params: { + needsFullReindex: boolean; + progress?: MemorySyncProgressState; + }) { const files = await listMemoryFiles(this.workspaceDir); const fileEntries = await Promise.all( files.map(async (file) => buildFileEntry(file, this.workspaceDir)), ); const activePaths = new Set(fileEntries.map((entry) => entry.path)); + if (params.progress) { + params.progress.total += fileEntries.length; + params.progress.report({ + completed: params.progress.completed, + total: params.progress.total, + label: "Indexing memory files…", + }); + } for (const entry of fileEntries) { const record = this.db .prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`) .get(entry.path, "memory") as { hash: string } | undefined; if (!params.needsFullReindex && record?.hash === entry.hash) { + if (params.progress) { + params.progress.completed += 1; + params.progress.report({ + completed: params.progress.completed, + total: params.progress.total, + }); + } continue; } await this.indexFile(entry, { source: "memory" }); + if (params.progress) { + params.progress.completed += 1; + params.progress.report({ + completed: params.progress.completed, + total: params.progress.total, + }); + } } const staleRows = this.db @@ -677,22 +718,65 @@ export class MemoryIndexManager { } } - private async syncSessionFiles(params: { needsFullReindex: boolean }) { + private async syncSessionFiles(params: { + needsFullReindex: boolean; + progress?: MemorySyncProgressState; + }) { const files = await this.listSessionFiles(); const activePaths = new Set(files.map((file) => this.sessionPathForFile(file))); const indexAll = params.needsFullReindex || this.sessionsDirtyFiles.size === 0; + if (params.progress) { + params.progress.total += files.length; + params.progress.report({ + completed: params.progress.completed, + total: params.progress.total, + label: "Indexing session files…", + }); + } for (const absPath of files) { - if (!indexAll && !this.sessionsDirtyFiles.has(absPath)) continue; + if (!indexAll && !this.sessionsDirtyFiles.has(absPath)) { + if (params.progress) { + params.progress.completed += 1; + params.progress.report({ + completed: params.progress.completed, + total: params.progress.total, + }); + } + continue; + } const entry = await this.buildSessionEntry(absPath); - if (!entry) continue; + if (!entry) { + if (params.progress) { + params.progress.completed += 1; + params.progress.report({ + completed: params.progress.completed, + total: params.progress.total, + }); + } + continue; + } const record = this.db .prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`) .get(entry.path, "sessions") as { hash: string } | undefined; if (!params.needsFullReindex && record?.hash === entry.hash) { + if (params.progress) { + params.progress.completed += 1; + params.progress.report({ + completed: params.progress.completed, + total: params.progress.total, + }); + } continue; } await this.indexFile(entry, { source: "sessions", content: entry.content }); + if (params.progress) { + params.progress.completed += 1; + params.progress.report({ + completed: params.progress.completed, + total: params.progress.total, + }); + } } const staleRows = this.db @@ -709,7 +793,14 @@ export class MemoryIndexManager { } } - private async runSync(params?: { reason?: string; force?: boolean }) { + private async runSync(params?: { + reason?: string; + force?: boolean; + progress?: (update: MemorySyncProgressUpdate) => void; + }) { + const progress: MemorySyncProgressState | null = params?.progress + ? { completed: 0, total: 0, report: params.progress } + : null; const vectorReady = await this.ensureVectorReady(); const meta = this.readMeta(); const needsFullReindex = @@ -729,12 +820,12 @@ export class MemoryIndexManager { const shouldSyncSessions = this.shouldSyncSessions(params, needsFullReindex); if (shouldSyncMemory) { - await this.syncMemoryFiles({ needsFullReindex }); + await this.syncMemoryFiles({ needsFullReindex, progress: progress ?? undefined }); this.dirty = false; } if (shouldSyncSessions) { - await this.syncSessionFiles({ needsFullReindex }); + await this.syncSessionFiles({ needsFullReindex, progress: progress ?? undefined }); this.sessionsDirty = false; this.sessionsDirtyFiles.clear(); } else if (needsFullReindex && this.sources.has("sessions")) { diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index b38822a31..69ac146bd 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -27,6 +27,7 @@ import { buildTelegramGroupFrom, buildTelegramGroupPeerId, buildTypingThreadParams, + expandTextLinks, normalizeForwardedContext, describeReplyTarget, extractTelegramLocation, @@ -271,7 +272,8 @@ export const buildTelegramMessageContext = async ({ const locationData = extractTelegramLocation(msg); const locationText = locationData ? formatLocationText(locationData) : undefined; - const rawText = (msg.text ?? msg.caption ?? "").trim(); + const rawTextSource = msg.text ?? msg.caption ?? ""; + const rawText = expandTextLinks(rawTextSource, msg.entities ?? msg.caption_entities).trim(); let rawBody = [rawText, locationText].filter(Boolean).join("\n").trim(); if (!rawBody) rawBody = placeholder; if (!rawBody && allMedia.length === 0) return null; diff --git a/src/telegram/bot/helpers.expand-text-links.test.ts b/src/telegram/bot/helpers.expand-text-links.test.ts new file mode 100644 index 000000000..1e7dd7494 --- /dev/null +++ b/src/telegram/bot/helpers.expand-text-links.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { expandTextLinks } from "./helpers.js"; + +describe("expandTextLinks", () => { + it("returns text unchanged when no entities are provided", () => { + expect(expandTextLinks("Hello world")).toBe("Hello world"); + expect(expandTextLinks("Hello world", null)).toBe("Hello world"); + expect(expandTextLinks("Hello world", [])).toBe("Hello world"); + }); + + it("returns text unchanged when there are no text_link entities", () => { + const entities = [ + { type: "mention", offset: 0, length: 5 }, + { type: "bold", offset: 6, length: 5 }, + ]; + expect(expandTextLinks("@user hello", entities)).toBe("@user hello"); + }); + + it("expands a single text_link entity", () => { + const text = "Check this link for details"; + const entities = [{ type: "text_link", offset: 11, length: 4, url: "https://example.com" }]; + expect(expandTextLinks(text, entities)).toBe( + "Check this [link](https://example.com) for details", + ); + }); + + it("expands multiple text_link entities", () => { + const text = "Visit Google or GitHub for more"; + const entities = [ + { type: "text_link", offset: 6, length: 6, url: "https://google.com" }, + { type: "text_link", offset: 16, length: 6, url: "https://github.com" }, + ]; + expect(expandTextLinks(text, entities)).toBe( + "Visit [Google](https://google.com) or [GitHub](https://github.com) for more", + ); + }); + + it("handles adjacent text_link entities", () => { + const text = "AB"; + const entities = [ + { type: "text_link", offset: 0, length: 1, url: "https://a.example" }, + { type: "text_link", offset: 1, length: 1, url: "https://b.example" }, + ]; + expect(expandTextLinks(text, entities)).toBe("[A](https://a.example)[B](https://b.example)"); + }); + + it("preserves offsets from the original string", () => { + const text = " Hello world"; + const entities = [{ type: "text_link", offset: 1, length: 5, url: "https://example.com" }]; + expect(expandTextLinks(text, entities)).toBe( + " [Hello](https://example.com) world", + ); + }); +}); diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index f068a71ed..ce3c0e123 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -114,6 +114,37 @@ export function hasBotMention(msg: TelegramMessage, botUsername: string) { return false; } +type TelegramTextLinkEntity = { + type: string; + offset: number; + length: number; + url?: string; +}; + +export function expandTextLinks( + text: string, + entities?: TelegramTextLinkEntity[] | null, +): string { + if (!text || !entities?.length) return text; + + const textLinks = entities + .filter( + (entity): entity is TelegramTextLinkEntity & { url: string } => + entity.type === "text_link" && Boolean(entity.url), + ) + .sort((a, b) => b.offset - a.offset); + + if (textLinks.length === 0) return text; + + let result = text; + for (const entity of textLinks) { + const linkText = text.slice(entity.offset, entity.offset + entity.length); + const markdown = `[${linkText}](${entity.url})`; + result = result.slice(0, entity.offset) + markdown + result.slice(entity.offset + entity.length); + } + return result; +} + export function resolveTelegramReplyId(raw?: string): number | undefined { if (!raw) return undefined; const parsed = Number(raw);