hyperagents 1.2.3rc5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. agents/__init__.py +31 -0
  2. agents/background_monitor.py +453 -0
  3. agents/learning.py +460 -0
  4. agents/planner.py +421 -0
  5. agents/transcriber.py +341 -0
  6. civilization/__init__.py +51 -0
  7. civilization/api/__init__.py +7 -0
  8. civilization/api/civilization_api.py +361 -0
  9. civilization/graph/__init__.py +19 -0
  10. civilization/graph/knowledge_linker.py +348 -0
  11. civilization/graph/org_graph.py +348 -0
  12. civilization/graph/process_graph.py +386 -0
  13. civilization/ingestion/__init__.py +20 -0
  14. civilization/ingestion/chunker.py +273 -0
  15. civilization/ingestion/document_ingestor.py +358 -0
  16. civilization/ingestion/embedder.py +262 -0
  17. civilization/ingestion/extractor.py +224 -0
  18. civilization/interview/__init__.py +19 -0
  19. civilization/interview/gap_detector.py +380 -0
  20. civilization/interview/interviewer.py +323 -0
  21. civilization/interview/questioner.py +363 -0
  22. civilization/retrieval/__init__.py +17 -0
  23. civilization/retrieval/civilization_rag.py +316 -0
  24. civilization/retrieval/context_injector.py +342 -0
  25. civilization/retrieval/relevance_ranker.py +287 -0
  26. civilization/schema.py +299 -0
  27. civilization/store.py +177 -0
  28. civilization/sync/__init__.py +16 -0
  29. civilization/sync/confluence_sync.py +317 -0
  30. civilization/sync/gdrive_sync.py +262 -0
  31. civilization/sync/notion_sync.py +237 -0
  32. civilization/versioning/__init__.py +19 -0
  33. civilization/versioning/diff_engine.py +351 -0
  34. civilization/versioning/staleness_detector.py +303 -0
  35. civilization/versioning/version_manager.py +293 -0
  36. cli/__init__.py +1 -0
  37. cli/doctor.py +334 -0
  38. cli/hyperclaw.py +1357 -0
  39. cli/onboarding.py +469 -0
  40. core/__init__.py +1 -0
  41. core/connectors/__init__.py +119 -0
  42. core/connectors/infrastructure.py +772 -0
  43. core/connectors/messaging.py +586 -0
  44. core/connectors/productivity.py +764 -0
  45. core/connectors/smarthome.py +528 -0
  46. core/connectors/social.py +730 -0
  47. core/hyperrouter/__init__.py +23 -0
  48. core/hyperrouter/bandit.py +218 -0
  49. core/hyperrouter/fast_loop.py +68 -0
  50. core/hyperrouter/slow_loop.py +143 -0
  51. core/hyperstate/__init__.py +24 -0
  52. core/hyperstate/certifier.py +154 -0
  53. core/hyperstate/schema.py +160 -0
  54. core/hyperstate/state_manager.py +103 -0
  55. core/hyperstate/store.py +163 -0
  56. core/providers/__init__.py +121 -0
  57. core/providers/embeddings.py +389 -0
  58. core/providers/image.py +534 -0
  59. core/providers/llm.py +1935 -0
  60. core/providers/search.py +608 -0
  61. core/providers/voice.py +474 -0
  62. hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
  63. hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
  64. hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
  65. hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
  66. hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
  67. hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
  68. hyperclaw/__init__.py +67 -0
  69. hyperclaw/__main__.py +220 -0
  70. hyperclaw/agent.py +775 -0
  71. hyperclaw/agent_coordinator.py +577 -0
  72. hyperclaw/anthropic_params.py +225 -0
  73. hyperclaw/api_utils.py +85 -0
  74. hyperclaw/approval_telegram.py +209 -0
  75. hyperclaw/approval_terminal.py +80 -0
  76. hyperclaw/attachments.py +177 -0
  77. hyperclaw/branding.py +55 -0
  78. hyperclaw/brief/__init__.py +11 -0
  79. hyperclaw/brief/cli.py +204 -0
  80. hyperclaw/brief/collect.py +146 -0
  81. hyperclaw/brief/compose.py +333 -0
  82. hyperclaw/brief/diff.py +110 -0
  83. hyperclaw/brief/errors.py +99 -0
  84. hyperclaw/brief/google.py +269 -0
  85. hyperclaw/brief/google_auth.py +294 -0
  86. hyperclaw/brief/links.py +124 -0
  87. hyperclaw/brief/service.py +424 -0
  88. hyperclaw/brief/store.py +205 -0
  89. hyperclaw/brief/tasks.py +200 -0
  90. hyperclaw/chat_scope.py +115 -0
  91. hyperclaw/cli_tui.py +497 -0
  92. hyperclaw/client_guard.py +717 -0
  93. hyperclaw/connection_check.py +113 -0
  94. hyperclaw/cost_guard.py +1728 -0
  95. hyperclaw/default_config/agents.yaml +534 -0
  96. hyperclaw/default_config/hyperclaw.yaml +27 -0
  97. hyperclaw/default_config/models.yaml +161 -0
  98. hyperclaw/deployment.py +39 -0
  99. hyperclaw/digest/__init__.py +9 -0
  100. hyperclaw/digest/cli.py +117 -0
  101. hyperclaw/digest/errors.py +100 -0
  102. hyperclaw/digest/gitlab.py +187 -0
  103. hyperclaw/digest/http.py +113 -0
  104. hyperclaw/digest/redact.py +32 -0
  105. hyperclaw/digest/service.py +271 -0
  106. hyperclaw/digest/slack.py +223 -0
  107. hyperclaw/digest/state.py +124 -0
  108. hyperclaw/digest/summarize.py +198 -0
  109. hyperclaw/digest/tokens.py +143 -0
  110. hyperclaw/distribution.py +46 -0
  111. hyperclaw/documents.py +351 -0
  112. hyperclaw/feeds.py +339 -0
  113. hyperclaw/google_workspace/__init__.py +5 -0
  114. hyperclaw/google_workspace/api.py +151 -0
  115. hyperclaw/google_workspace/auth.py +397 -0
  116. hyperclaw/google_workspace/calendar.py +157 -0
  117. hyperclaw/google_workspace/cli.py +72 -0
  118. hyperclaw/google_workspace/drive.py +236 -0
  119. hyperclaw/google_workspace/errors.py +58 -0
  120. hyperclaw/google_workspace/gmail.py +295 -0
  121. hyperclaw/google_workspace/meet.py +122 -0
  122. hyperclaw/google_workspace/tools.py +319 -0
  123. hyperclaw/hyperspeed.py +57 -0
  124. hyperclaw/integrations_layer.py +1427 -0
  125. hyperclaw/live_state.py +188 -0
  126. hyperclaw/managed_config.py +245 -0
  127. hyperclaw/media_hub.py +179 -0
  128. hyperclaw/memory_manager.py +584 -0
  129. hyperclaw/model_catalog.py +551 -0
  130. hyperclaw/model_policy.py +211 -0
  131. hyperclaw/model_router.py +595 -0
  132. hyperclaw/model_selector.py +72 -0
  133. hyperclaw/naming.py +284 -0
  134. hyperclaw/notices.py +65 -0
  135. hyperclaw/oai_stream.py +106 -0
  136. hyperclaw/orchestrator.py +591 -0
  137. hyperclaw/outbox.py +82 -0
  138. hyperclaw/persona.py +367 -0
  139. hyperclaw/prometheus.py +219 -0
  140. hyperclaw/prompt_assembly.py +157 -0
  141. hyperclaw/provider_setup.py +146 -0
  142. hyperclaw/providers.py +340 -0
  143. hyperclaw/qualification.py +341 -0
  144. hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
  145. hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
  146. hyperclaw/reasoning_display.py +35 -0
  147. hyperclaw/scheduler.py +454 -0
  148. hyperclaw/server.py +847 -0
  149. hyperclaw/setup.py +627 -0
  150. hyperclaw/skills.py +717 -0
  151. hyperclaw/solomon.py +442 -0
  152. hyperclaw/swarm.py +533 -0
  153. hyperclaw/taint.py +832 -0
  154. hyperclaw/team/__init__.py +28 -0
  155. hyperclaw/team/dispatcher.py +431 -0
  156. hyperclaw/team/genesis.py +106 -0
  157. hyperclaw/team/lead.py +110 -0
  158. hyperclaw/team/model.py +135 -0
  159. hyperclaw/team/morning.py +238 -0
  160. hyperclaw/team/repos.py +66 -0
  161. hyperclaw/team/roster.py +264 -0
  162. hyperclaw/team/specialist.py +235 -0
  163. hyperclaw/team/status.py +163 -0
  164. hyperclaw/team/watchers.py +112 -0
  165. hyperclaw/teamsetup/__init__.py +17 -0
  166. hyperclaw/teamsetup/checks.py +208 -0
  167. hyperclaw/teamsetup/item.py +391 -0
  168. hyperclaw/teamsetup/oauth_loopback.py +84 -0
  169. hyperclaw/teamsetup/onboard.py +550 -0
  170. hyperclaw/teamsetup/profile.py +82 -0
  171. hyperclaw/teamsetup/secrets.py +313 -0
  172. hyperclaw/teamsetup/selfcheck.py +295 -0
  173. hyperclaw/teamsetup/service.py +495 -0
  174. hyperclaw/teamsetup/slack_oauth.py +442 -0
  175. hyperclaw/teamsetup/tty.py +62 -0
  176. hyperclaw/telegram_bot.py +1106 -0
  177. hyperclaw/telegram_intake.py +126 -0
  178. hyperclaw/telegram_pairing.py +360 -0
  179. hyperclaw/terminal_provider.py +323 -0
  180. hyperclaw/tools/__init__.py +1 -0
  181. hyperclaw/tools/approval.py +949 -0
  182. hyperclaw/tools/args.py +212 -0
  183. hyperclaw/tools/catalog.py +244 -0
  184. hyperclaw/tools/classify.py +266 -0
  185. hyperclaw/tools/effects.py +724 -0
  186. hyperclaw/tools/egress.py +210 -0
  187. hyperclaw/tools/envelope.py +188 -0
  188. hyperclaw/tools/guard.py +367 -0
  189. hyperclaw/tools/ledger.py +339 -0
  190. hyperclaw/tools/receipts.py +70 -0
  191. hyperclaw/tools/registry.py +318 -0
  192. hyperclaw/tools/service_guard.py +151 -0
  193. hyperclaw/tools/surfaces.py +54 -0
  194. hyperclaw/tools/workspace.py +87 -0
  195. hyperclaw/trading/__init__.py +77 -0
  196. hyperclaw/truth_guard.py +791 -0
  197. hyperclaw/tts.py +124 -0
  198. hyperclaw/tui.py +4255 -0
  199. hyperclaw/tui_bridge.py +938 -0
  200. hyperclaw/unattended.py +60 -0
  201. hyperclaw/updates/__init__.py +54 -0
  202. hyperclaw/updates/check.py +131 -0
  203. hyperclaw/updates/index.py +178 -0
  204. hyperclaw/updates/install.py +110 -0
  205. hyperclaw/updates/managed.py +161 -0
  206. hyperclaw/updates/manifest.py +138 -0
  207. hyperclaw/updates/notices.py +86 -0
  208. hyperclaw/updates/paths.py +53 -0
  209. hyperclaw/updates/release.py +146 -0
  210. hyperclaw/updates/upgrade.py +253 -0
  211. hyperclaw/updates/versioning.py +104 -0
  212. hyperclaw/userconfig.py +119 -0
  213. hyperclaw/xai.py +83 -0
  214. integrations/__init__.py +31 -0
  215. integrations/automation/__init__.py +1 -0
  216. integrations/automation/make.py +87 -0
  217. integrations/automation/zapier.py +87 -0
  218. integrations/base.py +476 -0
  219. integrations/communication/__init__.py +1 -0
  220. integrations/communication/twilio.py +279 -0
  221. integrations/communication/zoom.py +290 -0
  222. integrations/data/__init__.py +1 -0
  223. integrations/data/google_analytics.py +135 -0
  224. integrations/data/postgres.py +182 -0
  225. integrations/data/supabase_connector.py +202 -0
  226. integrations/data/weather_feeds.py +450 -0
  227. integrations/developer/__init__.py +9 -0
  228. integrations/developer/github.py +406 -0
  229. integrations/developer/gitlab.py +298 -0
  230. integrations/enterprise/__init__.py +13 -0
  231. integrations/enterprise/confluence.py +266 -0
  232. integrations/enterprise/hubspot.py +293 -0
  233. integrations/enterprise/jira.py +314 -0
  234. integrations/enterprise/salesforce.py +286 -0
  235. integrations/finance/__init__.py +1 -0
  236. integrations/finance/polymarket_client.py +247 -0
  237. integrations/finance/quickbooks.py +281 -0
  238. integrations/finance/stripe.py +261 -0
  239. integrations/gateway.py +238 -0
  240. integrations/messaging/__init__.py +32 -0
  241. integrations/messaging/discord.py +159 -0
  242. integrations/messaging/email.py +401 -0
  243. integrations/messaging/imessage.py +443 -0
  244. integrations/messaging/signal.py +175 -0
  245. integrations/messaging/slack.py +217 -0
  246. integrations/messaging/sms.py +175 -0
  247. integrations/messaging/teams.py +208 -0
  248. integrations/messaging/telegram.py +260 -0
  249. integrations/messaging/whatsapp.py +203 -0
  250. integrations/productivity/__init__.py +17 -0
  251. integrations/productivity/airtable.py +217 -0
  252. integrations/productivity/asana.py +248 -0
  253. integrations/productivity/google/__init__.py +19 -0
  254. integrations/productivity/google/base.py +89 -0
  255. integrations/productivity/google/calendar.py +272 -0
  256. integrations/productivity/google/docs.py +202 -0
  257. integrations/productivity/google/drive.py +268 -0
  258. integrations/productivity/google/gmail.py +279 -0
  259. integrations/productivity/google/meet.py +218 -0
  260. integrations/productivity/google/sheets.py +222 -0
  261. integrations/productivity/google/tasks.py +199 -0
  262. integrations/productivity/linear.py +279 -0
  263. integrations/productivity/microsoft/__init__.py +13 -0
  264. integrations/productivity/microsoft/base.py +93 -0
  265. integrations/productivity/microsoft/calendar.py +207 -0
  266. integrations/productivity/microsoft/onedrive.py +222 -0
  267. integrations/productivity/microsoft/outlook.py +241 -0
  268. integrations/productivity/microsoft/sharepoint.py +211 -0
  269. integrations/productivity/notion.py +243 -0
  270. integrations/productivity/todoist.py +250 -0
  271. integrations/productivity/trello.py +244 -0
  272. integrations/storage/__init__.py +1 -0
  273. integrations/storage/box.py +279 -0
  274. integrations/storage/dropbox.py +215 -0
  275. integrations/telegram_inbound.py +179 -0
  276. integrations/telegram_poller.py +151 -0
  277. memory/__init__.py +1 -0
  278. memory/causal_graph.py +125 -0
  279. memory/impact_tracker.py +88 -0
  280. memory/vector_memory.py +436 -0
  281. models/__init__.py +12 -0
  282. models/chatjimmy_client.py +210 -0
  283. models/claude_client.py +235 -0
  284. models/claude_code_subagent.py +192 -0
  285. models/openai_client.py +120 -0
  286. models/router.py +162 -0
  287. recursive/discovery_loop.py +118 -0
  288. recursive/optimization_loop.py +135 -0
  289. recursive/validation_loop.py +38 -0
  290. security/__init__.py +1 -0
  291. security/audit_logger.py +106 -0
  292. security/filesystem_guard.py +100 -0
  293. security/hypershield.py +99 -0
  294. security/network_guard.py +97 -0
  295. security/policy_engine.py +198 -0
  296. swarm/__init__.py +1 -0
  297. swarm/agents/__init__.py +0 -0
  298. swarm/agents/base.py +230 -0
  299. swarm/agents/business/__init__.py +0 -0
  300. swarm/agents/business/counsel.py +31 -0
  301. swarm/agents/business/herald.py +32 -0
  302. swarm/agents/business/ledger.py +26 -0
  303. swarm/agents/business/nexus.py +30 -0
  304. swarm/agents/business/ops.py +30 -0
  305. swarm/agents/business/pipeline.py +28 -0
  306. swarm/agents/business/revenue.py +30 -0
  307. swarm/agents/business/sovereign.py +31 -0
  308. swarm/agents/business/strategos.py +27 -0
  309. swarm/agents/business/talent.py +27 -0
  310. swarm/agents/business/venture.py +32 -0
  311. swarm/agents/comms/__init__.py +1 -0
  312. swarm/agents/comms/cipher.py +32 -0
  313. swarm/agents/comms/echo_comms.py +31 -0
  314. swarm/agents/comms/envoy.py +31 -0
  315. swarm/agents/comms/herald_scheduler.py +33 -0
  316. swarm/agents/comms/pulse.py +29 -0
  317. swarm/agents/creative/__init__.py +0 -0
  318. swarm/agents/creative/author.py +27 -0
  319. swarm/agents/creative/lens.py +25 -0
  320. swarm/agents/executive/__init__.py +1 -0
  321. swarm/agents/intelligence/__init__.py +1 -0
  322. swarm/agents/intelligence/arbiter.py +30 -0
  323. swarm/agents/intelligence/sentinel.py +30 -0
  324. swarm/agents/media/__init__.py +0 -0
  325. swarm/agents/personal/__init__.py +0 -0
  326. swarm/agents/personal/atlas.py +27 -0
  327. swarm/agents/personal/hearth.py +26 -0
  328. swarm/agents/personal/midas.py +27 -0
  329. swarm/agents/personal/navigator.py +27 -0
  330. swarm/agents/personal/nourish.py +31 -0
  331. swarm/agents/personal/vitals.py +33 -0
  332. swarm/agents/recursive/__init__.py +0 -0
  333. swarm/agents/recursive/alchemist.py +73 -0
  334. swarm/agents/recursive/calibrator.py +45 -0
  335. swarm/agents/recursive/scout.py +97 -0
  336. swarm/agents/scientific/__init__.py +0 -0
  337. swarm/agents/scientific/cosmos.py +36 -0
  338. swarm/agents/scientific/gaia.py +26 -0
  339. swarm/agents/scientific/medicus.py +33 -0
  340. swarm/agents/scientific/oracle_agent.py +39 -0
  341. swarm/agents/scientific/scribe.py +39 -0
  342. swarm/agents/talent/__init__.py +0 -0
  343. swarm/agents/talent/deal.py +28 -0
  344. swarm/agents/talent/roster.py +29 -0
  345. swarm/agents/talent/scout.py +29 -0
  346. swarm/agents/talent/stage.py +27 -0
  347. swarm/agents/tech/__init__.py +0 -0
  348. swarm/agents/tech/aegis.py +28 -0
  349. swarm/agents/tech/bridge.py +28 -0
  350. swarm/agents/tech/forge.py +28 -0
  351. swarm/agents/trading/__init__.py +1 -0
  352. swarm/agents/trading/global_prediction_engine.py +611 -0
  353. swarm/agents/trading/polymarket_trader.py +165 -0
  354. swarm/agents/trading/prediction_strategist.py +218 -0
  355. swarm/autogen_bridge.py +68 -0
  356. swarm/bid_protocol.py +196 -0
  357. swarm/dispatcher.py +527 -0
  358. swarm/nexus.py +181 -0
  359. swarm/registry.py +112 -0
  360. telegram_listener.py +403 -0
  361. ui/tui/app.py +214 -0
@@ -0,0 +1,1106 @@
1
+ """
2
+ HyperClaw Telegram Bot
3
+ Routes messages to SOLOMON, maintains per-chat conversation history.
4
+ """
5
+
6
+ import os
7
+ import json as _json
8
+ import logging
9
+ from collections import defaultdict
10
+ from pathlib import Path as _Path
11
+ from typing import Optional
12
+
13
+ import httpx
14
+ from dotenv import load_dotenv
15
+ from telegram import InlineKeyboardButton, InlineKeyboardMarkup, LinkPreviewOptions, Update
16
+ from telegram.constants import ChatAction
17
+ from telegram.error import RetryAfter, TelegramError
18
+ from telegram.ext import (
19
+ Application,
20
+ ApplicationHandlerStop,
21
+ CallbackQueryHandler,
22
+ CommandHandler,
23
+ ContextTypes,
24
+ Defaults,
25
+ MessageHandler,
26
+ TypeHandler,
27
+ filters,
28
+ )
29
+
30
+ from .branding import POWERED_BY
31
+ from .chat_scope import OWNER, owner_ids, scope_for
32
+ from .notices import failure_notice
33
+ from .solomon import get_solomon
34
+ from . import taint
35
+ from .truth_guard import (CHAT_CUTOFF_NOTICE, CHAT_ERROR_NOTICE, FABRICATION_NOTICE,
36
+ detect_fabricated_tool_use, is_internal_error_chunk,
37
+ redact_secrets, strip_internal_errors)
38
+
39
+ from pathlib import Path
40
+
41
+ HYPERCLAW_ROOT = Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw"))
42
+ # ~/.hyperclaw/.env is where onboarding saves credentials; CWD .env as fallback
43
+ load_dotenv(HYPERCLAW_ROOT / ".env")
44
+ load_dotenv()
45
+ from hyperclaw.teamsetup.secrets import export_env as _export_secrets # noqa: E402
46
+ _export_secrets() # team installs keep the bot token in the OS secret store
47
+
48
+ logger = logging.getLogger("hyperclaw.telegram")
49
+
50
+ TENANT_ID = os.environ.get("OWNER_ID", "local")
51
+
52
+ # Every Telegram message from this bot goes out with link previews off.
53
+ NO_PREVIEW = LinkPreviewOptions(is_disabled=True)
54
+
55
+
56
+ # ── conversation history: local only ───────────────────────────────────
57
+ # Telegram history lives in one SQLite file under HYPERCLAW_ROOT/data (dir
58
+ # 0700, file 0600), OWNER chat only (guest chats stay in memory), secrets
59
+ # redacted on write. There is deliberately no database path: history never
60
+ # goes to Postgres, and never to a shared Hyper database, even when a secrets
61
+ # mount or POSTGRES_* variables exist (HC-027).
62
+
63
+ def _local_history_path() -> _Path:
64
+ root = _Path(os.environ.get("HYPERCLAW_ROOT", _Path.home() / ".hyperclaw"))
65
+ return root / "data" / "telegram_history.sqlite"
66
+
67
+
68
+ def _local_db():
69
+ import sqlite3
70
+ path = _local_history_path()
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ try:
73
+ os.chmod(path.parent, 0o700)
74
+ except OSError:
75
+ pass
76
+ new = not path.exists()
77
+ if new:
78
+ os.close(os.open(path, os.O_CREAT | os.O_WRONLY, 0o600))
79
+ conn = sqlite3.connect(str(path))
80
+ conn.execute("CREATE TABLE IF NOT EXISTS sessions (tenant_id TEXT NOT NULL, chat_id INTEGER "
81
+ "NOT NULL, messages TEXT NOT NULL, updated_at REAL NOT NULL, "
82
+ "PRIMARY KEY (tenant_id, chat_id))")
83
+ return conn
84
+
85
+
86
+ def _local_load(tenant_id: str, chat_id: int) -> list:
87
+ if not _local_history_path().exists():
88
+ return []
89
+ conn = _local_db()
90
+ try:
91
+ row = conn.execute("SELECT messages FROM sessions WHERE tenant_id=? AND chat_id=?",
92
+ (tenant_id, chat_id)).fetchone()
93
+ return _json.loads(row[0]) if row else []
94
+ finally:
95
+ conn.close()
96
+
97
+
98
+ def _local_save(tenant_id: str, chat_id: int, messages: list) -> None:
99
+ import time
100
+ conn = _local_db()
101
+ try:
102
+ with conn:
103
+ conn.execute("INSERT INTO sessions (tenant_id, chat_id, messages, updated_at) "
104
+ "VALUES (?, ?, ?, ?) ON CONFLICT (tenant_id, chat_id) DO UPDATE SET "
105
+ "messages=excluded.messages, updated_at=excluded.updated_at",
106
+ (tenant_id, chat_id, _json.dumps(redact_secrets(messages)), time.time()))
107
+ finally:
108
+ conn.close()
109
+
110
+
111
+ def _is_owner_chat(chat_id: int) -> bool:
112
+ return scope_for("telegram", chat_id) == OWNER
113
+
114
+
115
+ def taint_session(chat_id) -> str:
116
+ """The conversation id the taint guard (hyperclaw.taint) keys this chat on."""
117
+ return f"telegram:{chat_id}"
118
+
119
+
120
+ def _forwarded(message) -> bool:
121
+ """A forwarded message is someone else's words, even in the owner's chat."""
122
+ origin = getattr(message, "forward_origin", None)
123
+ return origin is not None and type(origin).__module__.startswith("telegram")
124
+
125
+
126
+ async def _load_history(tenant_id: str, chat_id: int) -> list:
127
+ if not _is_owner_chat(chat_id):
128
+ return []
129
+ try:
130
+ import asyncio as _asyncio
131
+ return await _asyncio.to_thread(_local_load, tenant_id, chat_id)
132
+ except Exception as e:
133
+ logger.warning(f"[{chat_id}] local history load failed: {type(e).__name__}")
134
+ return []
135
+
136
+
137
+ async def _save_history(tenant_id: str, chat_id: int, messages: list) -> None:
138
+ if not _is_owner_chat(chat_id):
139
+ return # guest chats are never written to disk
140
+ try:
141
+ import asyncio as _asyncio
142
+ await _asyncio.to_thread(_local_save, tenant_id, chat_id, messages)
143
+ except Exception as e:
144
+ logger.warning(f"[{chat_id}] local history save failed: {type(e).__name__}")
145
+
146
+
147
+ def _allowed_chat_ids() -> set:
148
+ """Union of TELEGRAM_ALLOWED_CHAT_IDS (comma list) and TELEGRAM_CHAT_ID
149
+ (single ID written by onboarding). Also reads from CSI-mounted secret file.
150
+ Deny-by-default when all sources are empty."""
151
+ ids = set()
152
+ for var in ("TELEGRAM_ALLOWED_CHAT_IDS", "TELEGRAM_CHAT_ID", "TELEGRAM_OWNER_CHAT_ID"):
153
+ for cid in os.environ.get(var, "").split(","):
154
+ cid = cid.strip()
155
+ if cid.lstrip("-").isdigit():
156
+ ids.add(int(cid))
157
+ mount = _Path(os.environ.get("SECRETS_MOUNT", "/mnt/secrets")) / "telegram"
158
+ if mount.exists():
159
+ try:
160
+ raw = _json.loads(mount.read_text())
161
+ for key in ("TELEGRAM_CHAT_ID", "ALLOWED_CHAT_IDS"):
162
+ for cid in str(raw.get(key, "")).split(","):
163
+ cid = cid.strip()
164
+ if cid.lstrip("-").isdigit():
165
+ ids.add(int(cid))
166
+ except Exception:
167
+ pass
168
+ return ids
169
+
170
+
171
+ # Config
172
+ BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
173
+ ALLOWED_CHAT_IDS = _allowed_chat_ids()
174
+ MAX_HISTORY = 20
175
+ # Live italic preview of the model's raw reasoning: OFF by default, forced off
176
+ # on Hyper-deployed installs; a standard-install owner may opt in with
177
+ # TELEGRAM_SHOW_THINKING=1 (hyperclaw.reasoning_display, Vaughn P1 2026-10-07).
178
+ def _show_thinking() -> bool:
179
+ from .reasoning_display import show_reasoning
180
+ return show_reasoning("telegram")
181
+
182
+
183
+ # Sent instead of an empty model answer; never saved to history.
184
+ EMPTY_REPLY_FALLBACK = failure_notice("get_answer")
185
+
186
+
187
+ # Every message the pairing flow sends is ALSO marked previews-off per call
188
+ # (build() also sets previews-off Defaults for every send): a preview makes
189
+ # Telegram's servers fetch the URL, which is data leaving the machine.
190
+ NO_PREVIEW_KW = {"link_preview_options": NO_PREVIEW}
191
+
192
+
193
+ def _pairing_store():
194
+ from .telegram_pairing import PairingStore
195
+ return PairingStore()
196
+
197
+
198
+ class JournaledApplication(Application):
199
+ """Application whose updates pass through the durable intake journal.
200
+
201
+ python-telegram-bot fetches and confirms updates itself; this only
202
+ claims each update before the handlers run and records the outcome, so
203
+ an update Telegram delivers again after a restart is never run twice and
204
+ nothing is acknowledged a second time (hyperclaw.telegram_intake)."""
205
+
206
+ hc_journal = None
207
+
208
+ async def process_update(self, update: object) -> None:
209
+ journal = self.hc_journal
210
+ if journal is None or not isinstance(update, Update):
211
+ return await super().process_update(update)
212
+ try:
213
+ claimed = journal.claim(update.to_dict())
214
+ except Exception as e:
215
+ logger.warning(f"intake journal refused update: {type(e).__name__}")
216
+ return
217
+ if not claimed:
218
+ logger.info(f"update {update.update_id} already handled or pre-cutover; not run again")
219
+ return
220
+ outcome = "uncertain"
221
+ try:
222
+ await super().process_update(update)
223
+ outcome = "processed"
224
+ finally:
225
+ try:
226
+ journal.finish(update.update_id, outcome)
227
+ except Exception as e:
228
+ logger.warning(f"intake journal finish failed: {type(e).__name__}")
229
+
230
+
231
+ def _served_by() -> str:
232
+ try:
233
+ from .providers import get_served_by
234
+ return get_served_by()
235
+ except Exception:
236
+ return ""
237
+
238
+
239
+ def start_greeting(name: str) -> str:
240
+ """The /start greeting: led by the agent's name, the command list, then
241
+ the product line."""
242
+ lead = f"Hi, I'm {name}. Good to see you!" if name else "Hi, good to see you!"
243
+ return (
244
+ f"{lead} What can I help you with today?\n\n"
245
+ "/status - System status\n"
246
+ "/clear - Clear history\n"
247
+ "/name - Keep or change my name\n"
248
+ "/persona - See or change what shapes my style\n"
249
+ "/model - See or switch the model (owner)\n\n"
250
+ f"{POWERED_BY}"
251
+ )
252
+
253
+
254
+ # Bot display name (Bot API setMyName). Runs ONLY when the owner first names
255
+ # the agent or confirms/changes the name with /name: never at startup and
256
+ # never on upgrade. A refusal is reported once and never retried in a loop.
257
+ RENAMED = "My Telegram display name is now {name}."
258
+ RENAME_REFUSED = ("Telegram refused to change my display name to {name} just now, so this "
259
+ "bot keeps working under its old display name. My name is still saved as "
260
+ "{name}; you can try again later with /name.")
261
+
262
+
263
+ class TelegramBot:
264
+ """HyperClaw Telegram bot — routes to SOLOMON."""
265
+
266
+ def __init__(self):
267
+ self.application: Optional[Application] = None
268
+ self.conversation_history: dict[int, list[dict]] = defaultdict(list)
269
+ self.pending_attachments: dict[int, list] = defaultdict(list)
270
+ self._db_loaded: set[int] = set()
271
+ self.solomon = get_solomon()
272
+ self._naming: dict = {} # chat_id -> naming.NamingFlow (owner first run)
273
+ self._consent_wait: dict = {} # chat_id -> pending persona item id
274
+ # Approval layer: Send / Edit / Cancel buttons for queued actions.
275
+ from .approval_telegram import TelegramApprovals
276
+ self.approvals = TelegramApprovals()
277
+ self._approval_task = None
278
+
279
+ def _is_allowed(self, chat_id: int) -> bool:
280
+ """Allowlist: configured chats, the owner chat, and guest chats the
281
+ owner approved with /pair (those stay GUESTS: chat_scope decides
282
+ owner vs guest, never the allowlist)."""
283
+ if chat_id in ALLOWED_CHAT_IDS or str(chat_id) in owner_ids("telegram"):
284
+ return True
285
+ try:
286
+ return _pairing_store().is_approved(chat_id)
287
+ except Exception:
288
+ return False
289
+
290
+ # ── pairing: unknown chats ask, the owner approves (guests only) ──────
291
+ PAIR_PRIVATE = ("This assistant is private, so your message was not read. "
292
+ "Pairing code: {code}. Ask the owner to approve it; the code "
293
+ "expires in 10 minutes.")
294
+
295
+ async def handle_gate(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
296
+ """Runs first for every update. Allowed chats continue to the normal
297
+ handlers; anything else stops here, and its content is never read.
298
+ An unknown direct chat gets a one-time code and the owner is asked."""
299
+ chat = update.effective_chat
300
+ if chat is None or self._is_allowed(chat.id):
301
+ return
302
+ if chat.type == "private" and update.message is not None:
303
+ await self._request_pairing(update, context)
304
+ raise ApplicationHandlerStop
305
+
306
+ async def _request_pairing(self, update: Update, context) -> None:
307
+ from .telegram_pairing import describe_sender
308
+ chat_id = update.effective_chat.id
309
+ owners = sorted(owner_ids("telegram"))
310
+ if not owners:
311
+ logger.warning(f"[{chat_id}] unknown chat; no owner chat configured to approve it")
312
+ return
313
+ who = describe_sender(update.effective_user)
314
+ try:
315
+ code, is_new = _pairing_store().request(chat_id, who)
316
+ except Exception as e:
317
+ logger.error(f"[{chat_id}] pairing store unavailable: {type(e).__name__}")
318
+ return
319
+ if not is_new:
320
+ logger.info(f"[{chat_id}] unknown chat; pairing code already pending (or cap reached)")
321
+ return
322
+ logger.warning(f"[{chat_id}] unknown chat issued a pairing code")
323
+ try:
324
+ await update.message.reply_text(self.PAIR_PRIVATE.format(code=code), **NO_PREVIEW_KW)
325
+ except Exception:
326
+ pass
327
+ for owner in owners:
328
+ try:
329
+ await context.bot.send_message(
330
+ chat_id=int(owner),
331
+ text=(f"Pairing request from {who} (chat {chat_id}). It would join as a "
332
+ f"guest: no access to your memory, files or accounts. Approve with "
333
+ f"/pair {code} within 10 minutes, or ignore it."),
334
+ **NO_PREVIEW_KW)
335
+ except Exception:
336
+ logger.warning("could not notify the owner chat of a pairing request")
337
+
338
+ async def pair_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
339
+ """/pair <code> - owner chat only: approve a waiting chat as a guest."""
340
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
341
+ return
342
+ if scope_for("telegram", update.effective_chat.id) != OWNER:
343
+ await update.message.reply_text("Only the owner can approve pairing requests.",
344
+ **NO_PREVIEW_KW)
345
+ return
346
+ args = list(getattr(context, "args", None) or [])
347
+ if not args:
348
+ await update.message.reply_text("Usage: /pair <code>", **NO_PREVIEW_KW)
349
+ return
350
+ info = _pairing_store().approve(args[0])
351
+ if not info:
352
+ await update.message.reply_text(
353
+ "No waiting request has that code (it may have expired). Nothing was paired.",
354
+ **NO_PREVIEW_KW)
355
+ return
356
+ await update.message.reply_text(
357
+ f"Paired {info.get('who') or 'that chat'} (chat {info['chat_id']}) as a guest. "
358
+ f"/unpair {info['chat_id']} removes it.", **NO_PREVIEW_KW)
359
+ try:
360
+ await context.bot.send_message(
361
+ chat_id=int(info["chat_id"]),
362
+ text="The owner approved this chat. You can talk to me now as a guest.",
363
+ **NO_PREVIEW_KW)
364
+ except Exception:
365
+ pass
366
+
367
+ async def unpair_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
368
+ """/unpair <chat id> - owner chat only: remove an approved guest chat."""
369
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
370
+ return
371
+ if scope_for("telegram", update.effective_chat.id) != OWNER:
372
+ await update.message.reply_text("Only the owner can remove paired chats.",
373
+ **NO_PREVIEW_KW)
374
+ return
375
+ args = list(getattr(context, "args", None) or [])
376
+ if not args or not args[0].lstrip("-").isdigit():
377
+ await update.message.reply_text("Usage: /unpair <chat id>", **NO_PREVIEW_KW)
378
+ return
379
+ gone = _pairing_store().revoke(int(args[0]))
380
+ await update.message.reply_text(
381
+ f"Removed chat {args[0]}." if gone else f"Chat {args[0]} was not a paired guest.",
382
+ **NO_PREVIEW_KW)
383
+
384
+ async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
385
+ """Handle /start command."""
386
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
387
+ return
388
+
389
+ chat_id = update.effective_chat.id
390
+ from . import naming
391
+ if scope_for("telegram", chat_id) == OWNER:
392
+ if not naming.is_named():
393
+ await update.message.reply_text(await self._start_naming(chat_id))
394
+ return
395
+ await update.message.reply_text(start_greeting(naming.current_name()))
396
+
397
+ # ── identity and persona (spec §14), owner only ─────────────────────
398
+ NOT_OWNER = "Only the owner of this assistant can change its name or persona."
399
+
400
+ async def _start_naming(self, chat_id: int) -> str:
401
+ import asyncio as _asyncio
402
+ from . import naming
403
+ call = await _asyncio.to_thread(naming.default_model_call)
404
+ flow = naming.NamingFlow(model_call=call)
405
+ self._naming[chat_id] = flow
406
+ return "Hi! Before we get going: " + flow.start()
407
+
408
+ async def _sync_display_name(self, bot, name: str) -> str:
409
+ """Set the bot's Telegram display name to the agent's name (setMyName),
410
+ only if it differs from the current one (getMyName). Called only when
411
+ the owner names the agent or confirms/changes the name via /name.
412
+ Returns a note for the owner ("" when nothing changed). One attempt:
413
+ a refusal is logged and reported, never retried."""
414
+ if not name or bot is None:
415
+ return ""
416
+ try:
417
+ current = await bot.get_my_name()
418
+ if (getattr(current, "name", "") or "") == name:
419
+ return ""
420
+ except Exception as e: # can't tell: try the rename once
421
+ logger.info(f"getMyName failed ({type(e).__name__}); trying setMyName once")
422
+ try:
423
+ ok = await bot.set_my_name(name=name)
424
+ except RetryAfter:
425
+ logger.warning("Telegram refused setMyName (rate limited); not retrying")
426
+ return RENAME_REFUSED.format(name=name)
427
+ except TelegramError as e:
428
+ logger.warning(f"Telegram refused setMyName ({type(e).__name__}: {e}); not retrying")
429
+ return RENAME_REFUSED.format(name=name)
430
+ except Exception as e:
431
+ logger.warning(f"setMyName failed ({type(e).__name__}); not retrying")
432
+ return RENAME_REFUSED.format(name=name)
433
+ if ok is False:
434
+ logger.warning("Telegram refused setMyName (returned false); not retrying")
435
+ return RENAME_REFUSED.format(name=name)
436
+ return RENAMED.format(name=name)
437
+
438
+ async def _identity_preflight(self, chat_id: int, text: str, bot=None):
439
+ """Owner-only steps that run before a model turn: the first-run
440
+ naming flow and a yes/no answer to a persona consent question.
441
+ Returns a reply to send instead of a model turn, or None."""
442
+ import asyncio as _asyncio
443
+ from . import naming, persona
444
+ flow = self._naming.get(chat_id)
445
+ if flow is not None:
446
+ reply = await _asyncio.to_thread(flow.handle, text)
447
+ if flow.done:
448
+ self._naming.pop(chat_id, None)
449
+ self.solomon.reload_context()
450
+ note = await self._sync_display_name(bot, flow.name)
451
+ if note:
452
+ reply = f"{reply}\n\n{note}"
453
+ return reply
454
+ if not naming.is_named():
455
+ return await self._start_naming(chat_id)
456
+ pid = self._consent_wait.pop(chat_id, None)
457
+ if pid is not None:
458
+ kind = persona.answer_kind(text)
459
+ store = persona.PersonaStore()
460
+ if kind == "yes":
461
+ item = store.accept(pid)
462
+ return (f"Saved: {persona.describe(item)}. /persona shows or changes it."
463
+ if item else None)
464
+ if kind == "no":
465
+ item = store.decline(pid)
466
+ return "OK, I won't keep that." if item else None
467
+ return None
468
+
469
+ async def _persona_followup(self, update: Update, chat_id: int, text: str) -> None:
470
+ """After an owner turn: ask before keeping any inferred preference."""
471
+ from . import persona
472
+ try:
473
+ item = persona.observe(text)
474
+ except Exception:
475
+ item = None
476
+ if item:
477
+ self._consent_wait[chat_id] = item["id"]
478
+ await update.message.reply_text(persona.consent_question(item))
479
+
480
+ NAME_KEEP, NAME_NEW = "name:keep", "name:new"
481
+
482
+ async def name_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
483
+ """/name (Keep / Choose new), /name <new name>, /name suggest — owner only."""
484
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
485
+ return
486
+ chat_id = update.effective_chat.id
487
+ if scope_for("telegram", chat_id) != OWNER:
488
+ await update.message.reply_text(self.NOT_OWNER)
489
+ return
490
+ import asyncio as _asyncio
491
+ from . import naming
492
+ args = list(getattr(context, "args", None) or [])
493
+ if not args:
494
+ cur = naming.current_name()
495
+ if not cur:
496
+ await update.message.reply_text(await self._new_naming_flow(chat_id))
497
+ return
498
+ buttons = InlineKeyboardMarkup([[
499
+ InlineKeyboardButton("Keep", callback_data=self.NAME_KEEP),
500
+ InlineKeyboardButton("Choose new", callback_data=self.NAME_NEW),
501
+ ]])
502
+ await update.message.reply_text(naming.KEEP_OR_NEW.format(name=cur),
503
+ reply_markup=buttons)
504
+ return
505
+ call = (await _asyncio.to_thread(naming.default_model_call)
506
+ if args[0].lower() == "suggest" else None)
507
+ reply = await _asyncio.to_thread(naming.run_command, args, call)
508
+ self._naming.pop(chat_id, None)
509
+ self.solomon.reload_context()
510
+ if args[0].lower() not in ("suggest", "show"):
511
+ new = naming.current_name()
512
+ if new and reply == naming.CONFIRMED.format(name=new):
513
+ note = await self._sync_display_name(getattr(context, "bot", None), new)
514
+ if note:
515
+ reply = f"{reply}\n\n{note}"
516
+ await update.message.reply_text(reply)
517
+
518
+ async def _new_naming_flow(self, chat_id: int) -> str:
519
+ """Start the normal naming flow (no "before we get going" lead-in)."""
520
+ import asyncio as _asyncio
521
+ from . import naming
522
+ call = await _asyncio.to_thread(naming.default_model_call)
523
+ flow = naming.NamingFlow(model_call=call)
524
+ self._naming[chat_id] = flow
525
+ return flow.start()
526
+
527
+ async def handle_name_choice(self, update: Update,
528
+ context: ContextTypes.DEFAULT_TYPE) -> None:
529
+ """The Keep / Choose new buttons under /name — owner only."""
530
+ query = getattr(update, "callback_query", None)
531
+ if query is None:
532
+ return
533
+ try:
534
+ await query.answer()
535
+ except Exception:
536
+ pass
537
+ chat = update.effective_chat
538
+ if not chat or not self._is_allowed(chat.id):
539
+ return
540
+ chat_id = chat.id
541
+ if scope_for("telegram", chat_id) != OWNER:
542
+ await query.message.reply_text(self.NOT_OWNER)
543
+ return
544
+ try: # the question is answered: take the buttons away
545
+ await query.edit_message_reply_markup(reply_markup=None)
546
+ except Exception:
547
+ pass
548
+ from . import naming
549
+ cur = naming.current_name()
550
+ if query.data == self.NAME_KEEP and cur:
551
+ self._naming.pop(chat_id, None)
552
+ reply = naming.KEPT.format(name=cur)
553
+ note = await self._sync_display_name(getattr(context, "bot", None), cur)
554
+ if note:
555
+ reply = f"{reply}\n\n{note}"
556
+ elif query.data in (self.NAME_KEEP, self.NAME_NEW):
557
+ reply = await self._new_naming_flow(chat_id)
558
+ else:
559
+ return
560
+ await query.message.reply_text(reply)
561
+
562
+ async def persona_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
563
+ """/persona [show|set|unset|pending|accept|decline|reset] — owner only."""
564
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
565
+ return
566
+ chat_id = update.effective_chat.id
567
+ if scope_for("telegram", chat_id) != OWNER:
568
+ await update.message.reply_text(self.NOT_OWNER)
569
+ return
570
+ from . import persona
571
+ self._consent_wait.pop(chat_id, None)
572
+ await update.message.reply_text(
573
+ persona.run_command(list(getattr(context, "args", None) or [])))
574
+
575
+ NOT_OWNER_MODEL = "Only the owner of this assistant can see or change its model."
576
+
577
+ async def model_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
578
+ """/model [next|default|<number|id>] — owner only (docs/MODEL-SWITCHING.md).
579
+ History is kept; each turn rebuilds the prompt (truth rules first) and
580
+ thinking parameters for the serving model; replies stay text only."""
581
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
582
+ return
583
+ chat_id = update.effective_chat.id
584
+ if scope_for("telegram", chat_id) != OWNER:
585
+ await update.message.reply_text(self.NOT_OWNER_MODEL)
586
+ return
587
+ import asyncio as _asyncio
588
+ from . import model_catalog as mc
589
+ from .providers import registry
590
+ reg = registry()
591
+ cands = mc.prefer_selected(reg, mc.TELEGRAM, reg.resolve("primary", {"chat", "streaming"}),
592
+ {"chat", "streaming"})
593
+ old_key = f"{cands[0][0].name}/{cands[0][1]}" if cands else ""
594
+ args = list(getattr(context, "args", None) or [])
595
+ res = await _asyncio.to_thread(mc.run_command, args, mc.TELEGRAM, old_key, reg)
596
+ if res.switched or res.cleared:
597
+ new = mc.prefer_selected(reg, mc.TELEGRAM,
598
+ reg.resolve("primary", {"chat", "streaming"}),
599
+ {"chat", "streaming"})
600
+ new_key = f"{new[0][0].name}/{new[0][1]}" if new else ""
601
+ mc.record_switch(mc.TELEGRAM, old_key, new_key, "chat_only",
602
+ len(self.conversation_history.get(chat_id, [])))
603
+ await update.message.reply_text(res.text)
604
+
605
+ async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
606
+ """Handle /status command — show system status."""
607
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
608
+ return
609
+
610
+ chat_id = update.effective_chat.id
611
+ history_count = len(self.conversation_history.get(chat_id, []))
612
+
613
+ # Try to get ATLAS_TRADING status
614
+ trading_status = "offline"
615
+ try:
616
+ async with httpx.AsyncClient(timeout=2.0) as client:
617
+ resp = await client.get("http://localhost:5001/status")
618
+ if resp.status_code == 200:
619
+ data = resp.json()
620
+ trading_status = data.get("status", "unknown")
621
+ except Exception:
622
+ trading_status = "unreachable"
623
+
624
+ # Get system uptime
625
+ try:
626
+ import subprocess
627
+ result = subprocess.run(["uptime"], capture_output=True, text=True, timeout=5)
628
+ uptime_line = result.stdout.strip()
629
+ except Exception:
630
+ uptime_line = "unknown"
631
+
632
+ # Memory stats
633
+ try:
634
+ import psutil
635
+ mem = psutil.virtual_memory()
636
+ mem_stats = f"{mem.percent}% used ({mem.used // (1024**3)}GB / {mem.total // (1024**3)}GB)"
637
+ except Exception:
638
+ mem_stats = "unavailable"
639
+
640
+ status_text = (
641
+ f"⚡ **Status**\n"
642
+ f"Agent: online\n"
643
+ f"ATLAS_TRADING: {trading_status}\n"
644
+ f"Uptime: {uptime_line}\n"
645
+ f"Memory: {mem_stats}\n"
646
+ f"History: {history_count} messages"
647
+ )
648
+ await update.message.reply_text(status_text, parse_mode="Markdown")
649
+
650
+ NOT_OWNER_TEAM = "Only the owner of this assistant can see or change its team."
651
+
652
+ async def team_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
653
+ """/team [stop|pause|resume|on|off|rename|proposals] — owner only.
654
+ Short status lines: who's doing what and today's spend."""
655
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
656
+ return
657
+ if scope_for("telegram", update.effective_chat.id) != OWNER:
658
+ await update.message.reply_text(self.NOT_OWNER_TEAM)
659
+ return
660
+ import asyncio as _asyncio
661
+ from hyperclaw.team import status as team_status
662
+ arg = " ".join(list(getattr(context, "args", None) or []))
663
+ text = await _asyncio.to_thread(team_status.command, arg, short=True)
664
+ await update.message.reply_text(text, link_preview_options=NO_PREVIEW)
665
+
666
+ async def clear_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
667
+ """Handle /clear command — clear conversation history."""
668
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
669
+ return
670
+
671
+ chat_id = update.effective_chat.id
672
+ self.conversation_history[chat_id] = []
673
+ self._db_loaded.discard(chat_id)
674
+ taint.clear(taint_session(chat_id)) # the history (and its connector content) is gone
675
+ await _save_history(TENANT_ID, chat_id, [])
676
+ await update.message.reply_text("Conversation history cleared.")
677
+
678
+ MAX_FILE_BYTES = 20 * 1024 * 1024 # Telegram bot API download cap
679
+
680
+ async def handle_file(self, update, context) -> None:
681
+ """Receive a document or photo and stage it for the next model turn.
682
+
683
+ hyperclaw.attachments decides how it reaches the model: native
684
+ document/image blocks only when a live provider reads them; else
685
+ PDFs are extracted to text locally and text files are inlined, all
686
+ framed as data (never instructions). A photo on a text-only model is
687
+ answered honestly here and never sent to the model."""
688
+ chat_id = update.effective_chat.id
689
+ if not self._is_allowed(chat_id):
690
+ return
691
+ from .attachments import intake
692
+
693
+ msg = update.message
694
+ try:
695
+ with taint.conversation(taint_session(chat_id)):
696
+ if msg.photo:
697
+ tg_file = await msg.photo[-1].get_file()
698
+ raw = bytes(await tg_file.download_as_bytearray())
699
+ got = intake(raw, photo=True)
700
+ else:
701
+ doc = msg.document
702
+ if doc.file_size and doc.file_size > self.MAX_FILE_BYTES:
703
+ await msg.reply_text(f"That file is too large for me to pull down "
704
+ f"({doc.file_size // (1024*1024)} MB - limit 20 MB).")
705
+ return
706
+ tg_file = await doc.get_file()
707
+ raw = bytes(await tg_file.download_as_bytearray())
708
+ got = intake(raw, name=doc.file_name or "file", mime=doc.mime_type or "")
709
+ except Exception as e:
710
+ logger.error(f"[{chat_id}] file intake failed: {type(e).__name__}", exc_info=True)
711
+ await msg.reply_text(failure_notice("read_file", detail="Please try sending it again.", retry=False))
712
+ return
713
+
714
+ if got.reply:
715
+ # e.g. a photo with no image-capable model: answer honestly, stage
716
+ # nothing, call no model (no invented description).
717
+ logger.info(f"[{chat_id}] attachment not sent to a model: {got.label}")
718
+ await msg.reply_text(got.reply, **NO_PREVIEW_KW)
719
+ return
720
+ self.pending_attachments[chat_id].extend(got.blocks)
721
+ logger.info(f"[{chat_id}] staged attachment: {got.label}")
722
+
723
+ caption = (msg.caption or "").strip()
724
+ if caption:
725
+ # A caption is a question about the file - answer it immediately.
726
+ # PTB Message objects are frozen; bypass to inject the caption as text.
727
+ object.__setattr__(update.message, "text", caption)
728
+ await self.handle_message(update, context)
729
+ else:
730
+ ack = f"Got it - {got.label} received."
731
+ if got.note:
732
+ ack += f" Heads-up: {got.note}"
733
+ await msg.reply_text(ack + " Ask me anything about it.")
734
+
735
+ async def _finalize_markdown(self, placeholder, text: str) -> None:
736
+ """Re-render the finished answer with Markdown; keep the plain-text
737
+ version if Telegram rejects the entities (unbalanced markers)."""
738
+ try:
739
+ await placeholder.edit_text(text, parse_mode="Markdown")
740
+ except Exception:
741
+ pass
742
+
743
+ async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
744
+ """Handle incoming text messages — stream response from SOLOMON."""
745
+ if not update.effective_chat or not self._is_allowed(update.effective_chat.id):
746
+ logger.warning(f"Blocked message from unauthorized chat: {update.effective_chat.id}")
747
+ return
748
+
749
+ if not update.message or not update.message.text:
750
+ return
751
+
752
+ chat_id = update.effective_chat.id
753
+ user_message = update.message.text.strip()
754
+
755
+ if not user_message:
756
+ return
757
+
758
+ logger.info(f"[{chat_id}] User: {user_message[:50]}...")
759
+
760
+ # Owner-only identity steps (naming first run, persona consent answers)
761
+ is_owner = scope_for("telegram", chat_id) == OWNER
762
+ if not is_owner or _forwarded(update.message):
763
+ # Someone else's words (a guest chat, or a forward): connector
764
+ # content for the taint guard, never memory or training data.
765
+ user_message = taint.mark(user_message, "telegram", "telegram",
766
+ session=taint_session(chat_id))
767
+ if is_owner and await self.approvals.on_owner_text(update, context):
768
+ return # the owner's new content after pressing Edit on an approval
769
+ if is_owner:
770
+ pre = await self._identity_preflight(chat_id, user_message,
771
+ bot=getattr(context, "bot", None))
772
+ if pre is not None:
773
+ await update.message.reply_text(pre)
774
+ return
775
+
776
+ # Lazy-load history from DB on first message this session
777
+ if chat_id not in self._db_loaded:
778
+ db_history = await _load_history(TENANT_ID, chat_id)
779
+ if db_history:
780
+ self.conversation_history[chat_id] = db_history
781
+ self._db_loaded.add(chat_id)
782
+
783
+ history = self.conversation_history[chat_id]
784
+
785
+ # Telegram expires the typing indicator after ~5s — keep it alive for
786
+ # the whole turn so the user always sees the bot working.
787
+ import asyncio as _asyncio
788
+
789
+ typing_alive = True
790
+
791
+ # First indicator immediately — the refresh task only keeps it alive.
792
+ try:
793
+ await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
794
+ except Exception:
795
+ pass
796
+
797
+ async def _keep_typing():
798
+ while typing_alive:
799
+ await _asyncio.sleep(4)
800
+ if not typing_alive:
801
+ break
802
+ try:
803
+ await context.bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING)
804
+ except Exception:
805
+ pass
806
+
807
+ typing_task = _asyncio.create_task(_keep_typing())
808
+
809
+ try:
810
+ # Placeholder — the user sees activity from the first moment
811
+ placeholder = await update.message.reply_text("💭 thinking…")
812
+
813
+ buffer = ""
814
+ last_edit = ""
815
+ full_response = []
816
+ thinking_buf = []
817
+ answer_started = False
818
+ EDIT_THRESHOLD = 60 # chars accumulated before pushing an edit
819
+ edit_count = 0
820
+ MAX_EDITS = 60 # stay well under Telegram rate limits (~20 edits/msg practical limit)
821
+
822
+ provider_error = False
823
+ attachments = self.pending_attachments.pop(chat_id, [])
824
+ show_thinking = _show_thinking()
825
+ # Guest fence (spec §6): only the configured owner chat gets owner
826
+ # memory/persona; every other paired chat is a guest. Unknown
827
+ # owner -> guest (fail closed).
828
+ scope = scope_for("telegram", chat_id)
829
+ async for kind, chunk in self.solomon.stream_events(
830
+ user_message, history, attachments=attachments, scope=scope):
831
+ if kind == "notice":
832
+ # Automatic fallback: one line naming the model now
833
+ # answering and why (model_policy). Never saved as the answer.
834
+ try:
835
+ await update.message.reply_text(chunk)
836
+ except Exception:
837
+ logger.warning(f"[{chat_id}] fallback notice not delivered")
838
+ continue
839
+ if kind == "thinking":
840
+ if not show_thinking or answer_started:
841
+ continue
842
+ thinking_buf.append(chunk)
843
+ buffer += chunk
844
+ # Update the italic thinking preview at the same cadence
845
+ if len(buffer) >= EDIT_THRESHOLD and edit_count < MAX_EDITS:
846
+ preview = "".join(thinking_buf)[-800:]
847
+ text = f"💭 _{preview}_"
848
+ if text != last_edit:
849
+ try:
850
+ await placeholder.edit_text(text, parse_mode="Markdown")
851
+ last_edit = text
852
+ edit_count += 1
853
+ buffer = ""
854
+ except Exception:
855
+ pass
856
+ continue
857
+
858
+ # kind == "text" — the answer itself
859
+ if is_internal_error_chunk(chunk):
860
+ # Provider failure text: log it, never show it in the chat.
861
+ logger.error(f"[{chat_id}] provider error during reply: {chunk.strip()[:300]}")
862
+ provider_error = True
863
+ continue
864
+ if not answer_started:
865
+ answer_started = True
866
+ buffer = ""
867
+ buffer += chunk
868
+ full_response.append(chunk)
869
+
870
+ # Push update when buffer is large enough or chunk ends a sentence
871
+ should_update = (
872
+ len(buffer) >= EDIT_THRESHOLD
873
+ or (len(buffer) >= 20 and chunk.endswith((".", "!", "?", "\n")))
874
+ )
875
+
876
+ if should_update and edit_count < MAX_EDITS:
877
+ current_text = "".join(full_response)
878
+ if current_text != last_edit and len(current_text) <= 4096:
879
+ try:
880
+ await placeholder.edit_text(current_text)
881
+ last_edit = current_text
882
+ edit_count += 1
883
+ buffer = ""
884
+ except Exception:
885
+ pass # rate limit or no-change — keep accumulating
886
+
887
+ # Final edit with complete response
888
+ response, stripped = strip_internal_errors("".join(full_response))
889
+ provider_error = provider_error or stripped
890
+ if not response and provider_error:
891
+ try:
892
+ await placeholder.edit_text(CHAT_ERROR_NOTICE)
893
+ except Exception:
894
+ await update.message.reply_text(CHAT_ERROR_NOTICE)
895
+ return
896
+ if provider_error:
897
+ # Partial answer, then the stream died: say so honestly.
898
+ response = f"{response}\n\n{CHAT_CUTOFF_NOTICE}"
899
+ if not response:
900
+ # Never send or save an empty reply (2026-10-07 V2 live test).
901
+ logger.warning(f"[{chat_id}] model returned no answer text "
902
+ f"(served by {_served_by() or 'unknown'}); sent fallback")
903
+ try:
904
+ await placeholder.edit_text(EMPTY_REPLY_FALLBACK)
905
+ except Exception:
906
+ await update.message.reply_text(EMPTY_REPLY_FALLBACK)
907
+ if is_owner:
908
+ await self._persona_followup(update, chat_id, user_message)
909
+ return
910
+ # This path has no tools — any described action did not happen
911
+ fabricated = detect_fabricated_tool_use(response, set(), tools_ran=False)
912
+
913
+ if len(response) <= 4096:
914
+ # Push the complete text, then re-render with Markdown so
915
+ # **bold** and lists display properly instead of raw markers.
916
+ if response != last_edit:
917
+ try:
918
+ await placeholder.edit_text(response)
919
+ except Exception:
920
+ pass
921
+ await self._finalize_markdown(placeholder, response)
922
+ else:
923
+ # Response too long — delete placeholder, send in chunks
924
+ try:
925
+ await placeholder.delete()
926
+ except Exception:
927
+ pass
928
+ for i in range(0, len(response), 4096):
929
+ await update.message.reply_text(response[i:i + 4096])
930
+
931
+ if fabricated:
932
+ logger.warning(f"[{chat_id}] fabricated action flagged: {fabricated}")
933
+ await update.message.reply_text(FABRICATION_NOTICE)
934
+ response += ("\n\n[HyperClaw system note: no tool executed during this "
935
+ "reply; the actions described above did not happen.]")
936
+
937
+ # Update history (secrets never persist)
938
+ history.append({"role": "user", "content": redact_secrets(user_message)})
939
+ history.append({"role": "assistant", "content": redact_secrets(response)})
940
+
941
+ # Trim to max history
942
+ if len(history) > MAX_HISTORY * 2:
943
+ self.conversation_history[chat_id] = history[-(MAX_HISTORY * 2):]
944
+
945
+ await _save_history(TENANT_ID, chat_id, self.conversation_history[chat_id])
946
+ # One line, secrets redacted (the answer's first chars, repr-quoted).
947
+ logger.info(f"[{chat_id}] SOLOMON streamed {len(response)} chars: "
948
+ f"{redact_secrets(response[:50])!r}")
949
+ if is_owner:
950
+ await self._persona_followup(update, chat_id, user_message)
951
+
952
+ except Exception as e:
953
+ # Internal error text is logged, never sent to the chat.
954
+ logger.error(f"[{chat_id}] Error: {e}", exc_info=True)
955
+ try:
956
+ await update.message.reply_text(CHAT_ERROR_NOTICE)
957
+ except Exception:
958
+ logger.error(f"[{chat_id}] could not send the failure notice (reply not delivered)", exc_info=True)
959
+ finally:
960
+ typing_alive = False
961
+ typing_task.cancel()
962
+
963
+ async def send_message(self, chat_id: int, text: str) -> bool:
964
+ """Send a message to a specific chat (for scheduler alerts)."""
965
+ if not self.application:
966
+ logger.error("Application not initialized")
967
+ return False
968
+
969
+ try:
970
+ # Handle Telegram's 4096 char limit
971
+ if len(text) <= 4096:
972
+ await self.application.bot.send_message(chat_id=chat_id, text=text,
973
+ link_preview_options=NO_PREVIEW)
974
+ else:
975
+ for i in range(0, len(text), 4096):
976
+ await self.application.bot.send_message(chat_id=chat_id, text=text[i:i + 4096],
977
+ link_preview_options=NO_PREVIEW)
978
+ return True
979
+ except Exception as e:
980
+ logger.error(f"Failed to send message to {chat_id}: {e}")
981
+ return False
982
+
983
+ async def _post_init(self, application: Application) -> None:
984
+ """Approval layer: an action interrupted mid-run is marked uncertain
985
+ (never re-run); queued cards from any surface reach the owner chat."""
986
+ import asyncio as _asyncio
987
+ from .approval_telegram import pending_loop
988
+ from .tools.approval import get_queue
989
+ try:
990
+ interrupted = get_queue().recover_interrupted()
991
+ if interrupted:
992
+ logger.warning(f"{len(interrupted)} approved action(s) were interrupted while "
993
+ f"running; marked uncertain, not re-run")
994
+ except Exception:
995
+ logger.warning("approval queue unavailable at startup", exc_info=True)
996
+ self._approval_task = _asyncio.get_running_loop().create_task(
997
+ pending_loop(application.bot))
998
+
999
+ def build(self, token: str = "", journal=None) -> Application:
1000
+ """Build and return the Telegram application. `journal`: a
1001
+ telegram_intake.TelegramIntake that every update passes through."""
1002
+ # Link previews off for EVERY message this bot sends or edits (a
1003
+ # preview fetch is an outbound request to whatever URL is in the text).
1004
+ self.application = (Application.builder().token(token or BOT_TOKEN)
1005
+ .application_class(JournaledApplication)
1006
+ .defaults(Defaults(link_preview_options=NO_PREVIEW))
1007
+ .post_init(self._post_init).build())
1008
+ self.application.hc_journal = journal
1009
+ self.application.add_handler(CallbackQueryHandler(self.approvals.on_callback,
1010
+ pattern=r"^ap:"))
1011
+
1012
+ # Unknown chats stop here (pairing request), before any other handler.
1013
+ self.application.add_handler(TypeHandler(Update, self.handle_gate), group=-1)
1014
+ # Register handlers
1015
+ self.application.add_handler(CommandHandler("pair", self.pair_command))
1016
+ self.application.add_handler(CommandHandler("unpair", self.unpair_command))
1017
+ self.application.add_handler(CommandHandler("start", self.start_command))
1018
+ self.application.add_handler(CommandHandler("status", self.status_command))
1019
+ self.application.add_handler(CommandHandler("clear", self.clear_command))
1020
+ self.application.add_handler(CommandHandler("name", self.name_command))
1021
+ self.application.add_handler(CommandHandler("persona", self.persona_command))
1022
+ self.application.add_handler(CommandHandler("model", self.model_command))
1023
+ self.application.add_handler(CommandHandler("team", self.team_command))
1024
+ self.application.add_handler(
1025
+ CallbackQueryHandler(self.handle_name_choice, pattern=r"^name:(keep|new)$"))
1026
+ self.application.add_handler(
1027
+ MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message)
1028
+ )
1029
+ self.application.add_handler(
1030
+ MessageHandler(filters.Document.ALL | filters.PHOTO, self.handle_file)
1031
+ )
1032
+
1033
+ return self.application
1034
+
1035
+
1036
+ # Singleton instance
1037
+ _bot: Optional[TelegramBot] = None
1038
+
1039
+
1040
+ def get_telegram_bot() -> TelegramBot:
1041
+ """Get or create the Telegram bot singleton."""
1042
+ global _bot
1043
+ if _bot is None:
1044
+ _bot = TelegramBot()
1045
+ return _bot
1046
+
1047
+
1048
+ def main() -> None:
1049
+ """Run the HyperClaw Telegram bot (entry point: hyperclaw-telegram)."""
1050
+ logging.basicConfig(level=logging.INFO,
1051
+ format="%(asctime)s %(name)s %(levelname)s %(message)s")
1052
+ for noisy in ("httpx", "httpcore", "telegram.ext"):
1053
+ logging.getLogger(noisy).setLevel(logging.WARNING)
1054
+
1055
+ # Telegram polling is unattended work: HYPERCLAW_UNATTENDED=1 only.
1056
+ # Off -> one log line and exit 0 (launchd KeepAlive SuccessfulExit=false
1057
+ # does not restart a clean exit).
1058
+ from hyperclaw import unattended
1059
+ unattended.exit_if_off(logger, "hyperclaw-telegram")
1060
+ from hyperclaw.updates import on_startup
1061
+ on_startup("telegram")
1062
+
1063
+ # One Telegram connection per bot: the team service holds the same lock.
1064
+ from hyperclaw.teamsetup.service import ANOTHER_POLLER, TelegramLock
1065
+ _lock = TelegramLock()
1066
+ if not _lock.acquire():
1067
+ logger.warning(ANOTHER_POLLER)
1068
+ raise SystemExit(0)
1069
+
1070
+ if not BOT_TOKEN:
1071
+ raise SystemExit("TELEGRAM_BOT_TOKEN is not set. Add it to ~/.hyperclaw/.env "
1072
+ "(get a token from @BotFather with /newbot).")
1073
+ if not ALLOWED_CHAT_IDS:
1074
+ raise SystemExit("No owner chat is paired yet. Run `hyperclaw telegram pair` and send "
1075
+ "your bot the code it shows (or set TELEGRAM_ALLOWED_CHAT_IDS in "
1076
+ "~/.hyperclaw/.env) - the bot denies all messages otherwise.")
1077
+
1078
+ # Drain any stale getUpdates connection before starting the polling loop.
1079
+ # Without this, Telegram returns a Conflict error when a previous process
1080
+ # didn't shut down cleanly (the long-poll stays open on Telegram's side for ~30s).
1081
+ async def _drain():
1082
+ import asyncio as _asyncio
1083
+ from telegram import Bot
1084
+ async with Bot(token=BOT_TOKEN) as _bot:
1085
+ await _bot.get_updates(offset=-1, timeout=0)
1086
+ import asyncio as _asyncio
1087
+ _asyncio.run(_drain())
1088
+
1089
+ bot = get_telegram_bot()
1090
+ app = bot.build()
1091
+ if isinstance(app, JournaledApplication):
1092
+ # Durable intake journal (HYPERCLAW_ROOT/data, 0600): no update runs twice.
1093
+ from .telegram_intake import TelegramIntake
1094
+ app.hc_journal = TelegramIntake()
1095
+ from hyperclaw.providers import registry
1096
+ logger.info(registry().startup_line())
1097
+ if not owner_ids("telegram"):
1098
+ logger.warning("No owner chat configured (TELEGRAM_OWNER_CHAT_ID / TELEGRAM_CHAT_ID): "
1099
+ "every paired chat is treated as a GUEST and gets no owner memory.")
1100
+ logger.info(f"HyperClaw Telegram bot starting — {len(ALLOWED_CHAT_IDS)} allowed chat(s), "
1101
+ f"reasoning preview {'on (owner opt-in)' if _show_thinking() else 'off'}")
1102
+ app.run_polling(drop_pending_updates=False)
1103
+
1104
+
1105
+ if __name__ == "__main__":
1106
+ main()