mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
* feat: better composer etc * docs: add desktop and dashboard run instructions * fix(desktop): address security scan findings * fix(dashboard): resolve @nous-research/ui path under npm workspaces The sync-assets prebuild step shelled out to 'cp -r node_modules/@nous-research/ui/dist/fonts ...' with a path relative to apps/dashboard/. That works only when the dep is installed locally in the dashboard workspace, but 'npm install' at the repo root (the documented setup — see apps/desktop/README.md) hoists shared deps to the root node_modules under npm workspaces. The relative cp then fails with 'No such file or directory', sync-assets exits 1, the Vite build aborts, and 'hermes dashboard' surfaces a generic 'Web UI build failed' message. Replace the shell one-liner with scripts/sync-assets.cjs, which walks up from the dashboard directory looking for node_modules/ @nous-research/ui — working in both the hoisted (workspaces) and co-located (standalone) layouts. Also guards against a missing dist/fonts or dist/assets with a clearer error pointing at a rebuild of the UI package rather than silently copying nothing. * feat(desktop): support connecting to a remote Hermes backend Add HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN env vars that, when set, short-circuit the local-child spawn in startHermes() and connect the Electron renderer to an already- running 'hermes dashboard' server reachable over the network. Motivating use case: WSL2 users who want to run the Hermes core (agent loop, tools, filesystem access) inside their WSL distribution while rendering the Electron GUI on native Windows. Before this change, the desktop app always spawned a local Python child on the same host as the renderer, which doesn't cross the WSL/Windows boundary. The remote path reuses waitForHermes() as a liveness probe (/api/status is in the backend's public endpoint allowlist), so the connection is only returned once the backend is actually ready. WebSocket URL derivation picks ws:// or wss:// based on the input scheme. URL validation rejects non-http(s) schemes and requires both env vars together to avoid a half-configured connection that would silently fall through to the spawn path. No behaviour change when the env vars are unset — the default local-spawn flow is untouched. Typical usage: # in WSL2 hermes dashboard --tui --no-open --host 0.0.0.0 --port 9119 --insecure # on Windows set HERMES_DESKTOP_REMOTE_URL=http://localhost:9119 set HERMES_DESKTOP_REMOTE_TOKEN=<session token> set HERMES_DESKTOP_IGNORE_EXISTING=1 (launch Hermes desktop) * ci(desktop): automate desktop releases Add GitHub Actions release channels for signed desktop installers and document the stable/nightly download paths. * feat: file tabs * refactor(desktop): tighten right-rail tab close API Promote closeRightRailTab/closeActiveRightRailTab as the single public entry point. Drops the activeTabRef + handleCloseDocument indirection in ChatPreviewRail, the unused $rightRailHasContent atom, and the legacy dismissFilePreviewTarget alias. -70 LOC. * feat(desktop): polish composer pill toward reference look Solid foreground-on-background send/voice-conversation circle (black-on-white in light, white-on-black in dark) anchors the right edge as the primary CTA instead of the orange theme primary. Bumps the primary control to 2.125rem so it visually outranks the ghost mic/plus controls. Opens up the surface padding (0.625rem x / 0.5rem y) so the input row breathes around its controls, and nudges the corner radius from 20 to 24px for a slightly pill-ier silhouette. LiquidGlass distortion is preserved. * feat(desktop): add startup and onboarding flow Add phase-based desktop boot progress, fresh-install sandbox testing, and first-run provider credential onboarding so packaged installs can start cleanly without manual settings detours. * fix(desktop): gate prompts on provider setup Show the desktop provider onboarding flow before prompt submission when no inference provider is configured, preventing fresh installs from falling through to backend credential errors. * fix(desktop): surface provider onboarding from session warnings Propagate credential warnings through session runtime info and open desktop onboarding whenever a session reports no usable provider, so unconfigured installs cannot fall through to prompt errors. * fix(desktop): route gateway provider errors to onboarding The "No inference provider configured" auth error reaches the renderer through gateway error events, not the prompt.submit promise; the previous patch only caught the latter, so the error toast still surfaced and onboarding never opened. Also strip credential-shaped env vars from the test:desktop:fresh sandbox so the packaged backend can't see provider keys leaking from the launching shell. * fix(desktop): use strict runtime check to drive onboarding setup.status returned True whenever any provider auth state was discoverable, including indirect fallbacks like a gh-CLI Copilot token. That made desktop think the user was set up while the agent's actual resolve_runtime_provider call still raised AuthError, leaving the user with a useless toast and no onboarding. Add a setup.runtime_check gateway method that runs the same resolver the agent uses on session creation, and switch the desktop onboarding overlay and prompt precheck to use it. * feat(desktop): OAuth-first onboarding using existing dashboard provider API Replace the engineer-flavored API key form with a Sign-in-first onboarding overlay that uses the dashboard's existing /api/providers/oauth catalog and PKCE/device-code endpoints (Anthropic, Nous, OpenAI Codex, etc.). API key entry is now a fallback tab with friendly provider names instead of env var prefixes, and the loud raw resolver error is gone in favor of a one-line welcome message. * fix(desktop): polish onboarding provider list Reorder OAuth providers so Nous Portal is first, give the segmented Sign in / API key control equal column widths, and replace the engineer-flavored backend names like "Anthropic (Claude API)" / "MiniMax (OAuth)" with friendlier in-app titles. External-CLI providers now show a softer subtitle and an external-link icon instead of a chevron. * refactor(desktop): split onboarding overlay into store + view Move the OAuth state machine, runtime check, copy-to-clipboard, and api-key save into store/onboarding.ts (matching the boot.ts pattern), leaving the overlay as a presentation layer that subscribes via useStore. Tabs are now table-driven, child panels read flow from the store instead of prop-drilling, and the polling/PKCE/error/success branches share a small Status atom. * fix(desktop): external CLI providers + center mode tabs External-CLI providers (Claude Code, Qwen Code) now open an in-overlay panel with the CLI command, copy button, and an "I've signed in" recheck instead of firing an invisible toast. Center the Sign in / API key tab control so it sits under the heading instead of hugging the left edge. * fix(desktop): drop onboarding tabs for an inline link, group device-code waiting state Replace the Sign in / API key tab pair with an "I have an API key" footer link under the OAuth provider list, with a "Back to sign in" affordance inside the API key form. Group the device-code "Waiting for you to authorize..." status next to the Cancel button so the alignment matches the action. * refactor(desktop): tighten onboarding store + overlay Drop the dead isOnboardingBusy/BUSY set, factor the catch-fallback dance into safeReq, and share a single reloadAndConnect helper between PKCE submit, device-code success, external recheck, and api-key save. In the overlay, extract Step / CodeBlock / FlowFooter / CancelBtn / DocsLink atoms so the four sign-in panels share the same chrome instead of repeating it inline. Net effect: fewer literal divs, one place to touch the spacing, and the code-block + footer rows are reusable across future flows. * fix(desktop): mount onboarding from frame 1 to kill the FOUT Default onboarding.configured to null (unknown until the runtime check resolves) and have the onboarding overlay render whenever it's not yet confirmed true. The boot overlay now yields to it, so the very first paint is the Welcome card with a "While we get you set up..." progress strip instead of a flash of the chat shell between boot dismiss and onboarding mount. The picker swaps in cleanly once the gateway opens and the runtime check confirms the user is not configured. Already-configured users see the same prep card briefly while their existing runtime warms up, then the overlay dismisses without touching the chat shell. * fix(desktop): top-align empty sessions placeholder The "Start a chat to build your history." empty state used a min-h-35 grid place-items-center container, which floated the text in a tall dead zone. Render it as a flat paragraph that sits right under the section header like the empty pinned state does. * refactor(desktop): drop dead boot overlay Onboarding overlay subsumes the boot card now that it mounts from frame 1 and renders boot progress inline. The standalone DesktopBootOverlay is unreachable in every flow (yields whenever onboarding has not confirmed configured, dismisses once it has). * fix(desktop): hide pinned/recents sections until first session A fresh sidebar showed the Pinned and Recent chats headers with floating empty-state copy underneath. Drop both sections (and the now-orphan SidebarEmptySessionState) when there are no sessions yet — they reappear after the first chat. Skeletons during initial load are unchanged. * feat(gui): route embedded TUI through dashboard gateway (#21979) Inject HERMES_TUI_GATEWAY_URL into dashboard PTY sessions so embedded ui-tui instances attach to the in-process websocket gateway, with coverage for the new env wiring. * Add desktop remote gateway settings Make the desktop gateway connection configurable from settings so local remains the default while remote backends can be saved, tested, and applied without environment variables. * feat(gui): first-class Messaging page + gateway menu redesign - Add Messaging page to the desktop app with per-platform setup, status, and inline guidance. Catalog derives from gateway.config Platform enum + plugin registry, so every messaging adapter the CLI supports (Telegram, Discord, Slack, Mattermost, Matrix, WhatsApp, Signal, BlueBubbles, Home Assistant, Email, SMS, DingTalk, Feishu, WeCom, Weixin, QQ, Yuanbao, API server, Webhooks, plugins) shows up without per-platform code. - New REST endpoints: GET /api/messaging/platforms, PUT and POST /test on the same path. Secrets go through the existing .env pipeline; enable/disable writes config.yaml. - Replace gateway statusbar dropdown with a richer panel: status row, icon-only restart + system-panel actions, recent activity (with timestamps trimmed in display, full text on hover), platform list. - Auto-poll the messaging page every 6s (paused when hidden) so status updates without a manual check. - Drop Settings / Command Center from the sidebar nav (still reachable via shortcuts and the titlebar cog). - Flatten top corners on Messaging/Skills/Artifacts/Chat panes. - Share new StatusDot component across messaging + gateway menu. - Fix gateway/config.py so an explicit platforms.<name>.enabled=false in config.yaml is honored when env tokens are present. - pb-9 on the chat content area for breathing room above the composer. * Potential fix for pull request finding 'CodeQL / Clear-text logging of sensitive information' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * pin electron version * hide application menu on non-mac systems * interpret compactPreview for non-string vlaues as JSON or an empty string * fix(desktop): keep composer contenteditable mounted across stacked toggle The composer rendered {input} inside two different parent fragments depending on `stacked`. When auto-expand flipped `stacked` (e.g. the moment typed text wrapped past two lines), React reconciled the two branches as different positions and unmounted/remounted the contenteditable. The fresh mount started empty, so any in-flight characters — most reliably reproduced by holding a key — were lost. Replace the conditional with a single CSS Grid whose template-areas swap on `stacked`. The three children (menu, input, controls) keep stable identities across the toggle; only their grid placement changes, which the browser handles without React tearing down the editor. * refactor(desktop): align install layout with install.ps1 / install.sh Make the desktop app's runtime layout match what scripts/install.ps1 and scripts/install.sh produce, so a desktop-only user and a CLI-only user end up with the same files in the same places and can share one install. Layout - ACTIVE_HERMES_ROOT = HERMES_HOME/hermes-agent (was: process.resourcesPath/hermes-agent, read-only) - VENV_ROOT = HERMES_HOME/hermes-agent/venv (was: userData/hermes-runtime) - desktop.log = HERMES_HOME/logs/desktop.log (was: userData/desktop.log) - HERMES_HOME default: %LOCALAPPDATA%\hermes on Windows, ~/.hermes elsewhere The packaged .app/.exe still ships a read-only payload at process.resourcesPath/hermes-agent (FACTORY_HERMES_ROOT). On first launch or after an installer-driven upgrade we sync factory -> active, then provision the venv and run pip install -e . against the active root. Key behaviors - Pin HERMES_HOME in the spawned Python's env so get_hermes_home() resolves to the same path resolveHermesHome() picked. Without this, Python falls back to ~/.hermes on every platform - fine on mac/linux, a split-state bug on Windows where our default is %LOCALAPPDATA%\hermes. - Detect developer installs by .git presence at ACTIVE; never overwrite a user's checkout via factory sync. - Marker at ACTIVE/.hermes-desktop-runtime.json (schema v4) tracks pyproject hash + factory version + runtime schema version. depsFresh fast-paths when nothing changed. - Dev (npm run dev) prefers SOURCE_REPO_ROOT over ACTIVE so devs run their local edits, not whatever's under HERMES_HOME. - Better error messages distinguish "no payload" from "no Python". - Preserve a legacy ~/.hermes on Windows when no %LOCALAPPDATA%\hermes exists, so users with prior pip/manual installs aren't orphaned. pyproject.toml - Promote fastapi, uvicorn[standard], ptyprocess (non-Windows), and pywinpty (Windows) to main dependencies. The dashboard backend (hermes dashboard) needs them at runtime; the previous lazy-import fallback was a footgun for fresh installs. - Empty the [pty] optional-extra; kept as a no-op back-compat alias for any existing pip install hermes-agent[pty] invocations. Drops the hardcoded BUNDLED_RUNTIME_REQUIREMENTS list in main.cjs - the desktop now installs whatever pyproject.toml says, single source of truth. Files - apps/desktop/electron/main.cjs: runtime layout, HERMES_HOME pin, factory->active sync, marker v4 - apps/desktop/scripts/test-desktop.mjs: track new venv location - apps/desktop/README.md: new Setup, Runtime Bootstrap, and Debugging sections - pyproject.toml: fastapi/uvicorn/pty backends in main dependencies; [pty] extra emptied Tested locally on Windows: npm run dev boots cleanly, sessions land at the new location, type-check + lint + test:desktop:platforms all pass. Verified end-to-end on a fresh Win11 VM via dist:win installer. Known gaps (filed as follow-ups, not in this PR): - Skills not seeded on packaged installs (sync_skills only runs in cmd_chat, not cmd_dashboard). Need to move to shared pre-dispatch. - Git Bash not bundled or detected; agent's terminal tool errors out with a useful message but desktop bootstrapper should pre-flight it. - install.ps1 / install.sh should be decomposed into composable phase libraries so the desktop bootstrapper can reuse them as a single source of truth across all install surfaces. * feat(desktop): theme polish, prose chat typography, composer chrome - DS tokens/midground, Backdrop, scoped scrollbars, typography plugin + prose - Composer liquid/radius utilities, thread font parity, tool/thinking cues - File tree label scale, preview flex, thread retry loading + streaming tests * feat(desktop): NSIS prereq detection page + auto-install via winget The packaged Windows installer now detects Python 3.11+ and Git for Windows at install time and offers to install missing prereqs via winget. Mirrors the prereq logic scripts/install.ps1 already runs for CLI installs, so desktop installer users get the same out-of-the-box experience as install.ps1 users. Why - Hermes' terminal tool calls bash.exe directly (tools/environments/ local.py); on Windows that's Git Bash from Git for Windows. Without it, the agent fails on the first terminal() call. - Hermes' Python runtime needs 3.11+. Without it, the desktop bootstrapper errors out at venv creation. - Both gaps surfaced on a fresh Windows 11 VM smoke test: VM had Python pre-installed but no Git, so the agent's first terminal call failed with "Git Bash isn't installed." - install.ps1 has had Install-Git + Install-Uv functions for ages. The desktop installer was the asymmetric outlier. How — NSIS prereq page - New file: apps/desktop/installer/prereq-check.nsh (plugged into electron-builder via build.nsis.include) - Real Wizard page using nsDialogs, inserted via customPageAfterChangeDir hook (between the Directory page and InstFiles). - Group boxes for Python and Git, each showing detection status. - Pre-checked install checkboxes when winget is available. - Auto-skips silently if both prereqs are already installed. - Falls back to manual download URLs when winget itself is missing. - Detection: - Python: probes `py -3.11`/`-3.12`/`-3.13`/`-3.14` via the Python launcher. Microsoft Store "Python stub" (no py.exe) is correctly classified as not-installed. - Git: `where git`. - winget: `where winget` (Win10 1809+ / Win11 with App Installer). - Install execution (in customInstall macro): - Python: nsExec::ExecToLog with `--scope user --silent`. Per-user install, no UAC prompt, output streams to install log. - Git: ExecShellWait via Windows ShellExecute. Critical because Git always installs per-machine and triggers UAC; ShellExecute preserves the foreground focus chain across non-elevated → elevated process spawns, so UAC actually comes to the foreground. nsExec::ExecToLog breaks the chain because winget runs hidden. - Both pass `--disable-interactivity --accept-package-agreements --accept-source-agreements` to suppress winget's own dialogs. - Verification: probes Git's standard install locations via FileExists rather than `where git`. NSIS's process inherits PATH at startup, so a freshly-installed Git won't be visible to `where` until restart. - Silent installs (/S) skip the prompts; managed deploys handle prereqs out-of-band via Group Policy / Intune. How — Electron-side safety net - New findGitBash() in main.cjs, parallel to findSystemPython(). Probes the same locations as tools/environments/local.py:_find_bash() so a positive result here means the agent's terminal tool will work. - ensureRuntime now throws a clear, actionable error on Windows when Git Bash isn't found, matching the existing "Python 3.11+ is required" error path. - Catches users the NSIS page doesn't: .msi installer users (NSIS prereq page doesn't run for MSI), `npm run dev` users, manual installers, anyone who unchecked the install boxes on the NSIS prereq page. - All gated on `IS_WINDOWS`; macOS / Linux unaffected. NSIS build issue (resolved) - electron-builder defaults to `-WX` (warnings as errors). NSIS optimizer emits "warning 6010: function not referenced" for our page functions because Page custom directives don't count as references in its static-analysis pass. The functions ARE called at runtime when NSIS invokes the page; the optimizer just can't see it statically. - Set `build.nsis.warningsAsErrors=false` in package.json so this spurious warning doesn't fail the build. (Documented option from electron-builder's nsisOptions.) Out of scope (filed for future work) - MSI prereq detection: Windows Installer custom actions are a different mechanism. Enterprise deploys typically handle prereqs via GP/Intune. - Bundle PortableGit + python-build-standalone in extraResources for zero-network installs. ~80MB increase. - Mac / Linux GUI prereq flows (different installer formats; Xcode CLT covers most macOS prereqs already; Linux is per-distro hard). Files - apps/desktop/installer/prereq-check.nsh (new, ~290 lines NSIS) - apps/desktop/package.json (build.nsis.include + warningsAsErrors) - apps/desktop/electron/main.cjs (findGitBash + preflight) - apps/desktop/README.md (Runtime prerequisites section) Cross-platform impact - macOS / Linux builds (dist:mac, dist:mac:dmg, dist:mac:zip): nsis config is ignored entirely; .nsh is dormant. - npm run dev: .nsh dormant; main.cjs preflight gated on IS_WINDOWS. - scripts/install.ps1, scripts/install.sh: no reference to any new files; CLI install paths untouched. - Hermes CLI / dashboard / gateway: no reference; runtime untouched. - All checks: node --check on main.cjs and test-desktop.mjs pass; npm run test:desktop:platforms 4/4 passing; node --test green. Tested - npm run dist:win produces signed .exe and .msi without errors. - Fresh Win11 VM (Python pre-installed, no Git): prereq page renders, Python check shows detected, Git checkbox pre-checked. Click Next → Git installs via winget with UAC prompt in foreground. - After install completes, Hermes launches and the agent's terminal tool can run bash commands. Verified Git Bash is detected at `C:\Program Files\Git\bin\bash.exe` by ensureRuntime's preflight. * feat: theme changes, composer tweaks, in app update ux, finesse * fix(cli): seed bundled skills on dashboard + gateway entrypoints `sync_skills(quiet=True)` was only being called from inside `cmd_chat`, which meant `hermes dashboard` (the desktop GUI's backend) and `hermes gateway` (Telegram/Discord/Slack/etc daemons) never seeded the bundled skill library into ~/.hermes/skills/. This surfaced as "No skills found" in the desktop GUI's skills panel on fresh installs, despite the agent having access to the full bundled library when invoked via `hermes chat`. scripts/install.ps1 worked around it by running skills_sync.py as part of Copy-ConfigTemplates, but that's not part of the desktop installer's bootstrap chain. Fix - Extract the skills-sync block from cmd_chat into a module-level `_sync_bundled_skills_quietly()` helper. - Call the helper from cmd_chat (preserving existing behavior), cmd_dashboard (after the --status/--stop early-return paths and fastapi import check, so we don't run skills_sync on management commands or when deps aren't installed), and cmd_gateway. Why these three entrypoints - cmd_chat: the user's primary CLI entrypoint - cmd_dashboard: the desktop GUI's backend; this is what `hermes dashboard --tui` invokes when the desktop bootstrapper spawns Hermes - cmd_gateway: long-running daemons where the user expects the agent to have full skill access Other entrypoints (cmd_config, cmd_doctor, cmd_login, cmd_status, etc.) are management commands that don't need skill discovery and were never running skills_sync in the first place — leaving them alone. Idempotence - tools/skills_sync.py is manifest-based: skipped skills cost milliseconds. Calling it from multiple entrypoints adds no real cost, and users running `hermes chat` then `hermes dashboard` get two fast no-ops on the second call. Failure handling - Helper wraps skills_sync in try/except. Skills are an enhancement, not a hard dependency — Hermes runs fine with an empty skills/ dir. Files - hermes_cli/main.py: + new helper `_sync_bundled_skills_quietly()` at module level + cmd_chat: replace inline block with helper call + cmd_dashboard: add helper call after fastapi import succeeds + cmd_gateway: add helper call before delegating to gateway_command * feat(desktop): hoisted todo widget, JSON tool summaries, history grouping & timer fixes - Hoist todo to first-class widget (shadcn checkboxes, brand colors, no tool-accordion). Header derives label from active task; non-active rows fade. - Replace raw JSON dumps with structured key/value summaries via formatToolResultSummary; nested error extraction for clearer failures. - Fix loaded-session grouping: stitch interleaved assistant/tool iterations into one bubble instead of orphaned synthetic messages. - Stable tool/thinking timers via keyed registry so unmount/scroll doesn't reset elapsed counts; gate "running" on real live thread state. - Reorganize chat-only assistant-ui components under components/chat/. * fix(desktop): address CodeQL alerts on PR #20059 - settings/helpers.ts: harden setNested against prototype pollution. POLLUTING_PATH_PARTS check is now applied at every assignment site (loop + leaf) and uses Object.defineProperty so CodeQL can see the guard inline rather than via a helper function call. - lib/markdown-preprocess.ts: rebuild the dangling-fence close regex from a fence-char + length instead of marker.replace(...). The marker is captured by `(`{3,}|~{3,})` so it can only be backticks or tildes, but CodeQL was tracing tainted input text into the RegExp source and flagging hostname dots from input as part of the pattern (false positive js/incomplete-hostname-regexp on the test fixture URLs). Reconstructing from a literal char breaks the dataflow. - scripts/notarize-artifact.cjs: drop args from the run() rejection message. Args carry --key-id / --issuer / key file path; the existing outer catch already squashes errors to a generic line, but CodeQL was flagging the args.join(' ') as clear-text logging of APPLE_API_KEY_ID. Composer DOM-text-as-HTML alerts (composer/index.tsx:379, :547) are already addressed in4dd9732a9— innerHTML assignment was replaced with renderComposerContents which builds DOM via replaceChildren / append text nodes (no HTML interpretation). * fix(desktop): inline prototype-pollution guard so CodeQL sees it CodeQL's dataflow doesn't follow the helper-function guard inside `safeSet`, so it kept flagging Object.defineProperty as prototype- polluting. Inline the literal `__proto__`/`constructor`/`prototype` check at the assignment site to break the dataflow. Behavior unchanged — same set of disallowed keys, same throw. * feat(ui-tui): resolve links to readable page titles Mirror desktop pretty-link behavior in the TUI by resolving HTTP links to page titles with shared caching and safe fetch filters, plus slug-based fallbacks so chat links stay readable even when title fetch fails. * fix(desktop): drop RegExp from dangling-fence close detection Previous attempt tried to break the dataflow by reconstructing the close-fence regex from a literal char + marker.length, but CodeQL still traced marker.length back to input and kept flagging the test-fixture URLs as hostname-regex sources (js/incomplete-hostname-regexp). Replace `new RegExp(...)` + `closeRe.test(body)` with a string-only hasCloseFenceLine() helper that splits on '\n' and uses ===. No regex on this path now, so input data can no longer reach a RegExp source. Behavior preserved: matches lines that are (whitespace + marker + whitespace), which is what the original `\n[ \t]*${marker}[ \t]*(?=\n|$)` matched. All 12 markdown-text tests still pass. * fix(process-registry): suppress windows-footgun false positive on guarded killpg Keep the existing POSIX-only process-group teardown path, but make the signal selection explicit via getattr and add an inline windows-footgun suppression marker on the guarded os.killpg line so the Windows footgun check no longer blocks CI on this intentionally platform-gated code. * feat(desktop): reconcile live tool events, polish thread chrome, harden boot - chat-messages: match tool rows by overlapping query/context/preview values so preview-first `tool.progress` rows reliably adopt later stable-id `tool.start` payloads instead of spawning ghost rows or mis-merging parallel same-name calls; preserve prior args/result across phases. - tui_gateway: emit full args + parsed result on `tool.start` / `tool.complete`, drop redundant `tool.started` re-emit from `tool.progress`. - electron/main: prefer SOURCE_REPO_ROOT before PATH `hermes` in dev so local backend edits actually run; split hardening helpers into `electron/hardening.cjs` with tests. - thread/tool UI: one-shot enter animation keyed by stable ids, braille spinner for running rows, Cursor-like disclosure rows, drill-down + duration/count formatting via new tool-fallback-model. - composer: extract `text-utils`, drop liquid-glass overrides. - right-rail: split preview-pane into preview-console / preview-file. - runtime: incremental external-store runtime + runtime-readiness gate; onboarding store + tests; route-resume hook test. - regression tests for live tool reconciliation (parallel tools, id-less progress, preview-first rows, structured args/results). * feat(desktop): add ripgrep to NSIS prereq page + polish layout Add ripgrep as a third (recommended) prereq alongside Python and Git in the NSIS prereq detection page, and clean up the page layout based on on-VM testing. Why ripgrep - Hermes' search_files tool calls `rg` directly for content + filename search (tools/file_operations.py:1382). Falls back to grep/find from Git Bash when missing — works but slower and noisier (no .gitignore awareness). - ~5MB winget install via `BurntSushi.ripgrep.MSVC --scope user` — no UAC prompt, parallel to how Python installs. - scripts/install.ps1 already installs ripgrep as part of Install-SystemPackages; this brings the desktop installer to parity. Why "recommended" not "required" - Python and Git are hard requirements: without them the agent runtime or terminal tool refuses to start. The bootstrapper preflight throws. - ripgrep is a performance enhancement: missing it just means slower searches. Page wording reflects this; failure to install is logged but doesn't show a MessageBox or block. Layout polish (response to on-VM screenshot review) - Wizard header now correctly reads "System Requirements" instead of the leftover "Choose Install Location" from the previous page. Set via `GetDlgItem $HWNDPARENT 1037/1038` + WM_SETTEXT — the standard NSIS pattern for overriding the page header on a custom Page. - Removed redundant in-body title + verbose intro paragraph; the wizard header IS the title now. Body has one short intro line. - Group boxes tightened to 26u with content positioned just below the groupbox title (not top-anchored status + bottom-anchored checkbox with empty space in the middle). All three panels + footer fit comfortably in 126u, well under the 140u page limit. - Checkbox labels simplified: dropped "(per-user, no admin prompt)" and "(administrator approval required)" suffixes. The footer note still calls out UAC for Git when relevant. - Footer text trimmed to fit cleanly without clipping. Install order (in customInstall macro) - Python → ripgrep → Git - Python and ripgrep are silent and run first; Git's UAC prompt comes last so the user's approval interaction isn't interrupted by silent activity afterwards. Skip behavior unchanged - All three detected → page auto-skips via Abort - Silent install (/S) → customInstall winget block skips - User unchecks all → page advances without running winget Files - apps/desktop/installer/prereq-check.nsh: ripgrep detection block, ripgrep page panel + checkbox, ripgrep customInstall block, GetDlgItem header override, layout reflow - apps/desktop/README.md: Runtime prerequisites section updated to list ripgrep as recommended, with manual winget command * feat(desktop): add model-confirmation step to onboarding After OAuth/API-key login completes, onboarding now shows a confirmation card with the curated default model and a Change button before dropping the user into chat. Closes the gap where the desktop's `model.default` was empty after first launch and the agent had to fall back to whatever heuristic happened to fire — leaving users wondering "why am I getting sonnet-4 when I logged into Nous Portal?" Why - Desktop onboarding only persisted credentials, never `model.default`. The CLI's `hermes model` command pairs provider + model selection, but the desktop's onboarding skipped the model step entirely. - Result: users saw whichever model the agent's auto-fallback picked, unpredictably and undocumented. - For the BUILD demo we want users to land on the model they expect for their provider, with a clear "this is what you're getting" UI and a one-click path to change it before chatting. How - New `confirming_model` flow status carries the just-authenticated provider slug, current default model, label, and a saving flag. - `completeWithModelConfirm()` runs after credentials succeed: reloads env, verifies runtime, fetches /api/model/options to find the curated first-model for the provider, persists it via /api/model/set, then transitions into `confirming_model`. - If anything fails (no providers returned, network error), falls through to the previous behaviour — onboarding completes without the confirm step. Polish, not a hard requirement. - All four credential paths (device_code OAuth, PKCE OAuth, external CLI flow, API key) now use completeWithModelConfirm instead of reloadAndConnect. UI - `ConfirmingModelPanel` shows: green "<provider> connected" banner, card with "Default model: <name>" + Change button, and a "Start chatting" CTA that finalises onboarding. - Reuses the existing `ModelPickerDialog` (the same picker available from the chat shell) for the change-model UX. Search, filtering, multi-provider listing — all already built. - Stacking: ModelPickerDialog defaults to z-130, which renders UNDER the onboarding overlay (z-1300) and breaks pointer events. Added optional `contentClassName` prop to ModelPickerDialog so callers can override; onboarding passes `z-[1310]`. Provider-slug matching - For OAuth flows: pass `provider.id` directly as the preferred slug. - For API-key flows: `OPENROUTER_API_KEY` → "openrouter" via env-key prefix strip. Also includes the user-visible label as a fallback candidate. - fetchProviderDefaultModel falls back to the first authenticated provider in the response if no preferred slug matches — so even a miss still surfaces a reasonable default. Files - apps/desktop/src/store/onboarding.ts: + new `confirming_model` flow variant + fetchProviderDefaultModel + completeWithModelConfirm helpers + setOnboardingModel (optimistic update + revert on failure) + confirmOnboardingModel (finalises onboarding from the card) - reloadAndConnect (replaced; the four call sites now go through completeWithModelConfirm) - apps/desktop/src/components/desktop-onboarding-overlay.tsx: + ConfirmingModelPanel component + new branch in FlowPanel for status `confirming_model` + ModelPickerDialog usage with z-[1310] content class - apps/desktop/src/components/model-picker.tsx: + optional `contentClassName` prop on ModelPickerDialog so the dialog can be stacked on top of other fixed overlays Tested - `npm run type-check` passes - `npx eslint` clean on touched files - Live test in `npm run dev`: cleared onboarding cache, walked through Nous device-code flow, saw confirm card with curated default, clicked Change → ModelPickerDialog rendered above the onboarding overlay with working pointer events, picked a different model, "Start chatting" persisted to ~/.hermes/config.yaml. * fix(desktop): suppress generic provider warning in onboarding Hide the red setup notice when the message is the generic missing-provider guidance, since onboarding already presents provider auth actions. Centralize provider-setup matching across desktop hooks and add coverage for the matcher. * fix(desktop): add 2u clearance below prereq checkboxes Group box bottom border was clipping the checkboxes by 1-2px. Bumped each box height 26u→30u; checkboxes now sit 2u above the bottom border. * fix(nix): refresh dashboard lockfile hash Update the web npm deps hash in nix/web.nix to match the committed apps/dashboard/package-lock.json so bb/gui passes the nix lockfile check. * fix(desktop): install TUI deps in release workflow Ensure desktop release builds install the standalone ui-tui package before bundling the TUI payload. * fix(desktop): run release builder from app package Invoke the desktop builder through the package script so electron-builder uses apps/desktop/package.json. * fix(desktop): expand release artifact names safely Build desktop artifact names from workflow version/channel while preserving electron-builder platform macros. * fix(desktop): use package artifact naming in release workflow Let electron-builder's desktop package config provide platform-specific artifact extensions while the workflow injects the release version/channel metadata. * fix(nix): fetch dashboard npm deps from package root Point the dashboard npm dependency fetch at apps/dashboard so Nix can find the package lockfile after the dashboard move. * fix(nix): build dashboard from package directory Set the web package source root to apps/dashboard so npm patch/build phases run beside the dashboard lockfile while keeping apps/shared available as a sibling. * feat(desktop): render LaTeX math via KaTeX after streaming completes Add @streamdown/math plugin to the chat markdown renderer. Inline ($x^2$) and block ($$...$$) math both supported with singleDollarTextMath enabled. Plugin is gated to non-streaming state to match the existing pattern for syntax highlighting — math renders when the message completes, avoiding KaTeX re-render churn during streaming. KaTeX CSS is imported in styles.css; ~30KB CSS + ~430KB JS added to the bundle. Smoothness improvements during streaming deferred to a follow-up. * perf(desktop): memoize KaTeX renders so math streams without re-rendering Wrap rehype-katex with a per-equation LRU cache (keyed by displayMode + source text) and re-enable math during streaming. Stock @streamdown/math runs rehype-katex on every markdown commit, so each new token re-katexes every equation in the message. For math-heavy responses (an equation derived step-by-step) that's hundreds of ms of wasted work per token and the streaming UI chokes. With memoization, each equation pays katex.renderToString exactly once; subsequent tokens re-walk the tree but hit cache for unchanged equations. The wrapper mirrors rehype-katex's semantics exactly: same class detection (language-math, math-inline, math-display), same <pre>-walk-up for fenced math blocks, same parent.children.splice replacement, same SKIP traversal, same strict-then-lenient render strategy with VFile message reporting. Cached children are structuredCloned on each splice so downstream rehype plugins or toJsxRuntime can't mutate the cache. * fix(desktop): declare katex-memo deps directly + drop per-app lockfile katex-memo.ts (added in112cad59b) imports hast-util-from-html-isomorphic, hast-util-to-text, remark-math, katex, and unist-util-visit-parents but those were never added to apps/desktop/package.json. They were silently resolving via @streamdown/math at the workspace root, which broke the moment `npm i --prefix apps/desktop` ran with the per-workspace lockfile because that install only consults apps/desktop/package.json. Add them as direct deps, plus unified/vfile/@types/hast for the type imports. Also delete apps/desktop/package-lock.json — root package.json declares workspaces: ["apps/*"], so npm manages all lockfile state at the root. The stale per-app lockfile is what made `npm i --prefix apps/desktop` diverge from the workspace install in the first place and left an empty apps/desktop/node_modules/@assistant-ui/ stub that Vite's dep optimizer then tried (and failed) to open at @assistant-ui/core/dist/internal.js. * feat(desktop): disable Backdrop noise overlay by default The noise overlay defaulted to on, which adds a busy speckle layer over the whole window for every new user. Flip the Leva default to off; the toggle stays in Backdrop / Noise for anyone who wants it back. * fix(desktop): polish LaTeX rendering — currency, code blocks, brackets Five distinct bugs surfaced from a math-heavy stress test: 1. Adjacent code fences glued together. scrubBacktickNoise's second-pass regex /``\s*``/g matched the LAST 2 backticks of one fence + whitespace + FIRST 2 backticks of the next, collapsing two blocks into one. Fixed with lookbehind/lookahead so we only match exactly 2 backticks not part of a longer run. 2. Whitespace eaten between fences and following content. stripPreviewTargets internally calls .trim() which strips leading/ trailing whitespace from each split-segment. For segments between two fences this collapsed \n\n to '', gluing fence close to next block. Fixed by capturing leading/trailing whitespace at the call site and restoring it after the transform. 3. Currency dollar signs eaten as math. With singleDollarTextMath:true remark-math greedy-matched any pair of $, so '$5 ... $10' became one inline math span. Added escapeCurrencyDollars to escape $<digit> patterns to \$<digit> in prose segments (not in code). Trade-off: math expressions starting with a digit (rare — '$5x = 10$') get escaped too. Mirrors the convention in ChatGPT/Claude's UIs. 4. \(...\) and \[...\] LaTeX brackets unsupported. Models often emit these instead of $...$ / $$...$$. Added rewriteLatexBracketDelimiters preprocessor pass. 5. ```latex / ```tex blocks were being routed to KaTeX via a rewrite to ```math. Aligns with GitHub markdown convention: ```math = render as math; ```latex / ```tex = LaTeX/TeX source code (syntax highlighted, not rendered). Conflating them broke teaching/showing-source use cases. MATH_FENCE_LANGUAGES pruned to {'math'} only. Also flipped parseIncompleteMarkdown to true (was !isStreaming) so the math parser can't see $ inside streaming-but-not-yet-closed code fences. Shiki was already deferred via defer={isStreaming} so this doesn't introduce new tokenization cost. Test: 18/18 existing tests still pass; one test updated to expect escaped \$ in currency-prose-with-URL case. * fix(desktop): detect Python via registry/filesystem; pin to 3.11–3.13 Two related fixes for Python detection on Windows: 1. py.exe (Python launcher) is missing from per-user installs that didn't check the launcher option, so 'py -3.X --version' alone misses real Python installs. User-reported case: clean Win11 + official Python.org 3.14 install -> 'where py' returned nothing, our installer offered to install Python again. Both NSIS prereq page and main.cjs now probe in this order: 1. py.exe launcher (when present) 2. PEP 514 registry: HKLM/HKCU\SOFTWARE\Python\PythonCore\<v>\InstallPath 3. Filesystem: %ProgramFiles%\Python<v>, %LocalAppData%\Programs\Python\Python<v> Crucially, we never fall back to running 'python.exe' from PATH on Windows — the WindowsApps stub at %LOCALAPPDATA%\Microsoft\ WindowsApps\python.exe is a redirector that opens the Microsoft Store window if no Store Python is installed. Triggering that during boot would be terrible UX. Registry/filesystem probes never execute the binary. 2. Drop 3.14 from the supported version set. Several Hermes deps (notably pywinpty, which carries Rust crates like windows_x86_64_msvc) don't yet publish 3.14 wheels. With wheels missing, 'pip install -e .' falls back to building from sdist, which needs a Rust toolchain — users see 'could not compile windows_x86_64_msvc build script' on first run. install.ps1 sidesteps this by pinning to 3.11 via uv; the desktop installer doesn't yet have the same uv-managed-Python pathway, so for now we accept 3.11/3.12/3.13 and tell winget to install 3.11 if none of those are present. Revisit when the wheel ecosystem catches up to 3.14 (~early 2026). * feat(desktop): Cron, Profiles, usage analytics, and titlebar fixes - Add Cron and Profiles sidebar routes with full CRUD-style flows and API wiring. - Extend Command Center with auxiliary task overrides and a Usage panel (7d/30d/90d). - Fix titlebar geometry for WSL/Windows (native overlay width, tool spacing). - Remove stray merge conflict markers from pyproject.toml optional deps. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(title-bar): position sidebar toggle button * feat(desktop): composer queue — queue many, edit/delete/cancel-edit, Cursor-style Press Enter while busy with a draft to queue it; with no draft to interrupt and send the next queued turn. Auto-drains one queued turn each time the session settles, same as Cursor. Queue persists across reloads so an interrupted-and-queued turn isn't lost on refresh. Each queued row supports edit-in-composer (with explicit Save/Cancel), send-now (↑), and delete. Drain skips only the entry currently being edited so the rest of the queue keeps flowing. Queue dequeue is transactional — an entry only leaves the queue after `prompt.submit` is accepted, so a rejected submit doesn't drop the turn. Also shrinks the `[interrupted]` marker to a muted one-liner and drops its assistant footer so it stops looking like a real reply. * fix(desktop): handle empty usage analytics totals Co-authored-by: Cursor <cursoragent@cursor.com> * fix(desktop): address PR review titlebar and usage races Co-authored-by: Cursor <cursoragent@cursor.com> * feat(desktop): add MCP settings and live subagent tree Surface configured MCP servers in Settings with JSON edit/save and a gateway-backed reload action so users can manage tool servers without falling back to slash commands. Track live subagent gateway events in a desktop store, show active subagent counts in the Agents statusbar item, and replace the Agents overlay stub with a live spawn tree for the active session. * fix(desktop): move power-user views out of sidebar Keep Cron and Profiles available through lower-prominence chrome entry points so the workspace sidebar stays focused on core chat navigation. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(desktop): subagent overlay reads like a live transcript, not a dashboard Strip the card chrome and rewire /agents to feel like peeking into the child agent's stream: - subagents store: single `stream` of typed entries (thinking/tool/progress/ summary) replaces the parallel notes/thinking/tools arrays. Drop unused fields (toolsets, depth, apiCalls, reasoningTokens, sessionId). - agents view: no OverlayCards, no boxed stream, no per-row borders. Goal + status pill + indented stream lines, full row width. - Group root spawns into "Delegation N" sections when batch shape + spawn time match — hides task-index interleaving and makes hierarchy obvious. - Sort tree by spawn time, then task_index. Step indicator is one colored pill (primary while running, emerald when done) inside the row, not a trailing pill that wrapped under the chevron. - Tree picks up `subagent.start` (not only `spawn_requested`) and prunes delegate-tool fallback rows once native subagent events land for the session — fixes duplicate "Delegated task" rows alongside the real ones. * feat(desktop): Esc closes every OverlayView-based overlay Lift the keyboard handler into the shared OverlayView so Agents, Settings, Command Center — and anything we build on top of it later — all dismiss on Esc by default. Nested Radix dialogs stop propagation themselves, so a modal opened inside an overlay (e.g. model picker inside Settings) still closes the modal first, not the overlay underneath. Drop the now-redundant Esc handlers in Settings (kept Cmd/Ctrl+P) and Command Center. * fix(desktop): drop numbered step pill on subagent rows The pill was getting clipped at the overlay edge anyway. Just use the status glyph (●/✓/✗/■/○) — the delegation header already conveys "3 workers, 3 active", and order in the list implies which step you're looking at. * fix(desktop): drop noisy "returned N items / empty object" stub strings When a tool returns nothing useful, the row should be silent — the title ("Search Files", etc.) already tells the user what happened. Counting the fields in an opaque payload is engineer-noise. `formatToolResultSummary` and `minimalValueSummary` now return '' for empty arrays / records / unrecognized values; tool-fallback already hides the detail section when its body is empty. * refactor(desktop): subagent rows borrow chat tool patterns (fade-in, lucide glyphs, shimmer) Pull the agents view closer to how chat tool blocks render: - statusGlyph() returns the same lucide BrailleSpinner / CheckCircle2 / AlertCircle vocabulary as tool-fallback's statusGlyph - Stream lines fade-in via useEnterAnimation (one-shot WAAPI), keyed per entry so streamed deltas settle in instead of popping - Subagent rows fade in too, and pick up the existing data-slot=tool-block spacing rules between blocks - Active stream line trails a BrailleSpinner instead of a hand-rolled pulsing rectangle - Goal text drops FadeText (which forces nowrap); keep FadeText only for the single-line meta subtitle - Running rows shimmer the title — same affordance the chat thinking row uses * refactor(desktop): make /agents subagent-only, drop sidebar + dead sections Activity rail and History stub were both noise. Strip the split layout, sidebar, route enum, and the rail/stub helpers — the overlay is now just the spawn tree, centered in a max-w-3xl column so it stops claiming the whole screen for one section's worth of content. * feat: update cron modals * Add dedicated GUI log stream for dashboard debugging. Capture dashboard and PTY websocket lifecycle failures in gui.log and expose it via hermes logs. * Improve desktop runtime UX by surfacing inference readiness in gateway status and hardening WSL link opening. This also stabilizes markdown code/table block spacing and adds root-install guards so desktop dev runs use a healthy workspace dependency tree. * Log detailed GUI websocket failure metadata. Capture richer reject/disconnect/send/parse context for dashboard gateway websocket flows so GUI connection failures are diagnosable from logs. * Default dashboard startup logging to GUI mode. Detect the dashboard subcommand during early CLI bootstrap so gui.log is attached from process start and GUI startup failures are always captured. * Clean up gateway status conditionals and logging bootstrap mode detection. Simplify nested dashboard gateway status branches for readability and use a concise first-subcommand check when selecting early GUI logging mode. * add logging to nsis installer * feat: glass ui pass * fix(desktop): persist inline assistant errors across hydrate/resume - Detect provider failure text arriving via message.complete (HTTP 4xx, "API call failed after N retries", Provider/Gateway error: ...) and persist as an inline assistant error instead of regular completion text, blocking the hydrate that was wiping it. - preserveLocalAssistantErrors: merge by id so same-id hydrated messages keep their local error, and preserve the optimistic user+error pair as a unit (with tail-user dedupe). - Hook all hydrate/resume writers (use-session-actions resume + fallback, hydrateFromStoredSession, syncSessionStateToView) into the merge so stale snapshots can't clobber a failed turn. - Add error to chatMessagesEquivalent so the resume diff actually sees error-only changes and paints them. - editMessage on a failed turn now submits a plain resend (no truncate_before_user_ordinal) and retries plainly on the "no longer in session history" race. Style polish on touched files: - Inline error: text-only treatment (no card). - User stop / edit-composer send: shared Tabler IconPlayerStopFilled glyph + shared icon-button class slot for parity. * feat(desktop): theme xterm with active light/dark mode The right-sidebar terminal hardcoded a light palette, which read poorly on the dark glass surface. Subscribe to `useTheme().resolvedMode` and hot-swap `term.options.theme` so Shift+X (and any other mode change) updates the terminal in place without tearing down the PTY session. Dark mode uses xterm's built-in defaults (white fg/cursor + vivid ANSI 16) with just a transparent background so the glass shows through; light mode keeps the existing hand-tuned overrides for legibility on a bright surface. * feat(sidebar): right-click + drag-reorder sessions and workspaces - Wire right-click on session rows to open the same actions menu; suppresses the OS-native context menu so Windows stops looking awful. - Share dropdown + context menu items via useSessionActions() driving a single declarative ItemSpec[]; render polymorphic over MenuItem. - New shadcn ContextMenu primitive mirroring DropdownMenu styling. - Restore drag-and-drop reordering for Agents (lost during the cwd cleanup) and add reordering of workspace groups via a right-side grab handle. Pinned reorder unchanged. - Generic orderByIds<T> replaces the duplicated session/group orderers; useSortableBindings() hook collapses the two Sortable wrappers. - cursor-pointer on every actionable element; cursor-grab on handles. - KISS pass: baseName() helper, AGE_TICKS table, single WORKSPACE_PAGE constant, flatter SidebarSessionsSection render. * feat(desktop): solarize the xterm palette in both light & dark xterm's default ANSI 16 is tuned for dark and reads candy-bright on the light glass surface (vivid cyans/greens). Ship the canonical Solarized palette (Schoonover) for both modes — same 16 accents either way, only fg/cursor swap between `base00/01` (light) and `base0/1` (dark), so a prompt's colors look uniform across a Shift+X toggle. Background stays transparent in both modes — Solarized's cream/slate backgrounds would fight the glass. * feat(desktop): virtualize chat thread + sidebar via TanStack Virtual Replaces `use-stick-to-bottom` and per-row session rendering with `@tanstack/react-virtual`, matching what Cursor uses. Chat thread (`thread-virtualizer.tsx`): - Natural-flow virtualization (padding spacers, not absolute items) so `position: sticky` on the human bubble still resolves cleanly against the scroller. - Custom at-bottom anchor: pins when armed, disarms on user-driven upward scroll, re-arms at bottom, jumps on session switch + `thread.runStart`. - Loading indicator and `--thread-last-message-clearance` move to a real `[data-slot=aui_composer-clearance]` node; drops the brittle `:nth-last-child(1 of …)` rule that can't fire reliably under virtualization. Sidebar (`virtual-session-list.tsx`): - Flat agents list virtualizes at >=25 rows; pinned and workspace-grouped paths stay direct-render. - `SortableContext` keeps all IDs; only the window mounts; dnd-kit's `setNodeRef` is merged with `virtualizer.measureElement` so rows participate in both DnD hit-testing and TanStack measurement. Drops `use-stick-to-bottom`. Streaming test gets a global `offsetWidth/offsetHeight` stub so the virtualizer's viewport sizing works in jsdom; the scroll-up-doesn't-pull-back invariant still passes. * feat: more ui qa * fix(desktop): trim sidebar terminal startup spacer Drop zsh's initial spacer row before writing the first terminal prompt so new sidebar terminal sessions do not open with a selectable blank line. * chore: uptick * feat(desktop): thin installer + first-launch install.ps1 bootstrap Converges the Windows packaged desktop installer onto a single canonical install topology: drop the Electron shell only (~80MB instead of ~500MB), clone Hermes Agent at a build-time-pinned commit on first launch via install.ps1's stage protocol, and treat the resulting git checkout at %LOCALAPPDATA%\hermes\hermes-agent\ as the canonical install location (same path the CLI installer uses). Future updates flow through the existing applyUpdates() git-pull path. Replaces the previous fat-installer architecture where the .exe bundled a pre-staged hermes-agent source tree under resources/hermes-agent/ that was then sync'd into ACTIVE_HERMES_ROOT at launch -- a complicated factory-vs-active dance with several footguns (FACTORY_HERMES_ROOT mismatch on path resolve, isGitCheckout guard regressions, pyproject hash drift detection inside the sync loop). Architecture overview --------------------- Build time apps/desktop/scripts/write-build-stamp.cjs writes apps/desktop/build/install-stamp.json with {commit, branch, builtAt, dirty}. Honours $GITHUB_SHA / $GITHUB_REF_NAME in CI, falls back to `git rev-parse HEAD` locally. apps/desktop/scripts/stage-native-deps.cjs copies the runtime subset of @homebridge/node-pty-prebuilt-multiarch from the workspace-root node_modules into apps/desktop/build/native-deps/. Workspace dedup hoists this dep to the root, out of reach of electron-builder's `files:`-restricted collector; staging gives us a deterministic path to extraResources. electron-builder ships both into resources/install-stamp.json and resources/native-deps/ respectively. Boot resolver (electron/main.cjs) Resolver order: 1. HERMES_DESKTOP_HERMES_ROOT override 2. SOURCE_REPO_ROOT (dev mode) 3. ACTIVE_HERMES_ROOT git checkout WITH .hermes-bootstrap-complete marker -- the post-install fast path 4. `hermes` on PATH (CLI-installed user adding the desktop) 5. pip-installed hermes_cli via system Python 6. bootstrap-needed sentinel -> hand off to runBootstrap Deletes the entire FACTORY_HERMES_ROOT / RUNTIME_MARKER / syncTreeExcludingVenv machinery (-200 lines). The isGitCheckout guard that bit us in the install.ps1 PR is gone. First-launch bootstrap (electron/bootstrap-runner.cjs) 1. Resolve install.ps1: prefer SOURCE_REPO_ROOT/scripts (dev), else download from GitHub raw at INSTALL_STAMP.commit (cached at HERMES_HOME\bootstrap-cache\install-<sha>.ps1). 2. Fetch the stage manifest via install.ps1 -Manifest -Commit X -Branch Y. 3. Iterate stages: install.ps1 -Stage <name> -NonInteractive -Json -Commit X -Branch Y per stage. 4. On all stages green: write the .hermes-bootstrap-complete marker with {schemaVersion, pinnedCommit, pinnedBranch, completedAt, desktopVersion}. Per-run log to HERMES_HOME\logs\bootstrap-<ts>.log. Cancellation via AbortSignal. Manifest cache so retries don't re-download. Install overlay (src/components/desktop-install-overlay.tsx) Mounted alongside the existing onboarding overlay; flexbox card with header (static) + middle (scrollable) + footer (failure-only, static). Subscribes to hermes:bootstrap:event IPC + resyncs from hermes:bootstrap:get on mount/reload. Renders: - 14-stage checklist with per-stage state icons - Overall progress bar + current-stage spotlight - Auto-expanded installer-output panel on failure - "Copy output" button (full ring buffer + error to clipboard) - "Reload and retry" wired through hermes:bootstrap:reset to clear main.cjs's latched failure Synthetic empty-manifest event from main.cjs flips the overlay to 'active' immediately so the slow install.ps1 download doesn't leave the user staring at the generic Preparing splash. Failure latching (main.cjs) bootstrapFailure module-scope variable holds the rejection after install.ps1 fails. startHermes() throws the latched error immediately when set, bypassing the entire ensureRuntime + runBootstrap chain. Without this, the renderer's ensureGatewayOpen retries would re-run install.ps1 in a 5-10 min hot loop while the user was still reading the failure overlay. Cleared via hermes:bootstrap:reset on user-driven retry. Unsupported-platform overlay (1F) macOS / Linux packaged builds (no install.sh stage protocol yet) emit an unsupported-platform event with a copy-pasteable install command + docs URL. Dedicated overlay branch with "Copy command" + "I've run it -- retry" buttons. install.ps1 additions (Phase 1F.3 + 1F.5) ----------------------------------------- New -Commit and -Tag string params. Precedence Commit > Tag > Branch. Honoured by all three code paths (update / fresh clone / ZIP fallback), with archive URL selection that handles each ref-type variant. Detached-HEAD checkouts intentionally -- they're pins, not branches the user pulls into. EAP=Continue wrap around the new pin-step git invocations. `git fetch origin <commit>` writes the routine 'From <url>' info line to stderr; under the script's global EAP=Stop that terminates the script even though fetch+checkout succeed. Matches the established pattern in Install-Uv, Test-Python, _Run-NpmInstall. Backend fix (hermes_cli/web_server.py) -------------------------------------- CORS allow_origin_regex now accepts Origin: 'null'. Packaged Electron loads index.html via file://; Chromium sets the WebSocket upgrade Origin header to the opaque origin 'null', which the old regex rejected with HTTP 403 before gateway_ws() ever ran. This failure mode was masked in the older FACTORY_HERMES_ROOT architecture because the resolver often found an existing hermes on PATH with different binding behavior. Security maintained: localhost-only bind keeps cross-machine pages out; per-process session token still gates every authenticated /api/ endpoint regardless of Origin. Desktop QoL ----------- DevTools is now enabled in packaged builds (F12 / Cmd+Opt+I). Field-debugging trade-off: tiny attack surface increase versus a much better support story when CSP / WS / theme issues surface. NSIS prereq-check page deleted (-767 lines). The standard Welcome -> License -> Directory -> InstallFiles -> Finish wizard now installs without custom Python/Git/ripgrep detection -- those prereqs are install.ps1's job at first launch. Test infrastructure (Phase 1G) ------------------------------ apps/desktop/scripts/test-desktop.mjs rewritten as a cross-platform bundle validator (was darwin-only and asserted on dead factory- payload paths): NEGATIVE: hermes_cli/main.py is NOT shipped (regression guard) POSITIVE: install-stamp.json carries a real commit + branch POSITIVE: node-pty native deps shipped under resources/native-deps POSITIVE: renderer dist/index.html reachable (asar or unpacked) New nsis mode and npm run test:desktop:nsis script. Validated end-to-end on clean Win10 VM -------------------------------------- Confirmed: NSIS installer drops Electron shell, app launches, install overlay shows progress, install.ps1 clones the pinned commit, 14 stages run to completion, marker written, backend spawns, WebSocket connects, onboarding overlay asks for API key, main UI loads, integrated terminal works. Failures handled: bootstrap stays failed (no hot-loop retry), "Copy output" gives actionable transcript, "Reload and retry" explicitly re-runs install.ps1. What's deferred --------------- - MSIX wrapping (Phase 2): same Electron .exe under MSIX manifest with runFullTrust, signed and submitted to Microsoft Store. - install.sh stage protocol parity (Phase 2): once shipped, the unsupported-platform overlay becomes drive-it-yourself and macOS/Linux packaged installers gain feature parity with Windows. * feat(desktop): persistent terminal pane + fullscreen takeover Adds a VSCode-style "focus terminal" toggle to the right sidebar's Terminal tab that takes over the chat pane area without unmounting the shell. The xterm host is mounted once at the layout root and CSS-overlayed onto whichever <TerminalSlot /> is currently active, so the PTY session, scrollback, selection, focus, and WebGL renderer survive every toggle. Also: - WebGL renderer (matching dashboard ChatPage) so Hermes' TUI skins paint faithfully instead of muting through xterm's default DOM renderer - File drag/drop from the project tree or OS into xterm — paths are shell-quoted (zsh/bash/pwsh/cmd) and written straight into the PTY - Solarized dark canvas with brights promoted to real accent variants (Schoonover's UI-gray brights washed out every TUI accent) - Strip NO_COLOR/FORCE_COLOR/COLORFGBG/TERM=dumb leaking from non-tty parents (CI runners, Cursor's agent shell) so the embedded shell gets truecolor regardless of how Electron was launched - rAF-debounced ResizeObserver — running fit.fit() synchronously during sibling pane transitions crashed the WebGL texture-atlas rebuild * fix(install.ps1): strip UTF-8 BOM regression that broke 'irm | iex' The canonical install flow irm https://raw.githubusercontent.com/.../scripts/install.ps1 | iex fails on PowerShell 5.1 with a cascade of 'The assignment expression is not valid' errors at every param() default value: [string]$Branch = 'main', ~~~~~~ The assignment expression is not valid. The input to an assignment operator must be an object that is able to accept assignments... Root cause: scripts/install.ps1 carries a UTF-8 BOM (0xEF 0xBB 0xBF) as its first three bytes. 'irm' returns the response body as a string; on PS 5.1 the BOM survives into that string as a leading \ufeff character. 'iex' then evaluates the string and PS's parser chokes on the invisible character before param() -- error recovery proceeds into the body but every assignment is reported as broken. This was the exact failure mode the install.ps1 hardening pass (PR #27224) deliberately fixed by stripping the BOM and ensuring the file body is pure ASCII. Commit4279da4db('fix(windows): make PowerShell installer parse in 5.1') re-introduced the BOM later, unintentionally undoing the irm|iex compatibility fix; the merge that brought it into bb/gui carried it forward. Fix: strip the three BOM bytes. File body is verified pure ASCII (any-byte > 127 returns false), so PS 5.1 with no BOM falls back to Windows-1252 decoding which is identical to ASCII for our content. Both install paths now work: - 'irm ... | iex' (canonical CLI) - 'powershell -File install.ps1' (programmatic / desktop bootstrap) * install.ps1: detect ARM64 Windows reliably for Node and Git stages Add a Get-WindowsArch helper that reads Win32_Processor.Architecture via CIM (invariant to PowerShell host bitness) with PROCESSOR_ARCHITEW6432 fallback. Use it in: - Install-Git: previously only triggered the arm64 PortableGit asset when invoked from a native-ARM64 PowerShell host. WoW64 / emulated x64 hosts (the default powershell.exe on Windows-on-ARM) saw PROCESSOR_ARCHITECTURE=AMD64 and fell through to the x64 PortableGit build, leaving ARM64 users on emulated Git for Windows. - Test-Node: previously hardcoded the Node download to win-x64 on any 64-bit OS, so ARM64 users always got x64 Node under Prism emulation even though Node ships an arm64 build for Windows. The winget fallback now also passes --architecture arm64 on ARM64. Python remains x86_64 by design: uv intentionally prefers windows-x86_64 cpython on ARM64 hosts for ecosystem (wheel) compatibility (see astral-sh/uv#19015). * install.ps1: harden Install-SystemPackages against winget msstore failures The previous winget invocation discarded stdout/stderr and trusted no signal at all -- not the exit code (winget exits 0 even when it bails "please specify --source"), not output (sent to Out-Null), not the catch handler (winget returning 0 means no exception fires). The only trust signal was a post-install Get-Command rg / Get-Command ffmpeg check, which would also miss the package because %LOCALAPPDATA%\ Microsoft\WinGet\Links (where winget puts command aliases) is added to PATH by AppExecutionAlias machinery only in fresh shells. End result on machines where the msstore source has a cert problem (0x8a15005e -- common on Windows-on-ARM and some corporate networks): silent failure, no log, no breadcrumb, and the user is told the install succeeded. Specifically: - Pin --source winget on every winget install call. Defeats the broken- msstore-source path. We ship nothing from msstore so this is safe and forward-compatible. - Add --exact --id for a tighter package match. - Capture each winget invocation's combined stdout/stderr + exit code to %TEMP%\hermes-winget-<pkg>-<n>.log instead of Out-Null. On the happy path the log is deleted after the post-install check confirms the binary is on PATH; on failure the log is kept and its path is named in a Write-Warn so the user has something to grep. - Refresh PATH to include %LOCALAPPDATA%\Microsoft\WinGet\Links in addition to the User/Machine env-var hives, so Get-Command sees newly- installed winget aliases in the same process. - No behavior change on the happy path. Same Write-Info/Success/Warn cadence, same fallback order (winget -> choco -> scoop -> manual), same $script:HasRipgrep / $script:HasFfmpeg outputs. Verified end-to-end on a real Snapdragon ARM64 Windows host: ripgrep uninstalled, stage re-run, [OK] ripgrep installed in 1.4s, ok:true. * desktop: swap node-pty fork for upstream microsoft/node-pty 1.1.0 The previous dependency, @homebridge/node-pty-prebuilt-multiarch@0.13.1, publishes no win32-arm64 prebuilds on its v0.13.x line, and its v0.14.x betas (which do add an arm64 Windows build) ship no electron-vXXX-win32- arm64 prebuilds at all -- so packaged Electron 40 builds (NMV 143) would fail at runtime even on a successful npm install. Net effect: the desktop's integrated terminal was unbuildable on Windows-on-ARM, in both dev (npm install fails: 404 fetching the node-vXXX-win32-arm64 prebuilt) and packaged builds (no Electron-ABI prebuilt exists). The homebridge fork was originally created because upstream node-pty shipped no prebuilds at all. That hasn't been true since node-pty@1.0 (April 2024), which: - bundles prebuilts for mac (arm64+x64) and Windows (arm64+x64) directly inside the npm tarball -- no GitHub-Releases fetch, no missing-binary failure mode - uses N-API (node-addon-api) for ABI stability across Node and Electron major versions, so the same pty.node binary loads under Node 22 (dev) and Electron 40+ (packaged) without per-ABI rebuilds - is what VS Code, Hyper, and Theia actually ship API surface is identical (spawn / onData / onExit / write / resize / kill) -- no call-site changes needed. Specifically: - apps/desktop/package.json: replace the @homebridge fork with node-pty@1.1.0 (exact pin). Widen `asarUnpack` from `["**/*.node"]` to also unpack `**/prebuilds/**`, because node-pty ships runtime- execed helpers alongside its .node files (darwin spawn-helper has no extension and would not be matched by `**/*.node`; conpty.dll, OpenConsole.exe, winpty.dll, winpty-agent.exe on Windows are also exec'd at runtime and cannot live inside asar). - apps/desktop/electron/main.cjs: update both require() strings to match the new package name and the new staged path under resources/native-deps/node-pty/. - apps/desktop/scripts/stage-native-deps.cjs: point at node_modules/ node-pty. node-pty's prebuilts live under prebuilds/<plat>-<arch>/ (not build/Release/), so update the include glob to copy that dir. Per-arch staging keeps the resource bundle small (target arch comes from npm_config_arch when electron-builder cross-builds, else process.arch). Explicitly enumerate file types in the prebuilds glob so the ~25 MB of .pdb debug symbols that prebuild-install bundles for Windows crash analysis don't bloat the installer (29 MB -> 2.6 MB staged on win32-arm64). Re-assert +x on the darwin spawn-helper defensively, since a stripped mode bit would manifest as a silent ENOENT at first pty.spawn(). - apps/desktop/scripts/test-desktop.mjs: update expectedNativeDepPaths() and its assertion site to look at prebuilds/<plat>-<arch>/ instead of build/Release/. Add an explicit spawn-helper-exists check on darwin so a regression in the asarUnpack glob would fail loudly in CI rather than at first PTY spawn. Trade-off: Linux end-users lose prebuilts and fall back to building node-pty from source on `npm install`. Acceptable because Hermes ships no Linux desktop builds (desktop-release.yml matrix is mac + win only, package.json declares no `linux` target), and Linux developers hacking on the desktop already need a C++ toolchain for the rest of the stack. Verified on Windows 11 ARM64 (Snapdragon): npm install -> exit 0 node -e "require('node-pty').spawn(...)" round-trip -> OK stage-native-deps -> 27 files, 2.6 MB load from staged tree (simulates packaged fallback) -> ConPTY round-trip OK * desktop+gateway: harden Slack socket recovery and Windows restart dedupe (#28873) * desktop+gateway: harden Slack socket recovery and Windows restart dedupe Fix Slack Socket Mode reliability by adding a watchdog/reconnect path so silent socket task drops no longer leave the adapter stuck. Harden Windows gateway lifecycle by avoiding desktop-binary path collisions, making gateway PID scans case/extension tolerant, and reusing in-flight restart actions to prevent duplicate gateway spawns. * test(slack): add Socket Mode watchdog/reconnect behavioural coverage Drive the new Slack Socket Mode self-healing logic through a fake AsyncSocketModeHandler so we can simulate the P0 silent-hang failure mode (task exit, transport disconnected, intentional shutdown, concurrent reconnect attempts) without touching real Slack. * fix(slack,desktop): address Copilot review on watchdog races and path normalization - connect(): explicitly cancel + await the prior socket watchdog before flipping _running, so an old monitor cannot exit between teardown and respawn (Copilot #1) - _socket_watchdog_loop: wrap the body in try/except + add a done-callback that respawns on unexpected crash, so a transient bug cannot permanently disable self-healing (Copilot #2) - normalizeExecutablePathForCompare: use the resolved path for realpathSync so non-string inputs cannot leak through (Copilot #3) - Add tests for crash-recovery and atomic watchdog replacement across reconnects * fix(slack): tighten connect() error path and clarify watchdog test intent Address Copilot review round 2. - connect(): wrap _start_socket_mode_handler/_ensure_socket_watchdog in a focused try/except so any failure rolls back partially-started handler/task state and leaves _running=False, ensuring the platform lock is always released by the outer finally - Defer _running=True until after the handler is actually started so the watchdog observes a live socket task immediately and never spins against a half-built adapter - Rename test_watchdog_self_restarts_after_unexpected_crash to test_watchdog_cancellation_does_not_respawn (matches what it actually asserts) and add test_watchdog_unexpected_exit_respawns_via_done_callback that drives a real RuntimeError through _on_socket_watchdog_done and verifies a fresh task replaces the crashed one * fix(web_server): serialize action spawn check+store under a threading lock Address Copilot review round 3. FastAPI runs sync handlers on its threadpool, so two near-simultaneous /api/gateway/restart (or /api/hermes/update) requests could both observe "no live process" in _spawn_hermes_action's poll-based dedupe and double-spawn. Add a module-level _ACTION_SPAWN_LOCK around the entire check + Popen + _ACTION_PROCS store sequence so the dedupe is atomic across threads. * fix: address Copilot review round 4 - slack.disconnect(): mirror connect()'s defensive cleanup — catch the broad Exception path on watchdog await so handler shutdown and lock release still run if the watchdog raised before cancellation took effect - web_server._spawn_hermes_action: wrap subprocess.Popen in try/except so a missing executable / permission error closes the log file handle, writes a failure marker, and re-raises instead of leaking a file descriptor - gateway._scan_gateway_pids: drop the over-broad "hermes.exe --profile" / "hermes.exe -p" patterns that would match any Hermes CLI subcommand using a profile flag (e.g. `hermes.exe --profile foo dashboard`); rely on the "hermes.exe gateway" + "hermes-gateway.exe" tokens instead - tests: tighten _fake_create_task to assert coroutine input and return a real asyncio.Task that stays pending until pytest teardown, and update the three callsites whose mocked AsyncSocketModeHandler.start_async returned a non-coroutine value * fix(slack): reset multi-workspace state on reconnect Address Copilot review round 5. connect() is reentrant (gateway restart, in-process reconnect), but it was leaving _bot_user_id / _team_clients / _team_bot_user_ids populated from the previous session. A reconnect that rotated the primary token or dropped a workspace would silently keep the stale bot user id and stale workspace client maps, leading to dispatch against gone workspaces. Clear these three pieces of state right after _stop_socket_mode_handler() and before the auth_test loop, then let the loop repopulate from the current tokens. Add test_reconnect_refreshes_multi_workspace_state to lock it in. * nix: package apps/desktop as .#desktop (#28964) Adds nix/desktop.nix building the Electron renderer with buildNpmPackage and wrapping nixpkgs' electron binary. Reuses .#default by setting HERMES_DESKTOP_HERMES to its hermes binary, so the desktop's resolver picks up the fully-wired nix hermes (venv, bundled skills/plugins, runtime PATH) without reimplementing agent resolution. - nix/desktop.nix: renderer + electron wrapper - nix/hermes-agent.nix: finalAttrs form, exposes hermesDesktop in passthru - nix/packages.nix: exposes .#desktop + adds to fix-lockfiles - apps/desktop/package-lock.json: standalone hermetic lockfile nix build .#desktop && nix run .#desktop both clean. * fix(desktop): probe steps 4 & 5 of resolveHermesBackend before trusting A user-reported failure on Windows-on-ARM: a pre-installed Python 3.13 on PATH makes findSystemPython() succeed, so resolveHermesBackend returns a backend pointing at it -- but hermes_cli isn't in that interpreter's site-packages. The spawn dies with ModuleNotFoundError and the user sees a dead GUI instead of the first-launch installer. Same shape can hit step 4 (existing `hermes` on PATH) when a stale shim survives a partial uninstall. Add cheap exit-code probes -- `python -c "import hermes_cli"` for step 5, `<hermes> --version` for step 4 -- and fall through to step 6 (bootstrap-needed) on failure. install.ps1 then runs as if on a clean box and the venv gets built. Probes live in a standalone electron/backend-probes.cjs module so they can be unit-tested with node --test, same pattern as bootstrap-platform.cjs and hardening.cjs. New test file wired into test:desktop:platforms. * test(desktop): allow `node-pty` bare-require in packaged entrypoints Pre-existing failure on bb/gui sincec858484b4swapped the node-pty fork for upstream microsoft/node-pty 1.1.0. main.cjs intentionally bare-requires node-pty (it's hoisted by workspace dedup in dev, and staged to resources/native-deps via scripts/stage-native-deps.cjs + extraResources for packaged builds, with a try/catch fallback at line ~38). The allowlist hadn't been updated to match -- same shape as `electron`, which was already allowed. * chore(deps): refresh root lockfile for dashboard @nous-research/ui 0.14.0 apps/dashboard/package.json was bumped to @nous-research/ui 0.14.0 (+ flag-icons ^7.5.0, motion ^12.38.0) but the root package-lock.json was never refreshed. Running `npm install` from the repo root now materialises 0.14.0's transitive closure (launder, bumps for @nanostores/react, nanostores, sanitize-html, tailwind-merge). No code changes; purely a lockfile catch-up so fresh checkouts on bb/gui get a working dashboard install. * chore(desktop): bump version to 0.0.1 First non-placeholder version so electron-builder's artifactName template produces `Hermes-0.0.1-win-x64.exe` instead of the obviously-unreleased `Hermes-0.0.0-...`. No release process yet; this just stops the artifact filename from telling users "you got a debug build." Bumped in three slots that all carry the desktop app's version: - apps/desktop/package.json (source of truth) - apps/desktop/package-lock.json (per-app lockfile, kept for CI parity) - root package-lock.json's apps/desktop workspace entry Identity-of-build for first-launch bootstrap continues to come from build/install-stamp.json (commit SHA + builtAt), unchanged. * fix: fs icon color * perf(desktop): cut per-keystroke layout + listener churn in chat composer Empirical work via CDP harnesses under apps/desktop/scripts/ (see profile-typing-lag.md): jsListeners growth (per round of 200 chars + GC): before: +35 (verified leak — listeners stuck after 1st trigger popover use) after: +0 Four narrow edits in src/app/chat/composer/index.tsx: 1. Drop the per-keystroke `editorRef.current.scrollHeight` read used to decide composer expansion. Replace with `draft.length > 60` heuristic; the existing ResizeObserver still catches edge cases. `scrollHeight` is a forced-layout call and was firing on every char until the first wrap. 2. Bucket measured composer height to 8px before writing `--composer-measured-height` / `--composer-surface-measured-height` on `documentElement`. Without this, the editor grows ~1px per char, setProperty fires every keystroke, computed style is invalidated tree- wide. 3. Remove the dead `$composerDraft` two-way sync. Nothing outside the composer subscribed to that atom (verified via grep). Two useEffects on `[draft]` were pushing draft→atom and atom→aui per keystroke for no consumer. Also drop the per-keystroke `reconcileComposerTerminalSelections` call; it was pruning stale labels for `terminalContextBlocksFromDraft`, but that helper already ignores labels not in the current submitted text, so pruning per keystroke was just bookkeeping. 4. `refreshTrigger` fast-bails when the draft contains neither `@` nor `/`. Previously `textBeforeCaret(editor)` ran on every input/keyup regardless; `range.toString()` inside is O(n) over draft length. Synthetic typing latency p50/p90/p99 is similar before vs after on a freshly-loaded session (Blink can already handle ~30cps typing into a contentEditable on its own); the real win is the listener leak being gone and the global computed-style invalidations dropping ~8× when the composer is sitting at a fixed height row. The `Enter → stall` follow-up (see profile-typing-lag.md §"Submit / TTFT stall") is unmeasured here — needs a throwaway session because the harness fires a real prompt. Not blocking this commit. * perf(desktop): cut FadeText forced layouts during streaming The slowest user-felt path is typing into the composer while the assistant is streaming. Profile (scripts/profile-under-stream.mjs): FadeText measureOverflow self time: 35.8 ms → 18.1 ms (-50%) total active CPU during 7s window: ~150 ms → ~50 ms Two changes in src/components/ui/fade-text.tsx: 1. Drop the `useEffect([children])` that re-ran `measureOverflow` (reads scrollWidth + clientWidth — forced layout) on every parent re-render. `useResizeObserver` already fires the same callback on mount and whenever the host span's box size changes; that covers the only case where overflow state can legitimately change. The previous explicit useEffect was a forced-layout flush on every parent render, which during streaming meant every token tick. 2. Wrap the component in `memo` with a custom comparator that short-circuits the entire render when scalar string `children` and the className/fadeWidth/style props are unchanged. The hot path was tool-fallback's title chips being re-rendered by parent streaming updates even though their text was stable; memo+ comparator skips that. Also adds two harness scripts under apps/desktop/scripts/: - latency-under-stream.mjs (key→paint latency while a turn streams) - profile-under-stream.mjs (CPU profile while a turn streams) Updates profile-typing-lag.md with the streaming numbers and confirms the Enter→paint submit path is already fast (≤320ms on the populated session; the 2s "stall after Enter" the user noticed once was a one-time cold-start, not reproducible at the UI layer). I'd guess the felt jank in real use is fast-burst typing during a long-form streaming reply (code blocks + markdown lists multiply the per-token render cost). The CPU savings here scale linearly with token volume. * chore(desktop): drop diag scratch scripts no longer needed * docs(desktop): correct leak-typing numbers on a real session Re-ran the leak harness on a populated session (Phaser thread) for both unpatched and patched builds. The original 'listener leak' was transient warm-up cost, not a steady-state leak — both versions show 0 listener growth/round in steady state. The load-bearing number is forced layouts per character: unpatched (HEAD~2): 7.02 layouts/char patched (HEAD): 2.35 layouts/char (3× fewer) The patches reduce per-char forced-layout work to Blink's natural floor. Document node count and heap are flat in both builds. * perf(desktop): fix "Enter jumps up" on long threads User reported: after pressing Enter on a long thread, the view jumps up — the just-submitted message disappears below the fold. Confirmed via apps/desktop/scripts/measure-jump.mjs: before: distFromBottom 0 → 49.5px, sticks there permanently after: distFromBottom 0 → ~0 (worst case 4px for one frame) Root cause in useThreadScrollAnchor (thread-virtualizer.tsx): 1. The sticky-bottom logic disarmed on any scroll event where `scrollTop < lastTopRef.current`. That check can't distinguish a user scrolling up from a programmatic `pinToBottom` write that the browser clamped short of bottom (because content also grew in the same frame, so `scrollTop = scrollHeight` lands at `scrollHeight - clientHeight` for the OLD scrollHeight, which is now below the NEW scrollHeight). Result: sticky-bottom disarmed permanently on the user's first submit. 2. There was no synchronous pin tied to React's commit phase. By the time the ResizeObserver fired and re-pinned, the user had already seen ~50ms of "message below the fold" — visually that reads as the view jumping up. Fix: - `programmaticScrollPendingRef` counter tracks scroll events we expect to be ours (one per `pinToBottom` write). The scroll handler skips the disarm check when consuming a pending tick, keeps the arm bit true, and re-pins synchronously if the browser clamped us short of bottom. A depth cap (8) breaks runaway loops in pathological streaming-burst layouts. - `useLayoutEffect` on `groupCount` increase pins BEFORE the browser paints, eliminating the visible ~50ms window between optimistic user-message insert and the RO/scroll-event chain firing. Verified on the long Cloud Shadows thread (7-8 turns, ~11k px tall): all three repro runs now hold within 0–4 px of bottom across the post-Enter transition. Submit latency unchanged (paint 77–107 ms), streaming-typing latency unchanged. Also adds three debug harnesses: - measure-jump.mjs — sample thread scroll across Enter - probe-thread.mjs — dump current thread / scroll state - diag-jump.mjs — intercept scrollTop + RO + mutations across Enter * perf(desktop): rate-limit thread auto-pin during streaming Follow-up to the Enter-jump fix. The first version did a synchronous re-pin loop inside the on-scroll handler when the browser clamped our `scrollTop = scrollHeight` write short of the new bottom; that gave a tight 4 px visible jump on Enter, but during streaming the ResizeObserver fires many times per second as content grows, and each RO callback re-entered the pin loop. CPU profile showed `Virtualizer.getMaxScrollOffset` climbing to 22 ms self over a typing- during-streaming window — the sync re-pin path was paying tanstack- virtual's recompute cost ~3× per token. Re-architect: - RO callback coalesces to one pin per animation frame. Streaming-rate RO bursts now cost the same as a single per-frame pin. - The on-scroll programmatic-counter guard remains (it's what prevents the false-disarm bug when the browser clamps a write). It no longer does sync re-pins; the next RO/rAF will catch up. - The useLayoutEffect on groupCount (the path that fires on user submit / new turn arrival) ALSO schedules one rAF pin in addition to the synchronous pin. This catches the case where React mounts the new message in a second commit (after our layout effect ran), which grows scrollHeight again. Two pins instead of a tight loop, paid only once per turn change. Net effect on the Cloud Shadows long thread: enter-jump transient: 12–20 px for 1 frame (was 49 px permanent) CPU during stream+type: `getMaxScrollOffset` dropped out of top-5 self-time list typing-during-stream: p50 ~10 ms paint, p99 ~20 ms (1 frame), occasional 40 ms+ outliers during burst token arrivals Also adds scripts/profile-long-stream.mjs: 20-second streaming profile with per-500ms FPS histogram + content-length tracking, so we can see whether streaming render cost grows with message length (it doesn't — sustained 60 fps). * perf(desktop): use textContent for trigger precondition Replace composerPlainText() call inside refreshTrigger's no-trigger fast-bail with a textContent check. textContent is a browser-native flat traversal; composerPlainText walks recursively with chip-aware logic. We only need to know if @ or / appears; either way the trigger char will be in textContent because chips contain @ in their refText. Profile shows composerPlainText was ~18ms self over a 12s typing-during- stream window, called from refreshTrigger on every keystroke. Most of that was the precondition check (the trigger detection path is the slow path but only runs when a trigger char is present). * Revert "perf(desktop): use textContent for trigger precondition" This reverts commita6a78ff08a. * Revert "perf(desktop): cut FadeText forced layouts during streaming" This reverts commit88e7d7537c. * Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer" This reverts commitbff1b3261d. * Revert "Revert "perf(desktop): cut per-keystroke layout + listener churn in chat composer"" This reverts commitb7b378e3a4. * Revert "Revert "perf(desktop): use textContent for trigger precondition"" This reverts commit0739588f48. * chore(desktop): synthetic-stream perf harness + scripts Drops the React `<Profiler>` approach (no-op because Vite is currently serving the production React build) in favor of an externally-observable measurement stack: rAF frame intervals, `PerformanceObserver({entryTypes: ['longtask']})`, and a `MutationObserver` on the live streaming message. Adds a synthetic stream driver — `window.__PERF_DRIVE__.stream({...})` — that pushes tokens through the live `$messages` atom at a controlled rate, so the assistant-ui runtime, incremental repository, and Streamdown markdown pipeline see the same workload they'd see during a real LLM stream, without the LLM cost. The driver lives in `src/app/chat/perf-probe.tsx`; `main.tsx` side-imports it under `import.meta.env.MODE !== 'production'` so it tree-shakes out of prod builds. (Using `MODE` rather than `DEV` because our Vite setup currently reports `DEV=false` even under `vite dev` — see the dev-build note in `profile-typing-lag.md`.) Scripts: - measure-synthetic-stream.mjs drive synthetic + record frame/longtask/mutation - profile-synth-stream.mjs CPU profile + top self-time during synthetic - measure-real-stream.mjs same harness, real LLM stream - profile-real-stream.mjs CPU profile bracketing the real stream window - eval.mjs / reload.mjs small CDP helpers A real-LLM measurement on Cloud Shadows (gpt-4o-mini, 39 s window) showed 12 longtasks in the same 75-127 ms range the synthetic predicted, so the synthetic is a faithful proxy. * perf(desktop): memo FadeText so it skips re-renders when text unchanged FadeText is used 110+ times inside `tool-fallback.tsx` on a tool-heavy thread. During streaming each parent re-render previously triggered the component's `useEffect([children])`, which forced a `scrollWidth` layout read even when the title text was unchanged. The `useResizeObserver` was already covering the genuine resize case, so that effect was strictly redundant work. Drops the effect and wraps the component in `React.memo` with a custom comparator that field-compares `className`, `fadeWidth`, and `style`, plus identity-compares `children` (scalar fast-path; correct for JSX nodes too since a new node should force a re-render). Verified via temporary render counter on the 34 MB `session_20260514_215353_fe0ac8` thread (110 FadeText instances): a 2 s synthetic stream went from ~11k FadeText render calls to 122 — roughly one render per truly-new instance instead of one per parent commit per instance. Doesn't move the longtask needle on its own (Streamdown's markdown re-parse dwarfs it) but eliminates a steady CPU floor and a class of forced layouts during streaming. Profile-typing-lag.md documents the full investigation, including the remaining Streamdown cost as the real source of the perceived "5 fps moment" hitches. * perf(desktop): memoize MarkdownText plugins to stop churning Streamdown The inline `plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}` on `<StreamdownTextPrimitive>` constructed a new object literal on every parent render. That broke `<Streamdown>`'s outer memo and forced its internal `rehypePlugins` / `remarkPlugins` array useMemos to rebuild, which propagates a new identity into every `<Block>` and defeats Block's memoization for stable historical blocks. After memoizing on `[isStreaming]` (the only real dimension of variance), CPU profile during a 5 s synthetic stream on the 34 MB session shows `parser` self-time dropping out of the top 10, `compile` cut roughly in half, and `bn$1` / `m$1` (micromark internals) leaving the top entries. Doesn't move the visible longtask count on its own — Streamdown's per-Block parse cost still dominates whenever the last block's content changes — but it removes a class of unnecessary re-parses for historical blocks during streaming. See `scripts/profile-typing-lag.md` for the full investigation. * perf(desktop): floor assistant-text flush gap to 33ms for predictable batching `scheduleDeltaFlush` previously coalesced via `requestAnimationFrame` only. The "at most one flush per frame" guarantee that gives you is fine for fast streams (>~80 tok/sec) where multiple tokens arrive within a single frame, but breaks down at typical LLM token rates (30-80 tok/sec) where each token arrives slower than the rAF cadence and triggers its own React commit + Streamdown markdown re-parse. Track `lastFlushAt` and require at least 33 ms between two flushes. React 18+ auto-batching probabilistically already collapsed some of these, but the floor makes it deterministic. A/B on the 34 MB session, 300 tokens at 50 tok/sec (markdown chunks): | | avgFps | p99 frame | LTs / 5 s | max LT | |---|---|---|---|---| | no floor (current rAF) | 54.0 | 38 ms | 2.0 | 145 ms | | 33 ms floor (this PR) | 54.3 | 41 ms | 1.7 | 110 ms | `inter-mutation` p50 also tightens from 22-28 ms to a clean 33 ms, which is the expected signature of a deterministic floor. Doesn't fully solve the user's perceived hitches — Streamdown's per-Block parse cost when the last block grows past ~2 k chars is still the elephant — but it consistently shaves the worst-case longtask and makes the streaming cadence visibly steadier. Also threads a matching `flushMinMs` option through the synthetic stream driver in `perf-probe.tsx` + `scripts/measure-synthetic-stream.mjs` so the harness can A/B both regimes without spending LLM credits. See `scripts/profile-typing-lag.md` for the full investigation. * perf(desktop): useDeferredValue for streaming markdown so parses don't block input Streamdown's per-Block parse cost grows with the live tail's length and is unavoidable inside the block-memo pattern (industry standard, see findings doc). The fix is to stop having that work block the main thread. `<DeferStreamingText>` is a 12-line wrapper that reads message-part state via `useMessagePartText`, runs it through `useDeferredValue`, and re-publishes via assistant-ui's `<TextMessagePartProvider>`. The inner `<StreamdownTextPrimitive>` reads the deferred value through the normal `useMessagePartText` hook — no fork, no internal-path imports, fully on assistant-ui's public API. React's concurrent scheduler then: - abandons in-flight deferred renders when a newer token arrives, so intermediate states get skipped under fast streams - deprioritises the markdown render when the main thread has urgent work (typing, scroll), so input stays responsive even while a 100ms parse is queued Streamdown already uses `useTransition` for its block-array setState; this lifts the deferral up to the consumer boundary so it covers the whole pipeline (preprocess → split → repair → parse → render). A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks (four trials each, with the 33ms flush throttle on for both): | | avgFps | p99 frame | LTs/5s | max LT | typing-while-stream p95 | |---|---|---|---|---|---| | pre | 54.3 | 41 ms | 1.7 | 110 ms | ~17 ms | | post | 58.5 | 31 ms | 2.0 | 117 ms | 14-18 ms | Longtask count + max LT unchanged — useDeferredValue doesn't reduce CPU, only its priority. The avgFps lift and p99 frame drop are the proof that the existing CPU is no longer blocking 60 fps cadence. One clean run logged MUTATIONS=0 — React skipped every intermediate text state and only committed the final one (textbook deferred-value behaviour). The actually-reduce-CPU path is replacing the parser with a state machine like Flowdown — left for a future PR; see `apps/desktop/scripts/profile-typing-lag.md` for the full investigation. * feat(desktop): add hermes gui launcher * feat(desktop): launch packaged gui builds by default * bump gui version to 0.0.2 * fix(dashboard): allow file:// origin on loopback WS + diagnostic logging Upstream commit2e66eefbc("fix(dashboard): validate WebSocket Host and Origin") added a WebSocket Host/Origin guard to block DNS rebinding against the dashboard. The guard rejects any Origin whose scheme is not http/https or whose netloc is empty — which includes Electron's renderer Origin: file:// when the desktop app loads its bundle from disk in production mode. That makes the bb/gui Electron desktop unable to open the gateway WebSocket against the embedded backend on Windows / macOS prod builds. The renderer reports "Desktop boot failed" and the backend logs: WARNING hermes_cli.web_server: gateway-ws reject peer=127.0.0.1:NNNN reason=non_loopback_or_bad_origin bound_host=127.0.0.1 close_code=4403 DNS-rebinding requires a DNS-resolvable hostname; file:// has no host component and therefore cannot be the attack vector this guard exists to block. When bound to a loopback interface (127.0.0.1 / ::1 / localhost), accept file:// origins so desktop wrappers can attach. Non-loopback binds (operator opted into network exposure) keep rejecting file:// — the loose policy doesn't apply. Also adds per-reason diagnostic logging in _ws_host_origin_is_allowed, so future ws-guard rejections name the specific clause that fired (bad_host / bad_origin_scheme / origin_host_mismatch) instead of the opaque "non_loopback_or_bad_origin" surfaced at the call site. Verified against tests/hermes_cli/test_web_server_host_header.py (all 11 upstream tests still pass) and hand-tested by opening the bb/gui Electron desktop dev build against the patched backend. * fix(tui_gateway): restore _content_display_text helper Bb/gui had dropped the helper but the orchestrator code merged from main still calls it (_inflight_text, _message_preview). Re-add the definition verbatim from main so session.create / _start_inflight_turn don't crash with NameError on first prompt submit. * fix(tui-gateway): restore _content_display_text helper lost in main merge The May 27 merge of origin/main into bb/gui re-introduced two callers of _content_display_text (in _inflight_text and _history_to_messages) but dropped the helper definition itself, leaving an unresolved reference. NameError fires on every user message via _start_inflight_turn -> _inflight_text, taking down both the TUI and the desktop (which share this gateway backend) the moment input is dispatched. Restores the helper verbatim from main (commit36c99af37) -- pure structured-content text extractor, no other dependencies. * fix(telegram): import Set for _dm_topic_chat_ids annotation self._dm_topic_chat_ids: Set[str] = {...} at line 460 references Set but only Dict, List, Optional, Any are imported from typing. The file has no 'from __future__ import annotations', so the annotation is evaluated at runtime and raises NameError on TelegramAdapter construction. * fix(setup): drop shadowing inner importlib.util re-imports _print_setup_summary and _setup_tts_provider each had 'import importlib.util' inside a try: block nested deeper in the function body. Python flips importlib to function-local for the whole scope, so earlier references in the same function (the neutts branches at lines 493 / 1109) hit UnboundLocalError before the late import can run. The top-of-module 'import importlib.util' at line 14 already covers both call sites, so dropping the redundant inner imports restores the intended behavior. * feat(install.ps1): add -IncludeDesktop switch + Stage-Desktop The new Hermes-Setup.exe (Tauri bootstrap installer) passes -IncludeDesktop so users who install via the GUI end up with a launchable Hermes.exe at apps/desktop/release/<os>-unpacked/. Existing flows are unchanged: * The 'irm install.ps1 | iex' CLI one-liner omits the flag — terminal users don't need a prebuilt desktop binary; 'hermes desktop' builds on demand. * The Electron desktop's bootstrap-runner.cjs also omits the flag — rebuilding apps/desktop from inside a running Hermes.exe would try to overwrite the live binary on disk and fail. Stage-Desktop runs after Stage-NodeDeps so workspace npm is already installed when electron-builder fires. It does: 1. 'npm install' at repo root so apps/* workspaces resolve their deps (Electron itself arrives via npm here, ~150MB) 2. 'npm run pack' in apps/desktop (tsc + vite + electron-builder --dir) 3. Probes apps/desktop/release/{win-unpacked,win-arm64-unpacked}/Hermes.exe The --dir mode produces an unpacked launchable binary without an NSIS/MSI installer artifact — we don't need one because Hermes-Setup.exe spawns the unpacked binary directly via launch_hermes_desktop. * feat(installer): Tauri bootstrap installer for first-time onboarding Hermes-Setup.exe is a small signed Rust+Tauri binary that drives scripts/install.ps1 stage-by-stage with a native UI matching the desktop's design language. Replaces the chicken-and-egg pattern of shipping a 200MB Electron app whose first launch existed only to run install.ps1. The architecture: Rust backend (src-tauri/): bootstrap.rs orchestrator -- Tauri commands, stage iteration install_script.rs resolve install.ps1 (dev checkout, cache, GitHub raw) powershell.rs spawn powershell, line-stream stdout/stderr, parse JSON events.rs BootstrapEvent types -- mirror bootstrap-runner.cjs paths.rs HERMES_HOME resolution + tracing log setup build.rs bakes BUILD_PIN_COMMIT / BUILD_PIN_BRANCH from 'git rev-parse HEAD' at compile time React frontend (src/): Tauri webview rendering 4 screens (welcome / progress / success / failure), driven by nanostores subscribing to the Rust event stream. Visual layer reuses the desktop's styles.css wholesale via @import so the installer and desktop never drift visually. Distribution: targets = ['app', 'dmg', 'appimage'] -- no NSIS/MSI wrapper. The raw target/release/Hermes-Setup.exe IS the artifact on Windows; .dmg + .app on macOS; AppImage on Linux. One file, double-click, no installer-installing-an-installer pattern. Compile-time pinning: build.rs reads 'git rev-parse HEAD' and emits cargo:rustc-env=BUILD_PIN_COMMIT=<sha> + BUILD_PIN_BRANCH=<branch>. bootstrap.rs's option_env!() picks these up so the binary fetches install.ps1 from the exact SHA it was tested against. CI / release builds can override via HERMES_BUILD_PIN_COMMIT env var. Windows manifest: hermes-setup.manifest declares level='asInvoker' so the productName 'Hermes Setup' doesn't trip Windows's installer- detection heuristic and refuse to launch without elevation. Also declares PerMonitorV2 DPI + UTF-8 active code page + Common Controls v6. Limitations of this initial version: * No code signing -- Windows SmartScreen will warn once on Hermes-Setup.exe ('More info -> Run anyway'). The downstream binaries it produces (Hermes.exe in win-unpacked/, the hermes CLI) are locally-built and therefore don't carry MOTW, so they launch without SmartScreen intervention. Cert procurement tracked separately. * macOS and Linux build paths defined but untested -- Windows-only V1. * fix(installer): pass -IncludeDesktop to manifest, surface launch errors, alias hermes desktop Three bugs found in the first VM end-to-end test: 1. install.ps1 -Manifest was called WITHOUT -IncludeDesktop, so the manifest came back with the 14-stage list (no desktop stage), the UI showed '14 steps' and Stage-Desktop never ran. Pass the flag to both the manifest fetch and the per-stage runs — install.ps1 gates the desktop stage's inclusion on the flag. 2. The Success screen's Launch button silently swallowed the Tauri error when no Hermes.exe existed (e.g. Stage-Desktop was skipped). Wire the error through to inline UI with an alert callout, so the user gets actionable text ('Hermes.exe missing, run hermes desktop from a terminal') instead of an unresponsive button. 3. The Success screen tells users to run 'hermes desktop' from a terminal but the CLI only accepted 'hermes gui' — invalid choice for 'desktop'. Rename the subcommand canonically to 'desktop' with 'gui' as a backwards-compatible alias. Update the _SUBCOMMANDS sets used by session-flag arg parsing + logging-mode probe so both names route to the same logic. * fix(install.ps1): pre-warm electron-builder winCodeSign cache + fix Stage-Desktop $HasNode false-skip Two bugs caught in the second VM end-to-end run: 1. electron-builder's winCodeSign extraction fails on grandma-class Windows boxes because the .7z archive contains macOS symlinks (darwin/10.12/lib/libcrypto.dylib and libssl.dylib pointing at versioned siblings). Creating symlinks on Windows requires SeCreateSymbolicLinkPrivilege, a per-user right that non-admin accounts don't have on stock Windows. Result: every fresh install on a non-admin user fails Stage-Desktop with a 7-Zip 'cannot create symbolic link' error, retried four times, then bails. Fix: Initialize-ElectronBuilderCache pre-extracts winCodeSign-2.6.0.7z ourselves with -snl (don't preserve symlinks, store as resolved file content) AND -x!darwin (skip the entire macOS subtree — irrelevant on Windows). Writes to electron-builder's expected cache dir before electron-builder gets a chance to try its own broken extraction. Idempotent — fast-paths via signtool.exe sentinel check. 2. Install-Desktop's first guard was 'if (-not $HasNode) skip'. $HasNode is set by Stage-Node into $script:HasNode, but in cross-process driver mode (each -Stage NAME is a fresh powershell.exe spawned by Hermes-Setup.exe), that script-scope variable from the PREVIOUS process is invisible — so the guard always fired and Install-Desktop returned in 900ms with a misleading 'Node.js not available' reason. The real npm probe below it never got to run. Fix: re-probe npm directly via Get-Command when $HasNode is empty/false, since by that point Stage-Node has already verified Node is installed and the only question is whether *this* process can see it on PATH (it can — installer-wide PATH update from Stage-Node). * fix(install.ps1): tell electron-builder we're NOT signing instead of pre-extracting winCodeSign The previous commit (c7e46f9f3) worked around the winCodeSign-symlinks- on-Windows extraction crash by pre-extracting the archive ourselves with -snl + -x!darwin. That fix was correct but addressed the wrong layer. The deeper question: why was electron-builder fetching winCodeSign at all when we have no signing cert configured? Answer: electron-builder unconditionally pre-warms the toolchain assuming any build MIGHT sign. The cert auto-discovery never finds anything (we never set CSC_LINK or anything else), so the signing never happens — but the 100MB fetch of winCodeSign and its broken-on-Windows symlink extraction does. Set CSC_IDENTITY_AUTO_DISCOVERY=false (with WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD also explicitly cleared as belt-and-suspenders) before invoking npm run pack, and electron-builder skips the entire winCodeSign apparatus. No download, no extraction, no privilege check. Env vars are saved/restored around the invocation so we don't leak the override into Stage-PlatformSdks etc. Net: removes the 100-line Initialize-ElectronBuilderCache helper that manually downloaded + extracted winCodeSign-2.6.0.7z. Replaced with 3 env-var assignments. The produced Hermes.exe is functionally identical — just no longer carries a code-signing-machinery dependency we never used. * fix(installer): bump bootstrap-installer.log to capture stage transitions + every install.ps1 line Diagnosing the second VM failure was impossible because bootstrap-installer.log contained only the 'starting' banner. Two causes: 1. emit_log() inside run_bootstrap() was tracing::debug! — dropped on the floor under the default INFO env-filter. 2. The per-stage sink callbacks (on_stdout_line / on_stderr_line) only emitted Tauri events to the frontend; they never tee'd to the log file at all. When the failure route mounts, the Tauri event stream is the only place the script output lived, and it gets discarded. 3. The Failed / Stage / Manifest / Complete lifecycle frames in emit_event() were also Tauri-only — so even the 'which stage failed' frame never reached the log. Fixes: * emit_log() → tracing::info! * Sink callbacks tee stdout to info!, stderr to warn!, with stage label as a structured field for grep'ability * emit_event() now matches on the variant and logs each lifecycle frame at the right level: Failed → tracing::error!, others → info! Result: a failing install leaves a complete forensic trail in bootstrap-installer.log — manifest stage list, every install.ps1 stdout/stderr line tagged by stage, the stage transitions, and the final error. Same path as before so nothing the user does changes. * fix(install.ps1): Stage-NodeDeps cross-process $HasNode + stream npm install output to bootstrap log VM run 3 diagnosis: node-deps stage skipped on the VM (logged 'Skipping Node.js dependencies (Node not installed)') and then desktop's npm install failed with exit 1 and zero diagnostic detail. Two root causes: 1. $HasNode false-skip in Stage-NodeDeps — same cross-process bug pattern we fixed for Stage-Desktop inc7e46f9f3. Stage-Node ran in process A and set $script:HasNode = $true, then exited. Stage- NodeDeps ran in fresh process B (Hermes-Setup.exe -Stage NAME spawns each stage independently), where that variable doesn't exist. Re-probe via Get-Command npm instead of trusting the stale script-scope global. The previous stage already verified Node so the re-probe succeeds. 2. npm install --silent + Tee to TEMP file hid the real error. When the workspace install failed on the VM, the actual reason was buffered in $env:TEMP\hermes-npm-desktop-install-*.log and the user saw only 'exit 1'. Drop --silent so npm streams its full output, drop the TEMP-file dance — the Tauri installer's streaming sink already tees every stdout/stderr line to the rolling bootstrap-installer.log, so a side log file is dead weight that hides the very error we need. After this, the bootstrap log on a failure will contain npm's full output (deprecation warnings, ETARGET, native-module compile errors, whatever) tagged with stage=desktop, making the actual cause diagnosable instead of an opaque exit code. * fix(install.ps1): restore Initialize-ElectronBuilderCache (CSC env vars alone aren't enough) VM run 4 diagnosis: even with CSC_IDENTITY_AUTO_DISCOVERY=false set, electron-builder still fetches winCodeSign and signs bundled binaries. The log shows the signing happens BEFORE the cache extraction: • signing with signtool.exe ...\winpty-agent.exe • signing with signtool.exe ...\OpenConsole.exe • downloading winCodeSign-2.6.0.7z • <symlink privilege error> Cause: node-pty's bundled prebuilds are listed in apps/desktop's asarUnpack ['**/*.node', '**/prebuilds/**']. electron-builder re-signs anything unpacked from asar, regardless of whether OUR binary gets signed. The signtool invocation needs winCodeSign on disk, which needs the .7z extracted, which hits the macOS-symlink crash on non-admin Windows. The CSC env vars I added ind5fe46727only kill IDENTITY DISCOVERY (so OUR Hermes.exe stays unsigned, which is fine — we have no cert). They don't prevent the toolchain fetch for the bundled-prebuild re-sign. I removed the pre-extract ind5fe46727thinking the env vars subsumed it; that was wrong. Both are needed. Restoring Initialize-ElectronBuilderCache verbatim fromc7e46f9f3and keeping the CSC env vars. Wrote a clearer doc-comment at the call site explaining the two-knob interaction so future maintainers don't drop one half again. * fix(desktop): disable signtool via signtoolOptions.sign=null, drop dead winCodeSign pre-extract VM run 5 diagnosis: the pre-extract from3b29e65c1ran (extracted 83 files, 24MB) but produced ZERO files at the expected sentinel path '/winCodeSign-2.6.0/windows-10/x64/signtool.exe'. Cause: the .7z archive's root entries are 'windows-10/', 'darwin/', 'linux/', etc. — not 'winCodeSign-2.6.0/<arch>'. Extracting with '-o$cacheRoot' put files at $cacheRoot/windows-10/..., NOT at $cacheRoot/winCodeSign-2.6.0/windows-10/.... I had the directory nesting wrong from the start. And then we observed: electron-builder downloads winCodeSign-2.6.0.7z under a random numeric filename ('384387955.7z') regardless of what's already extracted in the parent dir. The cache key isn't the dirname; it's content-addressed. So the pre-extract approach was doomed even if the path nesting had been right. Actual fix: signtoolOptions.sign=null in apps/desktop/package.json's win build config. electron-builder honors this and skips the bundled- prebuild signing entirely — no signtool invocation, no winCodeSign fetch, no symlink-privilege crash. The previous failures all stemmed from electron-builder pre-signing node-pty's bundled .exes (winpty-agent.exe, OpenConsole.exe) which are already author-signed upstream; re-signing with our nonexistent cert was overwriting good sigs with nothing useful anyway. Cost: when we DO get a real cert later, we'll add it back with the sign function pointing at the cert chain. Until then, all-null is the correct config and unblocks every non-admin Windows user. Removed Initialize-ElectronBuilderCache (the dead pre-extract). Removed the call site. Kept the CSC_IDENTITY_AUTO_DISCOVERY env vars as belt-and-suspenders against a future electron-builder change that might revive cert auto-discovery. * fix(desktop): use no-op sign function instead of sign=null VM run 6 still hit the symlink crash even with signtoolOptions.sign=null. electron-builder 26.8.1 treats null as 'use the default signtool path' rather than 'skip signing', so the winCodeSign fetch + extraction still fired for the bundled prebuild re-sign. The Electron docs (electronjs.org/docs/latest/tutorial/code-signing) make it clear signing is OPTIONAL and unsigned apps work fine — users just see SmartScreen on first launch. The electron-builder mechanism for 'don't actually sign anything' is to supply a custom sign function (via signtoolOptions.sign: '<path-to-cjs-module>') that resolves without invoking signtool. build-noop-sign.cjs is that module — a 5-line async function that returns undefined. electron-builder calls it for every binary it would have signed, gets back a resolved promise, and considers each binary 'signed.' No signtool spawn, no winCodeSign fetch, no symlink crash. When Nous's cert arrives, replace this file with a real signing hook (@electron/windows-sign-based or a direct signtool invocation). The architecture's signing-ready and the cutover is a one-file edit. * fix(desktop): signAndEditExecutable=false to skip signtool path entirely After reading app-builder-lib/winPackager.js line 216 + 231 directly: signAndEditExecutable is the ACTUAL hardcoded gate that short-circuits both signApp() (which signs Hermes.exe + every shouldSignFile match including bundled prebuilds) AND createTransformerForExtraFiles(). None of signtoolOptions.sign / sign:null / sign:<custom-fn> gate the winCodeSign download — that happens before they're consulted. What we lose: rcedit also runs through signAndEditResources, so disabling this drops PE metadata (file properties showing 'Hermes' / 'Nous Research' / file description). Cost is real but bounded: * Hermes.exe filename, icon, asar contents, app identity intact * Task Manager shows 'Hermes.exe' (the filename) not 'Hermes' (PE description) — minor downgrade * Start menu, taskbar, window title all work normally * SmartScreen will warn once (unsigned, same as before) When the cert lands, flip signAndEditExecutable back to default true, both signing AND rcedit return, PE metadata is restored. Removes the no-op sign function (build-noop-sign.cjs) since signAndEditExecutable=false prevents signtool from being invoked at all — the custom hook never gets called either. * feat(install.ps1): write .hermes-bootstrap-complete marker at end of install The desktop app's main.cjs resolver ladder has a 'bootstrap-needed' rung that fires when .hermes-bootstrap-complete is missing from ACTIVE_HERMES_ROOT. Pre-Hermes-Setup, this marker was written by the packaged-desktop's own bootstrap-runner.cjs at the end of its install flow. Now that Hermes-Setup.exe runs install.ps1 directly, install.ps1 needs to own the marker — otherwise the desktop sees no marker on first launch and triggers its legacy first-launch bootstrap (re-running install.ps1 from inside Electron, the exact recursion Hermes-Setup.exe was supposed to obviate). Implementation: * New Stage-BootstrapMarker (worker) → Write-BootstrapMarker (helper) * Slotted in the manifest right after platform-sdks, before the interactive configure/gateway stages, so it runs unconditionally when the install reaches the finalize phase * Schema mirrors apps/desktop/electron/main.cjs writeBootstrapMarker / isBootstrapComplete EXACTLY: {schemaVersion: 1, pinnedCommit, pinnedBranch, completedAt}. Schema version stays at 1 so old desktops that read marker files written by future install.ps1s can still parse them. * pinnedCommit comes from -Commit flag (Hermes-Setup.exe passes it) or falls back to 'git rev-parse HEAD' in InstallDir * pinnedBranch from -Branch flag, defaults to 'main' matching install.ps1's own param default Two PS-5.1 gotchas baked into comments: * The ?. null-conditional operator doesn't exist pre-PS7; use explicit if-checks on Get-Command results * Set-Content -Encoding UTF8 emits a BOM in 5.1 and Node's plain JSON.parse rejects BOM — write via .NET's UTF8Encoding(false) to produce BOM-less JSON the desktop's readJson() can parse * feat(installer): drive in-app updates through the Tauri installer Converge update on the same principle as bootstrap: one driver owns all repo mutation. The desktop becomes a pure consumer that hands off to Hermes-Setup.exe --update instead of re-implementing git/pip in Electron. - hermes desktop --build-only: build without launching, so the installer owns the post-update launch (CLI keeps build logic single-sourced). - Installer AppMode {Install,Update} from argv; get_mode exposed to the UI. - Installer self-copies to HERMES_HOME/hermes-setup.exe on install success (no-op guard during --update re-invocation to avoid the locked-exe copy). - Installer --update flow (update.rs): wait for the desktop to release the venv shim, run 'hermes update --yes --gateway' (branch on exit 0/2/other), then 'hermes desktop --build-only', then launch the rebuilt desktop. Reuses the bootstrap event channel + progress UI via a synthetic two-stage manifest. - Desktop applyUpdates() gutted (~105 lines of git/stash/pull/pyproject/pip removed) -> thin handoff: spawn updater, app.quit() to free the shim. Detection (checkUpdates, commit changelog, behind-count) kept intact. - install.ps1 creates Start Menu + Desktop shortcuts to the packed Hermes.exe (never bare 'hermes desktop', which would rebuild every launch). * test update * fix(installer): pass --branch to hermes update in the --update flow The install is a detached-HEAD checkout of a pinned commit. Without --branch, 'hermes update' fell back to its default (main) and switched the checkout to main — a divergent branch that lacks the desktop CLI command — so the update targeted the wrong branch and the rebuild stage failed with 'invalid choice: desktop'. Thread BUILD_PIN_BRANCH (the branch this installer was built against, and the same branch the desktop detected the update on) into 'hermes update --branch <b>' so update + rebuild stay on-branch. * test update * fix(installer): stamp Hermes icon onto Hermes.exe via rcedit (no winCodeSign) The unpacked Hermes.exe showed the stock Electron icon + name in the taskbar because build.win.signAndEditExecutable=false disables BOTH electron-builder's signing AND its rcedit metadata/icon stamping. That flag is load-bearing: enabling it re-triggers signtool -> winCodeSign, whose macOS symlinks crash 7-Zip on non-admin Windows (unfixable dead end). Decouple identity-stamping from signing entirely: after npm run pack, run rcedit ourselves on the produced exe. - Add rcedit as a direct devDependency of apps/desktop (the transitive electron-winstaller copy is fragile). - apps/desktop/scripts/set-exe-identity.cjs: Node helper that calls rcedit's named export to set icon + ProductName/FileDescription/ CompanyName. Node builds argv natively — avoids the PowerShell->exe ->JSON double-escaping that broke the app-builder rcedit path. - install.ps1 Set-DesktopExeIdentity invokes the script after the build, before shortcuts. Best-effort: failure keeps the stock icon, never fails the install. rcedit is a pure PE editor — no signtool, no winCodeSign, no symlinks. Verified locally: stamping a copy of the built Hermes.exe embeds the 32x32 icon and sets ProductName=Hermes. Also fix update-path success-screen flash: in update mode the installer hands off + exits in ~600ms, so don't route to the 'launch Hermes' success view (it flashed before the window closed). * update test * fix(desktop): show 'hermes update' guidance for CLI installs instead of dead-end error A user who installed via the CLI (irm|iex / install.sh) then ran `hermes desktop` has no staged hermes-setup.exe, so clicking Update in-app hit resolveUpdaterBinary()=null and showed a misleading error ('re-run the Hermes installer') with a Try-again button that could never succeed — a dead loop for a perfectly valid install. Treat the no-updater case as an intentional outcome, not a failure: - main.cjs applyUpdates returns { ok:true, manual:true, command:'hermes update' } (no throw, no 'error' stage) when no updater binary exists. - New 'manual' update stage + apply-state.command thread the command to the UI. - updates-overlay ManualView: a polished terminal-native card with the exact command and a copy button, framed as the correct path for a CLI user rather than an error. GUI-installer users are unaffected — hermes-setup.exe present => seamless auto-update runs as before. Zero new process orchestration; can't fail the update demo. * update test * fix(gui): pin /api/hermes/update to the current branch The desktop command-center 'update' action hits POST /api/hermes/update, which spawned bare `hermes update` with no --branch. cmd_update then falls back to its default (main) and checks the working tree OUT of the tracked branch — a bb/gui install silently jumped to main and lost the desktop CLI. Resolve the checkout's current branch and pass --branch <current> from this endpoint only. The engine default (main) is DELIBERATELY unchanged: bare `hermes update` from a terminal, the gateway /update bot command, and the CLI/TUI relaunch path all keep their long-standing 'update against main' contract for the existing user base. Only the GUI button is scoped to update-the-branch-you're-on. Detached HEAD / git failure falls back to the bare default. * update test * fix(desktop): branch-pin the CLI manual-update command card The 'Update from your terminal' card (shown to CLI installs with no staged updater) hardcoded bare `hermes update` — which defaults to main and would switch a bb/gui (or any non-main) checkout off-branch. Same bug we fixed for the GUI button, leaked into the card's copy text. Resolve the checkout's current branch and show `hermes update --branch <current>` for non-main checkouts; keep it bare for main so the card stays clean. Best-effort: bare fallback if branch detection fails. Matches the GUI button + installer --update contract; bare terminal/bot/TUI update paths still default to main, unchanged. * docs: phragg was here * feat(desktop): lead onboarding with Nous Portal + fix fresh-install detection (#34970) - Feature Nous Portal as the primary onboarding card (Recommended tag, app logo, single pitch line); collapse other OAuth providers behind an "Other providers" disclosure whose open/closed state persists. - Surface OpenRouter as a one-click API-key option inside the disclosure; move "I have an API key" to a quiet bottom-right link. - Treat "no provider configured" as a normal onboarding state, not a red error banner (provider-setup-errors copy match). - Fix setup.runtime_check: it reported ready when the resolved runtime had an empty credential or only implicit Bedrock/IAM, so fresh installs never saw onboarding. Now requires a usable credential. - Auto-wire Windows fonts for WSL2 users so the renderer renders real Segoe UI instead of the DejaVu fallback; make WSL detection env-independent via the /proc kernel marker. * feat(desktop): live elapsed timer on install bootstrap steps The first-launch install overlay showed a static "Installing" with no motion, so long steps (notably the repo clone) looked frozen. Stamp each stage's start time on the running transition and tick once a second so the active step shows live elapsed (e.g. "Installing · 1:23"), plus elapsed on the overall current-step line. Completed steps keep their final duration. * fix(desktop): resolve PortableGit for update checks + reserve titlebar tools space - runGit() hardcoded spawn('git'), which ENOENTs on fresh installer-driven Windows installs (git is PortableGit under %LOCALAPPDATA%\hermes\git, never on PATH) — so "Check for updates" failed with "Couldn't check for updates". Add resolveGitBinary() mirroring findGitBash (PortableGit → Git-for-Windows → PATH) and use it in runGit. - PageSearchShell rendered a full-width search input in the titlebar row, so on Windows its right edge slid under the fixed top-right tools + native window controls. Reserve that footprint via --titlebar-tools-* vars. * fix(desktop): stop streaming caret from shifting layout on completion The streaming caret (::after on the running message's last child) was an in-flow inline-block adding ~0.78em of inline width, which could wrap the last line mid-stream; when the caret is removed on completion the line un-wraps and reflows — the visible post-response layout shift. Net-zero its inline advance with a compensating negative margin so it paints at the text end without consuming layout width. * fix(desktop): stop completed-message layout shift while streaming The assistant message action bar used `hideWhenRunning`, which unmounts it whenever the thread is streaming. Since the bar reserves vertical space in each completed assistant message's footer (it's invisible-until-hover via opacity, not via mount), unmounting it collapsed every prior turn by the bar's height — then remounting on resolve grew them back, shifting the whole conversation (visible as "padding appears above the last user message"). Drop hideWhenRunning so the footer height is constant; the bar stays invisible during streaming via its existing opacity/pointer-events gating. * fix(merge): keep windows-footgun suppressions inline * fix(merge): keep remaining gateway footgun suppressions inline * fix(merge): restore contracts caught by main-target CI * fix(dashboard): honor injected HERMES_DASHBOARD_SESSION_TOKEN The desktop shell mints a session token and signs its /api + /api/ws calls with it via HERMES_DASHBOARD_SESSION_TOKEN, but the main-merge restored a web_server.py that ignored the env var and minted its own random _SESSION_TOKEN -- so every desktop request 401'd and the UI reported "gateway offline". Read the injected token (fall back to a fresh random one) so loopback HTTP + WS auth line up. Adds a regression test so a future merge can't silently drop the read. * fix(desktop): align fresh-install home so upgraders don't brick Two related first-launch bugs on machines with a legacy ~/.hermes: - install.ps1 hardcoded $HermesHome/$InstallDir to %LOCALAPPDATA%\hermes and ignored the HERMES_HOME the desktop passes through. The desktop freezes HERMES_HOME at module load and prefers a legacy ~/.hermes when %LOCALAPPDATA%\hermes is absent, so the installer wrote to a different home than the shell read -> "Could not connect to Hermes gateway". Honor $env:HERMES_HOME in the param defaults. - isBootstrapComplete() trusted the marker + checkout without verifying a runnable venv, so an interrupted/split install spawned a dead backend instead of re-bootstrapping. Also require the venv python to exist. * fix(dashboard): allow packaged desktop file:// origin on loopback WS The packaged Electron desktop loads its renderer over file://, so its /api/ws handshake carries Origin: file:// (or null). The DNS-rebinding WebSocket Origin guard only accepted http(s) origins matching the bound host, so it rejected the desktop's own renderer with 4403 -> "Could not connect to Hermes gateway" on macOS. A browser DNS-rebinding attacker can only ever present an http(s) origin (the site hosting the malicious page); it cannot forge file://, null, or a custom app scheme AND hold the loopback session token. So on loopback binds we now trust non-web origins -- the token in _ws_auth_ok remains the real authenticator. Public/gated binds still reject them, and cross-site http(s) origins are still rejected everywhere. * fix(desktop): resolve renderer assets relative to BASE_URL Absolute public asset paths (/apple-touch-icon.png, /ds-assets/...) work under the dev server but break in the packaged app, where the renderer is loaded from file://.../index.html and a leading slash resolves to the filesystem root -> broken onboarding provider icon and backdrop image on macOS. Prefix these with import.meta.env.BASE_URL so they resolve next to the bundled index.html in both dev and packaged builds. * feat(desktop): automate first-launch bootstrap on macOS/Linux Previously a packaged macOS/Linux app with no Hermes install hit a dead-end ("first-launch install is not yet automated -- run install.sh manually") because install.sh lacked the staged protocol install.ps1 exposes. Now both platforms bootstrap on first launch with the same structured, per-step progress UI as Windows. - install.sh: add --manifest / --stage / --json / --non-interactive plus a stage dispatcher (prerequisites, repository, venv, python-deps, node-deps, path, config, setup, gateway, complete). User-input stages (setup, gateway) are skipped under --non-interactive; the in-app onboarding overlay owns API keys/model, matching the Windows flow. Each stage runs inside the install dir (its own process) and a new --commit flag pins the checkout to the build-stamp SHA. - bootstrap-runner.cjs: drive the staged manifest/stage/JSON protocol for both install.ps1 (PowerShell) and install.sh (bash), selected by installer kind; removed the single-blob POSIX shim. - main.cjs: drop the macOS/Linux unsupported-platform dead-end so the bootstrap-needed path runs the installer on every platform. * fix(dashboard): return 404 JSON for unmatched /api paths instead of SPA HTML The SPA catch-all (serve_spa) served index.html for any unmatched GET, including unregistered /api/* endpoints. A missing API route therefore came back as <!doctype html> with status 200, and JSON clients (the desktop app's fetchJson) crashed with an opaque 'SyntaxError: Unexpected token <' instead of a clear error. - web_server.py: unmatched /api or /api/... now returns 404 JSON ('No such API endpoint'); non-api paths still serve the SPA for client-side routing. - main.cjs fetchJson: detect an HTML body / text/html content-type on a 2xx response and reject with a clear message naming the URL, rather than a raw JSON.parse SyntaxError. Empty bodies resolve to null; malformed JSON reports the URL plus a snippet. * say 'OS appearance' instead of 'macOS appearance' * feat(install): add --include-desktop stage + PowerShell-style flags to install.sh Brings install.sh to parity with install.ps1's bootstrap surface so the shared Rust/Tauri bootstrapper (apps/bootstrap-installer) can drive a macOS/Linux install the same way it drives Windows. - Accept the PowerShell-style aliases the bootstrapper emits to both installers: -Commit / -Branch (alongside existing -Manifest / -Stage / -Json / -NonInteractive). - Add --include-desktop / -IncludeDesktop. When set, the manifest gains a 'desktop' stage (immediately before 'complete'), and a new install_desktop runs a root workspace `npm install` + `npm run pack` (electron-builder --dir, signing auto-discovery disabled) to produce release/mac*/Hermes.app -- mirroring install.ps1's Install-Desktop / Stage-Desktop. - The flag is opt-in, exactly like Windows: the signed bootstrap installer passes it; the Electron app's own first-launch bootstrap and the CLI one-liner omit it (building the desktop from inside the running app would clobber it). * fix: tts endpoints * macOS desktop: install + in-app self-update (#35607) * fix(installer): align macOS HERMES_HOME with the rest of the stack paths.rs computed the macOS Hermes home as ~/Library/Application Support/ hermes, but nothing else does: hermes_constants.get_hermes_home() (Python), scripts/install.sh, and the Electron desktop's resolveHermesHome() all use ~/.hermes on macOS. The drift meant the Tauri installer wrote the install to one directory and the desktop looked for it in another, so a fresh GUI install never found its backend (the file's own comment warned this exact drift would break things). Use ~/.hermes on macOS to match. * fix(install.sh): always emit a stage result frame on failure Stage helpers (clone_repo, install_deps, check_python, …) were written for the monolithic flow and call `exit 1` on failure. Under `--stage`, that terminated the process before the JSON result frame was printed, so the installer's parse_stage_result saw "no frame" instead of a clean {ok:false,...} contract response. Run the stage body in a subshell so an `exit` only unwinds the subshell and the parent still emits the frame. * feat(install.sh): auto-provision git on macOS/Linux (parity with install.ps1) install.ps1 downloads PortableGit on Windows, but install.sh just printed a "please install git" hint and exited — so a fresh Mac with no developer tools (no Xcode CLT → no git) couldn't get past the clone step. check_git now tries to install git before bailing: - macOS: Homebrew if present (headless), else `xcode-select --install` (the CLT prompt also provides the compiler some wheels need), polling for git to appear. - Linux: apt/dnf/pacman via sudo when available. Falls back to the manual instructions only if auto-provision fails. * feat(desktop): in-app GUI+backend self-update on macOS/Linux On Windows the staged Hermes-Setup binary drives updates (quit → hermes update → hermes desktop --build-only → relaunch). The mac drag-install has no such binary, so "Update now" previously just printed `hermes update`. Since there's no venv-shim file lock on POSIX, the desktop can drive the whole update itself. applyUpdates now, when no staged updater exists on mac/linux: 1. runs `hermes update --yes [--branch <current>]` (backend git pull + deps), 2. runs `hermes desktop --build-only` (OS-aware GUI rebuild) with the Hermes-managed Node + venv on PATH, 3. spawns a detached swapper that waits for this process to exit, dittos the freshly built Hermes.app over the running bundle, clears quarantine, and relaunches. Degrades to "backend updated — restart to load the new GUI" if the rebuild fails or there's no .app bundle to swap (dev run, Linux AppImage). * chore: uptick * chore: uptick * chore: linux build * fix(install): detect xcode-select git stub on fresh macOS * chore: bump * fix(desktop): repair voice dictation on Windows Voice dictation was broken on Windows in two ways: 1. Mic access was denied. The Electron permission request handler only granted 'media' requests whose details.mediaTypes included 'audio', but Chromium on Windows frequently fires the mic request with an empty mediaTypes array, so getUserMedia threw NotAllowedError. The handler now grants audio-capture when mediaTypes includes 'audio' OR is empty/absent, handles the 'audioCapture' permission name, and adds a setPermissionCheckHandler (the synchronous path Chromium also consults for getUserMedia on Windows). Video is still denied. 2. Transcripts went nowhere. The composer's insertText handler (used by dictation and other inserts) only updated the assistant-ui composer store via setText, never the contentEditable editor DOM. The draft->editor sync effect only re-renders the editor when it is NOT focused, and dictation runs while the editor has/regains focus, so the transcript was stored but never shown and could not be sent. insertText now renders into the editor DOM and places the caret, mirroring appendExternalText. Also hardens fetchJson: a 2xx response with an HTML body (or text/html content-type) now rejects with a clear message naming the URL instead of an opaque JSON.parse 'Unexpected token <' error. * feat(desktop): route Nous subscribers onto the Tool Gateway from the GUI When the GUI sets the main provider to Nous via POST /api/model/set, call the same apply_nous_managed_defaults the CLI uses after model selection, so GUI/onboarding users land on the Nous Tool Gateway the same way CLI users do — no separate prompt, no duplicated logic. Purely additive: apply_nous_managed_defaults skips any tool where the user has a direct key (FIRECRAWL_API_KEY, FAL_KEY, etc.) or explicit config, so it never overwrites a user's own setup. Only unconfigured tools get routed. - web_server.py: in set_model_assignment (scope=main, provider=nous), resolve enabled toolsets and apply managed defaults; guarded so a Portal hiccup never blocks saving the model. Returns routed tools as gateway_tools. - onboarding.ts: surface a 'Tool Gateway enabled' toast listing routed tools. - types/hermes.ts: add gateway_tools to ModelAssignmentResponse. - tests: cover nous-applies, non-nous-skips, and failure-doesnt-block-save. * feat(desktop): mirror hermes model free/paid curation in GUI onboarding GUI onboarding picked models[0] from /api/model/options, which ignores the Nous free/paid tier — a free user could land on a paid default (e.g. anthropic/claude-opus-4). Now the recommended default mirrors what `hermes model` does. - web_server.py: new GET /api/model/recommended-default?provider=<slug>. For Nous it runs the same curation as the CLI (get_curated_nous_model_ids + pricing + check_nous_free_tier + union_with_portal_{free,paid}_recommendations + partition_nous_models_by_tier) so free users get a free model and paid users get the curated default. Other providers fall back to the first curated model. Never 500s — returns empty model on error so onboarding degrades gracefully. - hermes.ts: getRecommendedDefaultModel client + RecommendedDefaultModel type. - onboarding.ts: fetchProviderDefaultModel prefers the recommended endpoint, falls back to models[0] when unavailable. - tests: free-tier picks free model, paid-tier picks curated default, failure returns empty without 500. * feat(desktop): show model pricing + free/paid tier gating in GUI picker The CLI `hermes model` picker shows per-model $/Mtok pricing and gates paid models on free Nous accounts. The GUI picker showed bare model names. Bring it to parity across both the model-picker dialog and onboarding confirm card. Backend: - inventory.build_models_payload gains a pricing=True flag → _apply_pricing enriches each provider row with formatted per-model pricing ({input,output,cache,free}) via the same _format_price_per_mtok the CLI uses, and for Nous adds free_tier + unavailable_models (paid models a free user can't select) via check_nous_free_tier + partition_nous_models_by_tier. Best-effort: any pricing/tier failure is swallowed and fails open (no gating). - /api/model/options and TUI model.options now pass pricing=True so the global picker and in-session picker both carry pricing. Frontend: - ModelOptionProvider gains pricing/free_tier/unavailable_models; new ModelPricing type. - model-picker dialog renders In/Out $/Mtok (or a Free pill) per model, a Free tier/Pro badge on the Nous heading, and disables + grays unavailable paid models for free users with a 'Pro models need a paid subscription' note. - onboarding confirm card shows the chosen model's price + tier badge. Tests: test_inventory_pricing covers price formatting, free-tier gating, paid no-gating, providers without pricing, and swallowed failures. * fix(desktop): GUI model picker shows curated Nous list in curated order Two bugs made the GUI Nous model list diverge from the `hermes model` CLI picker: 1. Backend (model_switch.py): the Nous row in list_authenticated_providers fell through to cached_provider_model_ids("nous"), dumping the full live /v1/models catalog (~50 vendor-prefixed models, alphabetical). Now it uses the curated list AND applies the Portal free/paid recommendation union — exactly like _model_flow_nous in main.py — so newly-launched models such as stepfun/step-3.7-flash:free surface in curated order. Best-effort: falls back to the curated list alone if the Portal fetch fails. 2. Frontend (model-picker.tsx): cmdk's Command had shouldFilter on (default), which re-sorts items by fuzzy-match score (≈alphabetical) and ignores array order. Set shouldFilter={false} + own the search term and do an order-preserving substring filter, so the backend's curated order is shown verbatim. * feat(desktop): add/switch providers from the model picker via onboarding reuse The model picker could only select models from already-authenticated providers. Switching to a new provider had no in-app path. Rather than duplicate provider UI, reuse the existing onboarding provider selector (featured Nous + other providers + API-key form + device-code/PKCE flow + model-confirm with pricing/tier). - onboarding store: add a 'manual' flag with startManualOnboarding() / closeManualOnboarding(). Manual mode forces the onboarding overlay to show even when configured===true and refreshOnboarding no longer auto-dismisses on runtime-ready (the app is already working — the user is just adding or switching a provider). - onboarding overlay: render when manual even if configured; show a Close button (the first-run flow has none since the app can't run yet). - model picker: 'Add provider' footer button opens the onboarding selector; ModelResults lists only configured (model-bearing) providers. * feat(desktop): add PUT /api/tools/toolsets/{name} enable/disable endpoint * feat(desktop): add toggleToolset RPC binding * feat(desktop): toolset enable/disable switch in Tools settings * feat(desktop): tool configuration parity in GUI Tools settings Bring the desktop GUI Tools settings to parity with the CLI `hermes tools` for provider selection and API-key configuration. Backend (hermes_cli/web_server.py): - GET /api/tools/toolsets/{name}/config - provider matrix + key status - PUT /api/tools/toolsets/{name}/provider - persist provider selection Shared core (hermes_cli/tools_config.py): - Extract apply_provider_selection / _write_provider_config from the interactive _configure_provider so the CLI and GUI write identical config keys (web.backend, tts.provider, browser.cloud_provider, plugin image/video providers, use_gateway flags) through one code path. Desktop UI: - ToolsetConfigPanel: provider list with select, per-provider API-key entry (set/replace/clear/reveal via the shared env RPCs), Ready/Needs keys state, guidance for Nous-auth and post-setup providers. - Wire the Configured/Needs keys pill to expand the panel inline; refresh the toolset list after key changes so the pill updates live. - Add getToolsetConfig / selectToolsetProvider RPC bindings + types. Post-setup (OAuth/install) flows still defer to the CLI; see docs spike findings for the planned /api/tools/setup/* endpoint family. Tests: backend round-trip + 400 cases for the new endpoints and apply_provider_selection; desktop vitest coverage for the config panel (provider render, select, key save). No change-detector tests. Also removes three stale completed plan docs. * fix(desktop): show real Hermes version + sync package.json on release The desktop app version was disconnected from the Hermes version: the release script bumped pyproject.toml + hermes_cli/__init__.py but never touched apps/desktop/package.json, which sat stale at 0.0.2 (lockfile at 0.0.1). - main.cjs: hermes:version IPC now resolves __version__ from hermes_cli/__init__.py (the canonical source release.py bumps) via a new resolveHermesVersion() helper, falling back to app.getVersion() when the source tree isn't readable. The About panel now always shows the live Hermes version and can't drift. - release.py: update_version_files() also bumps apps/desktop/package.json in lockstep with pyproject (top-level version only; dep specs untouched). - One-time catch-up: package.json 0.0.2 -> 0.15.1 and the lockfile root mirrors 0.0.1 -> 0.15.1. * fix(desktop): stamp exe identity in afterPack hook so updates stay branded The packed Hermes.exe reverted to the stock Electron icon + "Electron" name after an in-app update. The icon/identity stamp (rcedit) lived only in install.ps1, but the installer's --update path rebuilds the desktop via `hermes desktop --build-only` -> `npm run pack`, which never ran install.ps1 and so never stamped the rebuilt exe. Move the stamp into an electron-builder afterPack hook so it runs for EVERY packed build regardless of caller (first install, hermes desktop, the update rebuild, or a manual npm run pack): - set-exe-identity.cjs: refactor to export stampExeIdentity(exe, desktopRoot); still runnable as a standalone CLI. - after-pack.cjs (new): afterPack hook calling stampExeIdentity. Windows-only guard; best-effort (logs + resolves on failure, never fails the build). - package.json: register build.afterPack. - install.ps1: remove the now-redundant Set-DesktopExeIdentity function + call; the hook handles it during npm run pack. electron-builder's own rcedit step stays disabled (signAndEditExecutable=false) to avoid the signtool -> winCodeSign -> 7-Zip macOS-symlink crash on non-admin Windows; the hook runs rcedit directly (pure PE resource edit, no signing). * fix(desktop): export afterPack hook as exports.default so electron-builder runs it The afterPack hook used `module.exports = fn`, which electron-builder's hook loader doesn't pick up — it expects the function as the module's default export (the same shape afterSign/notarize.cjs uses). The hook silently never ran, so even first install shipped the stock "Electron" exe. Switch to `exports.default = async function afterPack(...)`. Verified with a real `npm run pack`: electron-builder now invokes the hook and the produced release/win-unpacked/Hermes.exe carries ProductName/FileDescription=Hermes. * chore(desktop): drop auto-build release CI in favor of manual build + upload Remove desktop-release.yml (nightly-on-main + stable publish). Installers are now built locally per platform and uploaded to a GitHub Release by hand; the website points at them via NEXT_PUBLIC_HERMES_DL_* env. Update README + docs and drop the dead desktop-nightly channel links. * fix(desktop): stable shortcut icon + bust icon cache so updates repaint Symptom on a freshly-installed laptop: Hermes.exe itself shows the correct Hermes icon (Explorer reads the live exe's stamped PE resource), but the desktop shortcut still draws the stock Electron icon. Cause: New-DesktopShortcuts set IconLocation to "<exe>,0", so Windows cached the icon it extracted from the exe at shortcut-creation time. On an update the exe gets re-stamped, but the shortcut keeps rendering the stale cached bitmap. - package.json: ship assets/icon.ico beside the exe via extraResources (-> resources/icon.ico). Verified with a real npm run pack. - install.ps1 New-DesktopShortcuts: point IconLocation at resources/icon.ico (fallback to <exe>,0 if absent) — a dedicated .ico is cache-stable and skips the per-exe extraction that goes stale. Then run `ie4uinit.exe -show` to bust the shell icon cache so the shortcut repaints immediately instead of showing the old Electron icon until reboot. Both best-effort; never fail an otherwise-good install. * dummy update * feat(desktop): self-heal update branch + backend contract guard Two fixes for the bb/gui→main transition: - Self-update self-heals: if the tracked branch (e.g. bb/gui) no longer exists on origin (merged + deleted), the desktop updater falls back to main and persists it. Read-only ls-remote probe that only flips on a definitive "ref absent" (exit 2), never on a transient network error, so already-installed clients migrate themselves with no manual flip. - Backend contract guard: tui_gateway reports DESKTOP_BACKEND_CONTRACT in session runtime info; the desktop warns with a one-click "Update Hermes" when the backend predates the GUI's required contract (e.g. a bb/gui app pointed at a main checkout) instead of failing cryptically downstream. * docs(desktop): rewrite README to match current install/update/build flow The old README contradicted itself (claimed a bundled Python payload while also saying it no longer bundles source) and predated cross-platform support. Rewrite for accuracy: Linux is a first-class build target, install.sh/install.ps1 both drive the staged bootstrap, the real self-update handoff (Windows Hermes-Setup vs in-app macOS/Linux), and the bb/gui→main self-heal + backend contract guard. * docs(desktop): rewrite README as a real product readme Lead with what the app is and how to get it (download an installer, or `hermes desktop` for existing CLI users) plus a plain-language feature list, then keep contributor/build/internals as a clearly separated secondary section. * docs(desktop): fix install framing — releases no longer auto-build installers Lead with the install-with-Hermes path (`--include-desktop` / `hermes desktop`), which always works, and describe prebuilt installers as manually published when a release ships them rather than implying CI attaches them to every release. * docs(desktop): match base repo README style Adopt the root README's conventions: centered title + badge row, bold one-liner intro, a feature <table> grid, --- section dividers, and a Community / License footer. * feat(desktop): recover from gateway boot failures + validate API keys on entry (#35864) Fresh installs that hit a gateway boot failure had no recovery path: the shell rendered dead ("gateway offline"), logs were undiscoverable, and a mistyped API key was accepted because onboarding only checked credential presence, not validity. - Add BootFailureOverlay: a top-level recovery surface (Retry, Repair install, Use local gateway, Open logs + inline recent logs) that mounts on any hard boot failure, including post-install. Trims the now-redundant recovery button from the onboarding Preparing panel. - Add hermes:logs:reveal / :recent IPC (reveal desktop.log) and a hermes:bootstrap:repair IPC that drops the bootstrap marker to force a clean reinstall. Surface "Open logs" in Gateway settings too. - Add POST /api/providers/validate: a live per-provider probe (OpenRouter/OpenAI/xAI/Gemini key check, local endpoint connectivity) wired into saveOnboardingApiKey so a rejected key blocks before it's persisted, while an unreachable probe falls through (offline-safe). * test(model-catalog): fix stale nous picker test after curated-list changeac2e48907made the GUI/picker Nous row use the curated list (curated["nous"] = get_curated_nous_model_ids()) + Portal union, matching the `hermes model` CLI — but test_picker_nous_row_uses_manifest still asserted the old 2-model manifest snapshot, breaking the test shard. Rewrite it as an invariant: stub the Portal union to passthrough and assert the row equals get_curated_nous_model_ids() computed under the same conditions, so it tracks the real contract instead of a hardcoded model list that rots on every catalog update. --------- Co-authored-by: emozilla <emozilla@nousresearch.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Austin Pickett <pickett.austin@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: ethernet <arilotter@gmail.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
5976 lines
264 KiB
Python
5976 lines
264 KiB
Python
"""
|
||
Telegram platform adapter.
|
||
|
||
Uses python-telegram-bot library for:
|
||
- Receiving messages from users/groups
|
||
- Sending responses back
|
||
- Handling media and commands
|
||
"""
|
||
|
||
import asyncio
|
||
import dataclasses
|
||
import json
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
import html as _html
|
||
import re
|
||
from datetime import datetime, timezone
|
||
from typing import Dict, List, Optional, Set, Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
try:
|
||
from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup
|
||
try:
|
||
from telegram import LinkPreviewOptions
|
||
except ImportError:
|
||
LinkPreviewOptions = None
|
||
from telegram.ext import (
|
||
Application,
|
||
CommandHandler,
|
||
CallbackQueryHandler,
|
||
MessageHandler as TelegramMessageHandler,
|
||
ContextTypes,
|
||
filters,
|
||
)
|
||
from telegram.constants import ParseMode, ChatType
|
||
from telegram.request import HTTPXRequest
|
||
TELEGRAM_AVAILABLE = True
|
||
except ImportError:
|
||
TELEGRAM_AVAILABLE = False
|
||
Update = Any
|
||
Bot = Any
|
||
Message = Any
|
||
InlineKeyboardButton = Any
|
||
InlineKeyboardMarkup = Any
|
||
LinkPreviewOptions = None
|
||
Application = Any
|
||
CommandHandler = Any
|
||
CallbackQueryHandler = Any
|
||
TelegramMessageHandler = Any
|
||
HTTPXRequest = Any
|
||
filters = None
|
||
ParseMode = None
|
||
ChatType = None
|
||
|
||
# Mock ContextTypes so type annotations using ContextTypes.DEFAULT_TYPE
|
||
# don't crash during class definition when the library isn't installed.
|
||
class _MockContextTypes:
|
||
DEFAULT_TYPE = Any
|
||
ContextTypes = _MockContextTypes
|
||
|
||
import sys
|
||
from pathlib import Path as _Path
|
||
sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||
|
||
from gateway.config import Platform, PlatformConfig
|
||
from gateway.platforms.base import (
|
||
BasePlatformAdapter,
|
||
MessageEvent,
|
||
MessageType,
|
||
ProcessingOutcome,
|
||
SendResult,
|
||
cache_image_from_bytes,
|
||
cache_audio_from_bytes,
|
||
cache_video_from_bytes,
|
||
cache_document_from_bytes,
|
||
resolve_proxy_url,
|
||
SUPPORTED_VIDEO_TYPES,
|
||
SUPPORTED_DOCUMENT_TYPES,
|
||
SUPPORTED_IMAGE_DOCUMENT_TYPES,
|
||
utf16_len,
|
||
)
|
||
from gateway.platforms.telegram_network import (
|
||
TelegramFallbackTransport,
|
||
discover_fallback_ips,
|
||
parse_fallback_ip_env,
|
||
)
|
||
from utils import atomic_replace
|
||
|
||
_TELEGRAM_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||
_TELEGRAM_IMAGE_MIME_TO_EXT = {
|
||
"image/png": ".png",
|
||
"image/jpeg": ".jpg",
|
||
"image/jpg": ".jpg",
|
||
"image/webp": ".webp",
|
||
"image/gif": ".gif",
|
||
}
|
||
_TELEGRAM_IMAGE_EXT_TO_MIME = {
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".webp": "image/webp",
|
||
".gif": "image/gif",
|
||
}
|
||
|
||
|
||
MAX_COMMANDS_PER_SCOPE = 30
|
||
|
||
|
||
def check_telegram_requirements() -> bool:
|
||
"""Check if Telegram dependencies are available.
|
||
|
||
If python-telegram-bot is missing, attempts to lazy-install it via
|
||
``tools.lazy_deps.ensure("platform.telegram")``. After a successful
|
||
install, re-imports the SDK and flips ``TELEGRAM_AVAILABLE`` to True
|
||
so the adapter's class-level type aliases get rebound.
|
||
"""
|
||
global TELEGRAM_AVAILABLE, Update, Bot, Message, InlineKeyboardButton
|
||
global InlineKeyboardMarkup, LinkPreviewOptions, Application
|
||
global CommandHandler, CallbackQueryHandler, TelegramMessageHandler
|
||
global ContextTypes, filters, ParseMode, ChatType, HTTPXRequest
|
||
if TELEGRAM_AVAILABLE:
|
||
return True
|
||
try:
|
||
from tools.lazy_deps import ensure as _lazy_ensure
|
||
_lazy_ensure("platform.telegram", prompt=False)
|
||
except Exception:
|
||
return False
|
||
try:
|
||
from telegram import Update as _Update, Bot as _Bot, Message as _Message
|
||
from telegram import InlineKeyboardButton as _IKB, InlineKeyboardMarkup as _IKM
|
||
try:
|
||
from telegram import LinkPreviewOptions as _LPO
|
||
except ImportError:
|
||
_LPO = None
|
||
from telegram.ext import (
|
||
Application as _App, CommandHandler as _CH,
|
||
CallbackQueryHandler as _CQH,
|
||
MessageHandler as _MH,
|
||
ContextTypes as _CT, filters as _filters,
|
||
)
|
||
from telegram.constants import ParseMode as _PM, ChatType as _CtT
|
||
from telegram.request import HTTPXRequest as _HR
|
||
except ImportError:
|
||
return False
|
||
Update = _Update
|
||
Bot = _Bot
|
||
Message = _Message
|
||
InlineKeyboardButton = _IKB
|
||
InlineKeyboardMarkup = _IKM
|
||
LinkPreviewOptions = _LPO
|
||
Application = _App
|
||
CommandHandler = _CH
|
||
CallbackQueryHandler = _CQH
|
||
TelegramMessageHandler = _MH
|
||
ContextTypes = _CT
|
||
filters = _filters
|
||
ParseMode = _PM
|
||
ChatType = _CtT
|
||
HTTPXRequest = _HR
|
||
TELEGRAM_AVAILABLE = True
|
||
return True
|
||
|
||
|
||
# Matches every character that MarkdownV2 requires to be backslash-escaped
|
||
# when it appears outside a code span or fenced code block.
|
||
_MDV2_ESCAPE_RE = re.compile(r'([_*\[\]()~`>#\+\-=|{}.!\\])')
|
||
|
||
|
||
def _escape_mdv2(text: str) -> str:
|
||
"""Escape Telegram MarkdownV2 special characters with a preceding backslash."""
|
||
return _MDV2_ESCAPE_RE.sub(r'\\\1', text)
|
||
|
||
|
||
def _strip_mdv2(text: str) -> str:
|
||
"""Strip MarkdownV2 escape backslashes to produce clean plain text.
|
||
|
||
Also removes MarkdownV2 formatting markers so the fallback
|
||
doesn't show stray syntax characters from format_message conversion.
|
||
"""
|
||
# Remove escape backslashes before special characters
|
||
cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text)
|
||
# Remove MarkdownV2 bold markers that format_message converted from **bold**
|
||
cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
|
||
# Remove MarkdownV2 italic markers that format_message converted from *italic*
|
||
# Use word boundary (\b) to avoid breaking snake_case like my_variable_name
|
||
cleaned = re.sub(r'(?<!\w)_([^_]+)_(?!\w)', r'\1', cleaned)
|
||
# Remove MarkdownV2 strikethrough markers (~text~ → text)
|
||
cleaned = re.sub(r'~([^~]+)~', r'\1', cleaned)
|
||
# Remove MarkdownV2 spoiler markers (||text|| → text)
|
||
cleaned = re.sub(r'\|\|([^|]+)\|\|', r'\1', cleaned)
|
||
return cleaned
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Markdown table → Telegram-friendly row groups
|
||
# ---------------------------------------------------------------------------
|
||
# Telegram's MarkdownV2 has no table syntax — '|' is just an escaped literal,
|
||
# so pipe tables render as noisy backslash-pipe text with no alignment.
|
||
# Reformating each row into a bold heading plus bullet list keeps the content
|
||
# readable on mobile clients while preserving the source data.
|
||
|
||
# Matches a GFM table delimiter row: optional outer pipes, cells containing
|
||
# only dashes (with optional leading/trailing colons for alignment) separated
|
||
# by '|'. Requires at least one internal '|' so lone '---' horizontal rules
|
||
# are NOT matched.
|
||
_TABLE_SEPARATOR_RE = re.compile(
|
||
r'^\s*\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*){1,}\|?\s*$'
|
||
)
|
||
|
||
|
||
def _is_table_row(line: str) -> bool:
|
||
"""Return True if *line* could plausibly be a table data row."""
|
||
stripped = line.strip()
|
||
return bool(stripped) and '|' in stripped
|
||
|
||
|
||
def _split_markdown_table_row(line: str) -> list[str]:
|
||
"""Split a simple GFM table row into stripped cell values."""
|
||
stripped = line.strip()
|
||
if stripped.startswith("|"):
|
||
stripped = stripped[1:]
|
||
if stripped.endswith("|"):
|
||
stripped = stripped[:-1]
|
||
return [cell.strip() for cell in stripped.split("|")]
|
||
|
||
|
||
def _render_table_block_for_telegram(table_block: list[str]) -> str:
|
||
"""Render a detected GFM table as Telegram-friendly row groups."""
|
||
if len(table_block) < 3:
|
||
return "\n".join(table_block)
|
||
|
||
headers = _split_markdown_table_row(table_block[0])
|
||
if len(headers) < 2:
|
||
return "\n".join(table_block)
|
||
|
||
# Detect row-label column: present when data rows have one more cell
|
||
# than the header row (the row-label column carries no header).
|
||
first_data_row = _split_markdown_table_row(table_block[2]) if len(table_block) > 2 else []
|
||
has_row_label_col = len(first_data_row) == len(headers) + 1
|
||
|
||
rendered_groups: list[str] = []
|
||
for index, row in enumerate(table_block[2:], start=1):
|
||
cells = _split_markdown_table_row(row)
|
||
if has_row_label_col:
|
||
# First cell is the row-label (heading); remaining cells align with headers.
|
||
heading = cells[0] if cells and cells[0] else f"Row {index}"
|
||
data_cells = cells[1:]
|
||
else:
|
||
# No row-label column: use first non-empty cell as heading.
|
||
heading = next((cell for cell in cells if cell), f"Row {index}")
|
||
data_cells = cells
|
||
|
||
# Pad or trim data_cells to match headers length.
|
||
if len(data_cells) < len(headers):
|
||
data_cells.extend([""] * (len(headers) - len(data_cells)))
|
||
elif len(data_cells) > len(headers):
|
||
data_cells = data_cells[: len(headers)]
|
||
|
||
# Build the bulleted lines for this row. Skip any bullet whose value
|
||
# duplicates the heading text -- when has_row_label_col is False the
|
||
# heading IS the first data cell, and emitting it twice (once as the
|
||
# bold heading, once as the first bullet) is visual noise.
|
||
bullets: list[str] = []
|
||
for header, value in zip(headers, data_cells):
|
||
if not has_row_label_col and value == heading:
|
||
continue
|
||
bullets.append(f"• {header}: {value}")
|
||
|
||
# Within a row-group: single newline between heading and its bullets,
|
||
# and between successive bullets. This keeps the row visually tight
|
||
# on Telegram instead of stretching each bullet into its own paragraph.
|
||
group_lines = [f"**{heading}**", *bullets]
|
||
rendered_groups.append("\n".join(group_lines))
|
||
|
||
# Between row-groups: blank line so each group reads as a distinct block.
|
||
return "\n\n".join(rendered_groups)
|
||
|
||
|
||
def _wrap_markdown_tables(text: str) -> str:
|
||
"""Rewrite GFM-style pipe tables into Telegram-friendly bullet groups.
|
||
|
||
Detected by a row containing '|' immediately followed by a delimiter
|
||
row matching :data:`_TABLE_SEPARATOR_RE`. Subsequent pipe-containing
|
||
non-blank lines are consumed as the table body and rewritten as
|
||
per-row bullet groups. Tables inside existing fenced code blocks are left
|
||
alone.
|
||
"""
|
||
if '|' not in text or '-' not in text:
|
||
return text
|
||
|
||
lines = text.split('\n')
|
||
out: list[str] = []
|
||
in_fence = False
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
stripped = line.lstrip()
|
||
|
||
# Track existing fenced code blocks — never touch content inside.
|
||
if stripped.startswith('```'):
|
||
in_fence = not in_fence
|
||
out.append(line)
|
||
i += 1
|
||
continue
|
||
if in_fence:
|
||
out.append(line)
|
||
i += 1
|
||
continue
|
||
|
||
# Look for a header row (contains '|') immediately followed by a
|
||
# delimiter row.
|
||
if (
|
||
'|' in line
|
||
and i + 1 < len(lines)
|
||
and _TABLE_SEPARATOR_RE.match(lines[i + 1])
|
||
):
|
||
table_block = [line, lines[i + 1]]
|
||
j = i + 2
|
||
while j < len(lines) and _is_table_row(lines[j]):
|
||
table_block.append(lines[j])
|
||
j += 1
|
||
out.append(_render_table_block_for_telegram(table_block))
|
||
i = j
|
||
continue
|
||
|
||
out.append(line)
|
||
i += 1
|
||
|
||
return '\n'.join(out)
|
||
|
||
|
||
class TelegramAdapter(BasePlatformAdapter):
|
||
"""
|
||
Telegram bot adapter.
|
||
|
||
Handles:
|
||
- Receiving messages from users and groups
|
||
- Sending responses with Telegram markdown
|
||
- Forum topics (thread_id support)
|
||
- Media messages
|
||
"""
|
||
|
||
# Telegram message limits
|
||
MAX_MESSAGE_LENGTH = 4096
|
||
# Threshold for detecting Telegram client-side message splits.
|
||
# When a chunk is near this limit, a continuation is almost certain.
|
||
_SPLIT_THRESHOLD = 4000
|
||
MEDIA_GROUP_WAIT_SECONDS = 0.8
|
||
_GENERAL_TOPIC_THREAD_ID = "1"
|
||
|
||
# Telegram's edit_message applies MarkdownV2 formatting only on the
|
||
# finalize=True path. Without this flag, stream_consumer._send_or_edit
|
||
# short-circuits when the raw text is unchanged between the last streamed
|
||
# edit and the final edit, skipping the plain-text → MarkdownV2 conversion.
|
||
# Fixes #25710.
|
||
REQUIRES_EDIT_FINALIZE: bool = True
|
||
|
||
# Adaptive text-batch ingress: short messages need a tighter delay so the
|
||
# first token reaches the agent fast. Numbers tuned for "feels instant":
|
||
# ≤320 codepoints (one short paragraph) settles in ~180ms; ≤1024
|
||
# (a normal paragraph) in ~240ms; longer waits the configured cap.
|
||
# Always clamped to ``_text_batch_delay_seconds`` so an operator can lower
|
||
# the cap further via env var.
|
||
_TEXT_BATCH_FAST_LEN = 320
|
||
_TEXT_BATCH_FAST_DELAY_S = 0.18
|
||
_TEXT_BATCH_SHORT_LEN = 1024
|
||
_TEXT_BATCH_SHORT_DELAY_S = 0.24
|
||
|
||
@staticmethod
|
||
def _env_float_clamped(
|
||
name: str,
|
||
default: float,
|
||
*,
|
||
min_value: Optional[float] = None,
|
||
max_value: Optional[float] = None,
|
||
) -> float:
|
||
"""Read a float env var, reject non-finite values, and clamp to bounds.
|
||
|
||
Guarantees the returned value is a finite number usable directly in
|
||
``asyncio.sleep()`` and similar APIs that reject NaN / Inf.
|
||
"""
|
||
import math
|
||
|
||
raw = os.getenv(name)
|
||
try:
|
||
value = float(raw) if raw is not None else float(default)
|
||
except (TypeError, ValueError):
|
||
value = float(default)
|
||
if not math.isfinite(value):
|
||
value = float(default)
|
||
if min_value is not None:
|
||
value = max(value, min_value)
|
||
if max_value is not None:
|
||
value = min(value, max_value)
|
||
return value
|
||
|
||
@property
|
||
def message_len_fn(self):
|
||
"""Telegram measures message length in UTF-16 code units."""
|
||
return utf16_len
|
||
|
||
def __init__(self, config: PlatformConfig):
|
||
super().__init__(config, Platform.TELEGRAM)
|
||
self._app: Optional[Application] = None
|
||
self._bot: Optional[Bot] = None
|
||
self._webhook_mode: bool = False
|
||
self._mention_patterns = self._compile_mention_patterns()
|
||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
|
||
# Buffer rapid/album photo updates so Telegram image bursts are handled
|
||
# as a single MessageEvent instead of self-interrupting multiple turns.
|
||
self._media_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", "0.8"))
|
||
self._pending_photo_batches: Dict[str, MessageEvent] = {}
|
||
self._pending_photo_batch_tasks: Dict[str, asyncio.Task] = {}
|
||
self._media_group_events: Dict[str, MessageEvent] = {}
|
||
self._media_group_tasks: Dict[str, asyncio.Task] = {}
|
||
# Buffer rapid text messages so Telegram client-side splits of long
|
||
# messages are aggregated into a single MessageEvent. Lower defaults
|
||
# (0.3s / 1.0s instead of 0.6s / 2.0s) let short replies stream
|
||
# without a noticeable wait — combined with the adaptive fast-path
|
||
# in ``_calc_text_batch_delay`` below, ≤320-codepoint replies settle
|
||
# in ~180ms. All bounds are conservative for Telegram's
|
||
# ~1 edit/s flood envelope.
|
||
self._text_batch_delay_seconds = self._env_float_clamped(
|
||
"HERMES_TELEGRAM_TEXT_BATCH_DELAY_SECONDS",
|
||
0.3,
|
||
min_value=0.08,
|
||
max_value=2.0,
|
||
)
|
||
self._text_batch_split_delay_seconds = self._env_float_clamped(
|
||
"HERMES_TELEGRAM_TEXT_BATCH_SPLIT_DELAY_SECONDS",
|
||
1.0,
|
||
min_value=self._text_batch_delay_seconds,
|
||
max_value=4.0,
|
||
)
|
||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||
self._polling_error_task: Optional[asyncio.Task] = None
|
||
self._polling_conflict_count: int = 0
|
||
self._polling_network_error_count: int = 0
|
||
self._polling_error_callback_ref = None
|
||
# After sustained reconnect storms the PTB httpx pool can return
|
||
# SendResult(success=True) for sends that never actually transmit.
|
||
# _handle_polling_network_error sets this; _verify_polling_after_reconnect
|
||
# clears it once getMe() confirms the Bot client is healthy.
|
||
# While True, send() short-circuits to a failure so callers
|
||
# (cron live-adapter branch) fall through to standalone delivery.
|
||
self._send_path_degraded: bool = False
|
||
# DM Topics: map of topic_name -> message_thread_id (populated at startup)
|
||
self._dm_topics: Dict[str, int] = {}
|
||
# Track forum chats where we've already registered bot commands
|
||
self._forum_command_registered: set[int] = set()
|
||
# Lock per la registrazione sicura dei comandi nei forum supergroup
|
||
self._forum_lock = asyncio.Lock()
|
||
# DM Topics config from extra.dm_topics
|
||
self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", [])
|
||
# Precomputed chat_ids that have DM topics configured (for O(1) root-DM ignore check)
|
||
self._dm_topic_chat_ids: Set[str] = {
|
||
str(e["chat_id"]) for e in self._dm_topics_config if "chat_id" in e
|
||
}
|
||
# Document size cap. Telegram's public Bot API caps getFile at 20MB; a
|
||
# locally-hosted telegram-bot-api server (configured via extra.base_url)
|
||
# raises that to 2GB, so the presence of base_url is the opt-in.
|
||
self._max_doc_bytes: int = (
|
||
2 * 1024 * 1024 * 1024
|
||
if self.config.extra.get("base_url")
|
||
else 20 * 1024 * 1024
|
||
)
|
||
# Interactive model picker state per chat
|
||
self._model_picker_state: Dict[str, dict] = {}
|
||
# Approval button state: message_id → session_key
|
||
self._approval_state: Dict[int, str] = {}
|
||
# Slash-confirm button state: confirm_id → session_key (for /reload-mcp
|
||
# and any other slash-confirm prompts; see GatewayRunner._request_slash_confirm).
|
||
self._slash_confirm_state: Dict[str, str] = {}
|
||
# Clarify button state: clarify_id → session_key (for the clarify tool's
|
||
# multiple-choice prompts; see GatewayRunner clarify_callback wiring).
|
||
self._clarify_state: Dict[str, str] = {}
|
||
# Notification mode for message sends.
|
||
# "important" — only final responses, approvals, and slash confirmations
|
||
# trigger notifications; tool progress, streaming, status
|
||
# messages are delivered silently via disable_notification.
|
||
# This is the default — Telegram users found per-tool-call
|
||
# push notifications too noisy.
|
||
# "all" — every message triggers a push notification (legacy
|
||
# behavior; opt-in via display.platforms.telegram.notifications).
|
||
self._notifications_mode: str = "important"
|
||
# send_or_update_status() bookkeeping: {(chat_id, status_key) -> bot message_id}
|
||
# Tracks status bubbles owned by this adapter so subsequent calls with the
|
||
# same key edit the same message instead of appending new ones (#30045).
|
||
self._status_message_ids: Dict[tuple, str] = {}
|
||
|
||
def _notification_kwargs(
|
||
self, metadata: Optional[Dict[str, Any]]
|
||
) -> Dict[str, Any]:
|
||
"""Return disable_notification kwargs when the adapter is in silent mode.
|
||
|
||
In "important" mode, all message sends are silently delivered
|
||
(disable_notification=True) unless the caller explicitly requests a
|
||
notification by setting ``metadata["notify"] = True``.
|
||
"""
|
||
if getattr(self, "_notifications_mode", "important") != "important":
|
||
return {}
|
||
if (metadata or {}).get("notify"):
|
||
return {}
|
||
return {"disable_notification": True}
|
||
|
||
def _is_callback_user_authorized(
|
||
self,
|
||
user_id: str,
|
||
*,
|
||
chat_id: Optional[str] = None,
|
||
chat_type: Optional[str] = None,
|
||
thread_id: Optional[str] = None,
|
||
user_name: Optional[str] = None,
|
||
) -> bool:
|
||
"""Return whether a Telegram inline-button caller may perform gated actions."""
|
||
normalized_user_id = str(user_id or "").strip()
|
||
if not normalized_user_id:
|
||
return False
|
||
|
||
runner = getattr(getattr(self, "_message_handler", None), "__self__", None)
|
||
auth_fn = getattr(runner, "_is_user_authorized", None)
|
||
if callable(auth_fn):
|
||
try:
|
||
from gateway.session import SessionSource
|
||
|
||
normalized_chat_type = str(chat_type or "dm").strip().lower() or "dm"
|
||
if normalized_chat_type == "private":
|
||
normalized_chat_type = "dm"
|
||
elif normalized_chat_type == "supergroup":
|
||
normalized_chat_type = "forum" if thread_id is not None else "group"
|
||
|
||
source = SessionSource(
|
||
platform=Platform.TELEGRAM,
|
||
chat_id=str(chat_id or normalized_user_id),
|
||
chat_type=normalized_chat_type,
|
||
user_id=normalized_user_id,
|
||
user_name=str(user_name).strip() if user_name else None,
|
||
thread_id=str(thread_id) if thread_id is not None else None,
|
||
)
|
||
return bool(auth_fn(source))
|
||
except Exception:
|
||
logger.debug(
|
||
"[Telegram] Falling back to env-only callback auth for user %s",
|
||
normalized_user_id,
|
||
exc_info=True,
|
||
)
|
||
|
||
allowed_csv = os.getenv("TELEGRAM_ALLOWED_USERS", "").strip()
|
||
if not allowed_csv:
|
||
# Fail-closed: no allowlist means deny by default.
|
||
# The runner auth path in _is_user_authorized() handles
|
||
# GATEWAY_ALLOW_ALL_USERS; this fallback must not silently
|
||
# allow everyone (fixes #24457).
|
||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||
allowed_ids = {uid.strip() for uid in allowed_csv.split(",") if uid.strip()}
|
||
return "*" in allowed_ids or normalized_user_id in allowed_ids
|
||
|
||
@classmethod
|
||
def _metadata_thread_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:
|
||
if not metadata:
|
||
return None
|
||
thread_id = metadata.get("thread_id") or metadata.get("message_thread_id")
|
||
return str(thread_id) if thread_id is not None else None
|
||
|
||
@classmethod
|
||
def _metadata_direct_messages_topic_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[str]:
|
||
if not metadata:
|
||
return None
|
||
topic_id = metadata.get("direct_messages_topic_id") or metadata.get("telegram_direct_messages_topic_id")
|
||
return str(topic_id) if topic_id is not None else None
|
||
|
||
@classmethod
|
||
def _metadata_reply_to_message_id(cls, metadata: Optional[Dict[str, Any]]) -> Optional[int]:
|
||
if not metadata:
|
||
return None
|
||
reply_to = metadata.get("telegram_reply_to_message_id")
|
||
return int(reply_to) if reply_to is not None else None
|
||
|
||
@staticmethod
|
||
def _looks_like_private_chat_id(chat_id: str) -> bool:
|
||
try:
|
||
return int(chat_id) > 0
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
@classmethod
|
||
def _is_private_dm_topic_send(
|
||
cls,
|
||
chat_id: str,
|
||
thread_id: Optional[str],
|
||
metadata: Optional[Dict[str, Any]],
|
||
) -> bool:
|
||
if cls._metadata_direct_messages_topic_id(metadata) is not None:
|
||
return False
|
||
if metadata and metadata.get("telegram_dm_topic_created_for_send"):
|
||
return False
|
||
return bool(
|
||
thread_id
|
||
and (
|
||
metadata and metadata.get("telegram_dm_topic_reply_fallback")
|
||
or cls._looks_like_private_chat_id(chat_id)
|
||
)
|
||
)
|
||
|
||
@staticmethod
|
||
def _dm_topic_missing_anchor_error() -> str:
|
||
return "Telegram DM topic delivery requires a reply anchor; refusing to send outside the requested topic"
|
||
|
||
@classmethod
|
||
def _reply_to_message_id_for_send(
|
||
cls,
|
||
reply_to: Optional[str],
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
reply_to_mode: Optional[str] = None,
|
||
) -> Optional[int]:
|
||
if reply_to:
|
||
return int(reply_to)
|
||
if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
|
||
if reply_to_mode == "off":
|
||
return None
|
||
return cls._metadata_reply_to_message_id(metadata)
|
||
return None
|
||
|
||
@classmethod
|
||
def _thread_kwargs_for_send(
|
||
cls,
|
||
chat_id: str,
|
||
thread_id: Optional[str],
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
reply_to_message_id: Optional[int] = None,
|
||
reply_to_mode: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Return Telegram send kwargs for forum and direct-message topic routing.
|
||
|
||
Supergroup/forum topics use ``message_thread_id``. True Bot API Direct
|
||
Messages topics can opt in with explicit ``direct_messages_topic_id``
|
||
metadata. Hermes-created private-chat topic lanes are marked with
|
||
``telegram_dm_topic_reply_fallback``. Live replies send the private
|
||
topic thread id together with a reply anchor; synthetic/resumed sends
|
||
without an anchor use ``direct_messages_topic_id`` when metadata has it.
|
||
``message_thread_id`` alone can render outside the visible lane.
|
||
|
||
When ``reply_to_mode`` is ``"off"``, the reply anchor is suppressed for
|
||
DM topic fallback sends while preserving the ``message_thread_id`` so
|
||
the message still lands in the correct topic.
|
||
"""
|
||
if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
|
||
if reply_to_mode == "off":
|
||
return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
|
||
if reply_to_message_id is None:
|
||
reply_to_message_id = cls._metadata_reply_to_message_id(metadata)
|
||
if reply_to_message_id is None:
|
||
direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)
|
||
if direct_topic_id is not None:
|
||
return {
|
||
"message_thread_id": None,
|
||
"direct_messages_topic_id": int(direct_topic_id),
|
||
}
|
||
return {}
|
||
return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
|
||
direct_topic_id = cls._metadata_direct_messages_topic_id(metadata)
|
||
if direct_topic_id is not None:
|
||
return {
|
||
"message_thread_id": None,
|
||
"direct_messages_topic_id": int(direct_topic_id),
|
||
}
|
||
return {"message_thread_id": cls._message_thread_id_for_send(thread_id)}
|
||
|
||
@classmethod
|
||
def _message_thread_id_for_send(cls, thread_id: Optional[str]) -> Optional[int]:
|
||
if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID:
|
||
return None
|
||
return int(thread_id)
|
||
|
||
@classmethod
|
||
def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]:
|
||
# Asymmetric with _message_thread_id_for_send on purpose. Telegram's
|
||
# sendMessage and sendChatAction treat thread id "1" (the forum General
|
||
# topic) differently: sends reject message_thread_id=1 and must omit it,
|
||
# but sendChatAction needs message_thread_id=1 to place the typing
|
||
# bubble in the General topic (omitting it hides the bubble entirely
|
||
# from the client's view of that topic). Preserve the real id here —
|
||
# sends still map "1" → None via _message_thread_id_for_send.
|
||
if not thread_id:
|
||
return None
|
||
return int(thread_id)
|
||
|
||
@staticmethod
|
||
def _is_thread_not_found_error(error: Exception) -> bool:
|
||
return "thread not found" in str(error).lower()
|
||
|
||
@staticmethod
|
||
def _is_bad_request_error(error: Exception) -> bool:
|
||
name = error.__class__.__name__.lower()
|
||
if name == "badrequest" or name.endswith("badrequest"):
|
||
return True
|
||
try:
|
||
from telegram.error import BadRequest
|
||
return isinstance(error, BadRequest)
|
||
except ImportError:
|
||
return False
|
||
|
||
@classmethod
|
||
def _should_retry_without_dm_topic_reply_anchor(
|
||
cls,
|
||
error: Exception,
|
||
metadata: Optional[Dict[str, Any]],
|
||
reply_to_message_id: Optional[int],
|
||
) -> bool:
|
||
"""True when a DM-topic send should be retried with routing stripped.
|
||
|
||
Two cases trigger the retry:
|
||
|
||
1. The original anchor-stale case — the reply target was deleted, so
|
||
Bot API returns "message to be replied not found". The retry drops
|
||
the reply anchor and the topic id together.
|
||
|
||
2. The synthetic-event case (added when #27937 introduced
|
||
``direct_messages_topic_id`` fallback for sends without an anchor):
|
||
if Bot API rejects the topic id itself with any BadRequest that
|
||
mentions topic/thread routing, we retry without routing rather
|
||
than dropping the message.
|
||
"""
|
||
if not (metadata and metadata.get("telegram_dm_topic_reply_fallback")):
|
||
return False
|
||
if not cls._is_bad_request_error(error):
|
||
return False
|
||
err_lower = str(error).lower()
|
||
if reply_to_message_id is not None and "message to be replied not found" in err_lower:
|
||
return True
|
||
# Synthetic / resumed sends route via ``direct_messages_topic_id``
|
||
# instead of a reply anchor. If Telegram rejects the topic id, fall
|
||
# back to a plain DM send.
|
||
if metadata.get("direct_messages_topic_id"):
|
||
topic_markers = (
|
||
"direct_messages_topic",
|
||
"message thread not found",
|
||
"thread not found",
|
||
"topic_closed",
|
||
"topic_deleted",
|
||
"topic not found",
|
||
)
|
||
if any(marker in err_lower for marker in topic_markers):
|
||
return True
|
||
return False
|
||
|
||
async def _send_with_dm_topic_reply_anchor_retry(
|
||
self,
|
||
send_fn: Any,
|
||
send_kwargs: Dict[str, Any],
|
||
metadata: Optional[Dict[str, Any]],
|
||
reply_to_message_id: Optional[int],
|
||
media_label: str,
|
||
reset_media: Optional[Any] = None,
|
||
) -> Any:
|
||
"""Retry stale private-topic media replies once without the topic anchor."""
|
||
try:
|
||
return await send_fn(**send_kwargs)
|
||
except Exception as send_err:
|
||
if not self._should_retry_without_dm_topic_reply_anchor(
|
||
send_err,
|
||
metadata,
|
||
reply_to_message_id,
|
||
):
|
||
raise
|
||
logger.warning(
|
||
"[%s] Reply target deleted for Telegram %s, "
|
||
"retrying without reply/topic anchor: %s",
|
||
self.name,
|
||
media_label,
|
||
send_err,
|
||
)
|
||
if reset_media is not None:
|
||
reset_media()
|
||
retry_kwargs = dict(send_kwargs)
|
||
retry_kwargs["reply_to_message_id"] = None
|
||
retry_kwargs.pop("message_thread_id", None)
|
||
retry_kwargs.pop("direct_messages_topic_id", None)
|
||
return await send_fn(**retry_kwargs)
|
||
|
||
def _fallback_ips(self) -> list[str]:
|
||
"""Return validated fallback IPs from config (populated by _apply_env_overrides)."""
|
||
configured = self.config.extra.get("fallback_ips", []) if getattr(self.config, "extra", None) else []
|
||
if isinstance(configured, str):
|
||
configured = configured.split(",")
|
||
return parse_fallback_ip_env(",".join(str(v) for v in configured) if configured else None)
|
||
|
||
@staticmethod
|
||
def _looks_like_polling_conflict(error: Exception) -> bool:
|
||
text = str(error).lower()
|
||
return (
|
||
error.__class__.__name__.lower() == "conflict"
|
||
or "terminated by other getupdates request" in text
|
||
or "another bot instance is running" in text
|
||
)
|
||
|
||
@staticmethod
|
||
def _looks_like_network_error(error: Exception) -> bool:
|
||
"""Return True for transient network errors that warrant a reconnect attempt."""
|
||
name = error.__class__.__name__.lower()
|
||
if name in {"networkerror", "timedout", "connectionerror"}:
|
||
return True
|
||
try:
|
||
from telegram.error import NetworkError, TimedOut
|
||
if isinstance(error, (NetworkError, TimedOut)):
|
||
return True
|
||
except ImportError:
|
||
pass
|
||
return isinstance(error, OSError)
|
||
|
||
@staticmethod
|
||
def _looks_like_connect_timeout(error: Exception) -> bool:
|
||
"""Return True when a Telegram TimedOut wraps a connect-timeout.
|
||
|
||
A plain Telegram TimedOut may mean the request reached Telegram and
|
||
should not be re-sent. A ConnectTimeout means the TCP connection was
|
||
never established, so retrying is safe and prevents silent drops.
|
||
"""
|
||
seen: set[int] = set()
|
||
stack: list[BaseException] = [error]
|
||
while stack:
|
||
cur = stack.pop()
|
||
ident = id(cur)
|
||
if ident in seen:
|
||
continue
|
||
seen.add(ident)
|
||
name = cur.__class__.__name__.lower()
|
||
text = str(cur).lower()
|
||
if "connecttimeout" in name or "connect timeout" in text or "connect timed out" in text:
|
||
return True
|
||
cause = getattr(cur, "__cause__", None)
|
||
context = getattr(cur, "__context__", None)
|
||
if cause is not None:
|
||
stack.append(cause)
|
||
if context is not None:
|
||
stack.append(context)
|
||
return False
|
||
|
||
@staticmethod
|
||
def _looks_like_pool_timeout(error: Exception) -> bool:
|
||
"""Return True when a Telegram TimedOut wraps an httpx pool timeout.
|
||
|
||
PTB converts ``httpx.PoolTimeout`` into ``telegram.error.TimedOut`` with
|
||
a message that explicitly states the request was *not* sent
|
||
(``"Pool timeout: All connections in the connection pool are occupied.
|
||
Request was *not* sent to Telegram."``). Because the request never left
|
||
the process, re-sending is safe and cannot duplicate -- the opposite of
|
||
a generic TimedOut, which may have reached Telegram. We match the
|
||
wrapped ``httpx.PoolTimeout`` class as well as the message string so the
|
||
check survives PTB message-wording changes.
|
||
"""
|
||
seen: set[int] = set()
|
||
stack: list[BaseException] = [error]
|
||
while stack:
|
||
cur = stack.pop()
|
||
ident = id(cur)
|
||
if ident in seen:
|
||
continue
|
||
seen.add(ident)
|
||
name = cur.__class__.__name__.lower()
|
||
text = str(cur).lower()
|
||
if "pooltimeout" in name or "pool timeout" in text or (
|
||
"connection pool" in text and "occupied" in text
|
||
):
|
||
return True
|
||
cause = getattr(cur, "__cause__", None)
|
||
context = getattr(cur, "__context__", None)
|
||
if cause is not None:
|
||
stack.append(cause)
|
||
if context is not None:
|
||
stack.append(context)
|
||
return False
|
||
|
||
def _coerce_bool_extra(self, key: str, default: bool = False) -> bool:
|
||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||
if value is None:
|
||
return default
|
||
if isinstance(value, str):
|
||
lowered = value.strip().lower()
|
||
if lowered in {"true", "1", "yes", "on"}:
|
||
return True
|
||
if lowered in {"false", "0", "no", "off"}:
|
||
return False
|
||
return default
|
||
return bool(value)
|
||
|
||
def _link_preview_kwargs(self) -> Dict[str, Any]:
|
||
if not getattr(self, "_disable_link_previews", False):
|
||
return {}
|
||
if LinkPreviewOptions is not None:
|
||
return {"link_preview_options": LinkPreviewOptions(is_disabled=True)}
|
||
return {"disable_web_page_preview": True}
|
||
|
||
async def _drain_polling_connections(self) -> None:
|
||
"""Reset the httpx connection pool used for getUpdates polling.
|
||
|
||
Network errors (especially through proxies like sing-box) can leave
|
||
httpx connections in a half-closed state that still occupy pool slots.
|
||
After enough reconnect cycles the pool fills up entirely, causing
|
||
``Pool timeout: All connections in the connection pool are occupied.``
|
||
|
||
We reset ONLY ``_request[0]`` (the getUpdates request) — the general
|
||
request (``_request[1]``) is left untouched so concurrent
|
||
``send_message`` / ``edit_message`` calls are never interrupted.
|
||
|
||
Implementation note: accesses ``Bot._request[0]`` which is the
|
||
get-updates ``BaseRequest`` in the PTB 22.x internal tuple
|
||
``(get_updates_request, general_request)``. There is no public
|
||
accessor for the polling request; review if upgrading to PTB 23+.
|
||
"""
|
||
if not (self._app and self._app.bot):
|
||
return
|
||
try:
|
||
# PTB 22.x: _request is a (get_updates, general) tuple;
|
||
# no public accessor exists for the polling request.
|
||
polling_req = self._app.bot._request[0] # noqa: SLF001
|
||
except Exception:
|
||
return
|
||
try:
|
||
await polling_req.shutdown()
|
||
except Exception:
|
||
logger.debug(
|
||
"[%s] Polling request shutdown failed (non-fatal)",
|
||
self.name, exc_info=True,
|
||
)
|
||
try:
|
||
await polling_req.initialize()
|
||
logger.debug(
|
||
"[%s] Polling request pool drained before reconnect", self.name
|
||
)
|
||
except Exception:
|
||
logger.debug(
|
||
"[%s] Polling request re-initialize failed (non-fatal)",
|
||
self.name, exc_info=True,
|
||
)
|
||
|
||
async def _handle_polling_network_error(self, error: Exception) -> None:
|
||
"""Reconnect polling after a transient network interruption.
|
||
|
||
Triggered by NetworkError/TimedOut in the polling error callback, which
|
||
happen when the host loses connectivity (Mac sleep, WiFi switch, VPN
|
||
reconnect, etc.). The gateway process stays alive but the long-poll
|
||
connection silently dies; without this handler the bot never recovers.
|
||
|
||
Strategy: exponential back-off (5s, 10s, 20s, 40s, 60s cap) up to
|
||
MAX_NETWORK_RETRIES attempts, then mark the adapter retryable-fatal so
|
||
the supervisor restarts the gateway process.
|
||
"""
|
||
if self.has_fatal_error:
|
||
return
|
||
|
||
MAX_NETWORK_RETRIES = 10
|
||
BASE_DELAY = 5
|
||
MAX_DELAY = 60
|
||
|
||
self._polling_network_error_count += 1
|
||
self._send_path_degraded = True
|
||
attempt = self._polling_network_error_count
|
||
|
||
if attempt > MAX_NETWORK_RETRIES:
|
||
message = (
|
||
"Telegram polling could not reconnect after %d network error retries. "
|
||
"Restarting gateway." % MAX_NETWORK_RETRIES
|
||
)
|
||
logger.error("[%s] %s Last error: %s", self.name, message, error)
|
||
self._set_fatal_error("telegram_network_error", message, retryable=True)
|
||
await self._notify_fatal_error()
|
||
return
|
||
|
||
delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY)
|
||
logger.warning(
|
||
"[%s] Telegram network error (attempt %d/%d), reconnecting in %ds. Error: %s",
|
||
self.name, attempt, MAX_NETWORK_RETRIES, delay, error,
|
||
)
|
||
await asyncio.sleep(delay)
|
||
|
||
try:
|
||
if self._app and self._app.updater and self._app.updater.running:
|
||
await self._app.updater.stop()
|
||
except Exception:
|
||
pass
|
||
|
||
await self._drain_polling_connections()
|
||
|
||
try:
|
||
await self._app.updater.start_polling(
|
||
allowed_updates=Update.ALL_TYPES,
|
||
drop_pending_updates=False,
|
||
error_callback=self._polling_error_callback_ref,
|
||
)
|
||
logger.info(
|
||
"[%s] Telegram polling resumed after network error (attempt %d)",
|
||
self.name, attempt,
|
||
)
|
||
self._polling_network_error_count = 0
|
||
# start_polling() returning is necessary but not sufficient:
|
||
# PTB's Updater can be left in a state where `running` is True
|
||
# but the underlying long-poll task is wedged on a stale httpx
|
||
# connection and never makes progress. No error_callback fires
|
||
# in that state, so the reconnect ladder won't advance on its
|
||
# own. Schedule a deferred probe to detect the wedge and
|
||
# re-enter the ladder if needed.
|
||
if not self.has_fatal_error:
|
||
probe = asyncio.ensure_future(self._verify_polling_after_reconnect())
|
||
self._background_tasks.add(probe)
|
||
probe.add_done_callback(self._background_tasks.discard)
|
||
except Exception as retry_err:
|
||
logger.warning("[%s] Telegram polling reconnect failed: %s", self.name, retry_err)
|
||
# start_polling failed — polling is dead and no further error
|
||
# callbacks will fire, so schedule the next retry ourselves.
|
||
if not self.has_fatal_error:
|
||
task = asyncio.ensure_future(
|
||
self._handle_polling_network_error(retry_err)
|
||
)
|
||
self._background_tasks.add(task)
|
||
task.add_done_callback(self._background_tasks.discard)
|
||
|
||
async def _verify_polling_after_reconnect(self) -> None:
|
||
"""Heartbeat probe scheduled after a successful reconnect.
|
||
|
||
PTB's Updater can survive a botched stop()+start_polling() cycle
|
||
with `running=True` but a wedged consumer task. No error callback
|
||
fires, so the reconnect ladder doesn't advance on its own. This
|
||
probe detects the wedge by:
|
||
|
||
1. Sleeping HEARTBEAT_PROBE_DELAY so a healthy long-poll has time
|
||
to complete at least one cycle.
|
||
2. Verifying `Updater.running` is still True.
|
||
3. Probing the bot endpoint with a tight asyncio timeout. A
|
||
wedged httpx pool fails this probe; a healthy one returns
|
||
well under the timeout.
|
||
|
||
On any failure, re-enter the reconnect ladder so the existing
|
||
MAX_NETWORK_RETRIES path can ultimately escalate to fatal-error.
|
||
"""
|
||
HEARTBEAT_PROBE_DELAY = 60
|
||
PROBE_TIMEOUT = 10
|
||
|
||
await asyncio.sleep(HEARTBEAT_PROBE_DELAY)
|
||
|
||
if self.has_fatal_error:
|
||
return
|
||
if not (self._app and self._app.updater and self._app.updater.running):
|
||
logger.warning(
|
||
"[%s] Updater not running %ds after reconnect — treating as wedged",
|
||
self.name, HEARTBEAT_PROBE_DELAY,
|
||
)
|
||
await self._handle_polling_network_error(
|
||
RuntimeError("Updater not running after reconnect heartbeat")
|
||
)
|
||
return
|
||
|
||
try:
|
||
await asyncio.wait_for(self._app.bot.get_me(), PROBE_TIMEOUT)
|
||
self._send_path_degraded = False
|
||
except Exception as probe_err:
|
||
logger.warning(
|
||
"[%s] Polling heartbeat probe failed %ds after reconnect: %s",
|
||
self.name, HEARTBEAT_PROBE_DELAY, probe_err,
|
||
)
|
||
await self._handle_polling_network_error(probe_err)
|
||
|
||
async def _handle_polling_conflict(self, error: Exception) -> None:
|
||
if self.has_fatal_error and self.fatal_error_code == "telegram_polling_conflict":
|
||
return
|
||
# Transient 409 Conflict errors arise when the previous gateway process
|
||
# has been killed (e.g. during `hermes update` or `--replace` handoffs)
|
||
# but its long-poll connection hasn't yet expired on Telegram's servers.
|
||
# Telegram holds open getUpdates sessions for up to ~30s after the
|
||
# client disconnects, so a new gateway starting immediately will receive
|
||
# a 409 until that server-side session expires.
|
||
#
|
||
# Strategy: stop the local updater, wait long enough for Telegram's
|
||
# server-side session to expire (RETRY_DELAY grows with each attempt),
|
||
# drain the connection pool, then restart polling. We attempt this
|
||
# MAX_CONFLICT_RETRIES times before declaring a fatal error.
|
||
#
|
||
# Crucially, a failed retry must NOT leave polling in an ambiguous
|
||
# state. If start_polling() raises, the updater is neither running
|
||
# nor fatal — messages are silently dropped. We schedule another
|
||
# retry attempt instead of returning silently, and only escalate to
|
||
# fatal after all retries are exhausted.
|
||
self._polling_conflict_count += 1
|
||
|
||
MAX_CONFLICT_RETRIES = 5
|
||
# Delay grows with each attempt: 15s, 25s, 35s, 45s, 55s.
|
||
# Telegram server-side getUpdates sessions typically expire within
|
||
# 30s; the increasing back-off ensures we clear that window without
|
||
# hammering the API on fast-restart loops.
|
||
RETRY_DELAY = 10 + (self._polling_conflict_count * 10) # seconds
|
||
|
||
if self._polling_conflict_count <= MAX_CONFLICT_RETRIES:
|
||
logger.warning(
|
||
"[%s] Telegram polling conflict (%d/%d) — previous session still "
|
||
"held open on Telegram's servers. Waiting %ds for it to expire. "
|
||
"Error: %s",
|
||
self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES,
|
||
RETRY_DELAY, error,
|
||
)
|
||
# Stop the local updater cleanly before sleeping. If it's already
|
||
# stopped (e.g. PTB raised before updater.running was set) this is
|
||
# a no-op.
|
||
try:
|
||
if self._app and self._app.updater and self._app.updater.running:
|
||
await self._app.updater.stop()
|
||
except Exception:
|
||
pass
|
||
|
||
await asyncio.sleep(RETRY_DELAY)
|
||
await self._drain_polling_connections()
|
||
|
||
try:
|
||
await self._app.updater.start_polling(
|
||
allowed_updates=Update.ALL_TYPES,
|
||
drop_pending_updates=False,
|
||
error_callback=self._polling_error_callback_ref,
|
||
)
|
||
logger.info(
|
||
"[%s] Telegram polling resumed after conflict retry %d/%d",
|
||
self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES,
|
||
)
|
||
self._polling_conflict_count = 0 # reset counter on success
|
||
return
|
||
except Exception as retry_err:
|
||
logger.warning(
|
||
"[%s] Telegram polling retry %d/%d failed: %s. "
|
||
"Scheduling next attempt.",
|
||
self.name, self._polling_conflict_count, MAX_CONFLICT_RETRIES,
|
||
retry_err,
|
||
)
|
||
# Schedule the next retry rather than returning silently.
|
||
# Returning here without either restarting polling or setting
|
||
# a fatal error leaves the adapter in a limbo state: the
|
||
# gateway process is alive and reports "connected" but
|
||
# no messages are received or sent.
|
||
if self._polling_conflict_count < MAX_CONFLICT_RETRIES:
|
||
loop = asyncio.get_event_loop()
|
||
self._polling_error_task = loop.create_task(
|
||
self._handle_polling_conflict(retry_err)
|
||
)
|
||
return
|
||
# Fall through to fatal on the last retry.
|
||
|
||
# Exhausted all retries — declare a fatal error so the gateway
|
||
# runner can surface this clearly and the user knows to act.
|
||
message = (
|
||
"Telegram polling could not recover after %d retries (%ds total wait). "
|
||
"The previous gateway session is still held open on Telegram's servers, "
|
||
"or another process is using the same bot token. "
|
||
"To recover: ensure no other Hermes or OpenClaw instance is running "
|
||
"with this token, then restart the gateway with 'hermes gateway restart'."
|
||
% (MAX_CONFLICT_RETRIES, sum(10 + i * 10 for i in range(1, MAX_CONFLICT_RETRIES + 1)))
|
||
)
|
||
logger.error(
|
||
"[%s] %s Original error: %s",
|
||
self.name, message, error,
|
||
)
|
||
self._set_fatal_error("telegram_polling_conflict", message, retryable=False)
|
||
try:
|
||
if self._app and self._app.updater:
|
||
await self._app.updater.stop()
|
||
except Exception as stop_error:
|
||
logger.warning(
|
||
"[%s] Failed stopping Telegram updater after exhausting conflict retries: %s",
|
||
self.name, stop_error, exc_info=True,
|
||
)
|
||
await self._notify_fatal_error()
|
||
|
||
async def _create_dm_topic(
|
||
self,
|
||
chat_id: int,
|
||
name: str,
|
||
icon_color: Optional[int] = None,
|
||
icon_custom_emoji_id: Optional[str] = None,
|
||
) -> Optional[int]:
|
||
"""Create a forum topic in a private (DM) chat.
|
||
|
||
Uses Bot API 9.4's createForumTopic which now works for 1-on-1 chats.
|
||
Returns the message_thread_id on success, None on failure.
|
||
"""
|
||
if not self._bot:
|
||
return None
|
||
try:
|
||
kwargs: Dict[str, Any] = {"chat_id": chat_id, "name": name}
|
||
if icon_color is not None:
|
||
kwargs["icon_color"] = icon_color
|
||
if icon_custom_emoji_id:
|
||
kwargs["icon_custom_emoji_id"] = icon_custom_emoji_id
|
||
|
||
topic = await self._bot.create_forum_topic(**kwargs)
|
||
thread_id = topic.message_thread_id
|
||
logger.info(
|
||
"[%s] Created DM topic '%s' in chat %s -> thread_id=%s",
|
||
self.name, name, chat_id, thread_id,
|
||
)
|
||
return thread_id
|
||
except Exception as e:
|
||
error_text = str(e).lower()
|
||
# If topic already exists, try to find it via getForumTopicIconStickers
|
||
# or we just log and skip — Telegram doesn't provide a "list topics" API
|
||
if "topic_name_duplicate" in error_text or "already" in error_text:
|
||
logger.info(
|
||
"[%s] DM topic '%s' already exists in chat %s (will be mapped from incoming messages)",
|
||
self.name, name, chat_id,
|
||
)
|
||
elif "not a forum" in error_text or "forums_disabled" in error_text:
|
||
logger.warning(
|
||
"[%s] Cannot create DM topic '%s' in chat %s: Topics mode is not enabled. "
|
||
"The user must open the DM with this bot in Telegram, tap the bot name "
|
||
"at the top, and enable 'Topics' in chat settings before topics can be created.",
|
||
self.name, name, chat_id,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"[%s] Failed to create DM topic '%s' in chat %s: %s",
|
||
self.name, name, chat_id, e,
|
||
)
|
||
return None
|
||
|
||
async def create_handoff_thread(
|
||
self,
|
||
parent_chat_id: str,
|
||
name: str,
|
||
) -> Optional[str]:
|
||
"""Create a forum topic for a session handoff.
|
||
|
||
Works for DM topics (Bot API 9.4+, requires user to enable Topics
|
||
in their chat with the bot) and forum supergroups. Returns the
|
||
``message_thread_id`` as a string, or ``None`` on failure.
|
||
"""
|
||
try:
|
||
chat_id_int = int(parent_chat_id)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
thread_id = await self._create_dm_topic(chat_id_int, name=name)
|
||
return str(thread_id) if thread_id else None
|
||
|
||
async def ensure_dm_topic(self, chat_id: str, topic_name: str, force_create: bool = False) -> Optional[str]:
|
||
"""Return a private DM topic thread id, creating and persisting it if needed."""
|
||
name = str(topic_name or "").strip()
|
||
if not name:
|
||
return None
|
||
try:
|
||
chat_id_int = int(chat_id)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
cache_key = f"{chat_id_int}:{name}"
|
||
cached = self._dm_topics.get(cache_key)
|
||
if cached and not force_create:
|
||
return str(cached)
|
||
|
||
topic_conf: Optional[Dict[str, Any]] = None
|
||
chat_entry: Optional[Dict[str, Any]] = None
|
||
for entry in self._dm_topics_config:
|
||
if str(entry.get("chat_id")) != str(chat_id_int):
|
||
continue
|
||
chat_entry = entry
|
||
for candidate in entry.get("topics", []):
|
||
if candidate.get("name") == name:
|
||
topic_conf = candidate
|
||
break
|
||
break
|
||
|
||
if topic_conf and topic_conf.get("thread_id") and not force_create:
|
||
thread_id = int(topic_conf["thread_id"])
|
||
self._dm_topics[cache_key] = thread_id
|
||
return str(thread_id)
|
||
|
||
if chat_entry is None:
|
||
chat_entry = {"chat_id": chat_id_int, "topics": []}
|
||
self._dm_topics_config.append(chat_entry)
|
||
if topic_conf is None:
|
||
topic_conf = {"name": name}
|
||
chat_entry.setdefault("topics", []).append(topic_conf)
|
||
|
||
thread_id = await self._create_dm_topic(
|
||
chat_id_int,
|
||
name=name,
|
||
icon_color=topic_conf.get("icon_color"),
|
||
icon_custom_emoji_id=topic_conf.get("icon_custom_emoji_id"),
|
||
)
|
||
if not thread_id:
|
||
return None
|
||
|
||
topic_conf["thread_id"] = thread_id
|
||
self._dm_topics[cache_key] = int(thread_id)
|
||
self._persist_dm_topic_thread_id(chat_id_int, name, int(thread_id), replace_existing=force_create)
|
||
return str(thread_id)
|
||
|
||
async def rename_dm_topic(
|
||
self,
|
||
chat_id: int,
|
||
thread_id: int,
|
||
name: str,
|
||
) -> None:
|
||
"""Rename a forum topic in a private (DM) chat."""
|
||
if not self._bot:
|
||
return
|
||
try:
|
||
chat_id_arg = int(chat_id)
|
||
except (TypeError, ValueError):
|
||
chat_id_arg = chat_id
|
||
await self._bot.edit_forum_topic(
|
||
chat_id=chat_id_arg,
|
||
message_thread_id=int(thread_id),
|
||
name=name,
|
||
)
|
||
logger.info(
|
||
"[%s] Renamed DM topic in chat %s thread_id=%s -> '%s'",
|
||
self.name, chat_id, thread_id, name,
|
||
)
|
||
|
||
def _persist_dm_topic_thread_id(
|
||
self,
|
||
chat_id: int,
|
||
topic_name: str,
|
||
thread_id: int,
|
||
replace_existing: bool = False,
|
||
) -> None:
|
||
"""Save a newly created thread_id back into config.yaml so it persists across restarts."""
|
||
try:
|
||
from hermes_constants import get_hermes_home
|
||
config_path = get_hermes_home() / "config.yaml"
|
||
if not config_path.exists():
|
||
logger.warning("[%s] Config file not found at %s, cannot persist thread_id", self.name, config_path)
|
||
return
|
||
|
||
import yaml as _yaml
|
||
with open(config_path, "r", encoding="utf-8") as f:
|
||
config = _yaml.safe_load(f) or {}
|
||
|
||
# Navigate to platforms.telegram.extra.dm_topics, creating the path
|
||
# when a named delivery target asks us to create a topic that was
|
||
# not predeclared in config.yaml.
|
||
platforms = config.setdefault("platforms", {})
|
||
telegram_config = platforms.setdefault("telegram", {})
|
||
extra = telegram_config.setdefault("extra", {})
|
||
dm_topics = extra.setdefault("dm_topics", [])
|
||
|
||
changed = False
|
||
matching_chat_entry = None
|
||
for chat_entry in dm_topics:
|
||
try:
|
||
chat_matches = int(chat_entry.get("chat_id", 0)) == int(chat_id)
|
||
except (TypeError, ValueError):
|
||
chat_matches = False
|
||
if not chat_matches:
|
||
continue
|
||
matching_chat_entry = chat_entry
|
||
for t in chat_entry.setdefault("topics", []):
|
||
if t.get("name") == topic_name:
|
||
if replace_existing or not t.get("thread_id"):
|
||
if t.get("thread_id") != thread_id:
|
||
t["thread_id"] = thread_id
|
||
changed = True
|
||
break
|
||
else:
|
||
chat_entry.setdefault("topics", []).append(
|
||
{"name": topic_name, "thread_id": thread_id}
|
||
)
|
||
changed = True
|
||
break
|
||
|
||
if matching_chat_entry is None:
|
||
dm_topics.append({
|
||
"chat_id": chat_id,
|
||
"topics": [{"name": topic_name, "thread_id": thread_id}],
|
||
})
|
||
changed = True
|
||
|
||
if changed:
|
||
fd, tmp_path = tempfile.mkstemp(
|
||
dir=str(config_path.parent),
|
||
suffix=".tmp",
|
||
prefix=".config_",
|
||
)
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||
_yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
atomic_replace(tmp_path, config_path)
|
||
except BaseException:
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
logger.info(
|
||
"[%s] Persisted thread_id=%s for topic '%s' in config.yaml",
|
||
self.name, thread_id, topic_name,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("[%s] Failed to persist thread_id to config: %s", self.name, e, exc_info=True)
|
||
|
||
async def _setup_dm_topics(self) -> None:
|
||
"""Load or create configured DM topics for specified chats.
|
||
|
||
Reads config.extra['dm_topics'] — a list of dicts:
|
||
[
|
||
{
|
||
"chat_id": 123456789,
|
||
"topics": [
|
||
{"name": "General", "icon_color": 7322096, "thread_id": 100},
|
||
{"name": "Accessibility Auditor", "icon_color": 9367192, "skill": "accessibility-auditor"}
|
||
]
|
||
}
|
||
]
|
||
|
||
If a topic already has a thread_id in the config (persisted from a previous
|
||
creation), it is loaded into the cache without calling createForumTopic.
|
||
Only topics without a thread_id are created via the API, and their thread_id
|
||
is then saved back to config.yaml for future restarts.
|
||
"""
|
||
if not self._dm_topics_config:
|
||
return
|
||
|
||
for chat_entry in self._dm_topics_config:
|
||
chat_id = chat_entry.get("chat_id")
|
||
topics = chat_entry.get("topics", [])
|
||
if not chat_id or not topics:
|
||
continue
|
||
|
||
logger.info(
|
||
"[%s] Setting up %d DM topic(s) for chat %s",
|
||
self.name, len(topics), chat_id,
|
||
)
|
||
|
||
for topic_conf in topics:
|
||
topic_name = topic_conf.get("name")
|
||
if not topic_name:
|
||
continue
|
||
|
||
cache_key = f"{chat_id}:{topic_name}"
|
||
|
||
# If thread_id is already persisted in config, just load into cache
|
||
existing_thread_id = topic_conf.get("thread_id")
|
||
if existing_thread_id:
|
||
self._dm_topics[cache_key] = int(existing_thread_id)
|
||
logger.info(
|
||
"[%s] DM topic loaded from config: %s -> thread_id=%s",
|
||
self.name, cache_key, existing_thread_id,
|
||
)
|
||
continue
|
||
|
||
# No persisted thread_id — create the topic via API
|
||
icon_color = topic_conf.get("icon_color")
|
||
icon_emoji = topic_conf.get("icon_custom_emoji_id")
|
||
|
||
thread_id = await self._create_dm_topic(
|
||
chat_id=int(chat_id),
|
||
name=topic_name,
|
||
icon_color=icon_color,
|
||
icon_custom_emoji_id=icon_emoji,
|
||
)
|
||
|
||
if thread_id:
|
||
self._dm_topics[cache_key] = thread_id
|
||
logger.info(
|
||
"[%s] DM topic cached: %s -> thread_id=%s",
|
||
self.name, cache_key, thread_id,
|
||
)
|
||
# Persist thread_id to config so we don't recreate on next restart
|
||
self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
|
||
|
||
# Send a seed message so the topic is visible in Telegram's client.
|
||
# Empty topics are hidden by the client UI until they contain a message.
|
||
try:
|
||
await self._bot.send_message(
|
||
chat_id=int(chat_id),
|
||
message_thread_id=thread_id,
|
||
text=f"\U0001f4cc {topic_name}",
|
||
)
|
||
except Exception as seed_err:
|
||
logger.debug(
|
||
"[%s] Could not send seed message to topic '%s': %s",
|
||
self.name, topic_name, seed_err,
|
||
)
|
||
|
||
async def connect(self) -> bool:
|
||
"""Connect to Telegram via polling or webhook.
|
||
|
||
By default, uses long polling (outbound connection to Telegram).
|
||
If ``TELEGRAM_WEBHOOK_URL`` is set, starts an HTTP webhook server
|
||
instead. Webhook mode is useful for cloud deployments (Fly.io,
|
||
Railway) where inbound HTTP can wake a suspended machine.
|
||
|
||
Env vars for webhook mode::
|
||
|
||
TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram)
|
||
TELEGRAM_WEBHOOK_PORT Local listen port (default 8443)
|
||
TELEGRAM_WEBHOOK_SECRET Secret token for update verification
|
||
"""
|
||
if not TELEGRAM_AVAILABLE:
|
||
logger.error(
|
||
"[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot",
|
||
self.name,
|
||
)
|
||
return False
|
||
|
||
if not self.config.token:
|
||
logger.error("[%s] No bot token configured", self.name)
|
||
return False
|
||
|
||
try:
|
||
if not self._acquire_platform_lock('telegram-bot-token', self.config.token, 'Telegram bot token'):
|
||
return False
|
||
|
||
# Build the application
|
||
builder = Application.builder().token(self.config.token)
|
||
custom_base_url = self.config.extra.get("base_url")
|
||
if custom_base_url:
|
||
builder = builder.base_url(custom_base_url)
|
||
builder = builder.base_file_url(
|
||
self.config.extra.get("base_file_url", custom_base_url)
|
||
)
|
||
logger.info(
|
||
"[%s] Using custom Telegram base_url: %s",
|
||
self.name, custom_base_url,
|
||
)
|
||
# In local-mode telegram-bot-api, file_path is an absolute path on the
|
||
# server's filesystem rather than a relative HTTP path. PTB needs
|
||
# local_mode=True so download_*() reads from disk instead of issuing
|
||
# an HTTP GET that would 404. Requires that the same path is
|
||
# readable by the Hermes process (shared mount, same machine, etc.).
|
||
if self.config.extra.get("local_mode"):
|
||
builder = builder.local_mode(True)
|
||
logger.info("[%s] Using Telegram local_mode (read files from disk)", self.name)
|
||
|
||
# PTB defaults (pool_timeout=1s) are too aggressive on flaky networks and
|
||
# can trigger "Pool timeout: All connections in the connection pool are occupied"
|
||
# during reconnect/bootstrap. Use safer defaults and allow env overrides.
|
||
def _env_int(name: str, default: int) -> int:
|
||
try:
|
||
return int(os.getenv(name, str(default)))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
def _env_float(name: str, default: float) -> float:
|
||
try:
|
||
return float(os.getenv(name, str(default)))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
request_kwargs = {
|
||
"connection_pool_size": _env_int("HERMES_TELEGRAM_HTTP_POOL_SIZE", 512),
|
||
"pool_timeout": _env_float("HERMES_TELEGRAM_HTTP_POOL_TIMEOUT", 8.0),
|
||
"connect_timeout": _env_float("HERMES_TELEGRAM_HTTP_CONNECT_TIMEOUT", 10.0),
|
||
"read_timeout": _env_float("HERMES_TELEGRAM_HTTP_READ_TIMEOUT", 20.0),
|
||
"write_timeout": _env_float("HERMES_TELEGRAM_HTTP_WRITE_TIMEOUT", 20.0),
|
||
}
|
||
|
||
disable_fallback = (os.getenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "").strip().lower() in {"1", "true", "yes", "on"})
|
||
fallback_ips = self._fallback_ips()
|
||
if not fallback_ips:
|
||
fallback_ips = await discover_fallback_ips()
|
||
logger.info(
|
||
"[%s] Auto-discovered Telegram fallback IPs: %s",
|
||
self.name,
|
||
", ".join(fallback_ips),
|
||
)
|
||
|
||
proxy_targets = ["api.telegram.org", *fallback_ips]
|
||
proxy_url = resolve_proxy_url("TELEGRAM_PROXY", target_hosts=proxy_targets)
|
||
if fallback_ips and not proxy_url and not disable_fallback:
|
||
logger.info(
|
||
"[%s] Telegram fallback IPs active: %s",
|
||
self.name,
|
||
", ".join(fallback_ips),
|
||
)
|
||
# Keep request/update pools separate to reduce contention during
|
||
# polling reconnect + bot API bootstrap/delete_webhook calls.
|
||
request = HTTPXRequest(
|
||
**request_kwargs,
|
||
httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
|
||
)
|
||
get_updates_request = HTTPXRequest(
|
||
**request_kwargs,
|
||
httpx_kwargs={"transport": TelegramFallbackTransport(fallback_ips)},
|
||
)
|
||
elif proxy_url:
|
||
logger.info("[%s] Proxy detected; passing explicitly to HTTPXRequest: %s", self.name, proxy_url)
|
||
request = HTTPXRequest(**request_kwargs, proxy=proxy_url)
|
||
get_updates_request = HTTPXRequest(**request_kwargs, proxy=proxy_url)
|
||
else:
|
||
if disable_fallback:
|
||
logger.info("[%s] Telegram fallback-IP transport disabled via env", self.name)
|
||
request = HTTPXRequest(**request_kwargs)
|
||
get_updates_request = HTTPXRequest(**request_kwargs)
|
||
|
||
builder = builder.request(request).get_updates_request(get_updates_request)
|
||
self._app = builder.build()
|
||
self._bot = self._app.bot
|
||
|
||
# Register handlers
|
||
self._app.add_handler(TelegramMessageHandler(
|
||
filters.TEXT & ~filters.COMMAND,
|
||
self._handle_text_message
|
||
))
|
||
self._app.add_handler(TelegramMessageHandler(
|
||
filters.COMMAND,
|
||
self._handle_command
|
||
))
|
||
self._app.add_handler(TelegramMessageHandler(
|
||
filters.LOCATION | getattr(filters, "VENUE", filters.LOCATION),
|
||
self._handle_location_message
|
||
))
|
||
self._app.add_handler(TelegramMessageHandler(
|
||
filters.PHOTO | filters.VIDEO | filters.AUDIO | filters.VOICE | filters.Document.ALL | filters.Sticker.ALL,
|
||
self._handle_media_message
|
||
))
|
||
# Handle inline keyboard button callbacks (update prompts)
|
||
self._app.add_handler(CallbackQueryHandler(self._handle_callback_query))
|
||
|
||
# Start polling — retry initialize() for transient TLS resets
|
||
try:
|
||
from telegram.error import NetworkError, TimedOut
|
||
except ImportError:
|
||
NetworkError = TimedOut = OSError # type: ignore[misc,assignment]
|
||
_max_connect = 8
|
||
for _attempt in range(_max_connect):
|
||
try:
|
||
await self._app.initialize()
|
||
break
|
||
except (NetworkError, TimedOut, OSError) as init_err:
|
||
if _attempt < _max_connect - 1:
|
||
wait = min(2 ** _attempt, 15)
|
||
logger.warning(
|
||
"[%s] Connect attempt %d/%d failed: %s — retrying in %ds",
|
||
self.name, _attempt + 1, _max_connect, init_err, wait,
|
||
)
|
||
await asyncio.sleep(wait)
|
||
else:
|
||
raise
|
||
await self._app.start()
|
||
|
||
# Decide between webhook and polling mode
|
||
webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip()
|
||
|
||
if webhook_url:
|
||
# ── Webhook mode ─────────────────────────────────────
|
||
# Telegram pushes updates to our HTTP endpoint. This
|
||
# enables cloud platforms (Fly.io, Railway) to auto-wake
|
||
# suspended machines on inbound HTTP traffic.
|
||
#
|
||
# SECURITY: TELEGRAM_WEBHOOK_SECRET is REQUIRED. Without it,
|
||
# python-telegram-bot passes secret_token=None and the
|
||
# webhook endpoint accepts any HTTP POST — attackers can
|
||
# inject forged updates as if from Telegram. Refuse to
|
||
# start rather than silently run in fail-open mode.
|
||
# See GHSA-3vpc-7q5r-276h.
|
||
webhook_port = int(os.getenv("TELEGRAM_WEBHOOK_PORT", "8443"))
|
||
webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip()
|
||
if not webhook_secret:
|
||
raise RuntimeError(
|
||
"TELEGRAM_WEBHOOK_SECRET is required when "
|
||
"TELEGRAM_WEBHOOK_URL is set. Without it, the "
|
||
"webhook endpoint accepts forged updates from "
|
||
"anyone who can reach it — see "
|
||
"https://github.com/NousResearch/hermes-agent/"
|
||
"security/advisories/GHSA-3vpc-7q5r-276h.\n\n"
|
||
"Generate a secret and set it in your .env:\n"
|
||
" export TELEGRAM_WEBHOOK_SECRET=\"$(openssl rand -hex 32)\"\n\n"
|
||
"Then register it with Telegram when setting the "
|
||
"webhook via setWebhook's secret_token parameter."
|
||
)
|
||
from urllib.parse import urlparse
|
||
webhook_path = urlparse(webhook_url).path or "/telegram"
|
||
|
||
await self._app.updater.start_webhook(
|
||
listen="0.0.0.0",
|
||
port=webhook_port,
|
||
url_path=webhook_path,
|
||
webhook_url=webhook_url,
|
||
secret_token=webhook_secret,
|
||
allowed_updates=Update.ALL_TYPES,
|
||
drop_pending_updates=True,
|
||
)
|
||
self._webhook_mode = True
|
||
logger.info(
|
||
"[%s] Webhook server listening on 0.0.0.0:%d%s",
|
||
self.name, webhook_port, webhook_path,
|
||
)
|
||
else:
|
||
# ── Polling mode (default) ───────────────────────────
|
||
# Clear any stale webhook first so polling doesn't inherit a
|
||
# previous webhook registration and silently stop receiving updates.
|
||
delete_webhook = getattr(self._bot, "delete_webhook", None)
|
||
if callable(delete_webhook):
|
||
await delete_webhook(drop_pending_updates=False)
|
||
|
||
loop = asyncio.get_running_loop()
|
||
|
||
def _polling_error_callback(error: Exception) -> None:
|
||
if self._polling_error_task and not self._polling_error_task.done():
|
||
return
|
||
if self._looks_like_polling_conflict(error):
|
||
self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
|
||
elif self._looks_like_network_error(error):
|
||
logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
|
||
self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
|
||
else:
|
||
logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
|
||
|
||
# Store reference for retry use in _handle_polling_conflict
|
||
self._polling_error_callback_ref = _polling_error_callback
|
||
|
||
await self._app.updater.start_polling(
|
||
allowed_updates=Update.ALL_TYPES,
|
||
drop_pending_updates=True,
|
||
error_callback=_polling_error_callback,
|
||
)
|
||
|
||
# Register bot commands so Telegram shows a hint menu when users type /
|
||
# List is derived from the central COMMAND_REGISTRY — adding a new
|
||
# gateway command there automatically adds it to the Telegram menu.
|
||
try:
|
||
from telegram import (
|
||
BotCommand,
|
||
BotCommandScopeAllPrivateChats,
|
||
BotCommandScopeAllGroupChats,
|
||
BotCommandScopeDefault,
|
||
)
|
||
from hermes_cli.commands import telegram_menu_commands
|
||
# Telegram allows up to 100 commands but has an undocumented
|
||
# payload size limit (~4KB total). Limit to 30 core commands
|
||
# to stay well under the threshold while covering all categories.
|
||
menu_commands, hidden_count = telegram_menu_commands(max_commands=MAX_COMMANDS_PER_SCOPE)
|
||
bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
|
||
# Register for all scopes independently — Telegram picks the
|
||
# narrowest matching scope per chat type (forum topics fall
|
||
# through to AllGroupChats or Default).
|
||
for scope_cls in (BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats):
|
||
scope_name = scope_cls.__name__
|
||
try:
|
||
await self._bot.set_my_commands(bot_commands, scope=scope_cls())
|
||
logger.info("[%s] set_my_commands OK for scope %s (%d cmds)", self.name, scope_name, len(bot_commands))
|
||
except Exception as scope_err:
|
||
logger.warning("[%s] set_my_commands FAILED for scope %s: %s", self.name, scope_name, scope_err)
|
||
# Forum topics don't inherit AllGroupChats — Telegram resolves
|
||
# commands via BotCommandScopeChat(chat_id) for forum groups.
|
||
# Lazy registration happens in _ensure_forum_commands on first
|
||
# message from a forum topic (see _handle_text_message).
|
||
if hidden_count:
|
||
logger.info(
|
||
"[%s] Telegram menu: %d commands registered, %d hidden (over %d limit). Use /commands for full list.",
|
||
self.name, len(menu_commands), hidden_count, 30,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
"[%s] Could not register Telegram command menu: %s",
|
||
self.name,
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
|
||
self._mark_connected()
|
||
mode = "webhook" if self._webhook_mode else "polling"
|
||
logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode)
|
||
|
||
# Set up DM topics (Bot API 9.4 — Private Chat Topics)
|
||
# Runs after connection is established so the bot can call createForumTopic.
|
||
# Failures here are non-fatal — the bot works fine without topics.
|
||
try:
|
||
await self._setup_dm_topics()
|
||
except Exception as topics_err:
|
||
logger.warning(
|
||
"[%s] DM topics setup failed (non-fatal): %s",
|
||
self.name, topics_err, exc_info=True,
|
||
)
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
self._release_platform_lock()
|
||
message = f"Telegram startup failed: {e}"
|
||
self._set_fatal_error("telegram_connect_error", message, retryable=True)
|
||
logger.error("[%s] Failed to connect to Telegram: %s", self.name, e, exc_info=True)
|
||
return False
|
||
|
||
async def disconnect(self) -> None:
|
||
"""Stop polling/webhook, cancel pending album flushes, and disconnect."""
|
||
pending_media_group_tasks = list(self._media_group_tasks.values())
|
||
for task in pending_media_group_tasks:
|
||
task.cancel()
|
||
if pending_media_group_tasks:
|
||
await asyncio.gather(*pending_media_group_tasks, return_exceptions=True)
|
||
self._media_group_tasks.clear()
|
||
self._media_group_events.clear()
|
||
|
||
if self._app:
|
||
try:
|
||
# Only stop the updater if it's running
|
||
if self._app.updater and self._app.updater.running:
|
||
await self._app.updater.stop()
|
||
if self._app.running:
|
||
await self._app.stop()
|
||
await self._app.shutdown()
|
||
except Exception as e:
|
||
logger.warning("[%s] Error during Telegram disconnect: %s", self.name, e, exc_info=True)
|
||
self._release_platform_lock()
|
||
|
||
for task in self._pending_photo_batch_tasks.values():
|
||
if task and not task.done():
|
||
task.cancel()
|
||
self._pending_photo_batch_tasks.clear()
|
||
self._pending_photo_batches.clear()
|
||
|
||
self._mark_disconnected()
|
||
self._app = None
|
||
self._bot = None
|
||
logger.info("[%s] Disconnected from Telegram", self.name)
|
||
|
||
def _should_thread_reply(self, reply_to: Optional[str], chunk_index: int) -> bool:
|
||
"""Determine if this message chunk should thread to the original message.
|
||
|
||
Args:
|
||
reply_to: The original message ID to reply to
|
||
chunk_index: Index of this chunk (0 = first chunk)
|
||
|
||
Returns:
|
||
True if this chunk should be threaded to the original message
|
||
"""
|
||
if not reply_to:
|
||
return False
|
||
mode = self._reply_to_mode
|
||
if mode == "off":
|
||
return False
|
||
elif mode == "all":
|
||
return True
|
||
else: # "first" (default)
|
||
return chunk_index == 0
|
||
|
||
async def send(
|
||
self,
|
||
chat_id: str,
|
||
content: str,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None
|
||
) -> SendResult:
|
||
"""Send a message to a Telegram chat."""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
# getattr() — tests build adapters via object.__new__() (no __init__).
|
||
if getattr(self, "_send_path_degraded", False):
|
||
return SendResult(success=False, error="send_path_degraded", retryable=True)
|
||
|
||
# Skip whitespace-only text to prevent Telegram 400 empty-text errors.
|
||
if not content or not content.strip():
|
||
return SendResult(success=True, message_id=None)
|
||
|
||
try:
|
||
# Format and split message if needed
|
||
formatted = self.format_message(content)
|
||
chunks = self.truncate_message(
|
||
formatted, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len,
|
||
)
|
||
if len(chunks) > 1:
|
||
# truncate_message appends a raw " (1/2)" suffix. Escape the
|
||
# MarkdownV2-special parentheses so Telegram doesn't reject the
|
||
# chunk and fall back to plain text.
|
||
chunks = [
|
||
re.sub(r" \((\d+)/(\d+)\)$", r" \\(\1/\2\\)", chunk)
|
||
for chunk in chunks
|
||
]
|
||
|
||
message_ids = []
|
||
thread_id = self._metadata_thread_id(metadata)
|
||
requested_thread_id = self._message_thread_id_for_send(thread_id)
|
||
used_thread_fallback = False
|
||
|
||
try:
|
||
from telegram.error import NetworkError as _NetErr
|
||
except ImportError:
|
||
_NetErr = OSError # type: ignore[misc,assignment]
|
||
|
||
try:
|
||
from telegram.error import BadRequest as _BadReq
|
||
except ImportError:
|
||
_BadReq = None # type: ignore[assignment,misc]
|
||
|
||
try:
|
||
from telegram.error import TimedOut as _TimedOut
|
||
except (ImportError, AttributeError):
|
||
_TimedOut = None # type: ignore[assignment,misc]
|
||
|
||
for i, chunk in enumerate(chunks):
|
||
retried_thread_not_found = False
|
||
metadata_reply_to = self._metadata_reply_to_message_id(metadata)
|
||
private_dm_topic_send = self._is_private_dm_topic_send(chat_id, thread_id, metadata)
|
||
# reply_to_mode="off" on the existing telegram_dm_topic_reply_fallback path
|
||
# is an explicit user opt-in to "message_thread_id alone is enough" (PR #23994
|
||
# / commit 21a15b671). Honor it — don't fail loud just because the anchor was
|
||
# suppressed by config. The new fail-loud contract only applies when the caller
|
||
# didn't ask for the anchor to be dropped.
|
||
dm_topic_reply_to_off = (
|
||
private_dm_topic_send
|
||
and self._reply_to_mode == "off"
|
||
and bool(metadata and metadata.get("telegram_dm_topic_reply_fallback"))
|
||
)
|
||
reply_to_source = reply_to or (
|
||
str(metadata_reply_to) if private_dm_topic_send and metadata_reply_to is not None else None
|
||
)
|
||
if private_dm_topic_send:
|
||
should_thread = (
|
||
reply_to_source is not None
|
||
and self._reply_to_mode != "off"
|
||
)
|
||
else:
|
||
should_thread = self._should_thread_reply(reply_to_source, i)
|
||
reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None
|
||
if private_dm_topic_send and reply_to_id is None and not dm_topic_reply_to_off:
|
||
return SendResult(
|
||
success=False,
|
||
error=self._dm_topic_missing_anchor_error(),
|
||
retryable=False,
|
||
)
|
||
thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
thread_id,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode,
|
||
)
|
||
if used_thread_fallback and thread_kwargs.get("message_thread_id") is not None:
|
||
thread_kwargs = dict(thread_kwargs)
|
||
thread_kwargs["message_thread_id"] = None
|
||
effective_thread_id = thread_kwargs.get("message_thread_id")
|
||
|
||
msg = None
|
||
for _send_attempt in range(3):
|
||
try:
|
||
# Try Markdown first, fall back to plain text if it fails
|
||
try:
|
||
msg = await self._bot.send_message(
|
||
chat_id=int(chat_id),
|
||
text=chunk,
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_to_message_id=reply_to_id,
|
||
**thread_kwargs,
|
||
**self._link_preview_kwargs(),
|
||
**self._notification_kwargs(metadata),
|
||
)
|
||
except Exception as md_error:
|
||
# Markdown parsing failed, try plain text
|
||
if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower():
|
||
logger.warning("[%s] MarkdownV2 parse failed, falling back to plain text: %s", self.name, md_error)
|
||
plain_chunk = _strip_mdv2(chunk)
|
||
msg = await self._bot.send_message(
|
||
chat_id=int(chat_id),
|
||
text=plain_chunk,
|
||
parse_mode=None,
|
||
reply_to_message_id=reply_to_id,
|
||
**thread_kwargs,
|
||
**self._link_preview_kwargs(),
|
||
**self._notification_kwargs(metadata),
|
||
)
|
||
else:
|
||
raise
|
||
break # success
|
||
except _NetErr as send_err:
|
||
# BadRequest is a subclass of NetworkError in
|
||
# python-telegram-bot but represents permanent errors
|
||
# (not transient network issues). Detect and handle
|
||
# specific cases instead of blindly retrying.
|
||
if _BadReq and isinstance(send_err, _BadReq):
|
||
if self._is_thread_not_found_error(send_err) and effective_thread_id is not None:
|
||
if private_dm_topic_send or (metadata and metadata.get("telegram_dm_topic_created_for_send")):
|
||
return SendResult(
|
||
success=False,
|
||
error=str(send_err),
|
||
retryable=False,
|
||
)
|
||
# Telegram has been observed to return a
|
||
# one-off "thread not found" that recovers on
|
||
# an immediate retry (transient flake — see
|
||
# test_send_retries_transient_thread_not_found_before_fallback).
|
||
# Try the same thread_id once without sleeping
|
||
# before falling back to a plain send.
|
||
if not retried_thread_not_found:
|
||
retried_thread_not_found = True
|
||
logger.warning(
|
||
"[%s] Thread %s not found, retrying once with same thread_id",
|
||
self.name, effective_thread_id,
|
||
)
|
||
continue
|
||
# Second failure: the thread is genuinely gone.
|
||
# Retry without ``message_thread_id`` so the
|
||
# message still reaches the chat.
|
||
logger.warning(
|
||
"[%s] Thread %s not found, retrying without message_thread_id",
|
||
self.name, effective_thread_id,
|
||
)
|
||
used_thread_fallback = True
|
||
effective_thread_id = None
|
||
thread_kwargs = {"message_thread_id": None}
|
||
continue
|
||
err_lower = str(send_err).lower()
|
||
if "message to be replied not found" in err_lower and reply_to_id is not None:
|
||
if private_dm_topic_send:
|
||
return SendResult(
|
||
success=False,
|
||
error=str(send_err),
|
||
retryable=False,
|
||
)
|
||
# Original message was deleted before we
|
||
# could reply. For private-topic fallback
|
||
# sends, message_thread_id is only valid with
|
||
# the reply anchor, so drop both together.
|
||
logger.warning(
|
||
"[%s] Reply target deleted, retrying without reply_to: %s",
|
||
self.name, send_err,
|
||
)
|
||
reply_to_id = None
|
||
if metadata and metadata.get("telegram_dm_topic_reply_fallback"):
|
||
thread_kwargs = {}
|
||
effective_thread_id = None
|
||
else:
|
||
thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
thread_id,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode,
|
||
)
|
||
effective_thread_id = thread_kwargs.get("message_thread_id")
|
||
continue
|
||
# Other BadRequest errors are permanent — don't retry
|
||
raise
|
||
# TimedOut is also a subclass of NetworkError. A
|
||
# generic timeout may have reached Telegram, so don't
|
||
# retry; a wrapped ConnectTimeout means no connection
|
||
# was established, so retrying is safe. A pool timeout
|
||
# (httpx pool exhausted) is explicitly "not sent to
|
||
# Telegram" -- retrying through the loop is safe and
|
||
# prevents silent drops when the pool frees up.
|
||
if (
|
||
_TimedOut
|
||
and isinstance(send_err, _TimedOut)
|
||
and not self._looks_like_connect_timeout(send_err)
|
||
and not self._looks_like_pool_timeout(send_err)
|
||
):
|
||
raise
|
||
if _send_attempt < 2:
|
||
wait = 2 ** _send_attempt
|
||
logger.warning("[%s] Network error on send (attempt %d/3), retrying in %ds: %s",
|
||
self.name, _send_attempt + 1, wait, send_err)
|
||
await asyncio.sleep(wait)
|
||
else:
|
||
raise
|
||
except Exception as send_err:
|
||
retry_after = getattr(send_err, "retry_after", None)
|
||
if retry_after is not None or "retry after" in str(send_err).lower():
|
||
if _send_attempt < 2:
|
||
wait = float(retry_after) if retry_after is not None else 1.0
|
||
logger.warning(
|
||
"[%s] Telegram flood control on send (attempt %d/3), retrying in %.1fs: %s",
|
||
self.name,
|
||
_send_attempt + 1,
|
||
wait,
|
||
send_err,
|
||
)
|
||
await asyncio.sleep(wait)
|
||
continue
|
||
raise
|
||
message_ids.append(str(msg.message_id))
|
||
|
||
# Re-trigger typing indicator after sending a message.
|
||
# Telegram clears the typing state when a new message is delivered,
|
||
# so without this the "...typing" bubble disappears mid-response
|
||
# (especially noticeable when the agent sends intermediate progress
|
||
# messages like "Checking:" before running tools).
|
||
try:
|
||
await self.send_typing(chat_id, metadata=metadata)
|
||
except Exception:
|
||
pass # Typing failures are non-fatal
|
||
|
||
return SendResult(
|
||
success=True,
|
||
message_id=message_ids[0] if message_ids else None,
|
||
raw_response={
|
||
"message_ids": message_ids,
|
||
"requested_thread_id": requested_thread_id,
|
||
"thread_fallback": used_thread_fallback,
|
||
},
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error("[%s] Failed to send Telegram message: %s", self.name, e, exc_info=True)
|
||
err_str = str(e).lower()
|
||
# Message too long — content exceeded 4096 chars. Return failure so
|
||
# stream consumer enters fallback mode and sends the remainder.
|
||
if "message_too_long" in err_str or "too long" in err_str:
|
||
logger.debug(
|
||
"[%s] send() content too long, falling back to new-message continuation",
|
||
self.name,
|
||
)
|
||
return SendResult(success=False, error="message_too_long")
|
||
# TimedOut usually means the request may have reached Telegram —
|
||
# mark as non-retryable so _send_with_retry() doesn't re-send.
|
||
# Exceptions: a wrapped ConnectTimeout (no connection established)
|
||
# and an httpx pool timeout (request explicitly not sent) -- both
|
||
# are safe to re-send and must not be silently dropped.
|
||
_to = locals().get("_TimedOut")
|
||
is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
|
||
is_connect_timeout = self._looks_like_connect_timeout(e)
|
||
is_pool_timeout = self._looks_like_pool_timeout(e)
|
||
return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or is_pool_timeout or not is_timeout))
|
||
|
||
async def send_or_update_status(
|
||
self,
|
||
chat_id: str,
|
||
status_key: str,
|
||
content: str,
|
||
*,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Send a status message, or edit the previous one with the same key.
|
||
|
||
Issue #30045: progress/status callbacks (context-pressure, lifecycle,
|
||
compression, etc.) used to append a fresh bubble on every call. With
|
||
this method, the first call sends and the message id is remembered;
|
||
subsequent calls with the same (chat_id, status_key) edit that same
|
||
message in place. If the edit fails (message deleted, too old, etc.)
|
||
we drop the cached id and send fresh.
|
||
"""
|
||
key = (str(chat_id), str(status_key))
|
||
cached_id = self._status_message_ids.get(key)
|
||
if cached_id is not None:
|
||
result = await self.edit_message(
|
||
chat_id, cached_id, content, finalize=True, metadata=metadata,
|
||
)
|
||
if result.success:
|
||
if result.message_id:
|
||
self._status_message_ids[key] = str(result.message_id)
|
||
return result
|
||
# Edit failed — clear the cached id and fall through to a fresh send.
|
||
self._status_message_ids.pop(key, None)
|
||
result = await self.send(chat_id, content, metadata=metadata)
|
||
if result.success and result.message_id:
|
||
self._status_message_ids[key] = str(result.message_id)
|
||
return result
|
||
|
||
async def edit_message(
|
||
self,
|
||
chat_id: str,
|
||
message_id: str,
|
||
content: str,
|
||
*,
|
||
finalize: bool = False,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Edit a previously sent Telegram message.
|
||
|
||
Telegram caps single-message text at 4096 UTF-16 codeunits. Streaming
|
||
replies that grow past this limit must NOT be silently truncated and
|
||
must NOT return failure (the consumer would re-send and create a
|
||
duplicate). Instead this method split-and-delivers: edit the
|
||
existing message with the first chunk and send the rest as
|
||
continuation messages, returning the final chunk's id so subsequent
|
||
edits target the most recent visible message.
|
||
"""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
# Pre-flight: if content already exceeds the limit, split-and-deliver
|
||
# without round-tripping a doomed edit.
|
||
if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
|
||
return await self._edit_overflow_split(
|
||
chat_id, message_id, content, finalize=finalize, metadata=metadata,
|
||
)
|
||
|
||
try:
|
||
if not finalize:
|
||
await self._bot.edit_message_text(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
text=content,
|
||
)
|
||
return SendResult(success=True, message_id=message_id)
|
||
|
||
formatted = self.format_message(content)
|
||
try:
|
||
await self._bot.edit_message_text(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
text=formatted,
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
)
|
||
except Exception as fmt_err:
|
||
# "Message is not modified" is a no-op, not an error
|
||
if "not modified" in str(fmt_err).lower():
|
||
return SendResult(success=True, message_id=message_id)
|
||
# Fallback: retry without markdown formatting
|
||
await self._bot.edit_message_text(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
text=content,
|
||
)
|
||
return SendResult(success=True, message_id=message_id)
|
||
except Exception as e:
|
||
err_str = str(e).lower()
|
||
# "Message is not modified" — content identical, treat as success
|
||
if "not modified" in err_str:
|
||
return SendResult(success=True, message_id=message_id)
|
||
# Reactive split-and-deliver: parse_mode formatting can inflate
|
||
# the payload past the limit even when the raw text was under
|
||
# (e.g. MarkdownV2 escapes). Same fix as the pre-flight path.
|
||
if "message_too_long" in err_str or "too long" in err_str:
|
||
logger.debug(
|
||
"[%s] edit_message overflow (%d UTF-16 > %d), splitting",
|
||
self.name, utf16_len(content), self.MAX_MESSAGE_LENGTH,
|
||
)
|
||
return await self._edit_overflow_split(
|
||
chat_id, message_id, content, finalize=finalize, metadata=metadata,
|
||
)
|
||
# Flood control / RetryAfter — short waits are retried inline,
|
||
# long waits return a failure immediately so streaming can fall back
|
||
# to a normal final send instead of leaving a truncated partial.
|
||
retry_after = getattr(e, "retry_after", None)
|
||
if retry_after is not None or "retry after" in err_str:
|
||
wait = retry_after if retry_after else 1.0
|
||
logger.warning(
|
||
"[%s] Telegram flood control, waiting %.1fs",
|
||
self.name, wait,
|
||
)
|
||
if wait > 5.0:
|
||
return SendResult(success=False, error=f"flood_control:{wait}")
|
||
await asyncio.sleep(wait)
|
||
try:
|
||
await self._bot.edit_message_text(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
text=content,
|
||
)
|
||
return SendResult(success=True, message_id=message_id)
|
||
except Exception as retry_err:
|
||
logger.error(
|
||
"[%s] Edit retry failed after flood wait: %s",
|
||
self.name, retry_err,
|
||
)
|
||
return SendResult(success=False, error=str(retry_err))
|
||
# Transient network errors (ConnectError, timeouts, server
|
||
# disconnects) should not permanently disable progress-message
|
||
# editing. Mark the result retryable so the caller knows it
|
||
# can keep trying on the next update cycle.
|
||
_transient_markers = (
|
||
"connecterror",
|
||
"connect error",
|
||
"connection error",
|
||
"networkerror",
|
||
"network error",
|
||
"timed out",
|
||
"readtimeout",
|
||
"writetimeout",
|
||
"server disconnected",
|
||
"temporarily unavailable",
|
||
"temporary failure",
|
||
"httpx",
|
||
)
|
||
_is_transient = any(m in err_str for m in _transient_markers)
|
||
if _is_transient:
|
||
logger.warning(
|
||
"[%s] Transient network error editing message %s (will retry): %s",
|
||
self.name,
|
||
message_id,
|
||
e,
|
||
)
|
||
return SendResult(success=False, error=str(e), retryable=True)
|
||
logger.error(
|
||
"[%s] Failed to edit Telegram message %s: %s",
|
||
self.name,
|
||
message_id,
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
return SendResult(success=False, error=str(e))
|
||
|
||
async def _edit_overflow_split(
|
||
self,
|
||
chat_id: str,
|
||
message_id: str,
|
||
content: str,
|
||
*,
|
||
finalize: bool,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Split an oversized edit across the existing message + continuations.
|
||
|
||
Edit the original ``message_id`` with chunk 1 (with the platform's
|
||
usual ``(1/N)`` suffix preserved), then send the remaining chunks as
|
||
new messages threaded as replies to the previous chunk so the user
|
||
sees them grouped. Returns ``SendResult(success=True,
|
||
message_id=<last-chunk-id>, continuation_message_ids=(...))`` so the
|
||
stream consumer can keep editing the most recent visible message
|
||
and the gateway has full visibility into every message id we put on
|
||
screen.
|
||
|
||
Falls back to ``SendResult(success=False)`` only if even the first-
|
||
chunk edit fails — that's a real adapter problem, not an overflow.
|
||
"""
|
||
chunks = self.truncate_message(
|
||
content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len,
|
||
)
|
||
if len(chunks) <= 1:
|
||
# Defensive: shouldn't happen given the caller's pre-flight, but
|
||
# if truncate_message returned a single chunk just edit normally.
|
||
chunks = [content]
|
||
|
||
# Step 1 — edit the existing message with the first chunk.
|
||
first_chunk = chunks[0]
|
||
try:
|
||
if finalize:
|
||
# Use format_message + parse_mode for the final chunk;
|
||
# mirror edit_message's main happy-path.
|
||
formatted = self.format_message(first_chunk)
|
||
try:
|
||
await self._bot.edit_message_text(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
text=formatted,
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
)
|
||
except Exception as fmt_err:
|
||
if "not modified" not in str(fmt_err).lower():
|
||
await self._bot.edit_message_text(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
text=first_chunk,
|
||
)
|
||
else:
|
||
await self._bot.edit_message_text(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
text=first_chunk,
|
||
)
|
||
except Exception as e:
|
||
err_str = str(e).lower()
|
||
if "not modified" in err_str:
|
||
# First chunk identical to current text — fall through to
|
||
# send continuations.
|
||
pass
|
||
else:
|
||
logger.error(
|
||
"[%s] Overflow split: first-chunk edit failed: %s",
|
||
self.name, e, exc_info=True,
|
||
)
|
||
return SendResult(success=False, error=str(e))
|
||
|
||
# Step 2 — send each remaining chunk as a continuation message,
|
||
# threaded as a reply to the previous so the user sees them as a
|
||
# contiguous block. We call self._bot.send_message directly so the
|
||
# continuation skips ``self.send``'s own pre-chunking pass (chunks
|
||
# are already correctly sized). Best-effort MarkdownV2 with plain
|
||
# fallback, mirroring send().
|
||
continuation_ids: list[str] = []
|
||
prev_id = message_id
|
||
thread_id = self._metadata_thread_id(metadata)
|
||
for chunk in chunks[1:]:
|
||
sent_msg = None
|
||
reply_to_id = int(prev_id) if prev_id else None
|
||
thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
thread_id,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
)
|
||
for use_markdown in (True, False) if finalize else (False,):
|
||
try:
|
||
text = self.format_message(chunk) if use_markdown else chunk
|
||
sent_msg = await self._bot.send_message(
|
||
chat_id=int(chat_id),
|
||
text=text,
|
||
parse_mode=ParseMode.MARKDOWN_V2 if use_markdown else None,
|
||
reply_to_message_id=reply_to_id,
|
||
**thread_kwargs,
|
||
**self._link_preview_kwargs(),
|
||
**self._notification_kwargs(metadata),
|
||
)
|
||
break
|
||
except Exception as send_err:
|
||
if "reply message not found" in str(send_err).lower():
|
||
# Drop the reply anchor and try again. Private DM
|
||
# topic fallback needs the anchor and topic id together;
|
||
# forum topics can still safely keep message_thread_id.
|
||
retry_thread_kwargs = (
|
||
{}
|
||
if metadata and metadata.get("telegram_dm_topic_reply_fallback")
|
||
else self._thread_kwargs_for_send(
|
||
chat_id, thread_id, metadata, reply_to_message_id=None
|
||
)
|
||
)
|
||
try:
|
||
sent_msg = await self._bot.send_message(
|
||
chat_id=int(chat_id),
|
||
text=chunk,
|
||
**retry_thread_kwargs,
|
||
**self._link_preview_kwargs(),
|
||
**self._notification_kwargs(metadata),
|
||
)
|
||
break
|
||
except Exception as _retry_err:
|
||
logger.warning(
|
||
"[%s] Overflow continuation no-reply retry failed: %s",
|
||
self.name, _retry_err,
|
||
)
|
||
sent_msg = None
|
||
break
|
||
if use_markdown:
|
||
# try plain text on next loop iteration
|
||
continue
|
||
logger.warning(
|
||
"[%s] Overflow continuation send failed: %s",
|
||
self.name, send_err,
|
||
)
|
||
sent_msg = None
|
||
break
|
||
if sent_msg is None:
|
||
# Continuation failed — the user has chunk 1 + however many
|
||
# continuations succeeded. Report success with what we got
|
||
# so the stream consumer knows the edit landed; the
|
||
# remaining tail is lost on this attempt and the next
|
||
# streaming tick may retry.
|
||
logger.warning(
|
||
"[%s] Overflow split: stopped at %d/%d chunks delivered",
|
||
self.name, 1 + len(continuation_ids), len(chunks),
|
||
)
|
||
break
|
||
new_id = str(getattr(sent_msg, "message_id", "")) or prev_id
|
||
continuation_ids.append(new_id)
|
||
prev_id = new_id
|
||
|
||
last_id = continuation_ids[-1] if continuation_ids else message_id
|
||
logger.debug(
|
||
"[%s] Overflow split delivered %d chunks; last_id=%s",
|
||
self.name, 1 + len(continuation_ids), last_id,
|
||
)
|
||
return SendResult(
|
||
success=True,
|
||
message_id=last_id,
|
||
continuation_message_ids=tuple(continuation_ids),
|
||
)
|
||
|
||
async def delete_message(self, chat_id: str, message_id: str) -> bool:
|
||
"""Delete a previously sent Telegram message.
|
||
|
||
Used by the stream consumer's fresh-final cleanup path (ported
|
||
from openclaw/openclaw#72038) to remove long-lived preview
|
||
messages after sending the completed reply as a fresh message.
|
||
Telegram's Bot API ``deleteMessage`` works for bot-posted
|
||
messages in the last 48 hours. Failures are non-fatal — the
|
||
caller leaves the preview in place and logs at debug level.
|
||
"""
|
||
if not self._bot:
|
||
return False
|
||
try:
|
||
await self._bot.delete_message(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.debug(
|
||
"[%s] Failed to delete Telegram message %s: %s",
|
||
self.name, message_id, e,
|
||
)
|
||
return False
|
||
|
||
def supports_draft_streaming(
|
||
self,
|
||
chat_type: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> bool:
|
||
"""Telegram supports sendMessageDraft for private chats only.
|
||
|
||
Bot API 9.5 (March 2026) opened ``sendMessageDraft`` to all bots
|
||
unconditionally for private (DM) chats. Groups, supergroups, and
|
||
channels still rely on the edit-based path.
|
||
|
||
We additionally require ``self._bot`` to expose ``send_message_draft``
|
||
(added to python-telegram-bot in 22.6); older PTB installs gracefully
|
||
fall back to the edit path even on DMs.
|
||
"""
|
||
if not self._bot or not hasattr(self._bot, "send_message_draft"):
|
||
return False
|
||
return (chat_type or "").lower() in {"dm", "private"}
|
||
|
||
async def send_draft(
|
||
self,
|
||
chat_id: str,
|
||
draft_id: int,
|
||
content: str,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Stream a partial message via Telegram's native sendMessageDraft.
|
||
|
||
The Bot API animates the preview when the same ``draft_id`` is reused
|
||
across consecutive calls in the same chat. When the response
|
||
finishes, the caller sends the final text via the normal ``send``
|
||
path; the draft preview clears naturally on the client (Telegram has
|
||
no Bot API to "promote" a draft to a real message — the final
|
||
``sendMessage`` is what the user receives in their history).
|
||
"""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="not_connected")
|
||
if not hasattr(self._bot, "send_message_draft"):
|
||
return SendResult(success=False, error="api_unavailable")
|
||
|
||
# Trim to the same UTF-16 budget the platform enforces on regular
|
||
# sends. Drafts have the same length contract as messages.
|
||
text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \
|
||
self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
|
||
|
||
kwargs: Dict[str, Any] = {
|
||
"chat_id": int(chat_id),
|
||
"draft_id": int(draft_id),
|
||
"text": text,
|
||
}
|
||
thread_id = self._metadata_thread_id(metadata)
|
||
if thread_id is not None:
|
||
kwargs["message_thread_id"] = thread_id
|
||
|
||
try:
|
||
ok = await self._bot.send_message_draft(**kwargs)
|
||
if ok:
|
||
# Drafts have no message_id; we report success without one
|
||
# so the caller knows the animation frame landed.
|
||
return SendResult(success=True, message_id=None)
|
||
return SendResult(success=False, error="draft_rejected")
|
||
except Exception as e:
|
||
# Most likely: BadRequest because this bot/chat doesn't allow
|
||
# drafts, or a transient server hiccup. The caller treats any
|
||
# failure as "fall back to edit-based for this response".
|
||
logger.debug(
|
||
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
|
||
self.name, chat_id, draft_id, e,
|
||
)
|
||
return SendResult(success=False, error=str(e))
|
||
|
||
async def _send_message_with_thread_fallback(self, **kwargs):
|
||
"""Send a Telegram message, retrying once without message_thread_id
|
||
if Telegram returns 'Message thread not found'.
|
||
|
||
Used for control-style sends (approval prompts, model picker,
|
||
update prompts) that can carry a stale thread_id from a DM
|
||
reply chain. The streaming send loop has its own equivalent
|
||
(PR #3390) at the body of ``send``; this helper applies the
|
||
same retry pattern to the non-streaming control paths.
|
||
"""
|
||
if not self._bot:
|
||
raise RuntimeError("Not connected")
|
||
|
||
message_thread_id = kwargs.get("message_thread_id")
|
||
try:
|
||
return await self._bot.send_message(**kwargs)
|
||
except Exception as send_err:
|
||
if (
|
||
message_thread_id is not None
|
||
and self._is_bad_request_error(send_err)
|
||
and self._is_thread_not_found_error(send_err)
|
||
):
|
||
logger.warning(
|
||
"[%s] Thread %s not found for control message, retrying without message_thread_id",
|
||
self.name,
|
||
message_thread_id,
|
||
)
|
||
retry_kwargs = dict(kwargs)
|
||
retry_kwargs.pop("message_thread_id", None)
|
||
return await self._bot.send_message(**retry_kwargs)
|
||
raise
|
||
|
||
async def send_update_prompt(
|
||
self, chat_id: str, prompt: str, default: str = "",
|
||
session_key: str = "",
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Send an inline-keyboard update prompt (Yes / No buttons).
|
||
|
||
Used by the gateway ``/update`` watcher when ``hermes update --gateway``
|
||
needs user input (stash restore, config migration).
|
||
"""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
try:
|
||
default_hint = f" (default: {default})" if default else ""
|
||
text = self.format_message(f"⚕ *Update needs your input:*\n\n{prompt}{default_hint}")
|
||
keyboard = InlineKeyboardMarkup([
|
||
[
|
||
InlineKeyboardButton("✓ Yes", callback_data="update_prompt:y"),
|
||
InlineKeyboardButton("✗ No", callback_data="update_prompt:n"),
|
||
]
|
||
])
|
||
thread_id = self._metadata_thread_id(metadata)
|
||
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
|
||
msg = await self._send_message_with_thread_fallback(
|
||
chat_id=int(chat_id),
|
||
text=text,
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=keyboard,
|
||
reply_to_message_id=reply_to_id,
|
||
**self._thread_kwargs_for_send(
|
||
chat_id,
|
||
thread_id,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
),
|
||
**self._link_preview_kwargs(),
|
||
)
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
logger.warning("[%s] send_update_prompt failed: %s", self.name, e)
|
||
return SendResult(success=False, error=str(e))
|
||
|
||
async def send_exec_approval(
|
||
self, chat_id: str, command: str, session_key: str,
|
||
description: str = "dangerous command",
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Send an inline-keyboard approval prompt with interactive buttons.
|
||
|
||
The buttons call ``resolve_gateway_approval()`` to unblock the waiting
|
||
agent thread — same mechanism as the text ``/approve`` flow.
|
||
"""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
try:
|
||
cmd_preview = command[:3800] + "..." if len(command) > 3800 else command
|
||
text = (
|
||
f"⚠️ <b>Command Approval Required</b>\n\n"
|
||
f"<pre>{_html.escape(cmd_preview)}</pre>\n\n"
|
||
f"Reason: {_html.escape(description)}"
|
||
)
|
||
|
||
# Resolve thread context for thread replies
|
||
thread_id = self._metadata_thread_id(metadata)
|
||
|
||
# We'll use the message_id as part of callback_data to look up session_key
|
||
# Send a placeholder first, then update — or use a counter.
|
||
# Simpler: use a monotonic counter to generate short IDs.
|
||
import itertools
|
||
if not hasattr(self, "_approval_counter"):
|
||
self._approval_counter = itertools.count(1)
|
||
approval_id = next(self._approval_counter)
|
||
|
||
keyboard = InlineKeyboardMarkup([
|
||
[
|
||
InlineKeyboardButton("✅ Allow Once", callback_data=f"ea:once:{approval_id}"),
|
||
InlineKeyboardButton("✅ Session", callback_data=f"ea:session:{approval_id}"),
|
||
],
|
||
[
|
||
InlineKeyboardButton("✅ Always", callback_data=f"ea:always:{approval_id}"),
|
||
InlineKeyboardButton("❌ Deny", callback_data=f"ea:deny:{approval_id}"),
|
||
],
|
||
])
|
||
|
||
kwargs: Dict[str, Any] = {
|
||
"chat_id": int(chat_id),
|
||
"text": text,
|
||
"parse_mode": ParseMode.HTML,
|
||
"reply_markup": keyboard,
|
||
**self._link_preview_kwargs(),
|
||
}
|
||
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
|
||
kwargs["reply_to_message_id"] = reply_to_id
|
||
kwargs.update(
|
||
self._thread_kwargs_for_send(
|
||
chat_id,
|
||
thread_id,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
)
|
||
|
||
msg = await self._send_message_with_thread_fallback(**kwargs)
|
||
|
||
# Store session_key keyed by approval_id for the callback handler
|
||
self._approval_state[approval_id] = session_key
|
||
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
logger.warning("[%s] send_exec_approval failed: %s", self.name, e)
|
||
return SendResult(success=False, error=str(e))
|
||
|
||
async def send_slash_confirm(
|
||
self, chat_id: str, title: str, message: str, session_key: str,
|
||
confirm_id: str, metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Render a three-button slash-command confirmation prompt."""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
try:
|
||
preview = self.format_message(message if len(message) <= 3800 else message[:3800] + "...")
|
||
|
||
keyboard = InlineKeyboardMarkup([
|
||
[
|
||
InlineKeyboardButton("✅ Approve Once", callback_data=f"sc:once:{confirm_id}"),
|
||
InlineKeyboardButton("🔒 Always Approve", callback_data=f"sc:always:{confirm_id}"),
|
||
],
|
||
[
|
||
InlineKeyboardButton("❌ Cancel", callback_data=f"sc:cancel:{confirm_id}"),
|
||
],
|
||
])
|
||
|
||
thread_id = self._metadata_thread_id(metadata)
|
||
kwargs: Dict[str, Any] = {
|
||
"chat_id": int(chat_id),
|
||
"text": preview,
|
||
"parse_mode": ParseMode.MARKDOWN_V2,
|
||
"reply_markup": keyboard,
|
||
**self._link_preview_kwargs(),
|
||
}
|
||
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
|
||
kwargs["reply_to_message_id"] = reply_to_id
|
||
kwargs.update(
|
||
self._thread_kwargs_for_send(
|
||
chat_id,
|
||
thread_id,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
)
|
||
|
||
msg = await self._send_message_with_thread_fallback(**kwargs)
|
||
self._slash_confirm_state[confirm_id] = session_key
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
logger.warning("[%s] send_slash_confirm failed: %s", self.name, e)
|
||
return SendResult(success=False, error=str(e))
|
||
|
||
async def send_clarify(
|
||
self,
|
||
chat_id: str,
|
||
question: str,
|
||
choices: Optional[list],
|
||
clarify_id: str,
|
||
session_key: str,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Render a clarify prompt with one inline button per choice.
|
||
|
||
Multi-choice mode (``choices`` non-empty): renders one button per
|
||
option plus a final "✏️ Other (type answer)" button. Picking the
|
||
"Other" button flips the entry into text-capture mode so the next
|
||
message becomes the response.
|
||
|
||
Open-ended mode (``choices`` empty): renders the question as plain
|
||
text — no buttons. The next message in the session is captured by
|
||
the gateway's text-intercept and resolves the clarify.
|
||
"""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
try:
|
||
text = f"❓ {_html.escape(question)}"
|
||
thread_id = self._metadata_thread_id(metadata)
|
||
|
||
if choices:
|
||
# Render full option text in the message body so mobile
|
||
# users can read long choices that would be truncated in
|
||
# inline button labels. Buttons keep short numeric labels
|
||
# (1, 2, …, Other) to avoid Telegram truncation.
|
||
option_lines = "\n".join(
|
||
f"{i + 1}. {_html.escape(str(c))}"
|
||
for i, c in enumerate(choices)
|
||
)
|
||
text += f"\n\n{option_lines}"
|
||
|
||
kwargs: Dict[str, Any] = {
|
||
"chat_id": int(chat_id),
|
||
"text": text,
|
||
"parse_mode": ParseMode.HTML,
|
||
**self._link_preview_kwargs(),
|
||
}
|
||
|
||
if choices:
|
||
# Telegram caps callback_data at 64 bytes; keep "cl:<id>:<idx>"
|
||
# short.
|
||
rows = []
|
||
for idx in range(len(choices)):
|
||
rows.append([
|
||
InlineKeyboardButton(
|
||
str(idx + 1),
|
||
callback_data=f"cl:{clarify_id}:{idx}",
|
||
)
|
||
])
|
||
rows.append([
|
||
InlineKeyboardButton(
|
||
"✏️ Other (type answer)",
|
||
callback_data=f"cl:{clarify_id}:other",
|
||
)
|
||
])
|
||
kwargs["reply_markup"] = InlineKeyboardMarkup(rows)
|
||
|
||
reply_to_id = self._reply_to_message_id_for_send(None, metadata)
|
||
kwargs["reply_to_message_id"] = reply_to_id
|
||
kwargs.update(
|
||
self._thread_kwargs_for_send(
|
||
chat_id,
|
||
thread_id,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
)
|
||
)
|
||
|
||
msg = await self._send_message_with_thread_fallback(**kwargs)
|
||
self._clarify_state[clarify_id] = session_key
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
logger.warning("[%s] send_clarify failed: %s", self.name, e)
|
||
return SendResult(success=False, error=str(e))
|
||
|
||
async def send_model_picker(
|
||
self,
|
||
chat_id: str,
|
||
providers: list,
|
||
current_model: str,
|
||
current_provider: str,
|
||
session_key: str,
|
||
on_model_selected,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Send an interactive inline-keyboard model picker.
|
||
|
||
Two-step drill-down: provider selection → model selection.
|
||
Edits the same message in-place as the user navigates.
|
||
"""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
try:
|
||
from hermes_cli.providers import get_label
|
||
except ImportError:
|
||
def get_label(slug):
|
||
return slug
|
||
|
||
try:
|
||
# Build provider buttons — folds provider groups (display only).
|
||
keyboard = self._build_provider_keyboard(providers)
|
||
|
||
provider_label = get_label(current_provider)
|
||
text = self.format_message(
|
||
(
|
||
f"⚙ *Model Configuration*\n\n"
|
||
f"Current model: `{current_model or 'unknown'}`\n"
|
||
f"Provider: {provider_label}\n\n"
|
||
f"Select a provider:"
|
||
)
|
||
)
|
||
|
||
thread_id = metadata.get("thread_id") if metadata else None
|
||
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
|
||
msg = await self._send_message_with_thread_fallback(
|
||
chat_id=int(chat_id),
|
||
text=text,
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=keyboard,
|
||
reply_to_message_id=reply_to_id,
|
||
**self._thread_kwargs_for_send(
|
||
chat_id,
|
||
thread_id,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
),
|
||
**self._link_preview_kwargs(),
|
||
)
|
||
|
||
# Store picker state keyed by chat_id
|
||
self._model_picker_state[str(chat_id)] = {
|
||
"msg_id": msg.message_id,
|
||
"providers": providers,
|
||
"session_key": session_key,
|
||
"on_model_selected": on_model_selected,
|
||
"current_model": current_model,
|
||
"current_provider": current_provider,
|
||
}
|
||
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
logger.warning("[%s] send_model_picker failed: %s", self.name, e)
|
||
return SendResult(success=False, error=str(e))
|
||
|
||
_MODEL_PAGE_SIZE = 8
|
||
|
||
def _build_provider_keyboard(self, providers: list):
|
||
"""Build the top-level provider keyboard, folding provider groups.
|
||
|
||
Provider families (Kimi/Moonshot, MiniMax, xAI Grok, ...) collapse to
|
||
a single ``mpg:<gid>`` button; tapping it drills into a member
|
||
sub-keyboard. Single providers (and groups with only one authenticated
|
||
member) render as direct ``mp:<slug>`` buttons. Grouping mirrors the
|
||
CLI ``hermes model`` picker via the shared ``group_providers`` fold,
|
||
so all surfaces stay consistent.
|
||
"""
|
||
try:
|
||
from hermes_cli.models import group_providers
|
||
except Exception:
|
||
group_providers = None
|
||
|
||
by_slug = {p.get("slug"): p for p in providers}
|
||
|
||
def _provider_button(p):
|
||
count = p.get("total_models", len(p.get("models", [])))
|
||
label = f"{p['name']} ({count})"
|
||
if p.get("is_current"):
|
||
label = f"✓ {label}"
|
||
return InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||
|
||
buttons: list = []
|
||
if group_providers is not None:
|
||
for row in group_providers([p.get("slug") for p in providers]):
|
||
if row["kind"] == "group":
|
||
members = [by_slug[m] for m in row["members"] if m in by_slug]
|
||
count = sum(
|
||
m.get("total_models", len(m.get("models", []))) for m in members
|
||
)
|
||
label = f"{row['label']} ▸ ({count})"
|
||
if any(m.get("is_current") for m in members):
|
||
label = f"✓ {label}"
|
||
buttons.append(
|
||
InlineKeyboardButton(label, callback_data=f"mpg:{row['group_id']}")
|
||
)
|
||
else:
|
||
p = by_slug.get(row["slug"])
|
||
if p is not None:
|
||
buttons.append(_provider_button(p))
|
||
else:
|
||
for p in providers:
|
||
buttons.append(_provider_button(p))
|
||
|
||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||
return InlineKeyboardMarkup(rows)
|
||
|
||
def _build_model_keyboard(self, models: list, page: int) -> tuple:
|
||
"""Build paginated model buttons. Returns (keyboard, page_info_text)."""
|
||
page_size = self._MODEL_PAGE_SIZE
|
||
total = len(models)
|
||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||
page = max(0, min(page, total_pages - 1))
|
||
|
||
start = page * page_size
|
||
end = min(start + page_size, total)
|
||
page_models = models[start:end]
|
||
|
||
buttons: list = []
|
||
for i, model_id in enumerate(page_models):
|
||
abs_idx = start + i
|
||
short = model_id.split("/")[-1] if "/" in model_id else model_id
|
||
if len(short) > 38:
|
||
short = short[:35] + "..."
|
||
buttons.append(
|
||
InlineKeyboardButton(short, callback_data=f"mm:{abs_idx}")
|
||
)
|
||
|
||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||
|
||
# Pagination row (if needed)
|
||
if total_pages > 1:
|
||
nav: list = []
|
||
if page > 0:
|
||
nav.append(InlineKeyboardButton("◀ Prev", callback_data=f"mg:{page - 1}"))
|
||
nav.append(InlineKeyboardButton(f"{page + 1}/{total_pages}", callback_data="mx:noop"))
|
||
if page < total_pages - 1:
|
||
nav.append(InlineKeyboardButton("Next ▶", callback_data=f"mg:{page + 1}"))
|
||
rows.append(nav)
|
||
|
||
rows.append([
|
||
InlineKeyboardButton("◀ Back", callback_data="mb"),
|
||
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
|
||
])
|
||
|
||
page_info = f" ({start + 1}–{end} of {total})" if total_pages > 1 else ""
|
||
return InlineKeyboardMarkup(rows), page_info
|
||
|
||
async def _handle_model_picker_callback(
|
||
self, query, data: str, chat_id: str
|
||
) -> None:
|
||
"""Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:)."""
|
||
state = self._model_picker_state.get(chat_id)
|
||
if not state:
|
||
await query.answer(text="Picker expired — use /model again.")
|
||
return
|
||
|
||
try:
|
||
from hermes_cli.providers import get_label
|
||
except ImportError:
|
||
def get_label(slug):
|
||
return slug
|
||
|
||
if data.startswith("mp:"):
|
||
# --- Provider selected: show model buttons (page 0) ---
|
||
provider_slug = data[3:]
|
||
provider = next(
|
||
(p for p in state["providers"] if p["slug"] == provider_slug),
|
||
None,
|
||
)
|
||
if not provider:
|
||
await query.answer(text="Provider not found.")
|
||
return
|
||
|
||
models = provider.get("models", [])
|
||
state["selected_provider"] = provider_slug
|
||
state["selected_provider_name"] = provider.get("name", provider_slug)
|
||
state["model_list"] = models
|
||
state["model_page"] = 0
|
||
|
||
keyboard, page_info = self._build_model_keyboard(models, 0)
|
||
|
||
pname = provider.get("name", provider_slug)
|
||
total = provider.get("total_models", len(models))
|
||
shown = len(models)
|
||
extra = f"\n_{total - shown} more available — type `/model <name>` directly_" if total > shown else ""
|
||
|
||
await query.edit_message_text(
|
||
text=self.format_message(
|
||
(
|
||
f"⚙ *Model Configuration*\n\n"
|
||
f"Provider: *{pname}*{page_info}\n"
|
||
f"Select a model:{extra}"
|
||
)
|
||
),
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=keyboard,
|
||
)
|
||
await query.answer()
|
||
|
||
elif data.startswith("mg:"):
|
||
# --- Page navigation ---
|
||
try:
|
||
page = int(data[3:])
|
||
except ValueError:
|
||
await query.answer(text="Invalid page.")
|
||
return
|
||
|
||
models = state.get("model_list", [])
|
||
state["model_page"] = page
|
||
|
||
keyboard, page_info = self._build_model_keyboard(models, page)
|
||
|
||
pname = state.get("selected_provider_name", "")
|
||
provider_slug = state.get("selected_provider", "")
|
||
provider = next(
|
||
(p for p in state["providers"] if p["slug"] == provider_slug),
|
||
None,
|
||
)
|
||
total = provider.get("total_models", len(models)) if provider else len(models)
|
||
shown = len(models)
|
||
extra = f"\n_{total - shown} more available — type `/model <name>` directly_" if total > shown else ""
|
||
|
||
await query.edit_message_text(
|
||
text=self.format_message(
|
||
(
|
||
f"⚙ *Model Configuration*\n\n"
|
||
f"Provider: *{pname}*{page_info}\n"
|
||
f"Select a model:{extra}"
|
||
)
|
||
),
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=keyboard,
|
||
)
|
||
await query.answer()
|
||
|
||
elif data.startswith("mm:"):
|
||
# --- Model selected: perform the switch ---
|
||
try:
|
||
idx = int(data[3:])
|
||
except ValueError:
|
||
await query.answer(text="Invalid selection.")
|
||
return
|
||
|
||
model_list = state.get("model_list", [])
|
||
if idx < 0 or idx >= len(model_list):
|
||
await query.answer(text="Invalid model index.")
|
||
return
|
||
|
||
model_id = model_list[idx]
|
||
provider_slug = state.get("selected_provider", "")
|
||
callback = state.get("on_model_selected")
|
||
|
||
if not callback:
|
||
await query.answer(text="Picker expired.")
|
||
return
|
||
|
||
try:
|
||
result_text = await callback(chat_id, model_id, provider_slug)
|
||
except Exception as exc:
|
||
logger.error("Model picker switch failed: %s", exc)
|
||
result_text = f"Error switching model: {exc}"
|
||
|
||
# Edit message to show confirmation, remove buttons
|
||
try:
|
||
await query.edit_message_text(
|
||
text=self.format_message(result_text),
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=None,
|
||
)
|
||
except Exception:
|
||
# Markdown parse failure — retry as plain text
|
||
try:
|
||
await query.edit_message_text(
|
||
text=result_text,
|
||
parse_mode=None,
|
||
reply_markup=None,
|
||
)
|
||
except Exception:
|
||
pass
|
||
await query.answer(text="Model switched!")
|
||
|
||
# Clean up state
|
||
self._model_picker_state.pop(chat_id, None)
|
||
|
||
elif data.startswith("mpg:"):
|
||
# --- Provider group selected: show member providers ---
|
||
group_id = data[4:]
|
||
try:
|
||
from hermes_cli.models import PROVIDER_GROUPS
|
||
_label, _desc, member_slugs = PROVIDER_GROUPS.get(group_id, ("", "", []))
|
||
except Exception:
|
||
_label, member_slugs = "", []
|
||
|
||
by_slug = {p["slug"]: p for p in state["providers"]}
|
||
members = [by_slug[m] for m in member_slugs if m in by_slug]
|
||
if not members:
|
||
await query.answer(text="Group not found.")
|
||
return
|
||
|
||
buttons = []
|
||
for p in members:
|
||
count = p.get("total_models", len(p.get("models", [])))
|
||
label = f"{p['name']} ({count})"
|
||
if p.get("is_current"):
|
||
label = f"✓ {label}"
|
||
buttons.append(
|
||
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||
)
|
||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||
rows.append([
|
||
InlineKeyboardButton("◀ Back", callback_data="mb"),
|
||
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
|
||
])
|
||
keyboard = InlineKeyboardMarkup(rows)
|
||
|
||
await query.edit_message_text(
|
||
text=self.format_message(
|
||
(
|
||
f"⚙ *Model Configuration*\n\n"
|
||
f"Provider family: *{_label or group_id}*\n\n"
|
||
f"Select a provider:"
|
||
)
|
||
),
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=keyboard,
|
||
)
|
||
await query.answer()
|
||
|
||
elif data == "mb":
|
||
# --- Back to provider list (folds groups) ---
|
||
keyboard = self._build_provider_keyboard(state["providers"])
|
||
|
||
try:
|
||
provider_label = get_label(state["current_provider"])
|
||
except Exception:
|
||
provider_label = state["current_provider"]
|
||
|
||
await query.edit_message_text(
|
||
text=self.format_message(
|
||
(
|
||
f"⚙ *Model Configuration*\n\n"
|
||
f"Current model: `{state['current_model'] or 'unknown'}`\n"
|
||
f"Provider: {provider_label}\n\n"
|
||
f"Select a provider:"
|
||
)
|
||
),
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=keyboard,
|
||
)
|
||
await query.answer()
|
||
|
||
elif data == "mx":
|
||
# --- Cancel ---
|
||
self._model_picker_state.pop(chat_id, None)
|
||
await query.edit_message_text(
|
||
text="Model selection cancelled.",
|
||
reply_markup=None,
|
||
)
|
||
await query.answer()
|
||
|
||
else:
|
||
# Catch-all (e.g. page counter button "mx:noop")
|
||
await query.answer()
|
||
|
||
async def _handle_callback_query(
|
||
self, update: "Update", context: "ContextTypes.DEFAULT_TYPE"
|
||
) -> None:
|
||
"""Handle inline keyboard button clicks."""
|
||
query = update.callback_query
|
||
if not query or not query.data:
|
||
return
|
||
data = query.data
|
||
query_message = getattr(query, "message", None)
|
||
query_chat_id = getattr(query_message, "chat_id", None)
|
||
query_chat = getattr(query_message, "chat", None)
|
||
query_chat_type = getattr(query_chat, "type", None)
|
||
query_thread_id = getattr(query_message, "message_thread_id", None)
|
||
query_user_name = getattr(query.from_user, "first_name", None)
|
||
|
||
# --- Model picker callbacks ---
|
||
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
|
||
chat_id = str(query.message.chat_id) if query.message else None
|
||
if chat_id:
|
||
await self._handle_model_picker_callback(query, data, chat_id)
|
||
return
|
||
|
||
# --- Gmail-triage callbacks (gt:verb:arg) ---
|
||
if data.startswith("gt:"):
|
||
await self._handle_gmail_triage_callback(
|
||
query,
|
||
data,
|
||
query_chat_id=query_chat_id,
|
||
query_chat_type=query_chat_type,
|
||
query_thread_id=query_thread_id,
|
||
query_user_name=query_user_name,
|
||
)
|
||
return
|
||
|
||
# --- Exec approval callbacks (ea:choice:id) ---
|
||
if data.startswith("ea:"):
|
||
parts = data.split(":", 2)
|
||
if len(parts) == 3:
|
||
choice = parts[1] # once, session, always, deny
|
||
try:
|
||
approval_id = int(parts[2])
|
||
except (ValueError, IndexError):
|
||
await query.answer(text="Invalid approval data.")
|
||
return
|
||
|
||
# Only authorized users may click approval buttons.
|
||
caller_id = str(getattr(query.from_user, "id", ""))
|
||
if not self._is_callback_user_authorized(
|
||
caller_id,
|
||
chat_id=query_chat_id,
|
||
chat_type=str(query_chat_type) if query_chat_type is not None else None,
|
||
thread_id=str(query_thread_id) if query_thread_id is not None else None,
|
||
user_name=query_user_name,
|
||
):
|
||
await query.answer(text="⛔ You are not authorized to approve commands.")
|
||
return
|
||
|
||
session_key = self._approval_state.pop(approval_id, None)
|
||
if not session_key:
|
||
await query.answer(text="This approval has already been resolved.")
|
||
return
|
||
|
||
# Map choice to human-readable label
|
||
label_map = {
|
||
"once": "✅ Approved once",
|
||
"session": "✅ Approved for session",
|
||
"always": "✅ Approved permanently",
|
||
"deny": "❌ Denied",
|
||
}
|
||
user_display = getattr(query.from_user, "first_name", "User")
|
||
label = label_map.get(choice, "Resolved")
|
||
|
||
await query.answer(text=label)
|
||
|
||
# Edit message to show decision, remove buttons
|
||
try:
|
||
await query.edit_message_text(
|
||
text=self.format_message(f"{label} by {user_display}"),
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=None,
|
||
)
|
||
except Exception:
|
||
pass # non-fatal if edit fails
|
||
|
||
# Resolve the approval — unblocks the agent thread
|
||
try:
|
||
from tools.approval import resolve_gateway_approval
|
||
count = resolve_gateway_approval(session_key, choice)
|
||
logger.info(
|
||
"Telegram button resolved %d approval(s) for session %s (choice=%s, user=%s)",
|
||
count, session_key, choice, user_display,
|
||
)
|
||
except Exception as exc:
|
||
logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
|
||
count = 0
|
||
|
||
# Resume the typing indicator — paused when the approval was
|
||
# sent (gateway/run.py). The text /approve and /deny paths
|
||
# call resume_typing_for_chat here too; without it, typing
|
||
# stays paused for the rest of the turn after an inline
|
||
# button click.
|
||
if count and query_chat_id is not None:
|
||
self.resume_typing_for_chat(str(query_chat_id))
|
||
return
|
||
|
||
# --- Slash-confirm callbacks (sc:choice:confirm_id) ---
|
||
if data.startswith("sc:"):
|
||
parts = data.split(":", 2)
|
||
if len(parts) == 3:
|
||
choice = parts[1] # once, always, cancel
|
||
confirm_id = parts[2]
|
||
|
||
caller_id = str(getattr(query.from_user, "id", ""))
|
||
if not self._is_callback_user_authorized(
|
||
caller_id,
|
||
chat_id=query_chat_id,
|
||
chat_type=str(query_chat_type) if query_chat_type is not None else None,
|
||
thread_id=str(query_thread_id) if query_thread_id is not None else None,
|
||
user_name=query_user_name,
|
||
):
|
||
await query.answer(text="⛔ You are not authorized to answer this prompt.")
|
||
return
|
||
|
||
session_key = self._slash_confirm_state.pop(confirm_id, None)
|
||
if not session_key:
|
||
await query.answer(text="This prompt has already been resolved.")
|
||
return
|
||
|
||
label_map = {
|
||
"once": "✅ Approved once",
|
||
"always": "🔒 Always approve",
|
||
"cancel": "❌ Cancelled",
|
||
}
|
||
user_display = getattr(query.from_user, "first_name", "User")
|
||
label = label_map.get(choice, "Resolved")
|
||
|
||
await query.answer(text=label)
|
||
|
||
try:
|
||
await query.edit_message_text(
|
||
text=self.format_message(f"{label} by {user_display}"),
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=None,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
# Resolve via the module-level primitive. The runner stored
|
||
# a handler keyed by session_key; we run it on the event
|
||
# loop and (if it returns a string) send it as a follow-up
|
||
# message in the same chat.
|
||
try:
|
||
from tools import slash_confirm as _slash_confirm_mod
|
||
result_text = await _slash_confirm_mod.resolve(
|
||
session_key, confirm_id, choice,
|
||
)
|
||
if result_text and query.message:
|
||
# Inherit the prompt message's topic. Supergroup forums
|
||
# use message_thread_id; Telegram private DM-topic lanes
|
||
# need both the private topic id and the prompt reply anchor.
|
||
thread_id = getattr(query.message, "message_thread_id", None)
|
||
chat = getattr(query.message, "chat", None)
|
||
chat_type = getattr(chat, "type", None)
|
||
prompt_message_id = getattr(query.message, "message_id", None)
|
||
send_kwargs: Dict[str, Any] = {
|
||
"chat_id": int(query.message.chat_id),
|
||
"text": self.format_message(result_text),
|
||
"parse_mode": ParseMode.MARKDOWN_V2,
|
||
**self._link_preview_kwargs(),
|
||
}
|
||
chat_type_value = getattr(chat_type, "value", chat_type)
|
||
is_private_chat = str(chat_type_value).lower() in {
|
||
"private",
|
||
str(ChatType.PRIVATE).lower(),
|
||
str(getattr(ChatType.PRIVATE, "value", ChatType.PRIVATE)).lower(),
|
||
}
|
||
if thread_id is not None and is_private_chat and prompt_message_id is not None:
|
||
reply_to_id = int(prompt_message_id)
|
||
send_kwargs["reply_to_message_id"] = reply_to_id
|
||
send_kwargs.update(
|
||
self._thread_kwargs_for_send(
|
||
str(query.message.chat_id),
|
||
str(thread_id),
|
||
{
|
||
"thread_id": str(thread_id),
|
||
"telegram_dm_topic_reply_fallback": True,
|
||
},
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
)
|
||
elif thread_id is not None:
|
||
send_kwargs.update(
|
||
self._thread_kwargs_for_send(
|
||
str(query.message.chat_id),
|
||
str(thread_id),
|
||
{"thread_id": str(thread_id)},
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
)
|
||
await self._send_message_with_thread_fallback(**send_kwargs)
|
||
except Exception as exc:
|
||
logger.error("[%s] slash-confirm callback failed: %s", self.name, exc, exc_info=True)
|
||
return
|
||
|
||
# --- Clarify callbacks (cl:clarify_id:idx | cl:clarify_id:other) ---
|
||
if data.startswith("cl:"):
|
||
parts = data.split(":", 2)
|
||
if len(parts) == 3:
|
||
clarify_id = parts[1]
|
||
choice_token = parts[2]
|
||
|
||
caller_id = str(getattr(query.from_user, "id", ""))
|
||
if not self._is_callback_user_authorized(
|
||
caller_id,
|
||
chat_id=query_chat_id,
|
||
chat_type=str(query_chat_type) if query_chat_type is not None else None,
|
||
thread_id=str(query_thread_id) if query_thread_id is not None else None,
|
||
user_name=query_user_name,
|
||
):
|
||
await query.answer(text="⛔ You are not authorized to answer this prompt.")
|
||
return
|
||
|
||
session_key = self._clarify_state.get(clarify_id)
|
||
if not session_key:
|
||
await query.answer(text="This prompt has already been resolved.")
|
||
return
|
||
|
||
user_display = getattr(query.from_user, "first_name", "User")
|
||
|
||
if choice_token == "other":
|
||
# Flip into text-capture mode and tell the user to type
|
||
# their answer. The gateway's text-intercept will pick
|
||
# up the next message in this session and resolve the
|
||
# clarify. Do NOT pop _clarify_state yet — we still
|
||
# need it if the user is slow to respond and the entry
|
||
# is cleared by something else.
|
||
try:
|
||
from tools.clarify_gateway import mark_awaiting_text
|
||
mark_awaiting_text(clarify_id)
|
||
except Exception as exc:
|
||
logger.warning("[%s] mark_awaiting_text failed: %s", self.name, exc)
|
||
|
||
await query.answer(text="✏️ Type your answer in the chat.")
|
||
try:
|
||
await query.edit_message_text(
|
||
text=f"❓ {query.message.text or ''}\n\n<i>Awaiting typed response from {_html.escape(user_display)}…</i>",
|
||
parse_mode=ParseMode.HTML,
|
||
reply_markup=None,
|
||
)
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
# Numeric choice → resolve immediately with the chosen text
|
||
try:
|
||
idx = int(choice_token)
|
||
except (ValueError, TypeError):
|
||
await query.answer(text="Invalid choice.")
|
||
return
|
||
|
||
# Look up the choice text from the entry registered in the
|
||
# clarify primitive. Fall back to the index if the entry
|
||
# has been cleaned up (race with timeout / session reset).
|
||
resolved_text: Optional[str] = None
|
||
try:
|
||
from tools.clarify_gateway import _entries as _clarify_entries # type: ignore
|
||
entry = _clarify_entries.get(clarify_id)
|
||
if entry and entry.choices and 0 <= idx < len(entry.choices):
|
||
resolved_text = entry.choices[idx]
|
||
except Exception:
|
||
resolved_text = None
|
||
|
||
if resolved_text is None:
|
||
# Race: entry vanished. Echo the index as a number so
|
||
# the agent at least sees an intentional response
|
||
# rather than nothing.
|
||
resolved_text = f"choice {idx + 1}"
|
||
|
||
# Pop state and resolve
|
||
self._clarify_state.pop(clarify_id, None)
|
||
try:
|
||
from tools.clarify_gateway import resolve_gateway_clarify
|
||
resolved = resolve_gateway_clarify(clarify_id, resolved_text)
|
||
except Exception as exc:
|
||
logger.error("[%s] resolve_gateway_clarify failed: %s", self.name, exc)
|
||
resolved = False
|
||
|
||
await query.answer(text=f"✓ {resolved_text[:60]}")
|
||
try:
|
||
await query.edit_message_text(
|
||
text=f"❓ {_html.escape(query.message.text or '')}\n\n<b>{_html.escape(user_display)}:</b> {_html.escape(resolved_text)}",
|
||
parse_mode=ParseMode.HTML,
|
||
reply_markup=None,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
if resolved:
|
||
logger.info(
|
||
"Telegram clarify button resolved (id=%s, choice=%r, user=%s)",
|
||
clarify_id, resolved_text, user_display,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"Telegram clarify button: resolve_gateway_clarify returned False (id=%s)",
|
||
clarify_id,
|
||
)
|
||
return
|
||
|
||
# --- Update prompt callbacks ---
|
||
if not data.startswith("update_prompt:"):
|
||
return
|
||
answer = data.split(":", 1)[1] # "y" or "n"
|
||
caller_id = str(getattr(query.from_user, "id", ""))
|
||
if not self._is_callback_user_authorized(
|
||
caller_id,
|
||
chat_id=query_chat_id,
|
||
chat_type=str(query_chat_type) if query_chat_type is not None else None,
|
||
thread_id=str(query_thread_id) if query_thread_id is not None else None,
|
||
user_name=query_user_name,
|
||
):
|
||
await query.answer(text="⛔ You are not authorized to answer update prompts.")
|
||
return
|
||
await query.answer(text=f"Sent '{answer}' to the update process.")
|
||
# Edit the message to show the choice and remove buttons
|
||
label = "Yes" if answer == "y" else "No"
|
||
try:
|
||
await query.edit_message_text(
|
||
text=self.format_message(f"⚕ Update prompt answered: *{label}*"),
|
||
parse_mode=ParseMode.MARKDOWN_V2,
|
||
reply_markup=None,
|
||
)
|
||
except Exception:
|
||
pass # non-fatal if edit fails
|
||
# Write the response file
|
||
try:
|
||
from hermes_constants import get_hermes_home
|
||
home = get_hermes_home()
|
||
response_path = home / ".update_response"
|
||
tmp = response_path.with_suffix(".tmp")
|
||
tmp.write_text(answer)
|
||
tmp.replace(response_path)
|
||
logger.info("Telegram update prompt answered '%s' by user %s",
|
||
answer, getattr(query.from_user, "id", "unknown"))
|
||
except Exception as exc:
|
||
logger.error("Failed to write update response from callback: %s", exc)
|
||
|
||
# Maps `gt:<verb>` -> (script-name, extra-args, success-label, is_state).
|
||
# Scripts live in ~/.hermes/scripts/gmail-triage/. `arg` from the callback
|
||
# data is always passed as the first positional arg.
|
||
# is_state=True means the verb is a sticky sender-rule change (mute, trust,
|
||
# vip) that should leave the keyboard tappable for follow-on actions.
|
||
# is_state=False is a per-email one-shot (send, archive, draft, spam) that
|
||
# strips the keyboard on success.
|
||
_GT_VERB_DISPATCH = {
|
||
"send": ("send-draft.sh", [], "✓ sent draft", False),
|
||
"archive": ("archive.sh", [], "✓ archived", False),
|
||
"draft": ("draft-blank.sh", [], "✓ drafted reply", False),
|
||
"spam": ("spam.sh", [], "✓ marked spam", False),
|
||
"mute": ("mute-add.sh", ["email"], "✓ muted", True),
|
||
"mute-domain": ("mute-add.sh", ["domain"], "✓ muted domain", True),
|
||
"trust": ("trusted-ops-add.sh", ["email"], "✓ trusted", True),
|
||
"trust-domain": ("trusted-ops-add.sh", ["domain"], "✓ trusted domain", True),
|
||
"vip": ("vip-add.sh", ["email"], "✓ marked VIP", True),
|
||
"vip-domain": ("vip-add.sh", ["domain"], "✓ marked VIP domain", True),
|
||
}
|
||
|
||
async def _handle_gmail_triage_callback(
|
||
self,
|
||
query,
|
||
data: str,
|
||
*,
|
||
query_chat_id,
|
||
query_chat_type,
|
||
query_thread_id,
|
||
query_user_name,
|
||
) -> None:
|
||
"""Dispatch a gmail-triage inline-button callback (gt:verb:arg)."""
|
||
parts = data.split(":", 2)
|
||
if len(parts) != 3:
|
||
await query.answer(text="Invalid gmail-triage data.")
|
||
return
|
||
verb, arg = parts[1], parts[2]
|
||
|
||
caller_id = str(getattr(query.from_user, "id", ""))
|
||
if not self._is_callback_user_authorized(
|
||
caller_id,
|
||
chat_id=query_chat_id,
|
||
chat_type=str(query_chat_type) if query_chat_type is not None else None,
|
||
thread_id=str(query_thread_id) if query_thread_id is not None else None,
|
||
user_name=query_user_name,
|
||
):
|
||
await query.answer(text="⛔ You are not authorized to act on this email.")
|
||
return
|
||
|
||
entry = self._GT_VERB_DISPATCH.get(verb)
|
||
if not entry:
|
||
await query.answer(text=f"Unknown verb: {verb}")
|
||
return
|
||
script_name, extra_args, success_label, is_state_verb = entry
|
||
|
||
script_path = _Path.home() / ".hermes" / "scripts" / "gmail-triage" / script_name
|
||
if not script_path.exists():
|
||
await query.answer(text=f"❌ {script_name} missing")
|
||
logger.error("[%s] gmail-triage script missing: %s", self.name, script_path)
|
||
return
|
||
|
||
cmd = [str(script_path), arg, *extra_args]
|
||
success = False
|
||
try:
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*cmd,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
)
|
||
_stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||
proc.communicate(), timeout=60,
|
||
)
|
||
if proc.returncode == 0:
|
||
label = success_label
|
||
success = True
|
||
logger.info(
|
||
"[%s] gmail-triage callback ok: verb=%s arg=%s",
|
||
self.name, verb, arg,
|
||
)
|
||
else:
|
||
stderr_text = stderr_bytes.decode("utf-8", errors="replace").strip()
|
||
last_line = stderr_text.splitlines()[-1] if stderr_text else f"exit {proc.returncode}"
|
||
label = f"❌ {verb} failed: {last_line[:80]}"
|
||
logger.error(
|
||
"[%s] gmail-triage callback failed: verb=%s arg=%s rc=%s stderr=%s",
|
||
self.name, verb, arg, proc.returncode, stderr_text,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
label = f"❌ {verb} timed out"
|
||
logger.error("[%s] gmail-triage callback timed out: verb=%s arg=%s", self.name, verb, arg)
|
||
except Exception as exc:
|
||
label = f"❌ {verb} error: {exc}"
|
||
logger.error(
|
||
"[%s] gmail-triage callback exception: verb=%s arg=%s err=%s",
|
||
self.name, verb, arg, exc, exc_info=True,
|
||
)
|
||
|
||
await query.answer(text=label)
|
||
if not success:
|
||
return
|
||
|
||
user_display = getattr(query.from_user, "first_name", "User")
|
||
original_text = (query.message.text or "") if query.message else ""
|
||
appended = f"{original_text}\n— {label} by {user_display}"
|
||
try:
|
||
if is_state_verb:
|
||
# Sticky state change: append confirmation, KEEP keyboard so
|
||
# the user can stack further actions on this email.
|
||
await query.edit_message_text(text=appended)
|
||
else:
|
||
# Per-email one-shot: strip keyboard so the action can't fire twice.
|
||
await query.edit_message_text(text=appended, reply_markup=None)
|
||
except Exception:
|
||
pass
|
||
|
||
def _missing_media_path_error(self, label: str, path: str) -> str:
|
||
"""Build an actionable file-not-found error for gateway MEDIA delivery.
|
||
|
||
Paths like /workspace/... or /output/... often only exist inside the
|
||
Docker sandbox, while the gateway process runs on the host.
|
||
"""
|
||
error = f"{label} file not found: {path}"
|
||
if path.startswith(("/workspace/", "/output/", "/outputs/")):
|
||
error += (
|
||
" (path may only exist inside the Docker sandbox. "
|
||
"Bind-mount a host directory and emit the host-visible "
|
||
"path in MEDIA: for gateway file delivery.)"
|
||
)
|
||
return error
|
||
|
||
async def send_voice(
|
||
self,
|
||
chat_id: str,
|
||
audio_path: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
**kwargs,
|
||
) -> SendResult:
|
||
"""Send audio as a native Telegram voice message or audio file."""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
try:
|
||
if not os.path.exists(audio_path):
|
||
return SendResult(success=False, error=self._missing_media_path_error("Audio", audio_path))
|
||
|
||
with open(audio_path, "rb") as audio_file:
|
||
ext = os.path.splitext(audio_path)[1].lower()
|
||
# .ogg / .opus files -> send as voice (round playable bubble)
|
||
if ext in {".ogg", ".opus"}:
|
||
_voice_thread = self._metadata_thread_id(metadata)
|
||
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
|
||
voice_thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
_voice_thread,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
msg = await self._send_with_dm_topic_reply_anchor_retry(
|
||
self._bot.send_voice,
|
||
{
|
||
"chat_id": int(chat_id),
|
||
"voice": audio_file,
|
||
"caption": caption[:1024] if caption else None,
|
||
"reply_to_message_id": reply_to_id,
|
||
**voice_thread_kwargs,
|
||
**self._notification_kwargs(metadata),
|
||
},
|
||
metadata,
|
||
reply_to_id,
|
||
"voice",
|
||
reset_media=lambda: audio_file.seek(0),
|
||
)
|
||
elif ext in {".mp3", ".m4a"}:
|
||
# Telegram's Bot API sendAudio only accepts MP3 / M4A.
|
||
_audio_thread = self._metadata_thread_id(metadata)
|
||
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
|
||
audio_thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
_audio_thread,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
msg = await self._send_with_dm_topic_reply_anchor_retry(
|
||
self._bot.send_audio,
|
||
{
|
||
"chat_id": int(chat_id),
|
||
"audio": audio_file,
|
||
"caption": caption[:1024] if caption else None,
|
||
"reply_to_message_id": reply_to_id,
|
||
**audio_thread_kwargs,
|
||
**self._notification_kwargs(metadata),
|
||
},
|
||
metadata,
|
||
reply_to_id,
|
||
"audio",
|
||
reset_media=lambda: audio_file.seek(0),
|
||
)
|
||
else:
|
||
# Formats Telegram can't play natively (.wav, .flac, ...)
|
||
# — fall back to document delivery instead of raising.
|
||
return await self.send_document(
|
||
chat_id=chat_id,
|
||
file_path=audio_path,
|
||
caption=caption,
|
||
reply_to=reply_to,
|
||
metadata=metadata,
|
||
)
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
logger.error(
|
||
"[%s] Failed to send Telegram voice/audio, falling back to base adapter: %s",
|
||
self.name,
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata)
|
||
|
||
async def send_multiple_images(
|
||
self,
|
||
chat_id: str,
|
||
images: List[tuple],
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
human_delay: float = 0.0,
|
||
) -> None:
|
||
"""Send a batch of images natively via Telegram's media group API.
|
||
|
||
Telegram's ``send_media_group`` bundles up to 10 photos/videos into
|
||
a single album. Larger batches are chunked. Animated GIFs cannot
|
||
go into a media group (they require ``send_animation``), so they
|
||
are peeled off and sent individually via the base default path.
|
||
|
||
URL-based photos go into the group directly; local files are
|
||
opened as byte streams. On failure the whole batch falls back to
|
||
the base adapter's per-image loop.
|
||
"""
|
||
if not self._bot:
|
||
return
|
||
if not images:
|
||
return
|
||
|
||
try:
|
||
from telegram import InputMediaPhoto
|
||
except Exception as exc: # pragma: no cover - missing SDK
|
||
logger.warning(
|
||
"[%s] InputMediaPhoto unavailable, falling back to per-image send: %s",
|
||
self.name, exc,
|
||
)
|
||
await super().send_multiple_images(chat_id, images, metadata, human_delay)
|
||
return
|
||
|
||
# Peel off animations — they need send_animation, not send_media_group
|
||
animations: List[tuple] = []
|
||
photos: List[tuple] = []
|
||
for image_url, alt_text in images:
|
||
if not image_url.startswith("file://") and self._is_animation_url(image_url):
|
||
animations.append((image_url, alt_text))
|
||
else:
|
||
photos.append((image_url, alt_text))
|
||
|
||
# Animations: route through the base default (per-image send_animation)
|
||
if animations:
|
||
await super().send_multiple_images(
|
||
chat_id, animations, metadata, human_delay=human_delay,
|
||
)
|
||
|
||
if not photos:
|
||
return
|
||
|
||
from urllib.parse import unquote as _unquote
|
||
_thread = self._metadata_thread_id(metadata)
|
||
|
||
# Chunk into groups of 10 (Telegram's album limit)
|
||
CHUNK = 10
|
||
chunks = [photos[i:i + CHUNK] for i in range(0, len(photos), CHUNK)]
|
||
|
||
for chunk_idx, chunk in enumerate(chunks):
|
||
if human_delay > 0 and chunk_idx > 0:
|
||
await asyncio.sleep(human_delay)
|
||
|
||
media: List[Any] = []
|
||
opened_files: List[Any] = []
|
||
try:
|
||
for image_url, alt_text in chunk:
|
||
caption = alt_text[:1024] if alt_text else None
|
||
if image_url.startswith("file://"):
|
||
local_path = _unquote(image_url[7:])
|
||
if not os.path.exists(local_path):
|
||
logger.warning(
|
||
"[%s] Skipping missing image in media group: %s",
|
||
self.name, local_path,
|
||
)
|
||
continue
|
||
fh = open(local_path, "rb")
|
||
opened_files.append(fh)
|
||
media.append(InputMediaPhoto(media=fh, caption=caption))
|
||
else:
|
||
media.append(InputMediaPhoto(media=image_url, caption=caption))
|
||
|
||
if not media:
|
||
continue
|
||
|
||
logger.info(
|
||
"[%s] Sending media group of %d photo(s) (chunk %d/%d)",
|
||
self.name, len(media), chunk_idx + 1, len(chunks),
|
||
)
|
||
reply_to_id = self._reply_to_message_id_for_send(None, metadata, reply_to_mode=self._reply_to_mode)
|
||
thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
_thread,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
|
||
def _reset_opened_files() -> None:
|
||
for fh in opened_files:
|
||
try:
|
||
fh.seek(0)
|
||
except Exception:
|
||
pass
|
||
|
||
await self._send_with_dm_topic_reply_anchor_retry(
|
||
self._bot.send_media_group,
|
||
{
|
||
"chat_id": int(chat_id),
|
||
"media": media,
|
||
"reply_to_message_id": reply_to_id,
|
||
**thread_kwargs,
|
||
**self._notification_kwargs(metadata),
|
||
},
|
||
metadata,
|
||
reply_to_id,
|
||
"media group",
|
||
reset_media=_reset_opened_files,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
"[%s] send_media_group failed (chunk %d/%d), falling back to per-image: %s",
|
||
self.name, chunk_idx + 1, len(chunks), e,
|
||
exc_info=True,
|
||
)
|
||
# Fallback: send each photo in this chunk individually
|
||
await super().send_multiple_images(
|
||
chat_id, chunk, metadata, human_delay=human_delay,
|
||
)
|
||
finally:
|
||
for fh in opened_files:
|
||
try:
|
||
fh.close()
|
||
except Exception:
|
||
pass
|
||
|
||
async def send_image_file(
|
||
self,
|
||
chat_id: str,
|
||
image_path: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
**kwargs,
|
||
) -> SendResult:
|
||
"""Send a local image file natively as a Telegram photo."""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
try:
|
||
if not os.path.exists(image_path):
|
||
return SendResult(success=False, error=self._missing_media_path_error("Image", image_path))
|
||
|
||
_thread = self._metadata_thread_id(metadata)
|
||
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
|
||
thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
_thread,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
with open(image_path, "rb") as image_file:
|
||
msg = await self._send_with_dm_topic_reply_anchor_retry(
|
||
self._bot.send_photo,
|
||
{
|
||
"chat_id": int(chat_id),
|
||
"photo": image_file,
|
||
"caption": caption[:1024] if caption else None,
|
||
"reply_to_message_id": reply_to_id,
|
||
**thread_kwargs,
|
||
**self._notification_kwargs(metadata),
|
||
},
|
||
metadata,
|
||
reply_to_id,
|
||
"photo",
|
||
reset_media=lambda: image_file.seek(0),
|
||
)
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
error_str = str(e)
|
||
# Dimension-related errors are the expected case for valid image
|
||
# files that Telegram just refuses as photos (screenshots, extreme
|
||
# aspect ratios). Log at INFO because the document fallback is
|
||
# the correct path. Any other send_photo failure also falls back
|
||
# to document (rate limits, corrupt file markers, format edge
|
||
# cases), but at WARNING because it's unexpected and worth
|
||
# surfacing in logs.
|
||
is_dim_error = (
|
||
"Photo_invalid_dimensions" in error_str
|
||
or "PHOTO_INVALID_DIMENSIONS" in error_str
|
||
)
|
||
if is_dim_error:
|
||
logger.info(
|
||
"[%s] Image dimensions exceed Telegram photo limits, "
|
||
"sending as document: %s",
|
||
self.name,
|
||
image_path,
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"[%s] Failed to send Telegram local image as photo, "
|
||
"trying document fallback: %s",
|
||
self.name,
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
# Fallback to sending as document (file) — no dimension limit,
|
||
# only 50MB size limit. If even that fails, fall back to the
|
||
# base adapter's text-only "Image: /path" rendering.
|
||
try:
|
||
return await self.send_document(
|
||
chat_id=chat_id,
|
||
file_path=image_path,
|
||
caption=caption,
|
||
file_name=os.path.basename(image_path),
|
||
reply_to=reply_to,
|
||
metadata=metadata,
|
||
)
|
||
except Exception as doc_err:
|
||
logger.error(
|
||
"[%s] Failed to send Telegram local image as document, "
|
||
"falling back to base adapter: %s",
|
||
self.name,
|
||
doc_err,
|
||
exc_info=True,
|
||
)
|
||
return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata)
|
||
|
||
async def send_document(
|
||
self,
|
||
chat_id: str,
|
||
file_path: str,
|
||
caption: Optional[str] = None,
|
||
file_name: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
**kwargs,
|
||
) -> SendResult:
|
||
"""Send a document/file natively as a Telegram file attachment."""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
try:
|
||
if not os.path.exists(file_path):
|
||
return SendResult(success=False, error=self._missing_media_path_error("File", file_path))
|
||
|
||
display_name = file_name or os.path.basename(file_path)
|
||
_thread = self._metadata_thread_id(metadata)
|
||
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
|
||
thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
_thread,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
|
||
with open(file_path, "rb") as f:
|
||
msg = await self._send_with_dm_topic_reply_anchor_retry(
|
||
self._bot.send_document,
|
||
{
|
||
"chat_id": int(chat_id),
|
||
"document": f,
|
||
"filename": display_name,
|
||
"caption": caption[:1024] if caption else None,
|
||
"reply_to_message_id": reply_to_id,
|
||
**thread_kwargs,
|
||
**self._notification_kwargs(metadata),
|
||
},
|
||
metadata,
|
||
reply_to_id,
|
||
"document",
|
||
reset_media=lambda: f.seek(0),
|
||
)
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
print(f"[{self.name}] Failed to send document: {e}")
|
||
return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata)
|
||
|
||
async def send_video(
|
||
self,
|
||
chat_id: str,
|
||
video_path: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
**kwargs,
|
||
) -> SendResult:
|
||
"""Send a video natively as a Telegram video message."""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
try:
|
||
if not os.path.exists(video_path):
|
||
return SendResult(success=False, error=self._missing_media_path_error("Video", video_path))
|
||
|
||
_thread = self._metadata_thread_id(metadata)
|
||
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
|
||
thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
_thread,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
with open(video_path, "rb") as f:
|
||
msg = await self._send_with_dm_topic_reply_anchor_retry(
|
||
self._bot.send_video,
|
||
{
|
||
"chat_id": int(chat_id),
|
||
"video": f,
|
||
"caption": caption[:1024] if caption else None,
|
||
"reply_to_message_id": reply_to_id,
|
||
**thread_kwargs,
|
||
**self._notification_kwargs(metadata),
|
||
},
|
||
metadata,
|
||
reply_to_id,
|
||
"video",
|
||
reset_media=lambda: f.seek(0),
|
||
)
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
print(f"[{self.name}] Failed to send video: {e}")
|
||
return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata)
|
||
|
||
async def send_image(
|
||
self,
|
||
chat_id: str,
|
||
image_url: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Send an image natively as a Telegram photo.
|
||
|
||
Tries URL-based send first (fast, works for <5MB images).
|
||
Falls back to downloading and uploading as file (supports up to 10MB).
|
||
"""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
from tools.url_safety import is_safe_url
|
||
if not is_safe_url(image_url):
|
||
logger.warning("[%s] Blocked unsafe image URL (SSRF protection)", self.name)
|
||
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
|
||
|
||
try:
|
||
# Telegram can send photos directly from URLs (up to ~5MB)
|
||
_photo_thread = self._metadata_thread_id(metadata)
|
||
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
|
||
photo_thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
_photo_thread,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
msg = await self._send_with_dm_topic_reply_anchor_retry(
|
||
self._bot.send_photo,
|
||
{
|
||
"chat_id": int(chat_id),
|
||
"photo": image_url,
|
||
"caption": caption[:1024] if caption else None,
|
||
"reply_to_message_id": reply_to_id,
|
||
**photo_thread_kwargs,
|
||
**self._notification_kwargs(metadata),
|
||
},
|
||
metadata,
|
||
reply_to_id,
|
||
"URL photo",
|
||
)
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
logger.warning(
|
||
"[%s] URL-based send_photo failed, trying file upload: %s",
|
||
self.name,
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
# Fallback: download and upload as file (supports up to 10MB)
|
||
try:
|
||
import httpx
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
resp = await client.get(image_url)
|
||
resp.raise_for_status()
|
||
image_data = resp.content
|
||
|
||
upload_thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
_photo_thread,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
msg = await self._send_with_dm_topic_reply_anchor_retry(
|
||
self._bot.send_photo,
|
||
{
|
||
"chat_id": int(chat_id),
|
||
"photo": image_data,
|
||
"caption": caption[:1024] if caption else None,
|
||
"reply_to_message_id": reply_to_id,
|
||
**upload_thread_kwargs,
|
||
**self._notification_kwargs(metadata),
|
||
},
|
||
metadata,
|
||
reply_to_id,
|
||
"uploaded photo",
|
||
)
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e2:
|
||
logger.error(
|
||
"[%s] File upload send_photo also failed: %s",
|
||
self.name,
|
||
e2,
|
||
exc_info=True,
|
||
)
|
||
# Final fallback: send URL as text
|
||
return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata)
|
||
|
||
async def send_animation(
|
||
self,
|
||
chat_id: str,
|
||
animation_url: str,
|
||
caption: Optional[str] = None,
|
||
reply_to: Optional[str] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
) -> SendResult:
|
||
"""Send an animated GIF natively as a Telegram animation (auto-plays inline)."""
|
||
if not self._bot:
|
||
return SendResult(success=False, error="Not connected")
|
||
|
||
try:
|
||
_anim_thread = self._metadata_thread_id(metadata)
|
||
reply_to_id = self._reply_to_message_id_for_send(reply_to, metadata, reply_to_mode=self._reply_to_mode)
|
||
animation_thread_kwargs = self._thread_kwargs_for_send(
|
||
chat_id,
|
||
_anim_thread,
|
||
metadata,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_mode=self._reply_to_mode
|
||
)
|
||
msg = await self._send_with_dm_topic_reply_anchor_retry(
|
||
self._bot.send_animation,
|
||
{
|
||
"chat_id": int(chat_id),
|
||
"animation": animation_url,
|
||
"caption": caption[:1024] if caption else None,
|
||
"reply_to_message_id": reply_to_id,
|
||
**animation_thread_kwargs,
|
||
**self._notification_kwargs(metadata),
|
||
},
|
||
metadata,
|
||
reply_to_id,
|
||
"animation",
|
||
)
|
||
return SendResult(success=True, message_id=str(msg.message_id))
|
||
except Exception as e:
|
||
logger.error(
|
||
"[%s] Failed to send Telegram animation, falling back to photo: %s",
|
||
self.name,
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
# Fallback: try as a regular photo
|
||
return await self.send_image(chat_id, animation_url, caption, reply_to, metadata=metadata)
|
||
|
||
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
||
"""Send typing indicator."""
|
||
if self._bot:
|
||
_is_dm_topic: bool = False
|
||
message_thread_id: Optional[int] = None
|
||
try:
|
||
_typing_thread = self._metadata_thread_id(metadata)
|
||
_is_dm_topic = bool(metadata and metadata.get("telegram_dm_topic_reply_fallback"))
|
||
message_thread_id = self._message_thread_id_for_typing(_typing_thread)
|
||
await self._bot.send_chat_action(
|
||
chat_id=int(chat_id),
|
||
action="typing",
|
||
message_thread_id=message_thread_id,
|
||
)
|
||
except Exception as e:
|
||
# For DM topic lanes, Telegram may reject message_thread_id.
|
||
# Fall back to sending typing without thread_id so the typing
|
||
# indicator at least appears in the main DM view.
|
||
if _is_dm_topic and message_thread_id is not None:
|
||
try:
|
||
await self._bot.send_chat_action(
|
||
chat_id=int(chat_id),
|
||
action="typing",
|
||
)
|
||
return
|
||
except Exception:
|
||
pass
|
||
# Typing failures are non-fatal; log at debug level only.
|
||
logger.debug(
|
||
"[%s] Failed to send Telegram typing indicator: %s",
|
||
self.name,
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
|
||
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
||
"""Get information about a Telegram chat."""
|
||
if not self._bot:
|
||
return {"name": "Unknown", "type": "dm"}
|
||
|
||
try:
|
||
chat = await self._bot.get_chat(int(chat_id))
|
||
|
||
chat_type = "dm"
|
||
if chat.type == ChatType.GROUP:
|
||
chat_type = "group"
|
||
elif chat.type == ChatType.SUPERGROUP:
|
||
chat_type = "group"
|
||
if chat.is_forum:
|
||
chat_type = "forum"
|
||
elif chat.type == ChatType.CHANNEL:
|
||
chat_type = "channel"
|
||
|
||
return {
|
||
"name": chat.title or chat.full_name or str(chat_id),
|
||
"type": chat_type,
|
||
"username": chat.username,
|
||
"is_forum": getattr(chat, "is_forum", False),
|
||
}
|
||
except Exception as e:
|
||
logger.error(
|
||
"[%s] Failed to get Telegram chat info for %s: %s",
|
||
self.name,
|
||
chat_id,
|
||
e,
|
||
exc_info=True,
|
||
)
|
||
return {"name": str(chat_id), "type": "dm", "error": str(e)}
|
||
|
||
def format_message(self, content: str) -> str:
|
||
"""
|
||
Convert standard markdown to Telegram MarkdownV2 format.
|
||
|
||
Protected regions (code blocks, inline code) are extracted first so
|
||
their contents are never modified. Standard markdown constructs
|
||
(headers, bold, italic, links) are translated to MarkdownV2 syntax,
|
||
and all remaining special characters are escaped.
|
||
"""
|
||
if not content:
|
||
return content
|
||
|
||
placeholders: dict = {}
|
||
counter = [0]
|
||
|
||
def _ph(value: str) -> str:
|
||
"""Stash *value* behind a placeholder token that survives escaping."""
|
||
key = f"\x00PH{counter[0]}\x00"
|
||
counter[0] += 1
|
||
placeholders[key] = value
|
||
return key
|
||
|
||
text = content
|
||
|
||
# 0) Rewrite GFM-style pipe tables into Telegram-friendly row groups
|
||
# before the normal MarkdownV2 conversions run.
|
||
text = _wrap_markdown_tables(text)
|
||
|
||
# 1) Protect fenced code blocks (``` ... ```)
|
||
# Per MarkdownV2 spec, \ and ` inside pre/code must be escaped.
|
||
def _protect_fenced(m):
|
||
raw = m.group(0)
|
||
# Split off opening ``` (with optional language) and closing ```
|
||
open_end = raw.index('\n') + 1 if '\n' in raw[3:] else 3
|
||
opening = raw[:open_end]
|
||
body_and_close = raw[open_end:]
|
||
body = body_and_close[:-3]
|
||
body = body.replace('\\', '\\\\').replace('`', '\\`')
|
||
return _ph(opening + body + '```')
|
||
|
||
text = re.sub(
|
||
r'(```(?:[^\n]*\n)?[\s\S]*?```)',
|
||
_protect_fenced,
|
||
text,
|
||
)
|
||
|
||
# 2) Protect inline code (`...`)
|
||
# Escape \ inside inline code per MarkdownV2 spec.
|
||
text = re.sub(
|
||
r'(`[^`]+`)',
|
||
lambda m: _ph(m.group(0).replace('\\', '\\\\')),
|
||
text,
|
||
)
|
||
|
||
# 3) Convert markdown links – escape the display text; inside the URL
|
||
# only ')' and '\' need escaping per the MarkdownV2 spec.
|
||
def _convert_link(m):
|
||
display = _escape_mdv2(m.group(1))
|
||
url = m.group(2).replace('\\', '\\\\').replace(')', '\\)')
|
||
return _ph(f'[{display}]({url})')
|
||
|
||
text = re.sub(r'\[([^\]]+)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)', _convert_link, text)
|
||
|
||
# 4) Convert markdown headers (## Title) → bold *Title*
|
||
def _convert_header(m):
|
||
inner = m.group(1).strip()
|
||
# Strip redundant bold markers that may appear inside a header
|
||
inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner)
|
||
return _ph(f'*{_escape_mdv2(inner)}*')
|
||
|
||
text = re.sub(
|
||
r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE
|
||
)
|
||
|
||
# 5) Convert bold: **text** → *text* (MarkdownV2 bold)
|
||
text = re.sub(
|
||
r'\*\*(.+?)\*\*',
|
||
lambda m: _ph(f'*{_escape_mdv2(m.group(1))}*'),
|
||
text,
|
||
)
|
||
|
||
# 6) Convert italic: *text* (single asterisk) → _text_ (MarkdownV2 italic)
|
||
# [^*\n]+ prevents matching across newlines (which would corrupt
|
||
# bullet lists using * markers and multi-line content).
|
||
text = re.sub(
|
||
r'\*([^*\n]+)\*',
|
||
lambda m: _ph(f'_{_escape_mdv2(m.group(1))}_'),
|
||
text,
|
||
)
|
||
|
||
# 7) Convert strikethrough: ~~text~~ → ~text~ (MarkdownV2)
|
||
text = re.sub(
|
||
r'~~(.+?)~~',
|
||
lambda m: _ph(f'~{_escape_mdv2(m.group(1))}~'),
|
||
text,
|
||
)
|
||
|
||
# 8) Convert spoiler: ||text|| → ||text|| (protect from | escaping)
|
||
text = re.sub(
|
||
r'\|\|(.+?)\|\|',
|
||
lambda m: _ph(f'||{_escape_mdv2(m.group(1))}||'),
|
||
text,
|
||
)
|
||
|
||
# 9) Convert blockquotes: > at line start → protect > from escaping
|
||
# Handle both regular blockquotes (> text) and expandable blockquotes
|
||
# (Telegram MarkdownV2: **> for expandable start, || to end the quote)
|
||
def _convert_blockquote(m):
|
||
prefix = m.group(1) # >, >>, >>>, **>, or **>> etc.
|
||
content = m.group(2)
|
||
# Check if content ends with || (expandable blockquote end marker)
|
||
# In this case, preserve the trailing || unescaped for Telegram
|
||
if prefix.startswith('**') and content.endswith('||'):
|
||
return _ph(f'{prefix} {_escape_mdv2(content[:-2])}||')
|
||
return _ph(f'{prefix} {_escape_mdv2(content)}')
|
||
|
||
text = re.sub(
|
||
r'^((?:\*\*)?>{1,3}) (.+)$',
|
||
_convert_blockquote,
|
||
text,
|
||
flags=re.MULTILINE,
|
||
)
|
||
|
||
# 10) Escape remaining special characters in plain text
|
||
text = _escape_mdv2(text)
|
||
|
||
# 11) Restore placeholders in reverse insertion order so that
|
||
# nested references (a placeholder inside another) resolve correctly.
|
||
for key in reversed(list(placeholders.keys())):
|
||
text = text.replace(key, placeholders[key])
|
||
|
||
# 12) Safety net: escape unescaped ( ) { } that slipped through
|
||
# placeholder processing. Split the text into code/non-code
|
||
# segments so we never touch content inside ``` or ` spans.
|
||
_code_split = re.split(r'(```[\s\S]*?```|`[^`]+`)', text)
|
||
_safe_parts = []
|
||
for _idx, _seg in enumerate(_code_split):
|
||
if _idx % 2 == 1:
|
||
# Inside code span/block — leave untouched
|
||
_safe_parts.append(_seg)
|
||
else:
|
||
# Outside code — escape bare ( ) { }
|
||
def _esc_bare(m, _seg=_seg):
|
||
s = m.start()
|
||
ch = m.group(0)
|
||
# Already escaped
|
||
if s > 0 and _seg[s - 1] == '\\':
|
||
return ch
|
||
# ( that opens a MarkdownV2 link [text](url)
|
||
if ch == '(' and s > 0 and _seg[s - 1] == ']':
|
||
return ch
|
||
# ) that closes a link URL
|
||
if ch == ')':
|
||
before = _seg[:s]
|
||
if '](http' in before or '](' in before:
|
||
# Check depth
|
||
depth = 0
|
||
for j in range(s - 1, max(s - 2000, -1), -1):
|
||
if _seg[j] == '(':
|
||
depth -= 1
|
||
if depth < 0:
|
||
if j > 0 and _seg[j - 1] == ']':
|
||
return ch
|
||
break
|
||
elif _seg[j] == ')':
|
||
depth += 1
|
||
return '\\' + ch
|
||
_safe_parts.append(re.sub(r'[(){}]', _esc_bare, _seg))
|
||
text = ''.join(_safe_parts)
|
||
|
||
return text
|
||
|
||
# ── Group mention gating ──────────────────────────────────────────────
|
||
|
||
def _telegram_require_mention(self) -> bool:
|
||
"""Return whether group chats should require an explicit bot trigger."""
|
||
configured = self.config.extra.get("require_mention")
|
||
if configured is not None:
|
||
if isinstance(configured, str):
|
||
return configured.lower() in {"true", "1", "yes", "on"}
|
||
return bool(configured)
|
||
return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"}
|
||
|
||
def _telegram_observe_unmentioned_group_messages(self) -> bool:
|
||
"""Return whether skipped unmentioned group messages are stored as context.
|
||
|
||
When enabled with ``require_mention``, Telegram matches the Yuanbao /
|
||
OpenClaw-style group UX: observe ordinary group chatter in the session
|
||
transcript, but only dispatch the agent when the bot is explicitly
|
||
addressed.
|
||
"""
|
||
configured = self.config.extra.get("observe_unmentioned_group_messages")
|
||
if configured is None:
|
||
configured = self.config.extra.get("ingest_unmentioned_group_messages")
|
||
if configured is not None:
|
||
if isinstance(configured, str):
|
||
return configured.lower() in {"true", "1", "yes", "on"}
|
||
return bool(configured)
|
||
return os.getenv("TELEGRAM_OBSERVE_UNMENTIONED_GROUP_MESSAGES", "false").lower() in {"true", "1", "yes", "on"}
|
||
|
||
def _telegram_guest_mode(self) -> bool:
|
||
"""Return whether non-allowlisted groups may trigger via direct @mention."""
|
||
configured = self.config.extra.get("guest_mode")
|
||
if configured is not None:
|
||
if isinstance(configured, str):
|
||
return configured.lower() in {"true", "1", "yes", "on"}
|
||
return bool(configured)
|
||
return os.getenv("TELEGRAM_GUEST_MODE", "false").lower() in {"true", "1", "yes", "on"}
|
||
|
||
def _telegram_exclusive_bot_mentions(self) -> bool:
|
||
"""Return whether explicit @...bot mentions exclusively route group messages."""
|
||
configured = self.config.extra.get("exclusive_bot_mentions")
|
||
if configured is not None:
|
||
if isinstance(configured, str):
|
||
return configured.lower() in {"true", "1", "yes", "on"}
|
||
return bool(configured)
|
||
return os.getenv("TELEGRAM_EXCLUSIVE_BOT_MENTIONS", "true").lower() in {"true", "1", "yes", "on"}
|
||
|
||
def _telegram_free_response_chats(self) -> set[str]:
|
||
raw = self.config.extra.get("free_response_chats")
|
||
if raw is None:
|
||
raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "")
|
||
if isinstance(raw, list):
|
||
return {str(part).strip() for part in raw if str(part).strip()}
|
||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||
|
||
def _telegram_allowed_chats(self) -> set[str]:
|
||
"""Return the whitelist of group/supergroup chat IDs the bot will respond in.
|
||
|
||
When non-empty, group messages from chats NOT in this set are
|
||
silently ignored unless ``guest_mode`` is enabled and the bot is
|
||
explicitly @mentioned. DMs are never filtered.
|
||
Empty set means no restriction (fully backward compatible).
|
||
"""
|
||
raw = self.config.extra.get("allowed_chats")
|
||
if raw is None:
|
||
raw = os.getenv("TELEGRAM_ALLOWED_CHATS", "")
|
||
if isinstance(raw, list):
|
||
return {str(part).strip() for part in raw if str(part).strip()}
|
||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||
|
||
def _telegram_group_allowed_chats(self) -> set[str]:
|
||
"""Return Telegram chats authorized at group scope."""
|
||
raw = self.config.extra.get("group_allowed_chats")
|
||
if raw is None:
|
||
raw = os.getenv("TELEGRAM_GROUP_ALLOWED_CHATS", "")
|
||
if isinstance(raw, list):
|
||
return {str(part).strip() for part in raw if str(part).strip()}
|
||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||
|
||
def _telegram_observe_allowed_chats(self) -> set[str]:
|
||
"""Chats where observed group context may use a shared source.
|
||
|
||
``group_allowed_chats`` is the gateway authorization allowlist for
|
||
user-less group sources. ``allowed_chats`` remains an optional response
|
||
gate; when set, observed context must satisfy both lists.
|
||
"""
|
||
group_allowed = self._telegram_group_allowed_chats()
|
||
if not group_allowed:
|
||
return set()
|
||
response_allowed = self._telegram_allowed_chats()
|
||
if response_allowed:
|
||
return group_allowed & response_allowed
|
||
return group_allowed
|
||
|
||
def _telegram_allowed_topics(self) -> set[str]:
|
||
"""Return the whitelist of Telegram forum topic IDs this bot handles.
|
||
|
||
When non-empty, group/supergroup messages from other topics are
|
||
silently ignored. DMs are never filtered by topic. Telegram may omit
|
||
``message_thread_id`` for the forum General topic, so ``None`` is
|
||
treated as topic ``1`` for matching purposes.
|
||
"""
|
||
raw = self.config.extra.get("allowed_topics")
|
||
if raw is None:
|
||
raw = os.getenv("TELEGRAM_ALLOWED_TOPICS", "")
|
||
if isinstance(raw, list):
|
||
return {str(part).strip() for part in raw if str(part).strip()}
|
||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||
|
||
def _telegram_ignored_threads(self) -> set[int]:
|
||
raw = self.config.extra.get("ignored_threads")
|
||
if raw is None:
|
||
raw = os.getenv("TELEGRAM_IGNORED_THREADS", "")
|
||
|
||
if isinstance(raw, list):
|
||
values = raw
|
||
else:
|
||
values = str(raw).split(",")
|
||
|
||
ignored: set[int] = set()
|
||
for value in values:
|
||
text = str(value).strip()
|
||
if not text:
|
||
continue
|
||
try:
|
||
ignored.add(int(text))
|
||
except (TypeError, ValueError):
|
||
logger.warning("[%s] Ignoring invalid Telegram thread id: %r", self.name, value)
|
||
return ignored
|
||
|
||
def _compile_mention_patterns(self) -> List[re.Pattern]:
|
||
"""Compile optional regex wake-word patterns for group triggers."""
|
||
patterns = self.config.extra.get("mention_patterns")
|
||
if patterns is None:
|
||
raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip()
|
||
if raw:
|
||
try:
|
||
loaded = json.loads(raw)
|
||
except Exception:
|
||
loaded = [part.strip() for part in raw.splitlines() if part.strip()]
|
||
if not loaded:
|
||
loaded = [part.strip() for part in raw.split(",") if part.strip()]
|
||
patterns = loaded
|
||
|
||
if patterns is None:
|
||
return []
|
||
if isinstance(patterns, str):
|
||
patterns = [patterns]
|
||
if not isinstance(patterns, list):
|
||
logger.warning(
|
||
"[%s] telegram mention_patterns must be a list or string; got %s",
|
||
self.name,
|
||
type(patterns).__name__,
|
||
)
|
||
return []
|
||
|
||
compiled: List[re.Pattern] = []
|
||
for pattern in patterns:
|
||
if not isinstance(pattern, str) or not pattern.strip():
|
||
continue
|
||
try:
|
||
compiled.append(re.compile(pattern, re.IGNORECASE))
|
||
except re.error as exc:
|
||
logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc)
|
||
if compiled:
|
||
logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled))
|
||
return compiled
|
||
|
||
def _is_group_chat(self, message: Message) -> bool:
|
||
chat = getattr(message, "chat", None)
|
||
if not chat:
|
||
return False
|
||
chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
|
||
return chat_type in {"group", "supergroup"}
|
||
|
||
def _is_reply_to_bot(self, message: Message) -> bool:
|
||
if not self._bot or not getattr(message, "reply_to_message", None):
|
||
return False
|
||
reply_user = getattr(message.reply_to_message, "from_user", None)
|
||
return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None))
|
||
|
||
@staticmethod
|
||
def _extract_bot_mention_usernames(message: Message) -> set[str]:
|
||
"""Extract explicit Telegram bot usernames mentioned in text/captions.
|
||
|
||
Telegram bot usernames are 5-32 characters and must end in "bot".
|
||
Entity mentions are authoritative. The raw-text fallback is intentionally narrow so
|
||
entity-less mobile/client variants still work without treating email
|
||
addresses or arbitrary substrings as bot mentions.
|
||
"""
|
||
mentioned_bot_usernames: set[str] = set()
|
||
|
||
def _iter_sources():
|
||
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
|
||
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
|
||
|
||
for source_text, entities in _iter_sources():
|
||
for entity in entities:
|
||
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
|
||
if entity_type not in {"mention", "bot_command"}:
|
||
continue
|
||
offset = int(getattr(entity, "offset", -1))
|
||
length = int(getattr(entity, "length", 0))
|
||
if offset < 0 or length <= 0:
|
||
continue
|
||
|
||
entity_text = source_text[offset:offset + length].strip()
|
||
if entity_type == "mention":
|
||
handle = entity_text.lstrip("@").lower()
|
||
if re.fullmatch(r"[a-z0-9_]{2,29}bot", handle, re.IGNORECASE):
|
||
mentioned_bot_usernames.add(handle)
|
||
continue
|
||
|
||
# Telegram emits /cmd@botname as one bot_command entity, not as
|
||
# a separate mention entity. Treat that suffix as an explicit
|
||
# bot address for exclusive multi-bot routing even when the
|
||
# group has require_mention/free-response disabled.
|
||
at_index = entity_text.find("@")
|
||
if at_index < 0:
|
||
continue
|
||
command_target = entity_text[at_index + 1:].strip().lower()
|
||
if re.fullmatch(r"[a-z0-9_]{2,29}bot", command_target, re.IGNORECASE):
|
||
mentioned_bot_usernames.add(command_target)
|
||
|
||
# Entity-less fallback for older/client-specific updates. If Telegram
|
||
# supplied entities for a source, trust them and do not regex-rescue
|
||
# malformed/URL/code spans that the server did not mark as mentions.
|
||
for raw_text, entities in _iter_sources():
|
||
if not raw_text or entities:
|
||
continue
|
||
for match in re.finditer(r"(?i)(?<![A-Za-z0-9_`/])@([A-Za-z0-9_]{2,29}bot)\b", raw_text):
|
||
mentioned_bot_usernames.add(match.group(1).lower())
|
||
|
||
return mentioned_bot_usernames
|
||
|
||
def _message_mentions_bot(self, message: Message) -> bool:
|
||
if not self._bot:
|
||
return False
|
||
|
||
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
|
||
bot_id = getattr(self._bot, "id", None)
|
||
expected = f"@{bot_username}" if bot_username else None
|
||
|
||
def _iter_sources():
|
||
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
|
||
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
|
||
|
||
# Telegram parses mentions server-side and emits MessageEntity objects
|
||
# (type=mention for @username, type=text_mention for @FirstName targeting
|
||
# a user without a public username). Those entities are authoritative:
|
||
# raw substring matches like "foo@hermes_bot.example" are not mentions
|
||
# (bug #12545). Entities also correctly handle @handles inside URLs, code
|
||
# blocks, and quoted text, where a regex scan would over-match.
|
||
for source_text, entities in _iter_sources():
|
||
for entity in entities:
|
||
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
|
||
if entity_type == "mention" and expected:
|
||
offset = int(getattr(entity, "offset", -1))
|
||
length = int(getattr(entity, "length", 0))
|
||
if offset < 0 or length <= 0:
|
||
continue
|
||
if source_text[offset:offset + length].strip().lower() == expected:
|
||
return True
|
||
elif entity_type == "text_mention":
|
||
user = getattr(entity, "user", None)
|
||
if user and getattr(user, "id", None) == bot_id:
|
||
return True
|
||
elif entity_type == "bot_command" and expected:
|
||
# Telegram's official group-disambiguation form for slash
|
||
# commands (``/cmd@botname``) is emitted as a single
|
||
# ``bot_command`` entity covering the whole span — there
|
||
# is no accompanying ``mention`` entity. Treat it as a
|
||
# direct address to this bot when the ``@botname`` suffix
|
||
# matches. This is the form Telegram's own command menu
|
||
# autocomplete produces in groups, so dropping it at the
|
||
# mention gate would break /new, /reset, /help, ... for
|
||
# every group that has ``require_mention`` enabled (#15415).
|
||
offset = int(getattr(entity, "offset", -1))
|
||
length = int(getattr(entity, "length", 0))
|
||
if offset < 0 or length <= 0:
|
||
continue
|
||
command_text = source_text[offset:offset + length]
|
||
at_index = command_text.find("@")
|
||
if at_index < 0:
|
||
continue
|
||
if command_text[at_index:].strip().lower() == expected:
|
||
return True
|
||
if bot_username and re.fullmatch(r"[a-z0-9_]{2,29}bot", bot_username, re.IGNORECASE):
|
||
return bot_username in self._extract_bot_mention_usernames(message)
|
||
return False
|
||
|
||
def _explicit_bot_mentions_exclude_self(self, message: Message) -> bool:
|
||
"""Return True when explicit bot handles target other bots, not this one.
|
||
|
||
Telegram groups can contain several Hermes bot profiles. A message like
|
||
``@bot3 hi @bot4`` must not wake ``@bot1`` through reply/wake-word
|
||
fallbacks. Treat explicit bot-handle mentions as an exclusive routing
|
||
hint: if at least one @...bot username is present and none matches this
|
||
adapter's own bot username, this adapter should ignore the message.
|
||
|
||
MessageEntity values are preferred, but some Telegram clients expose
|
||
selected bot handles as plain text in group messages. The raw-text
|
||
fallback is intentionally limited to usernames ending in "bot", which
|
||
Telegram requires for bot accounts.
|
||
"""
|
||
if not self._bot:
|
||
return False
|
||
|
||
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
|
||
if not bot_username:
|
||
return False
|
||
|
||
mentioned_bot_usernames = self._extract_bot_mention_usernames(message)
|
||
return bool(mentioned_bot_usernames) and bot_username not in mentioned_bot_usernames
|
||
|
||
def _message_matches_mention_patterns(self, message: Message) -> bool:
|
||
if not self._mention_patterns:
|
||
return False
|
||
for candidate in (getattr(message, "text", None), getattr(message, "caption", None)):
|
||
if not candidate:
|
||
continue
|
||
for pattern in self._mention_patterns:
|
||
if pattern.search(candidate):
|
||
return True
|
||
return False
|
||
|
||
def _is_guest_mention(self, message: Message) -> bool:
|
||
"""Return True for the narrow guest-mode bypass: explicit bot mention.
|
||
|
||
The caller (:meth:`_should_process_message`) has already verified
|
||
the message is a group chat, so that check is not repeated here.
|
||
"""
|
||
return self._telegram_guest_mode() and self._message_mentions_bot(message)
|
||
|
||
def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
|
||
if not text or not self._bot or not getattr(self._bot, "username", None):
|
||
return text
|
||
username = re.escape(self._bot.username)
|
||
cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
|
||
return cleaned or text
|
||
|
||
def _should_observe_unmentioned_group_message(self, message: Message) -> bool:
|
||
"""Return True when a group message should be stored but not dispatched."""
|
||
if not self._telegram_observe_unmentioned_group_messages():
|
||
return False
|
||
if not self._is_group_chat(message):
|
||
return False
|
||
|
||
thread_id = getattr(message, "message_thread_id", None)
|
||
allowed_topics = self._telegram_allowed_topics()
|
||
if allowed_topics:
|
||
topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID
|
||
if topic_id not in allowed_topics:
|
||
return False
|
||
|
||
if thread_id is not None:
|
||
try:
|
||
if int(thread_id) in self._telegram_ignored_threads():
|
||
return False
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
|
||
if self._telegram_exclusive_bot_mentions() and self._explicit_bot_mentions_exclude_self(message):
|
||
return False
|
||
|
||
allowed = self._telegram_observe_allowed_chats()
|
||
# Observed context is shared at chat/topic scope so a later trigger from
|
||
# another user can see it. Require an explicit chat allowlist; that
|
||
# keeps shared observed history limited to operator-approved groups and
|
||
# lets gateway authorization pass even after the shared session source
|
||
# drops the per-sender user_id.
|
||
if not allowed or chat_id_str not in allowed:
|
||
return False
|
||
|
||
# Only observe messages skipped by the require_mention gate. If the
|
||
# message would be processed normally, let the dispatcher handle it;
|
||
# if require_mention is disabled, every group message is a request.
|
||
if chat_id_str in self._telegram_free_response_chats():
|
||
return False
|
||
if not self._telegram_require_mention():
|
||
return False
|
||
if self._is_reply_to_bot(message):
|
||
return False
|
||
if self._message_mentions_bot(message):
|
||
return False
|
||
if self._message_matches_mention_patterns(message):
|
||
return False
|
||
return True
|
||
|
||
def _telegram_group_observe_shared_source(self, source):
|
||
"""Return a chat/topic-scoped source for observed Telegram group context."""
|
||
return dataclasses.replace(source, user_id=None, user_name=None, user_id_alt=None)
|
||
|
||
def _telegram_group_observe_attributed_text(self, event: MessageEvent) -> str:
|
||
user_id = event.source.user_id or "unknown"
|
||
sender = event.source.user_name or user_id
|
||
return f"[{sender}|{user_id}]\n{event.text or ''}"
|
||
|
||
def _telegram_group_observe_channel_prompt(self) -> str:
|
||
username = getattr(getattr(self, "_bot", None), "username", None) or "unknown"
|
||
bot_id = getattr(getattr(self, "_bot", None), "id", None) or "unknown"
|
||
return (
|
||
"You are handling a Telegram group chat message.\n"
|
||
f"- Your identity: user_id={bot_id}, @-mention name in this group=@{username}\n"
|
||
"- observed Telegram group context may be provided in a separate context-only block "
|
||
"before the current message; it is not necessarily addressed to you.\n"
|
||
"- Treat only the current new message as a request explicitly directed at you, "
|
||
"and use observed context only when the current message asks for it."
|
||
)
|
||
|
||
def _apply_telegram_group_observe_attribution(self, event: MessageEvent) -> MessageEvent:
|
||
"""Align triggered group turns with observed-history attribution."""
|
||
if not self._telegram_observe_unmentioned_group_messages():
|
||
return event
|
||
raw_message = getattr(event, "raw_message", None)
|
||
if not raw_message or not self._is_group_chat(raw_message):
|
||
return event
|
||
chat_id_str = str(getattr(getattr(raw_message, "chat", None), "id", ""))
|
||
allowed = self._telegram_observe_allowed_chats()
|
||
if not allowed or chat_id_str not in allowed:
|
||
return event
|
||
shared_source = self._telegram_group_observe_shared_source(event.source)
|
||
observe_prompt = self._telegram_group_observe_channel_prompt()
|
||
channel_prompt = f"{event.channel_prompt}\n\n{observe_prompt}" if event.channel_prompt else observe_prompt
|
||
if event.message_type == MessageType.COMMAND:
|
||
return dataclasses.replace(
|
||
event,
|
||
source=shared_source,
|
||
channel_prompt=channel_prompt,
|
||
)
|
||
return dataclasses.replace(
|
||
event,
|
||
text=self._telegram_group_observe_attributed_text(event),
|
||
source=shared_source,
|
||
channel_prompt=channel_prompt,
|
||
)
|
||
|
||
def _observe_unmentioned_group_message(self, message: Message, msg_type: MessageType, update_id: Optional[int] = None) -> None:
|
||
"""Append skipped group chatter to the target session without dispatching."""
|
||
store = getattr(self, "_session_store", None)
|
||
if not store:
|
||
return
|
||
try:
|
||
event = self._build_message_event(message, msg_type, update_id=update_id)
|
||
shared_source = self._telegram_group_observe_shared_source(event.source)
|
||
session_entry = store.get_or_create_session(shared_source)
|
||
entry = {
|
||
"role": "user",
|
||
"content": self._telegram_group_observe_attributed_text(event),
|
||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
||
"observed": True,
|
||
}
|
||
if event.message_id:
|
||
entry["message_id"] = str(event.message_id)
|
||
store.append_to_transcript(session_entry.session_id, entry)
|
||
adapter_name = getattr(self, "name", "telegram")
|
||
logger.info(
|
||
"[%s] Telegram group message observed (no bot trigger): chat=%s from=%s",
|
||
adapter_name,
|
||
getattr(getattr(message, "chat", None), "id", "unknown"),
|
||
event.source.user_id or "unknown",
|
||
)
|
||
except Exception as exc:
|
||
adapter_name = getattr(self, "name", "telegram")
|
||
logger.warning("[%s] Failed to observe Telegram group message: %s", adapter_name, exc)
|
||
|
||
def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
|
||
"""Apply Telegram group trigger rules.
|
||
|
||
DMs remain unrestricted. Group/supergroup messages are accepted when:
|
||
- the chat passes the ``allowed_chats`` whitelist (when set), or
|
||
``guest_mode`` is enabled and the bot is explicitly mentioned
|
||
- the chat is explicitly allowlisted in ``free_response_chats``
|
||
- ``require_mention`` is disabled
|
||
- the message replies to the bot
|
||
- the bot is @mentioned
|
||
- the text/caption matches a configured regex wake-word pattern
|
||
|
||
When ``allowed_chats`` is non-empty, it remains a hard gate except for
|
||
the narrow ``guest_mode`` bypass: group/supergroup messages that
|
||
explicitly @mention this bot. Replies and regex wake words do not bypass
|
||
``allowed_chats``. When ``require_mention`` is enabled, slash commands are not given
|
||
special treatment — they must pass the same mention/reply checks
|
||
as any other group message. Users can still trigger commands via
|
||
the Telegram bot menu (``/command@botname``) or by explicitly
|
||
mentioning the bot (``@botname /command``), both of which are
|
||
recognised as mentions by :meth:`_message_mentions_bot`.
|
||
"""
|
||
if not self._is_group_chat(message):
|
||
return True
|
||
|
||
thread_id = getattr(message, "message_thread_id", None)
|
||
allowed_topics = self._telegram_allowed_topics()
|
||
if allowed_topics:
|
||
topic_id = str(thread_id) if thread_id is not None else self._GENERAL_TOPIC_THREAD_ID
|
||
if topic_id not in allowed_topics:
|
||
return False
|
||
|
||
# Check ignored_threads first — applies to both groups and DM topics
|
||
if thread_id is not None:
|
||
try:
|
||
if int(thread_id) in self._telegram_ignored_threads():
|
||
return False
|
||
except (TypeError, ValueError):
|
||
logger.warning("[%s] Ignoring non-numeric Telegram message_thread_id: %r", self.name, thread_id)
|
||
|
||
if not self._is_group_chat(message):
|
||
# Root DM (non-topic): ignore if ignore_root_dm is configured
|
||
if thread_id is None and self.config.extra.get("ignore_root_dm", False):
|
||
chat_id = str(getattr(getattr(message, "chat", None), "id", ""))
|
||
if not is_command and chat_id in self._dm_topic_chat_ids:
|
||
return False
|
||
return True
|
||
|
||
chat_id_str = str(getattr(getattr(message, "chat", None), "id", ""))
|
||
|
||
if self._telegram_exclusive_bot_mentions() and self._explicit_bot_mentions_exclude_self(message):
|
||
return False
|
||
|
||
# Resolve guest-mode mention bypass once so _message_mentions_bot
|
||
# is not called redundantly in the normal flow below.
|
||
guest_mention = self._is_guest_mention(message)
|
||
|
||
# allowed_chats check (whitelist). When set, group messages from chats
|
||
# outside the whitelist are ignored unless guest_mode permits this
|
||
# exact message as an explicit direct mention. DMs are excluded above.
|
||
allowed = self._telegram_allowed_chats()
|
||
if allowed and chat_id_str not in allowed:
|
||
return guest_mention
|
||
|
||
if guest_mention:
|
||
return True
|
||
if chat_id_str in self._telegram_free_response_chats():
|
||
return True
|
||
if not self._telegram_require_mention():
|
||
return True
|
||
if self._is_reply_to_bot(message):
|
||
return True
|
||
# When guest_mode is True, _is_guest_mention already called
|
||
# _message_mentions_bot above — skip the redundant second call.
|
||
if not self._telegram_guest_mode() and self._message_mentions_bot(message):
|
||
return True
|
||
return self._message_matches_mention_patterns(message)
|
||
|
||
async def _ensure_forum_commands(self, message) -> None:
|
||
"""Lazy-register bot commands for forum supergroups.
|
||
|
||
Forum topics don't inherit AllGroupChats scope — Telegram resolves
|
||
via BotCommandScopeChat(chat_id). Register on first message so the
|
||
command menu works in topic views.
|
||
"""
|
||
async with self._forum_lock:
|
||
try:
|
||
chat = getattr(message, "chat", None)
|
||
if not chat or not getattr(chat, "is_forum", False):
|
||
return
|
||
chat_id = int(chat.id)
|
||
if chat_id in self._forum_command_registered:
|
||
return
|
||
from telegram import BotCommand, BotCommandScopeChat
|
||
from hermes_cli.commands import telegram_menu_commands
|
||
menu_commands, _ = telegram_menu_commands(max_commands=MAX_COMMANDS_PER_SCOPE)
|
||
bot_commands = [BotCommand(name, desc) for name, desc in menu_commands]
|
||
await self._bot.set_my_commands(bot_commands, scope=BotCommandScopeChat(chat_id=chat_id))
|
||
self._forum_command_registered.add(chat_id)
|
||
logger.info("[%s] Lazy-registered %d commands for forum chat %s", self.name, len(bot_commands), chat_id)
|
||
except Exception as e:
|
||
logger.warning("[%s] Forum command lazy-registration failed: %s", self.name, e)
|
||
|
||
def _effective_update_message(self, update: Update) -> Optional[Message]:
|
||
"""Return the message-like payload for normal messages and channel posts.
|
||
|
||
Telegram exposes channel broadcasts as ``update.channel_post`` rather
|
||
than ``update.message``. MessageHandler filters can still dispatch
|
||
those updates, so handlers must use ``effective_message`` to avoid
|
||
consuming channel posts without ever building a gateway event.
|
||
"""
|
||
return getattr(update, "effective_message", None) or getattr(update, "message", None)
|
||
|
||
async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
"""Handle incoming text messages.
|
||
|
||
Telegram clients split long messages into multiple updates. Buffer
|
||
rapid successive text messages from the same user/chat and aggregate
|
||
them into a single MessageEvent before dispatching.
|
||
"""
|
||
msg = self._effective_update_message(update)
|
||
if not msg or not msg.text:
|
||
return
|
||
if not self._should_process_message(msg):
|
||
if self._should_observe_unmentioned_group_message(msg):
|
||
self._observe_unmentioned_group_message(msg, MessageType.TEXT, update_id=update.update_id)
|
||
return
|
||
await self._ensure_forum_commands(update.message)
|
||
|
||
event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id)
|
||
event.text = self._clean_bot_trigger_text(event.text)
|
||
event = self._apply_telegram_group_observe_attribution(event)
|
||
self._enqueue_text_event(event)
|
||
|
||
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
"""Handle incoming command messages."""
|
||
msg = self._effective_update_message(update)
|
||
if not msg or not msg.text:
|
||
return
|
||
if not self._should_process_message(msg, is_command=True):
|
||
return
|
||
await self._ensure_forum_commands(msg)
|
||
|
||
event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id)
|
||
event.text = self._clean_bot_trigger_text(event.text)
|
||
event = self._apply_telegram_group_observe_attribution(event)
|
||
await self.handle_message(event)
|
||
|
||
async def _handle_location_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
"""Handle incoming location/venue pin messages."""
|
||
msg = self._effective_update_message(update)
|
||
if not msg:
|
||
return
|
||
if not self._should_process_message(msg):
|
||
if self._should_observe_unmentioned_group_message(msg):
|
||
self._observe_unmentioned_group_message(msg, MessageType.LOCATION, update_id=update.update_id)
|
||
return
|
||
|
||
venue = getattr(msg, "venue", None)
|
||
location = getattr(venue, "location", None) if venue else getattr(msg, "location", None)
|
||
|
||
if not location:
|
||
return
|
||
|
||
lat = getattr(location, "latitude", None)
|
||
lon = getattr(location, "longitude", None)
|
||
if lat is None or lon is None:
|
||
return
|
||
|
||
# Build a text message with coordinates and context
|
||
parts = ["[The user shared a location pin.]"]
|
||
if venue:
|
||
title = getattr(venue, "title", None)
|
||
address = getattr(venue, "address", None)
|
||
if title:
|
||
parts.append(f"Venue: {title}")
|
||
if address:
|
||
parts.append(f"Address: {address}")
|
||
parts.append(f"latitude: {lat}")
|
||
parts.append(f"longitude: {lon}")
|
||
parts.append(f"Map: https://www.google.com/maps/search/?api=1&query={lat},{lon}")
|
||
parts.append("Ask what they'd like to find nearby (restaurants, cafes, etc.) and any preferences.")
|
||
|
||
event = self._build_message_event(msg, MessageType.LOCATION, update_id=update.update_id)
|
||
event.text = "\n".join(parts)
|
||
event = self._apply_telegram_group_observe_attribution(event)
|
||
await self.handle_message(event)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Text message aggregation (handles Telegram client-side splits)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||
"""Session-scoped key for text message batching.
|
||
|
||
Applies the installed topic-recovery hook first so DM-topic batches
|
||
coalesce on (and dispatch to) the recovered lane rather than the
|
||
raw inbound ``message_thread_id`` Telegram may have attached.
|
||
"""
|
||
from gateway.session import build_session_key
|
||
self._apply_topic_recovery(event)
|
||
return build_session_key(
|
||
event.source,
|
||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||
)
|
||
|
||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||
"""Buffer a text event and reset the flush timer.
|
||
|
||
When Telegram splits a long user message into multiple updates,
|
||
they arrive within a few hundred milliseconds. This method
|
||
concatenates them and waits for a short quiet period before
|
||
dispatching the combined message.
|
||
"""
|
||
key = self._text_batch_key(event)
|
||
existing = self._pending_text_batches.get(key)
|
||
chunk_len = len(event.text or "")
|
||
if existing is None:
|
||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||
self._pending_text_batches[key] = event
|
||
else:
|
||
# Append text from the follow-up chunk
|
||
if event.text:
|
||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||
# Merge any media that might be attached
|
||
if event.media_urls:
|
||
existing.media_urls.extend(event.media_urls)
|
||
existing.media_types.extend(event.media_types)
|
||
|
||
# Cancel any pending flush and restart the timer
|
||
prior_task = self._pending_text_batch_tasks.get(key)
|
||
if prior_task and not prior_task.done():
|
||
prior_task.cancel()
|
||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||
self._flush_text_batch(key)
|
||
)
|
||
|
||
async def _flush_text_batch(self, key: str) -> None:
|
||
"""Wait for the quiet period then dispatch the aggregated text.
|
||
|
||
Uses a longer delay when the latest chunk is near Telegram's 4096-char
|
||
split point, since a continuation chunk is almost certain.
|
||
"""
|
||
current_task = asyncio.current_task()
|
||
try:
|
||
# Adaptive delay tiers:
|
||
# - last chunk ≥ _SPLIT_THRESHOLD: a continuation is almost
|
||
# certain → wait the longer split delay.
|
||
# - total accumulated text ≤ _TEXT_BATCH_FAST_LEN (~320 cp):
|
||
# short message → cap delay at _TEXT_BATCH_FAST_DELAY_S
|
||
# so the agent sees the text near-instantly.
|
||
# - total ≤ _TEXT_BATCH_SHORT_LEN (~1024 cp):
|
||
# medium → cap at _TEXT_BATCH_SHORT_DELAY_S.
|
||
# - otherwise: use the configured cap.
|
||
# Tiers compose with operator overrides via the env-var-driven
|
||
# ``_text_batch_delay_seconds`` (e.g. an operator who sets the
|
||
# cap below 0.18s gets that lower number on every tier).
|
||
pending = self._pending_text_batches.get(key)
|
||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||
total_len = len(getattr(pending, "text", "") or "") if pending else 0
|
||
if last_len >= self._SPLIT_THRESHOLD:
|
||
delay = self._text_batch_split_delay_seconds
|
||
elif total_len <= self._TEXT_BATCH_FAST_LEN:
|
||
delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_FAST_DELAY_S)
|
||
elif total_len <= self._TEXT_BATCH_SHORT_LEN:
|
||
delay = min(self._text_batch_delay_seconds, self._TEXT_BATCH_SHORT_DELAY_S)
|
||
else:
|
||
delay = self._text_batch_delay_seconds
|
||
await asyncio.sleep(delay)
|
||
event = self._pending_text_batches.pop(key, None)
|
||
if not event:
|
||
return
|
||
logger.info(
|
||
"[Telegram] Flushing text batch %s (%d chars)",
|
||
key, len(event.text or ""),
|
||
)
|
||
await self.handle_message(event)
|
||
finally:
|
||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||
self._pending_text_batch_tasks.pop(key, None)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Photo batching
|
||
# ------------------------------------------------------------------
|
||
|
||
def _photo_batch_key(self, event: MessageEvent, msg: Message) -> str:
|
||
"""Return a batching key for Telegram photos/albums."""
|
||
from gateway.session import build_session_key
|
||
session_key = build_session_key(
|
||
event.source,
|
||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||
)
|
||
media_group_id = getattr(msg, "media_group_id", None)
|
||
if media_group_id:
|
||
return f"{session_key}:album:{media_group_id}"
|
||
return f"{session_key}:photo-burst"
|
||
|
||
async def _flush_photo_batch(self, batch_key: str) -> None:
|
||
"""Send a buffered photo burst/album as a single MessageEvent."""
|
||
current_task = asyncio.current_task()
|
||
try:
|
||
await asyncio.sleep(self._media_batch_delay_seconds)
|
||
event = self._pending_photo_batches.pop(batch_key, None)
|
||
if not event:
|
||
return
|
||
logger.info("[Telegram] Flushing photo batch %s with %d image(s)", batch_key, len(event.media_urls))
|
||
await self.handle_message(event)
|
||
finally:
|
||
if self._pending_photo_batch_tasks.get(batch_key) is current_task:
|
||
self._pending_photo_batch_tasks.pop(batch_key, None)
|
||
|
||
def _enqueue_photo_event(self, batch_key: str, event: MessageEvent) -> None:
|
||
"""Merge photo events into a pending batch and schedule flush."""
|
||
existing = self._pending_photo_batches.get(batch_key)
|
||
if existing is None:
|
||
self._pending_photo_batches[batch_key] = event
|
||
else:
|
||
existing.media_urls.extend(event.media_urls)
|
||
existing.media_types.extend(event.media_types)
|
||
if event.text:
|
||
existing.text = self._merge_caption(existing.text, event.text)
|
||
|
||
prior_task = self._pending_photo_batch_tasks.get(batch_key)
|
||
if prior_task and not prior_task.done():
|
||
prior_task.cancel()
|
||
|
||
self._pending_photo_batch_tasks[batch_key] = asyncio.create_task(self._flush_photo_batch(batch_key))
|
||
|
||
async def _handle_media_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
"""Handle incoming media messages, downloading images to local cache."""
|
||
if not update.message:
|
||
return
|
||
if not self._should_process_message(update.message):
|
||
if self._should_observe_unmentioned_group_message(update.message):
|
||
_m = update.message
|
||
if _m.sticker:
|
||
_observe_type = MessageType.STICKER
|
||
elif _m.photo:
|
||
_observe_type = MessageType.PHOTO
|
||
elif _m.video:
|
||
_observe_type = MessageType.VIDEO
|
||
elif _m.audio:
|
||
_observe_type = MessageType.AUDIO
|
||
elif _m.voice:
|
||
_observe_type = MessageType.VOICE
|
||
else:
|
||
_observe_type = MessageType.DOCUMENT
|
||
self._observe_unmentioned_group_message(_m, _observe_type, update_id=update.update_id)
|
||
return
|
||
|
||
msg = update.message
|
||
|
||
# Determine media type
|
||
if msg.sticker:
|
||
msg_type = MessageType.STICKER
|
||
elif msg.photo:
|
||
msg_type = MessageType.PHOTO
|
||
elif msg.video:
|
||
msg_type = MessageType.VIDEO
|
||
elif msg.audio:
|
||
msg_type = MessageType.AUDIO
|
||
elif msg.voice:
|
||
msg_type = MessageType.VOICE
|
||
elif msg.document:
|
||
msg_type = MessageType.DOCUMENT
|
||
else:
|
||
msg_type = MessageType.DOCUMENT
|
||
|
||
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
|
||
|
||
# Add caption as text
|
||
if msg.caption:
|
||
event.text = self._clean_bot_trigger_text(msg.caption)
|
||
|
||
# Handle stickers: describe via vision tool with caching
|
||
if msg.sticker:
|
||
await self._handle_sticker(msg, event)
|
||
event = self._apply_telegram_group_observe_attribution(event)
|
||
await self.handle_message(event)
|
||
return
|
||
|
||
# Apply observe attribution after caption is set; sticker is handled above
|
||
# because _handle_sticker overwrites event.text with its vision description.
|
||
event = self._apply_telegram_group_observe_attribution(event)
|
||
|
||
# Download photo to local image cache so the vision tool can access it
|
||
# even after Telegram's ephemeral file URLs expire (~1 hour).
|
||
if msg.photo:
|
||
try:
|
||
# msg.photo is a list of PhotoSize sorted by size; take the largest
|
||
photo = msg.photo[-1]
|
||
file_obj = await photo.get_file()
|
||
# Download the image bytes directly into memory
|
||
image_bytes = await file_obj.download_as_bytearray()
|
||
# Determine extension from the file path if available
|
||
ext = ".jpg"
|
||
if file_obj.file_path:
|
||
for candidate in [".png", ".webp", ".gif", ".jpeg", ".jpg"]:
|
||
if file_obj.file_path.lower().endswith(candidate):
|
||
ext = candidate
|
||
break
|
||
# Save to local cache (for vision tool access)
|
||
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=ext)
|
||
event.media_urls = [cached_path]
|
||
event.media_types = [f"image/{ext.lstrip('.')}" ]
|
||
logger.info("[Telegram] Cached user photo at %s", cached_path)
|
||
media_group_id = getattr(msg, "media_group_id", None)
|
||
if media_group_id:
|
||
await self._queue_media_group_event(str(media_group_id), event)
|
||
else:
|
||
batch_key = self._photo_batch_key(event, msg)
|
||
self._enqueue_photo_event(batch_key, event)
|
||
return
|
||
|
||
except Exception as e:
|
||
logger.warning("[Telegram] Failed to cache photo: %s", e, exc_info=True)
|
||
|
||
# Download voice/audio messages to cache for STT transcription
|
||
if msg.voice:
|
||
try:
|
||
file_obj = await msg.voice.get_file()
|
||
audio_bytes = await file_obj.download_as_bytearray()
|
||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".ogg")
|
||
event.media_urls = [cached_path]
|
||
event.media_types = ["audio/ogg"]
|
||
logger.info("[Telegram] Cached user voice at %s", cached_path)
|
||
except Exception as e:
|
||
logger.warning("[Telegram] Failed to cache voice: %s", e, exc_info=True)
|
||
elif msg.audio:
|
||
try:
|
||
file_obj = await msg.audio.get_file()
|
||
audio_bytes = await file_obj.download_as_bytearray()
|
||
cached_path = cache_audio_from_bytes(bytes(audio_bytes), ext=".mp3")
|
||
event.media_urls = [cached_path]
|
||
event.media_types = ["audio/mp3"]
|
||
logger.info("[Telegram] Cached user audio at %s", cached_path)
|
||
except Exception as e:
|
||
logger.warning("[Telegram] Failed to cache audio: %s", e, exc_info=True)
|
||
|
||
elif msg.video:
|
||
try:
|
||
file_obj = await msg.video.get_file()
|
||
video_bytes = await file_obj.download_as_bytearray()
|
||
ext = ".mp4"
|
||
if getattr(file_obj, "file_path", None):
|
||
for candidate in SUPPORTED_VIDEO_TYPES:
|
||
if file_obj.file_path.lower().endswith(candidate):
|
||
ext = candidate
|
||
break
|
||
cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)
|
||
event.media_urls = [cached_path]
|
||
event.media_types = [SUPPORTED_VIDEO_TYPES.get(ext, "video/mp4")]
|
||
logger.info("[Telegram] Cached user video at %s", cached_path)
|
||
except Exception as e:
|
||
logger.warning("[Telegram] Failed to cache video: %s", e, exc_info=True)
|
||
|
||
# Download document files to cache for agent processing
|
||
elif msg.document:
|
||
doc = msg.document
|
||
try:
|
||
# Determine file extension
|
||
ext = ""
|
||
original_filename = doc.file_name or ""
|
||
if original_filename:
|
||
_, ext = os.path.splitext(original_filename)
|
||
ext = ext.lower()
|
||
|
||
# Normalize mime_type for robust comparisons (some clients send
|
||
# uppercase like "IMAGE/PNG").
|
||
doc_mime = (doc.mime_type or "").lower()
|
||
|
||
# If no extension from filename, reverse-lookup from MIME type
|
||
if not ext and doc_mime:
|
||
ext = _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, "")
|
||
if not ext:
|
||
mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()}
|
||
ext = mime_to_ext.get(doc_mime, "")
|
||
|
||
# Check file size early so image documents cannot bypass the
|
||
# document size limit by taking the image path.
|
||
if not doc.file_size or doc.file_size > self._max_doc_bytes:
|
||
limit_mb = self._max_doc_bytes // (1024 * 1024)
|
||
event.text = (
|
||
"The document is too large or its size could not be verified. "
|
||
f"Maximum: {limit_mb} MB."
|
||
)
|
||
logger.info("[Telegram] Document too large: %s bytes", doc.file_size)
|
||
await self.handle_message(event)
|
||
return
|
||
|
||
# Telegram may deliver screenshots/photos as documents. If the
|
||
# payload is actually an image, route it through the image cache
|
||
# and batching path instead of rejecting it as a document.
|
||
if ext in _TELEGRAM_IMAGE_EXTENSIONS or doc_mime.startswith("image/"):
|
||
file_obj = await doc.get_file()
|
||
image_bytes = await file_obj.download_as_bytearray()
|
||
image_ext = ext if ext in _TELEGRAM_IMAGE_EXTENSIONS else _TELEGRAM_IMAGE_MIME_TO_EXT.get(doc_mime, ".jpg")
|
||
try:
|
||
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=image_ext)
|
||
except ValueError as e:
|
||
logger.warning("[Telegram] Failed to cache image document: %s", e, exc_info=True)
|
||
event.text = (
|
||
f"Image document '{original_filename or doc_mime or ext or 'unknown'}' "
|
||
"could not be read as an image."
|
||
)
|
||
await self.handle_message(event)
|
||
return
|
||
|
||
event.message_type = MessageType.PHOTO
|
||
event.media_urls = [cached_path]
|
||
event.media_types = [doc_mime if doc_mime.startswith("image/") else _TELEGRAM_IMAGE_EXT_TO_MIME.get(image_ext, "image/jpeg")]
|
||
logger.info("[Telegram] Cached user image-document at %s", cached_path)
|
||
|
||
media_group_id = getattr(msg, "media_group_id", None)
|
||
if media_group_id:
|
||
await self._queue_media_group_event(str(media_group_id), event)
|
||
else:
|
||
batch_key = self._photo_batch_key(event, msg)
|
||
self._enqueue_photo_event(batch_key, event)
|
||
return
|
||
|
||
if not ext and doc.mime_type:
|
||
video_mime_to_ext = {v: k for k, v in SUPPORTED_VIDEO_TYPES.items()}
|
||
ext = video_mime_to_ext.get(doc.mime_type, "")
|
||
|
||
if not ext and doc.mime_type:
|
||
# SUPPORTED_IMAGE_DOCUMENT_TYPES has duplicate values (.jpg + .jpeg
|
||
# both map to image/jpeg); keep the first ext we encounter.
|
||
image_mime_to_ext: dict[str, str] = {}
|
||
for _ext, _mime in SUPPORTED_IMAGE_DOCUMENT_TYPES.items():
|
||
image_mime_to_ext.setdefault(_mime, _ext)
|
||
ext = image_mime_to_ext.get(doc.mime_type, "")
|
||
|
||
if ext in SUPPORTED_VIDEO_TYPES:
|
||
file_obj = await doc.get_file()
|
||
video_bytes = await file_obj.download_as_bytearray()
|
||
cached_path = cache_video_from_bytes(bytes(video_bytes), ext=ext)
|
||
event.media_urls = [cached_path]
|
||
event.media_types = [SUPPORTED_VIDEO_TYPES[ext]]
|
||
event.message_type = MessageType.VIDEO
|
||
logger.info("[Telegram] Cached user video document at %s", cached_path)
|
||
await self.handle_message(event)
|
||
return
|
||
|
||
# NOTE: image-document handling is performed earlier in this
|
||
# function (ext in _TELEGRAM_IMAGE_EXTENSIONS or image/* mime),
|
||
# which returns before reaching here. Any subsequent
|
||
# ext-in-SUPPORTED_IMAGE_DOCUMENT_TYPES branch would be dead
|
||
# code — the extension sets are identical.
|
||
|
||
# Check if supported
|
||
if ext not in SUPPORTED_DOCUMENT_TYPES:
|
||
supported_list = ", ".join(sorted(SUPPORTED_DOCUMENT_TYPES.keys()))
|
||
event.text = (
|
||
f"Unsupported document type '{ext or 'unknown'}'. "
|
||
f"Supported types: {supported_list}"
|
||
)
|
||
logger.info("[Telegram] Unsupported document type: %s", ext or "unknown")
|
||
await self.handle_message(event)
|
||
return
|
||
|
||
# Download and cache
|
||
file_obj = await doc.get_file()
|
||
doc_bytes = await file_obj.download_as_bytearray()
|
||
raw_bytes = bytes(doc_bytes)
|
||
cached_path = cache_document_from_bytes(raw_bytes, original_filename or f"document{ext}")
|
||
mime_type = SUPPORTED_DOCUMENT_TYPES[ext]
|
||
event.media_urls = [cached_path]
|
||
event.media_types = [mime_type]
|
||
logger.info("[Telegram] Cached user document at %s", cached_path)
|
||
|
||
# For text files, inject content into event.text (capped at 100 KB)
|
||
MAX_TEXT_INJECT_BYTES = 100 * 1024
|
||
if ext in {".md", ".txt"} and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES:
|
||
try:
|
||
text_content = raw_bytes.decode("utf-8")
|
||
display_name = original_filename or f"document{ext}"
|
||
display_name = re.sub(r'[^\w.\- ]', '_', display_name)
|
||
injection = f"[Content of {display_name}]:\n{text_content}"
|
||
if event.text:
|
||
event.text = f"{injection}\n\n{event.text}"
|
||
else:
|
||
event.text = injection
|
||
except UnicodeDecodeError:
|
||
logger.warning(
|
||
"[Telegram] Could not decode text file as UTF-8, skipping content injection",
|
||
exc_info=True,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning("[Telegram] Failed to cache document: %s", e, exc_info=True)
|
||
|
||
media_group_id = getattr(msg, "media_group_id", None)
|
||
if media_group_id:
|
||
await self._queue_media_group_event(str(media_group_id), event)
|
||
return
|
||
|
||
await self.handle_message(event)
|
||
|
||
async def _queue_media_group_event(self, media_group_id: str, event: MessageEvent) -> None:
|
||
"""Buffer Telegram media-group items so albums arrive as one logical event.
|
||
|
||
Telegram delivers albums as multiple updates with a shared media_group_id.
|
||
If we forward each item immediately, the gateway thinks the second image is a
|
||
new user message and interrupts the first. We debounce briefly and merge the
|
||
attachments into a single MessageEvent.
|
||
"""
|
||
existing = self._media_group_events.get(media_group_id)
|
||
if existing is None:
|
||
self._media_group_events[media_group_id] = event
|
||
else:
|
||
existing.media_urls.extend(event.media_urls)
|
||
existing.media_types.extend(event.media_types)
|
||
if event.text:
|
||
existing.text = self._merge_caption(existing.text, event.text)
|
||
|
||
prior_task = self._media_group_tasks.get(media_group_id)
|
||
if prior_task:
|
||
prior_task.cancel()
|
||
|
||
self._media_group_tasks[media_group_id] = asyncio.create_task(
|
||
self._flush_media_group_event(media_group_id)
|
||
)
|
||
|
||
async def _flush_media_group_event(self, media_group_id: str) -> None:
|
||
try:
|
||
await asyncio.sleep(self.MEDIA_GROUP_WAIT_SECONDS)
|
||
event = self._media_group_events.pop(media_group_id, None)
|
||
if event is not None:
|
||
await self.handle_message(event)
|
||
except asyncio.CancelledError:
|
||
return
|
||
finally:
|
||
self._media_group_tasks.pop(media_group_id, None)
|
||
|
||
async def _handle_sticker(self, msg: Message, event: "MessageEvent") -> None:
|
||
"""
|
||
Describe a Telegram sticker via vision analysis, with caching.
|
||
|
||
For static stickers (WEBP), we download, analyze with vision, and cache
|
||
the description by file_unique_id. For animated/video stickers, we inject
|
||
a placeholder noting the emoji.
|
||
"""
|
||
from gateway.sticker_cache import (
|
||
get_cached_description,
|
||
cache_sticker_description,
|
||
build_sticker_injection,
|
||
build_animated_sticker_injection,
|
||
STICKER_VISION_PROMPT,
|
||
)
|
||
|
||
sticker = msg.sticker
|
||
emoji = sticker.emoji or ""
|
||
set_name = sticker.set_name or ""
|
||
|
||
# Animated and video stickers can't be analyzed as static images
|
||
if sticker.is_animated or sticker.is_video:
|
||
event.text = build_animated_sticker_injection(emoji)
|
||
return
|
||
|
||
# Check the cache first
|
||
cached = get_cached_description(sticker.file_unique_id)
|
||
if cached:
|
||
event.text = build_sticker_injection(
|
||
cached["description"], cached.get("emoji", emoji), cached.get("set_name", set_name)
|
||
)
|
||
logger.info("[Telegram] Sticker cache hit: %s", sticker.file_unique_id)
|
||
return
|
||
|
||
# Cache miss -- download and analyze
|
||
try:
|
||
file_obj = await sticker.get_file()
|
||
image_bytes = await file_obj.download_as_bytearray()
|
||
cached_path = cache_image_from_bytes(bytes(image_bytes), ext=".webp")
|
||
logger.info("[Telegram] Analyzing sticker at %s", cached_path)
|
||
|
||
from tools.vision_tools import vision_analyze_tool
|
||
result_json = await vision_analyze_tool(
|
||
image_url=cached_path,
|
||
user_prompt=STICKER_VISION_PROMPT,
|
||
)
|
||
result = json.loads(result_json)
|
||
|
||
if result.get("success"):
|
||
description = result.get("analysis", "a sticker")
|
||
cache_sticker_description(sticker.file_unique_id, description, emoji, set_name)
|
||
event.text = build_sticker_injection(description, emoji, set_name)
|
||
else:
|
||
# Vision failed -- use emoji as fallback
|
||
event.text = build_sticker_injection(
|
||
f"a sticker with emoji {emoji}" if emoji else "a sticker",
|
||
emoji, set_name,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("[Telegram] Sticker analysis error: %s", e, exc_info=True)
|
||
event.text = build_sticker_injection(
|
||
f"a sticker with emoji {emoji}" if emoji else "a sticker",
|
||
emoji, set_name,
|
||
)
|
||
|
||
def _reload_dm_topics_from_config(self) -> None:
|
||
"""Re-read dm_topics from config.yaml and load any new thread_ids into cache.
|
||
|
||
This allows topics created externally (e.g. by the agent via API) to be
|
||
recognized without a gateway restart.
|
||
"""
|
||
try:
|
||
from hermes_constants import get_hermes_home
|
||
config_path = get_hermes_home() / "config.yaml"
|
||
if not config_path.exists():
|
||
return
|
||
|
||
import yaml as _yaml
|
||
with open(config_path, "r", encoding="utf-8") as f:
|
||
config = _yaml.safe_load(f) or {}
|
||
|
||
dm_topics = (
|
||
config.get("platforms", {})
|
||
.get("telegram", {})
|
||
.get("extra", {})
|
||
.get("dm_topics", [])
|
||
)
|
||
if not dm_topics:
|
||
# Clear both config and precomputed set when all topics are removed
|
||
self._dm_topics_config = []
|
||
self._dm_topic_chat_ids = set()
|
||
return
|
||
|
||
# Update in-memory config and cache any new thread_ids
|
||
self._dm_topics_config = dm_topics
|
||
# Rebuild the chat_id set for O(1) root-DM ignore lookup
|
||
self._dm_topic_chat_ids = {
|
||
str(chat_entry["chat_id"]) for chat_entry in dm_topics if "chat_id" in chat_entry
|
||
}
|
||
for chat_entry in dm_topics:
|
||
cid = chat_entry.get("chat_id")
|
||
if not cid:
|
||
continue
|
||
for t in chat_entry.get("topics", []):
|
||
tid = t.get("thread_id")
|
||
name = t.get("name")
|
||
if tid and name:
|
||
cache_key = f"{cid}:{name}"
|
||
if cache_key not in self._dm_topics:
|
||
self._dm_topics[cache_key] = int(tid)
|
||
logger.info(
|
||
"[%s] Hot-loaded DM topic from config: %s -> thread_id=%s",
|
||
self.name, cache_key, tid,
|
||
)
|
||
except Exception as e:
|
||
logger.debug("[%s] Failed to reload dm_topics from config: %s", self.name, e)
|
||
|
||
def _get_dm_topic_info(self, chat_id: str, thread_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||
"""Look up DM topic config by chat_id and thread_id.
|
||
|
||
Returns the topic config dict (name, skill, etc.) if this thread_id
|
||
matches a known DM topic, or None.
|
||
"""
|
||
if not thread_id:
|
||
return None
|
||
|
||
thread_id_int = int(thread_id)
|
||
|
||
# Check cached topics first (created by us or loaded at startup)
|
||
for key, cached_tid in self._dm_topics.items():
|
||
if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
|
||
topic_name = key.split(":", 1)[1]
|
||
# Find the full config for this topic
|
||
for chat_entry in self._dm_topics_config:
|
||
if str(chat_entry.get("chat_id")) == chat_id:
|
||
for t in chat_entry.get("topics", []):
|
||
if t.get("name") == topic_name:
|
||
return t
|
||
return {"name": topic_name}
|
||
|
||
# Not in cache — hot-reload config in case topics were added externally
|
||
self._reload_dm_topics_from_config()
|
||
|
||
# Check cache again after reload
|
||
for key, cached_tid in self._dm_topics.items():
|
||
if cached_tid == thread_id_int and key.startswith(f"{chat_id}:"):
|
||
topic_name = key.split(":", 1)[1]
|
||
for chat_entry in self._dm_topics_config:
|
||
if str(chat_entry.get("chat_id")) == chat_id:
|
||
for t in chat_entry.get("topics", []):
|
||
if t.get("name") == topic_name:
|
||
return t
|
||
return {"name": topic_name}
|
||
|
||
return None
|
||
|
||
def _cache_dm_topic_from_message(self, chat_id: str, thread_id: str, topic_name: str) -> None:
|
||
"""Cache a thread_id -> topic_name mapping discovered from an incoming message."""
|
||
cache_key = f"{chat_id}:{topic_name}"
|
||
if cache_key not in self._dm_topics:
|
||
self._dm_topics[cache_key] = int(thread_id)
|
||
logger.info(
|
||
"[%s] Cached DM topic from message: %s -> thread_id=%s",
|
||
self.name, cache_key, thread_id,
|
||
)
|
||
|
||
def _build_message_event(
|
||
self,
|
||
message: Message,
|
||
msg_type: MessageType,
|
||
update_id: Optional[int] = None,
|
||
) -> MessageEvent:
|
||
"""Build a MessageEvent from a Telegram message.
|
||
|
||
``update_id`` is the ``Update.update_id`` from PTB; passing it through
|
||
lets ``/restart`` record the triggering offset so the new gateway
|
||
process can advance past it (prevents ``/restart`` being re-delivered
|
||
when PTB's graceful-shutdown ACK fails).
|
||
"""
|
||
chat = message.chat
|
||
user = message.from_user
|
||
|
||
# Determine chat type. Normalize through ``str`` so tests/mocks and
|
||
# python-telegram-bot enum values both work (``ChatType.CHANNEL`` is
|
||
# string-like, but mocks often provide plain strings).
|
||
telegram_chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
|
||
chat_type = "dm"
|
||
if telegram_chat_type in {"group", "supergroup"}:
|
||
chat_type = "group"
|
||
elif telegram_chat_type == "channel":
|
||
chat_type = "channel"
|
||
|
||
# Resolve Telegram topic name and skill binding.
|
||
# Only preserve message_thread_id when Telegram marks the message as
|
||
# a real topic/forum message. Telegram can also populate
|
||
# message_thread_id for ordinary reply UI anchors; treating those as
|
||
# durable session threads fragments workflows such as CAPTCHA/login
|
||
# handoffs where the user later replies "done" in the same group.
|
||
# Private chats have the same pitfall: only real DM topic messages
|
||
# (is_topic_message=True) should keep the thread id, otherwise sends
|
||
# can hit Telegram's 'Message thread not found' error (#3206).
|
||
thread_id_raw = message.message_thread_id
|
||
is_topic_message = bool(getattr(message, "is_topic_message", False))
|
||
is_forum_group = getattr(chat, "is_forum", False) is True
|
||
thread_id_str = None
|
||
if thread_id_raw is not None:
|
||
if chat_type == "group" and (is_topic_message or is_forum_group):
|
||
thread_id_str = str(thread_id_raw)
|
||
elif chat_type == "dm" and is_topic_message:
|
||
thread_id_str = str(thread_id_raw)
|
||
# For forum groups without an explicit topic, default to the
|
||
# General-topic id so the gateway routes back to the General topic
|
||
# rather than dropping into the bot's main channel (#22423).
|
||
if chat_type == "group" and thread_id_str is None and is_forum_group:
|
||
thread_id_str = self._GENERAL_TOPIC_THREAD_ID
|
||
chat_topic = None
|
||
topic_skill = None
|
||
|
||
if chat_type == "dm" and thread_id_str:
|
||
topic_info = self._get_dm_topic_info(str(chat.id), thread_id_str)
|
||
if topic_info:
|
||
chat_topic = topic_info.get("name")
|
||
topic_skill = topic_info.get("skill")
|
||
|
||
# Also check forum_topic_created service message for topic discovery
|
||
if hasattr(message, "forum_topic_created") and message.forum_topic_created:
|
||
created_name = message.forum_topic_created.name
|
||
if created_name:
|
||
self._cache_dm_topic_from_message(str(chat.id), thread_id_str, created_name)
|
||
if not chat_topic:
|
||
chat_topic = created_name
|
||
|
||
elif chat_type == "group" and thread_id_str:
|
||
# Group/supergroup forum topic skill binding via config.extra['group_topics']
|
||
group_topics_config: list = self.config.extra.get("group_topics", [])
|
||
for chat_entry in group_topics_config:
|
||
if str(chat_entry.get("chat_id", "")) == str(chat.id):
|
||
for topic in chat_entry.get("topics", []):
|
||
tid = topic.get("thread_id")
|
||
if tid is not None and str(tid) == thread_id_str:
|
||
chat_topic = topic.get("name")
|
||
topic_skill = topic.get("skill")
|
||
break
|
||
break
|
||
|
||
# Build source
|
||
source = self.build_source(
|
||
chat_id=str(chat.id),
|
||
chat_name=chat.title or (chat.full_name if hasattr(chat, "full_name") else None),
|
||
chat_type=chat_type,
|
||
user_id=(
|
||
str(user.id)
|
||
if user
|
||
else (str(chat.id) if chat_type in {"dm", "channel"} else None)
|
||
),
|
||
user_name=(
|
||
user.full_name
|
||
if user
|
||
else (
|
||
chat.full_name
|
||
if hasattr(chat, "full_name") and chat_type == "dm"
|
||
else (chat.title if chat_type == "channel" else None)
|
||
)
|
||
),
|
||
thread_id=thread_id_str,
|
||
chat_topic=chat_topic,
|
||
message_id=str(message.message_id),
|
||
)
|
||
|
||
# Extract reply context if this message is a reply.
|
||
# Prefer Telegram's native partial quote (message.quote, TextQuote)
|
||
# so a user replying to a single selected substring of a prior
|
||
# multi-section message doesn't get the whole replied-to message
|
||
# injected into the agent's context — which can cause the agent
|
||
# to act on unrelated actionable-looking text the user didn't
|
||
# quote (#22619). Fall back to the full replied-to message text
|
||
# / caption when no native quote is present.
|
||
reply_to_id = None
|
||
reply_to_text = None
|
||
if message.reply_to_message:
|
||
reply_to_id = str(message.reply_to_message.message_id)
|
||
quote = getattr(message, "quote", None)
|
||
quote_text = getattr(quote, "text", None) if quote is not None else None
|
||
if quote_text:
|
||
reply_to_text = quote_text
|
||
else:
|
||
reply_to_text = (
|
||
message.reply_to_message.text
|
||
or message.reply_to_message.caption
|
||
or None
|
||
)
|
||
|
||
# Per-channel/topic ephemeral prompt
|
||
from gateway.platforms.base import resolve_channel_prompt
|
||
_chat_id_str = str(chat.id)
|
||
_channel_prompt = resolve_channel_prompt(
|
||
self.config.extra,
|
||
thread_id_str or _chat_id_str,
|
||
_chat_id_str if thread_id_str else None,
|
||
)
|
||
|
||
return MessageEvent(
|
||
text=message.text or "",
|
||
message_type=msg_type,
|
||
source=source,
|
||
raw_message=message,
|
||
message_id=str(message.message_id),
|
||
platform_update_id=update_id,
|
||
reply_to_message_id=reply_to_id,
|
||
reply_to_text=reply_to_text,
|
||
auto_skill=topic_skill,
|
||
channel_prompt=_channel_prompt,
|
||
timestamp=message.date,
|
||
)
|
||
|
||
# ── Message reactions (processing lifecycle) ──────────────────────────
|
||
|
||
def _reactions_enabled(self) -> bool:
|
||
"""Check if message reactions are enabled via config/env."""
|
||
return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in {"false", "0", "no"}
|
||
|
||
async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
|
||
"""Set a single emoji reaction on a Telegram message."""
|
||
if not self._bot:
|
||
return False
|
||
try:
|
||
await self._bot.set_message_reaction(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
reaction=emoji,
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.debug("[%s] set_message_reaction failed (%s): %s", self.name, emoji, e)
|
||
return False
|
||
|
||
async def _clear_reactions(self, chat_id: str, message_id: str) -> bool:
|
||
"""Clear all reactions from a Telegram message.
|
||
|
||
Calling ``set_message_reaction`` with ``reaction=None`` (or an empty
|
||
sequence) is the documented Bot API way to remove all bot-set
|
||
reactions on a message — equivalent to Bot API 10.0's
|
||
``deleteMessageReaction`` but supported in PTB 22.6 already.
|
||
"""
|
||
if not self._bot:
|
||
return False
|
||
try:
|
||
await self._bot.set_message_reaction(
|
||
chat_id=int(chat_id),
|
||
message_id=int(message_id),
|
||
reaction=None,
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.debug("[%s] clear reactions failed: %s", self.name, e)
|
||
return False
|
||
|
||
async def on_processing_start(self, event: MessageEvent) -> None:
|
||
"""Add an in-progress reaction when message processing begins."""
|
||
if not self._reactions_enabled():
|
||
return
|
||
chat_id = getattr(event.source, "chat_id", None)
|
||
message_id = getattr(event, "message_id", None)
|
||
if chat_id and message_id:
|
||
await self._set_reaction(chat_id, message_id, "\U0001f440")
|
||
|
||
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
|
||
"""Swap the in-progress reaction for a final success/failure reaction.
|
||
|
||
Unlike Discord (additive reactions), Telegram's set_message_reaction
|
||
replaces all existing reactions in one call — no remove step needed.
|
||
|
||
On CANCELLED outcomes (e.g. the user runs ``/stop``, or a session is
|
||
interrupted mid-flight), we explicitly clear the 👀 in-progress
|
||
reaction so it doesn't linger on the user's message indefinitely.
|
||
Without this clear, the only way to remove the 👀 was to wait for
|
||
another agent run to swap it to 👍/👎 — which never happens if the
|
||
cancellation was the last activity in the chat.
|
||
"""
|
||
if not self._reactions_enabled():
|
||
return
|
||
chat_id = getattr(event.source, "chat_id", None)
|
||
message_id = getattr(event, "message_id", None)
|
||
if not (chat_id and message_id):
|
||
return
|
||
if outcome == ProcessingOutcome.CANCELLED:
|
||
await self._clear_reactions(chat_id, message_id)
|
||
else:
|
||
await self._set_reaction(
|
||
chat_id,
|
||
message_id,
|
||
"\U0001f44d" if outcome == ProcessingOutcome.SUCCESS else "\U0001f44e",
|
||
)
|