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,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()
|