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.
- agents/__init__.py +31 -0
- agents/background_monitor.py +453 -0
- agents/learning.py +460 -0
- agents/planner.py +421 -0
- agents/transcriber.py +341 -0
- civilization/__init__.py +51 -0
- civilization/api/__init__.py +7 -0
- civilization/api/civilization_api.py +361 -0
- civilization/graph/__init__.py +19 -0
- civilization/graph/knowledge_linker.py +348 -0
- civilization/graph/org_graph.py +348 -0
- civilization/graph/process_graph.py +386 -0
- civilization/ingestion/__init__.py +20 -0
- civilization/ingestion/chunker.py +273 -0
- civilization/ingestion/document_ingestor.py +358 -0
- civilization/ingestion/embedder.py +262 -0
- civilization/ingestion/extractor.py +224 -0
- civilization/interview/__init__.py +19 -0
- civilization/interview/gap_detector.py +380 -0
- civilization/interview/interviewer.py +323 -0
- civilization/interview/questioner.py +363 -0
- civilization/retrieval/__init__.py +17 -0
- civilization/retrieval/civilization_rag.py +316 -0
- civilization/retrieval/context_injector.py +342 -0
- civilization/retrieval/relevance_ranker.py +287 -0
- civilization/schema.py +299 -0
- civilization/store.py +177 -0
- civilization/sync/__init__.py +16 -0
- civilization/sync/confluence_sync.py +317 -0
- civilization/sync/gdrive_sync.py +262 -0
- civilization/sync/notion_sync.py +237 -0
- civilization/versioning/__init__.py +19 -0
- civilization/versioning/diff_engine.py +351 -0
- civilization/versioning/staleness_detector.py +303 -0
- civilization/versioning/version_manager.py +293 -0
- cli/__init__.py +1 -0
- cli/doctor.py +334 -0
- cli/hyperclaw.py +1357 -0
- cli/onboarding.py +469 -0
- core/__init__.py +1 -0
- core/connectors/__init__.py +119 -0
- core/connectors/infrastructure.py +772 -0
- core/connectors/messaging.py +586 -0
- core/connectors/productivity.py +764 -0
- core/connectors/smarthome.py +528 -0
- core/connectors/social.py +730 -0
- core/hyperrouter/__init__.py +23 -0
- core/hyperrouter/bandit.py +218 -0
- core/hyperrouter/fast_loop.py +68 -0
- core/hyperrouter/slow_loop.py +143 -0
- core/hyperstate/__init__.py +24 -0
- core/hyperstate/certifier.py +154 -0
- core/hyperstate/schema.py +160 -0
- core/hyperstate/state_manager.py +103 -0
- core/hyperstate/store.py +163 -0
- core/providers/__init__.py +121 -0
- core/providers/embeddings.py +389 -0
- core/providers/image.py +534 -0
- core/providers/llm.py +1935 -0
- core/providers/search.py +608 -0
- core/providers/voice.py +474 -0
- hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
- hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
- hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
- hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
- hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
- hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
- hyperclaw/__init__.py +67 -0
- hyperclaw/__main__.py +220 -0
- hyperclaw/agent.py +775 -0
- hyperclaw/agent_coordinator.py +577 -0
- hyperclaw/anthropic_params.py +225 -0
- hyperclaw/api_utils.py +85 -0
- hyperclaw/approval_telegram.py +209 -0
- hyperclaw/approval_terminal.py +80 -0
- hyperclaw/attachments.py +177 -0
- hyperclaw/branding.py +55 -0
- hyperclaw/brief/__init__.py +11 -0
- hyperclaw/brief/cli.py +204 -0
- hyperclaw/brief/collect.py +146 -0
- hyperclaw/brief/compose.py +333 -0
- hyperclaw/brief/diff.py +110 -0
- hyperclaw/brief/errors.py +99 -0
- hyperclaw/brief/google.py +269 -0
- hyperclaw/brief/google_auth.py +294 -0
- hyperclaw/brief/links.py +124 -0
- hyperclaw/brief/service.py +424 -0
- hyperclaw/brief/store.py +205 -0
- hyperclaw/brief/tasks.py +200 -0
- hyperclaw/chat_scope.py +115 -0
- hyperclaw/cli_tui.py +497 -0
- hyperclaw/client_guard.py +717 -0
- hyperclaw/connection_check.py +113 -0
- hyperclaw/cost_guard.py +1728 -0
- hyperclaw/default_config/agents.yaml +534 -0
- hyperclaw/default_config/hyperclaw.yaml +27 -0
- hyperclaw/default_config/models.yaml +161 -0
- hyperclaw/deployment.py +39 -0
- hyperclaw/digest/__init__.py +9 -0
- hyperclaw/digest/cli.py +117 -0
- hyperclaw/digest/errors.py +100 -0
- hyperclaw/digest/gitlab.py +187 -0
- hyperclaw/digest/http.py +113 -0
- hyperclaw/digest/redact.py +32 -0
- hyperclaw/digest/service.py +271 -0
- hyperclaw/digest/slack.py +223 -0
- hyperclaw/digest/state.py +124 -0
- hyperclaw/digest/summarize.py +198 -0
- hyperclaw/digest/tokens.py +143 -0
- hyperclaw/distribution.py +46 -0
- hyperclaw/documents.py +351 -0
- hyperclaw/feeds.py +339 -0
- hyperclaw/google_workspace/__init__.py +5 -0
- hyperclaw/google_workspace/api.py +151 -0
- hyperclaw/google_workspace/auth.py +397 -0
- hyperclaw/google_workspace/calendar.py +157 -0
- hyperclaw/google_workspace/cli.py +72 -0
- hyperclaw/google_workspace/drive.py +236 -0
- hyperclaw/google_workspace/errors.py +58 -0
- hyperclaw/google_workspace/gmail.py +295 -0
- hyperclaw/google_workspace/meet.py +122 -0
- hyperclaw/google_workspace/tools.py +319 -0
- hyperclaw/hyperspeed.py +57 -0
- hyperclaw/integrations_layer.py +1427 -0
- hyperclaw/live_state.py +188 -0
- hyperclaw/managed_config.py +245 -0
- hyperclaw/media_hub.py +179 -0
- hyperclaw/memory_manager.py +584 -0
- hyperclaw/model_catalog.py +551 -0
- hyperclaw/model_policy.py +211 -0
- hyperclaw/model_router.py +595 -0
- hyperclaw/model_selector.py +72 -0
- hyperclaw/naming.py +284 -0
- hyperclaw/notices.py +65 -0
- hyperclaw/oai_stream.py +106 -0
- hyperclaw/orchestrator.py +591 -0
- hyperclaw/outbox.py +82 -0
- hyperclaw/persona.py +367 -0
- hyperclaw/prometheus.py +219 -0
- hyperclaw/prompt_assembly.py +157 -0
- hyperclaw/provider_setup.py +146 -0
- hyperclaw/providers.py +340 -0
- hyperclaw/qualification.py +341 -0
- hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
- hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
- hyperclaw/reasoning_display.py +35 -0
- hyperclaw/scheduler.py +454 -0
- hyperclaw/server.py +847 -0
- hyperclaw/setup.py +627 -0
- hyperclaw/skills.py +717 -0
- hyperclaw/solomon.py +442 -0
- hyperclaw/swarm.py +533 -0
- hyperclaw/taint.py +832 -0
- hyperclaw/team/__init__.py +28 -0
- hyperclaw/team/dispatcher.py +431 -0
- hyperclaw/team/genesis.py +106 -0
- hyperclaw/team/lead.py +110 -0
- hyperclaw/team/model.py +135 -0
- hyperclaw/team/morning.py +238 -0
- hyperclaw/team/repos.py +66 -0
- hyperclaw/team/roster.py +264 -0
- hyperclaw/team/specialist.py +235 -0
- hyperclaw/team/status.py +163 -0
- hyperclaw/team/watchers.py +112 -0
- hyperclaw/teamsetup/__init__.py +17 -0
- hyperclaw/teamsetup/checks.py +208 -0
- hyperclaw/teamsetup/item.py +391 -0
- hyperclaw/teamsetup/oauth_loopback.py +84 -0
- hyperclaw/teamsetup/onboard.py +550 -0
- hyperclaw/teamsetup/profile.py +82 -0
- hyperclaw/teamsetup/secrets.py +313 -0
- hyperclaw/teamsetup/selfcheck.py +295 -0
- hyperclaw/teamsetup/service.py +495 -0
- hyperclaw/teamsetup/slack_oauth.py +442 -0
- hyperclaw/teamsetup/tty.py +62 -0
- hyperclaw/telegram_bot.py +1106 -0
- hyperclaw/telegram_intake.py +126 -0
- hyperclaw/telegram_pairing.py +360 -0
- hyperclaw/terminal_provider.py +323 -0
- hyperclaw/tools/__init__.py +1 -0
- hyperclaw/tools/approval.py +949 -0
- hyperclaw/tools/args.py +212 -0
- hyperclaw/tools/catalog.py +244 -0
- hyperclaw/tools/classify.py +266 -0
- hyperclaw/tools/effects.py +724 -0
- hyperclaw/tools/egress.py +210 -0
- hyperclaw/tools/envelope.py +188 -0
- hyperclaw/tools/guard.py +367 -0
- hyperclaw/tools/ledger.py +339 -0
- hyperclaw/tools/receipts.py +70 -0
- hyperclaw/tools/registry.py +318 -0
- hyperclaw/tools/service_guard.py +151 -0
- hyperclaw/tools/surfaces.py +54 -0
- hyperclaw/tools/workspace.py +87 -0
- hyperclaw/trading/__init__.py +77 -0
- hyperclaw/truth_guard.py +791 -0
- hyperclaw/tts.py +124 -0
- hyperclaw/tui.py +4255 -0
- hyperclaw/tui_bridge.py +938 -0
- hyperclaw/unattended.py +60 -0
- hyperclaw/updates/__init__.py +54 -0
- hyperclaw/updates/check.py +131 -0
- hyperclaw/updates/index.py +178 -0
- hyperclaw/updates/install.py +110 -0
- hyperclaw/updates/managed.py +161 -0
- hyperclaw/updates/manifest.py +138 -0
- hyperclaw/updates/notices.py +86 -0
- hyperclaw/updates/paths.py +53 -0
- hyperclaw/updates/release.py +146 -0
- hyperclaw/updates/upgrade.py +253 -0
- hyperclaw/updates/versioning.py +104 -0
- hyperclaw/userconfig.py +119 -0
- hyperclaw/xai.py +83 -0
- integrations/__init__.py +31 -0
- integrations/automation/__init__.py +1 -0
- integrations/automation/make.py +87 -0
- integrations/automation/zapier.py +87 -0
- integrations/base.py +476 -0
- integrations/communication/__init__.py +1 -0
- integrations/communication/twilio.py +279 -0
- integrations/communication/zoom.py +290 -0
- integrations/data/__init__.py +1 -0
- integrations/data/google_analytics.py +135 -0
- integrations/data/postgres.py +182 -0
- integrations/data/supabase_connector.py +202 -0
- integrations/data/weather_feeds.py +450 -0
- integrations/developer/__init__.py +9 -0
- integrations/developer/github.py +406 -0
- integrations/developer/gitlab.py +298 -0
- integrations/enterprise/__init__.py +13 -0
- integrations/enterprise/confluence.py +266 -0
- integrations/enterprise/hubspot.py +293 -0
- integrations/enterprise/jira.py +314 -0
- integrations/enterprise/salesforce.py +286 -0
- integrations/finance/__init__.py +1 -0
- integrations/finance/polymarket_client.py +247 -0
- integrations/finance/quickbooks.py +281 -0
- integrations/finance/stripe.py +261 -0
- integrations/gateway.py +238 -0
- integrations/messaging/__init__.py +32 -0
- integrations/messaging/discord.py +159 -0
- integrations/messaging/email.py +401 -0
- integrations/messaging/imessage.py +443 -0
- integrations/messaging/signal.py +175 -0
- integrations/messaging/slack.py +217 -0
- integrations/messaging/sms.py +175 -0
- integrations/messaging/teams.py +208 -0
- integrations/messaging/telegram.py +260 -0
- integrations/messaging/whatsapp.py +203 -0
- integrations/productivity/__init__.py +17 -0
- integrations/productivity/airtable.py +217 -0
- integrations/productivity/asana.py +248 -0
- integrations/productivity/google/__init__.py +19 -0
- integrations/productivity/google/base.py +89 -0
- integrations/productivity/google/calendar.py +272 -0
- integrations/productivity/google/docs.py +202 -0
- integrations/productivity/google/drive.py +268 -0
- integrations/productivity/google/gmail.py +279 -0
- integrations/productivity/google/meet.py +218 -0
- integrations/productivity/google/sheets.py +222 -0
- integrations/productivity/google/tasks.py +199 -0
- integrations/productivity/linear.py +279 -0
- integrations/productivity/microsoft/__init__.py +13 -0
- integrations/productivity/microsoft/base.py +93 -0
- integrations/productivity/microsoft/calendar.py +207 -0
- integrations/productivity/microsoft/onedrive.py +222 -0
- integrations/productivity/microsoft/outlook.py +241 -0
- integrations/productivity/microsoft/sharepoint.py +211 -0
- integrations/productivity/notion.py +243 -0
- integrations/productivity/todoist.py +250 -0
- integrations/productivity/trello.py +244 -0
- integrations/storage/__init__.py +1 -0
- integrations/storage/box.py +279 -0
- integrations/storage/dropbox.py +215 -0
- integrations/telegram_inbound.py +179 -0
- integrations/telegram_poller.py +151 -0
- memory/__init__.py +1 -0
- memory/causal_graph.py +125 -0
- memory/impact_tracker.py +88 -0
- memory/vector_memory.py +436 -0
- models/__init__.py +12 -0
- models/chatjimmy_client.py +210 -0
- models/claude_client.py +235 -0
- models/claude_code_subagent.py +192 -0
- models/openai_client.py +120 -0
- models/router.py +162 -0
- recursive/discovery_loop.py +118 -0
- recursive/optimization_loop.py +135 -0
- recursive/validation_loop.py +38 -0
- security/__init__.py +1 -0
- security/audit_logger.py +106 -0
- security/filesystem_guard.py +100 -0
- security/hypershield.py +99 -0
- security/network_guard.py +97 -0
- security/policy_engine.py +198 -0
- swarm/__init__.py +1 -0
- swarm/agents/__init__.py +0 -0
- swarm/agents/base.py +230 -0
- swarm/agents/business/__init__.py +0 -0
- swarm/agents/business/counsel.py +31 -0
- swarm/agents/business/herald.py +32 -0
- swarm/agents/business/ledger.py +26 -0
- swarm/agents/business/nexus.py +30 -0
- swarm/agents/business/ops.py +30 -0
- swarm/agents/business/pipeline.py +28 -0
- swarm/agents/business/revenue.py +30 -0
- swarm/agents/business/sovereign.py +31 -0
- swarm/agents/business/strategos.py +27 -0
- swarm/agents/business/talent.py +27 -0
- swarm/agents/business/venture.py +32 -0
- swarm/agents/comms/__init__.py +1 -0
- swarm/agents/comms/cipher.py +32 -0
- swarm/agents/comms/echo_comms.py +31 -0
- swarm/agents/comms/envoy.py +31 -0
- swarm/agents/comms/herald_scheduler.py +33 -0
- swarm/agents/comms/pulse.py +29 -0
- swarm/agents/creative/__init__.py +0 -0
- swarm/agents/creative/author.py +27 -0
- swarm/agents/creative/lens.py +25 -0
- swarm/agents/executive/__init__.py +1 -0
- swarm/agents/intelligence/__init__.py +1 -0
- swarm/agents/intelligence/arbiter.py +30 -0
- swarm/agents/intelligence/sentinel.py +30 -0
- swarm/agents/media/__init__.py +0 -0
- swarm/agents/personal/__init__.py +0 -0
- swarm/agents/personal/atlas.py +27 -0
- swarm/agents/personal/hearth.py +26 -0
- swarm/agents/personal/midas.py +27 -0
- swarm/agents/personal/navigator.py +27 -0
- swarm/agents/personal/nourish.py +31 -0
- swarm/agents/personal/vitals.py +33 -0
- swarm/agents/recursive/__init__.py +0 -0
- swarm/agents/recursive/alchemist.py +73 -0
- swarm/agents/recursive/calibrator.py +45 -0
- swarm/agents/recursive/scout.py +97 -0
- swarm/agents/scientific/__init__.py +0 -0
- swarm/agents/scientific/cosmos.py +36 -0
- swarm/agents/scientific/gaia.py +26 -0
- swarm/agents/scientific/medicus.py +33 -0
- swarm/agents/scientific/oracle_agent.py +39 -0
- swarm/agents/scientific/scribe.py +39 -0
- swarm/agents/talent/__init__.py +0 -0
- swarm/agents/talent/deal.py +28 -0
- swarm/agents/talent/roster.py +29 -0
- swarm/agents/talent/scout.py +29 -0
- swarm/agents/talent/stage.py +27 -0
- swarm/agents/tech/__init__.py +0 -0
- swarm/agents/tech/aegis.py +28 -0
- swarm/agents/tech/bridge.py +28 -0
- swarm/agents/tech/forge.py +28 -0
- swarm/agents/trading/__init__.py +1 -0
- swarm/agents/trading/global_prediction_engine.py +611 -0
- swarm/agents/trading/polymarket_trader.py +165 -0
- swarm/agents/trading/prediction_strategist.py +218 -0
- swarm/autogen_bridge.py +68 -0
- swarm/bid_protocol.py +196 -0
- swarm/dispatcher.py +527 -0
- swarm/nexus.py +181 -0
- swarm/registry.py +112 -0
- telegram_listener.py +403 -0
- 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
|