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,126 @@
1
+ """Durable Telegram intake journal: each update is claimed once, then finished.
2
+
3
+ Why: python-telegram-bot confirms an update to Telegram (by asking for the
4
+ next offset) before our handlers finish with it. If the bot stops mid-turn
5
+ and Telegram delivers the same update again after a restart, the journal
6
+ already holds that update_id, so it is not run a second time. The journal
7
+ never calls getUpdates itself (python-telegram-bot owns the offset), so
8
+ nothing is acknowledged twice.
9
+
10
+ Rules:
11
+ - claim(update) -> True only the first time an update_id is seen AND the
12
+ update is newer than the journal's cutover (the moment the journal was
13
+ first created). Older updates are recorded as ``held_cutover`` and never
14
+ run: the bot cannot know what a previous, journal-less process already did.
15
+ - finish(update_id, "processed" | "uncertain"). A row left in
16
+ ``processing`` after a crash stays there; it is never replayed.
17
+ - Only routing metadata is stored (update id, kind, chat id, date, status),
18
+ never message text: guest chats are not written to disk.
19
+ - The file lives under HYPERCLAW_ROOT/data (dir 0700, file 0600).
20
+
21
+ Adapted from an earlier HyperClaw reference implementation; the stored
22
+ payload, the self-managed polling offset and the unbounded growth were
23
+ removed (see the MR's lifted-vs-stripped table).
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import sqlite3
29
+ import time
30
+ from pathlib import Path
31
+ from typing import Optional
32
+
33
+ STATUSES = ("processing", "processed", "uncertain", "held_cutover")
34
+ KEEP_DAYS = 30
35
+ _KINDS = ("message", "edited_message", "callback_query", "channel_post",
36
+ "edited_channel_post", "my_chat_member", "chat_member")
37
+
38
+
39
+ def default_path() -> Path:
40
+ from hyperclaw.userconfig import root
41
+ return root() / "data" / "telegram_intake.sqlite"
42
+
43
+
44
+ def _kind(update: dict) -> str:
45
+ return next((k for k in _KINDS if k in update), "other")
46
+
47
+
48
+ def _chat_and_date(update: dict):
49
+ for key in ("message", "edited_message", "channel_post", "edited_channel_post"):
50
+ msg = update.get(key)
51
+ if isinstance(msg, dict):
52
+ chat = (msg.get("chat") or {}).get("id")
53
+ date = msg.get("edit_date") if key.startswith("edited") else None
54
+ return chat, date or msg.get("date")
55
+ cq = update.get("callback_query")
56
+ if isinstance(cq, dict):
57
+ return ((cq.get("message") or {}).get("chat") or {}).get("id"), None
58
+ return None, None
59
+
60
+
61
+ class TelegramIntake:
62
+ def __init__(self, path=None, clock=time.time):
63
+ self.path = Path(path) if path else default_path()
64
+ self.path.parent.mkdir(parents=True, exist_ok=True)
65
+ try:
66
+ os.chmod(self.path.parent, 0o700)
67
+ except OSError:
68
+ pass
69
+ if not self.path.exists():
70
+ os.close(os.open(str(self.path), os.O_CREAT | os.O_WRONLY, 0o600))
71
+ os.chmod(self.path, 0o600)
72
+ self.clock = clock
73
+ self.db = sqlite3.connect(str(self.path), timeout=10, check_same_thread=False)
74
+ with self.db:
75
+ self.db.execute("CREATE TABLE IF NOT EXISTS metadata "
76
+ "(key TEXT PRIMARY KEY, value REAL NOT NULL)")
77
+ self.db.execute("CREATE TABLE IF NOT EXISTS updates (id INTEGER PRIMARY KEY, "
78
+ "status TEXT NOT NULL, updated REAL NOT NULL, kind TEXT NOT NULL, "
79
+ "chat_id INTEGER, date REAL)")
80
+ self.db.execute("INSERT OR IGNORE INTO metadata VALUES ('cutover', ?)", (clock(),))
81
+ self.cutover = self.db.execute(
82
+ "SELECT value FROM metadata WHERE key = 'cutover'").fetchone()[0]
83
+ self.prune()
84
+
85
+ def claim(self, update: dict) -> bool:
86
+ """True: run the handlers for this update now. False: it was seen
87
+ before (or predates the cutover) and must not run again."""
88
+ update_id = update.get("update_id")
89
+ if not isinstance(update_id, int) or isinstance(update_id, bool) or update_id < 0:
90
+ raise ValueError("invalid Telegram update id; not run")
91
+ chat_id, date = _chat_and_date(update)
92
+ # An update with no timestamp (e.g. a button press) cannot predate the
93
+ # cutover in a way we can check; the update_id claim still dedupes it.
94
+ recent = date is None or (isinstance(date, (int, float)) and date >= int(self.cutover))
95
+ status = "processing" if recent else "held_cutover"
96
+ with self.db:
97
+ cur = self.db.execute("INSERT OR IGNORE INTO updates VALUES (?, ?, ?, ?, ?, ?)",
98
+ (update_id, status, self.clock(), _kind(update),
99
+ chat_id if isinstance(chat_id, int) else None,
100
+ date if isinstance(date, (int, float)) else None))
101
+ return cur.rowcount == 1 and recent
102
+
103
+ def finish(self, update_id: int, status: str) -> None:
104
+ if status not in ("processed", "uncertain"):
105
+ raise ValueError("invalid intake outcome")
106
+ with self.db:
107
+ self.db.execute("UPDATE updates SET status = ?, updated = ? "
108
+ "WHERE id = ? AND status = 'processing'",
109
+ (status, self.clock(), update_id))
110
+
111
+ def status(self, update_id: int) -> Optional[str]:
112
+ row = self.db.execute("SELECT status FROM updates WHERE id = ?", (update_id,)).fetchone()
113
+ return row[0] if row else None
114
+
115
+ def counts(self) -> dict:
116
+ return dict(self.db.execute("SELECT status, COUNT(*) FROM updates GROUP BY status"))
117
+
118
+ def prune(self, keep_days: int = KEEP_DAYS) -> None:
119
+ """Drop finished rows older than keep_days. Telegram never redelivers
120
+ an update that old, so its claim is no longer needed."""
121
+ with self.db:
122
+ self.db.execute("DELETE FROM updates WHERE status != 'processing' AND updated < ?",
123
+ (self.clock() - keep_days * 86400,))
124
+
125
+ def close(self) -> None:
126
+ self.db.close()
@@ -0,0 +1,360 @@
1
+ """Telegram pairing: who may talk to the bot, and who is the owner.
2
+
3
+ Two flows:
4
+
5
+ 1. Owner auto-detect (onboarding, before the bot runs). After the owner
6
+ enters their bot token, onboarding shows a one-time code and asks them to
7
+ send it to their bot. The first direct chat that sends exactly that code
8
+ within 10 minutes becomes TELEGRAM_OWNER_CHAT_ID (written with
9
+ userconfig.env_update, file 0600). A wrong or expired code pairs nothing.
10
+ No lookup bot or manual chat-id step.
11
+
12
+ 2. Guest pairing (while the bot runs). A message from an unknown direct chat
13
+ is never read: the chat gets a one-time code that expires in 10 minutes,
14
+ and the owner chat is asked to approve it with ``/pair <code>``. An
15
+ approved chat is a GUEST (hyperclaw.chat_scope): pairing never grants owner
16
+ access, owner memory or owner tools. The pairing file is 0600.
17
+
18
+ The bot token is never printed, logged or put in an error message.
19
+
20
+ Adapted from an earlier HyperClaw reference implementation; codes now
21
+ expire, the file is 0600, paired chats are guests, and only the owner chat
22
+ can approve (see the MR's lifted-vs-stripped table).
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import os
29
+ import secrets
30
+ import time
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Callable, Optional
34
+
35
+ log = logging.getLogger("hyperclaw.telegram.pairing")
36
+
37
+ CODE_TTL = 600 # seconds a pairing code stays valid (10 minutes)
38
+ MAX_PENDING = 20 # cap on open guest requests (spam guard)
39
+ _ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no O/0, I/1
40
+ API = "https://api.telegram.org"
41
+
42
+
43
+ def new_code() -> str:
44
+ """A one-time code like 'K7QM-3XRA' (40 bits, unambiguous characters)."""
45
+ raw = "".join(secrets.choice(_ALPHABET) for _ in range(8))
46
+ return f"{raw[:4]}-{raw[4:]}"
47
+
48
+
49
+ def code_matches(text, code: str) -> bool:
50
+ """Exact match only (surrounding whitespace ignored)."""
51
+ return isinstance(text, str) and secrets.compare_digest(text.strip().encode("utf-8"),
52
+ code.encode("utf-8"))
53
+
54
+
55
+ # ───────────────────────── 1. owner auto-detect ─────────────────────────
56
+
57
+ @dataclass
58
+ class OwnerPairing:
59
+ chat_id: Optional[int]
60
+ reason: str # "paired" | "expired" | "rejected_token" | "conflict" | "network"
61
+
62
+
63
+ class _TokenSafeError(Exception):
64
+ """An error whose text never contains the token."""
65
+
66
+
67
+ def _call(client, token: str, method: str, params: dict, timeout: float) -> dict:
68
+ try:
69
+ resp = client.post(f"{API}/bot{token}/{method}", json=params, timeout=timeout)
70
+ except Exception as e: # httpx errors carry the URL (and so the token): drop the text
71
+ raise _TokenSafeError(f"network error ({type(e).__name__})") from None
72
+ try:
73
+ data = resp.json()
74
+ except Exception:
75
+ data = {}
76
+ if resp.status_code == 401 or resp.status_code == 404:
77
+ raise _TokenSafeError("rejected_token")
78
+ if resp.status_code == 409:
79
+ raise _TokenSafeError("conflict")
80
+ if not data.get("ok"):
81
+ raise _TokenSafeError(f"telegram error ({resp.status_code})")
82
+ return data
83
+
84
+
85
+ def bot_username(token: str, client=None) -> Optional[str]:
86
+ """getMe -> the bot's @username, or None if Telegram rejects the token."""
87
+ client = client or _client()
88
+ try:
89
+ data = _call(client, token, "getMe", {}, timeout=15)
90
+ except _TokenSafeError:
91
+ return None
92
+ return (data.get("result") or {}).get("username") or None
93
+
94
+
95
+ def _client():
96
+ import httpx
97
+ # httpx logs request URLs at INFO, and the URL contains the bot token.
98
+ logging.getLogger("httpx").setLevel(logging.WARNING)
99
+ return httpx.Client()
100
+
101
+
102
+ def wait_for_owner_code(token: str, code: str, *, issued_at: Optional[float] = None,
103
+ ttl: float = CODE_TTL, client=None, clock: Callable[[], float] = time.time,
104
+ poll_seconds: int = 20) -> OwnerPairing:
105
+ """Long-poll getUpdates until a direct chat sends exactly `code`.
106
+
107
+ Only messages sent after the code was issued count; anything already
108
+ waiting on the bot is skipped. Returns OwnerPairing(chat_id, "paired") or
109
+ OwnerPairing(None, reason). Must run while the bot itself is not polling
110
+ (Telegram allows one getUpdates consumer per bot)."""
111
+ client = client or _client()
112
+ issued_at = clock() if issued_at is None else issued_at
113
+ deadline = issued_at + ttl
114
+ try:
115
+ # Skip whatever is already queued: start after the newest update.
116
+ first = _call(client, token, "getUpdates", {"offset": -1, "timeout": 0}, timeout=15)
117
+ pending = first.get("result") or []
118
+ offset = (pending[-1]["update_id"] + 1) if pending else None
119
+ while clock() < deadline:
120
+ wait = max(0, min(poll_seconds, int(deadline - clock())))
121
+ params = {"timeout": wait, "allowed_updates": ["message"]}
122
+ if offset is not None:
123
+ params["offset"] = offset
124
+ data = _call(client, token, "getUpdates", params, timeout=wait + 10)
125
+ for upd in data.get("result") or []:
126
+ offset = upd["update_id"] + 1
127
+ msg = upd.get("message") or {}
128
+ chat = msg.get("chat") or {}
129
+ if chat.get("type") != "private":
130
+ continue # the owner chat is a direct chat with the bot
131
+ if (msg.get("date") or 0) < int(issued_at):
132
+ continue # sent before this code existed
133
+ if clock() >= deadline:
134
+ break
135
+ if code_matches(msg.get("text"), code) and isinstance(chat.get("id"), int):
136
+ # Confirm the updates read so far so the bot never sees the code.
137
+ _call(client, token, "getUpdates", {"offset": offset, "timeout": 0}, timeout=15)
138
+ return OwnerPairing(chat["id"], "paired")
139
+ if wait == 0:
140
+ break
141
+ except _TokenSafeError as e:
142
+ reason = str(e)
143
+ if reason not in ("rejected_token", "conflict"):
144
+ reason = "network"
145
+ return OwnerPairing(None, reason)
146
+ return OwnerPairing(None, "expired")
147
+
148
+
149
+ def save_owner_chat(chat_id: int) -> None:
150
+ """Persist the owner chat (0600 .env, applied to this process too).
151
+ TELEGRAM_CHAT_ID is kept equal so tools that send to the owner use it."""
152
+ from hyperclaw.userconfig import env_update
153
+ env_update({"TELEGRAM_OWNER_CHAT_ID": str(int(chat_id)),
154
+ "TELEGRAM_CHAT_ID": str(int(chat_id))})
155
+
156
+
157
+ PAIRED_OWNER_TEXT = ("Paired. This chat is now the owner chat for your HyperAgents assistant. "
158
+ "Other chats that message the bot need your approval here first, and "
159
+ "they never get access to your memory, files or accounts.")
160
+
161
+
162
+ def confirm_owner_chat(token: str, chat_id: int, client=None) -> bool:
163
+ client = client or _client()
164
+ try:
165
+ _call(client, token, "sendMessage",
166
+ {"chat_id": chat_id, "text": PAIRED_OWNER_TEXT,
167
+ "link_preview_options": {"is_disabled": True}}, timeout=15)
168
+ return True
169
+ except _TokenSafeError:
170
+ return False
171
+
172
+
173
+ REASON_TEXT = {
174
+ "expired": "No chat sent the code within 10 minutes, so nothing was paired.",
175
+ "rejected_token": "Telegram rejected the bot token, so nothing was paired.",
176
+ "conflict": ("Another program is already reading this bot's messages (for example a "
177
+ "running HyperAgents Telegram service). Pair while it is stopped."),
178
+ "network": "Couldn't reach Telegram, so nothing was paired.",
179
+ }
180
+
181
+
182
+ def run_owner_pairing(token: str, say: Callable[[str], None], *, client=None,
183
+ clock: Callable[[], float] = time.time, ttl: float = CODE_TTL) -> Optional[int]:
184
+ """The whole owner step: check the token, show a code, wait, save.
185
+ `say` prints one line to the owner (never the token)."""
186
+ client = client or _client()
187
+ username = bot_username(token, client=client)
188
+ if not username:
189
+ say(REASON_TEXT["rejected_token"])
190
+ return None
191
+ code = new_code()
192
+ issued = clock()
193
+ say(f"Open Telegram, start a chat with @{username} and send it this code: {code}")
194
+ say(f"The code works once and expires in {int(ttl // 60)} minutes. Waiting...")
195
+ got = wait_for_owner_code(token, code, issued_at=issued, ttl=ttl, client=client, clock=clock)
196
+ if got.chat_id is None:
197
+ say(REASON_TEXT.get(got.reason, REASON_TEXT["network"]))
198
+ return None
199
+ save_owner_chat(got.chat_id)
200
+ confirm_owner_chat(token, got.chat_id, client=client)
201
+ say(f"Paired: chat {got.chat_id} is now your owner chat.")
202
+ return got.chat_id
203
+
204
+
205
+ # ───────────────────────── 2. guest pairing ─────────────────────────
206
+
207
+ def default_store_path() -> Path:
208
+ from hyperclaw.userconfig import root
209
+ return root() / "data" / "telegram_pairing.json"
210
+
211
+
212
+ class PairingStore:
213
+ """Approved guest chats and open pairing requests, in one 0600 JSON file.
214
+
215
+ {"approved": {"<chat_id>": {"who": str, "ts": float}},
216
+ "pending": {"<CODE>": {"chat_id": int, "who": str, "ts": float}}}
217
+ """
218
+
219
+ def __init__(self, path=None, clock: Optional[Callable[[], float]] = None,
220
+ ttl: float = CODE_TTL):
221
+ self.path = Path(path) if path else default_store_path()
222
+ self.clock = clock or (lambda: time.time())
223
+ self.ttl = ttl
224
+
225
+ def _load(self) -> dict:
226
+ try:
227
+ data = json.loads(self.path.read_text(encoding="utf-8"))
228
+ except Exception:
229
+ data = {}
230
+ approved = data.get("approved") if isinstance(data.get("approved"), dict) else {}
231
+ pending = data.get("pending") if isinstance(data.get("pending"), dict) else {}
232
+ return {"approved": approved, "pending": pending}
233
+
234
+ def _save(self, data: dict) -> None:
235
+ self.path.parent.mkdir(parents=True, exist_ok=True)
236
+ try:
237
+ os.chmod(self.path.parent, 0o700)
238
+ except OSError:
239
+ pass
240
+ tmp = self.path.with_suffix(".tmp")
241
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
242
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
243
+ json.dump(data, f, indent=2)
244
+ os.chmod(tmp, 0o600)
245
+ os.replace(tmp, self.path)
246
+
247
+ def _purge(self, data: dict) -> bool:
248
+ now = self.clock()
249
+ stale = [c for c, info in data["pending"].items()
250
+ if now - float(info.get("ts", 0)) >= self.ttl]
251
+ for c in stale:
252
+ data["pending"].pop(c, None)
253
+ return bool(stale)
254
+
255
+ def approved_ids(self) -> set:
256
+ return {int(c) for c in self._load()["approved"] if str(c).lstrip("-").isdigit()}
257
+
258
+ def is_approved(self, chat_id) -> bool:
259
+ return chat_id is not None and int(chat_id) in self.approved_ids()
260
+
261
+ def request(self, chat_id: int, who: str = ""):
262
+ """(code, is_new). One live code per chat; None when the pending cap
263
+ is reached. An expired code is replaced by a new one."""
264
+ data = self._load()
265
+ changed = self._purge(data)
266
+ for code, info in data["pending"].items():
267
+ if info.get("chat_id") == chat_id:
268
+ if changed:
269
+ self._save(data)
270
+ return code, False
271
+ if len(data["pending"]) >= MAX_PENDING:
272
+ if changed:
273
+ self._save(data)
274
+ return None, False
275
+ code = new_code()
276
+ data["pending"][code] = {"chat_id": int(chat_id), "who": str(who)[:80], "ts": self.clock()}
277
+ self._save(data)
278
+ return code, True
279
+
280
+ def approve(self, code: str) -> Optional[dict]:
281
+ """Approve an open, unexpired request. Returns its info, or None."""
282
+ data = self._load()
283
+ changed = self._purge(data)
284
+ key = (code or "").strip().upper()
285
+ info = data["pending"].pop(key, None)
286
+ if info is None:
287
+ if changed:
288
+ self._save(data)
289
+ return None
290
+ data["approved"][str(info["chat_id"])] = {"who": info.get("who", ""), "ts": self.clock()}
291
+ self._save(data)
292
+ return info
293
+
294
+ def revoke(self, chat_id) -> bool:
295
+ data = self._load()
296
+ gone = data["approved"].pop(str(int(chat_id)), None) is not None
297
+ if gone:
298
+ self._save(data)
299
+ return gone
300
+
301
+
302
+ def describe_sender(user) -> str:
303
+ """'First @username' for the owner's approval prompt (no other profile data)."""
304
+ if user is None:
305
+ return "unknown sender"
306
+ get = user.get if isinstance(user, dict) else (lambda k, d=None: getattr(user, k, d))
307
+ name = (get("first_name") or "").strip()
308
+ handle = get("username")
309
+ return (f"{name} @{handle}" if handle else name or "unknown sender")[:80]
310
+
311
+
312
+ # ───────────────────────── onboarding step ─────────────────────────
313
+ # The user-facing text of the onboarding offer lives here, not in
314
+ # cli/onboarding.py: onboarding names no integration itself (truth rule,
315
+ # tests/unit/test_onboarding_live_state.py). The offer is made only where the
316
+ # install profile says Telegram can run (managed config ``"telegram": true``).
317
+
318
+ OFFER_QUESTION = "[cyan]Connect a Telegram bot now? (optional)[/cyan]"
319
+ OFFER_LATER = "[dim]You can connect it later with [bold]hyperclaw telegram pair[/bold].[/dim]"
320
+ OFFER_HOWTO = ("[white]Create a bot with Telegram's @BotFather (/newbot) and paste its "
321
+ "token.[/white]")
322
+ TOKEN_PROMPT = "[cyan]Bot token (hidden; Enter to skip)[/cyan]"
323
+ TOKEN_BAD = "[yellow]That doesn't look like a Telegram bot token, so it wasn't saved.[/yellow]"
324
+ TOKEN_SAVED = "[green]Bot token saved to ~/.hyperclaw/.env (readable only by you).[/green]"
325
+ _TOKEN_SHAPE = r"\d{5,}:[A-Za-z0-9_-]{30,}"
326
+
327
+
328
+ def offer_allowed() -> bool:
329
+ """True when this install's profile lets Telegram run (team and client)."""
330
+ from hyperclaw.managed_config import telegram_allowed
331
+ return telegram_allowed()
332
+
333
+
334
+ def onboarding_offer(*, say: Callable[[str], None], confirm: Callable[..., bool],
335
+ prompt: Callable[..., str], line: Callable[[str], None]) -> bool:
336
+ """Optional onboarding step: connect a bot and pair the owner's chat by
337
+ code. Does nothing (and names nothing) unless ``offer_allowed()``.
338
+
339
+ The token is asked hidden and never printed. Pairing writes
340
+ TELEGRAM_OWNER_CHAT_ID to ~/.hyperclaw/.env (0600); no chat-id lookup."""
341
+ import re
342
+
343
+ from hyperclaw.userconfig import env_update
344
+
345
+ if not offer_allowed():
346
+ return False
347
+ say("")
348
+ if not confirm(OFFER_QUESTION, default=False):
349
+ say(OFFER_LATER)
350
+ return False
351
+ say(OFFER_HOWTO)
352
+ token = (prompt(TOKEN_PROMPT, password=True, default="", show_default=False) or "").strip()
353
+ if not token:
354
+ return False
355
+ if not re.fullmatch(_TOKEN_SHAPE, token):
356
+ say(TOKEN_BAD)
357
+ return False
358
+ env_update({"TELEGRAM_BOT_TOKEN": token})
359
+ say(TOKEN_SAVED)
360
+ return run_owner_pairing(token, line) is not None