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,269 @@
|
|
|
1
|
+
"""Read-only Google Docs and Sheets readers (GET only).
|
|
2
|
+
|
|
3
|
+
Docs API v1 gives the title, the text and the links in a Doc (no Drive scope
|
|
4
|
+
needed); Sheets API v4 gives a spreadsheet's title, tab names and values.
|
|
5
|
+
Per-file failures are named (no access, deleted, couldn't be returned);
|
|
6
|
+
whole-run failures (expired access, Google unreachable) raise a BriefError.
|
|
7
|
+
No URL, file id or response body is ever logged: the logger gets reason
|
|
8
|
+
codes only, and the brief's link filter is installed on the HTTP loggers.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Callable, List, Optional, Tuple
|
|
17
|
+
from urllib.parse import quote, urlsplit
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from hyperclaw.brief import links
|
|
22
|
+
from hyperclaw.brief.errors import BriefError, Reason
|
|
23
|
+
from hyperclaw.taint import source as taint_source
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("hyperclaw.brief")
|
|
26
|
+
|
|
27
|
+
DOCS_API = "https://docs.googleapis.com"
|
|
28
|
+
SHEETS_API = "https://sheets.googleapis.com"
|
|
29
|
+
TIMEOUT = 20.0
|
|
30
|
+
MAX_RETRIES = 3
|
|
31
|
+
MAX_WAIT = 30.0
|
|
32
|
+
MAX_TEXT = 20_000 # characters kept per file
|
|
33
|
+
MAX_ROWS = 300 # rows kept per tab
|
|
34
|
+
MAX_COLS = 15
|
|
35
|
+
MAX_TABS = 10
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _api(env: str, default: str) -> str:
|
|
39
|
+
"""API base. A loopback override exists for tests only."""
|
|
40
|
+
override = os.environ.get(env, "").strip()
|
|
41
|
+
if override and (urlsplit(override).hostname or "") in ("127.0.0.1", "localhost", "::1"):
|
|
42
|
+
return override.rstrip("/")
|
|
43
|
+
return default
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Link:
|
|
48
|
+
url: str
|
|
49
|
+
anchor: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class DocContent:
|
|
54
|
+
title: str
|
|
55
|
+
text: str
|
|
56
|
+
links: List[Link] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class SheetContent:
|
|
61
|
+
title: str
|
|
62
|
+
tabs: List[Tuple[str, list]] = field(default_factory=list) # (tab title, rows)
|
|
63
|
+
|
|
64
|
+
def text(self) -> str:
|
|
65
|
+
out = []
|
|
66
|
+
for tab, rows in self.tabs:
|
|
67
|
+
out.append(f"[tab: {tab}]")
|
|
68
|
+
for r in rows:
|
|
69
|
+
out.append(" | ".join(str(c) for c in r))
|
|
70
|
+
return "\n".join(out)[:MAX_TEXT]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def new_client() -> httpx.Client:
|
|
74
|
+
return httpx.Client(timeout=TIMEOUT, follow_redirects=False)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FileError(Exception):
|
|
78
|
+
"""A per-file failure with a skip reason (no access, deleted, ...)."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, reason: Reason):
|
|
81
|
+
self.reason = reason
|
|
82
|
+
super().__init__(reason.value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GoogleReader:
|
|
86
|
+
def __init__(self, token: str, *, client: Optional[httpx.Client] = None,
|
|
87
|
+
sleep: Callable[[float], None] = time.sleep):
|
|
88
|
+
links.install_log_filter()
|
|
89
|
+
self._headers = {"Authorization": f"Bearer {token}"}
|
|
90
|
+
self.client = client or new_client()
|
|
91
|
+
self.sleep = sleep
|
|
92
|
+
self.docs_api = _api("HYPERCLAW_BRIEF_DOCS_API", DOCS_API)
|
|
93
|
+
self.sheets_api = _api("HYPERCLAW_BRIEF_SHEETS_API", SHEETS_API)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
try:
|
|
97
|
+
self.client.close()
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# ── HTTP ──
|
|
102
|
+
def _get(self, url: str, params: Optional[list] = None) -> dict:
|
|
103
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
104
|
+
try:
|
|
105
|
+
resp = self.client.get(url, params=params or [], headers=self._headers)
|
|
106
|
+
except httpx.HTTPError as e:
|
|
107
|
+
if attempt >= MAX_RETRIES:
|
|
108
|
+
raise BriefError(Reason.GOOGLE_UNREACHABLE, type(e).__name__)
|
|
109
|
+
self.sleep(min(2 ** attempt, MAX_WAIT))
|
|
110
|
+
continue
|
|
111
|
+
code = resp.status_code
|
|
112
|
+
if code == 429:
|
|
113
|
+
if attempt >= MAX_RETRIES:
|
|
114
|
+
raise BriefError(Reason.GOOGLE_RATE_LIMITED, "429")
|
|
115
|
+
self.sleep(_retry_after(resp))
|
|
116
|
+
continue
|
|
117
|
+
if code >= 500:
|
|
118
|
+
if attempt >= MAX_RETRIES:
|
|
119
|
+
raise BriefError(Reason.GOOGLE_UNREACHABLE, f"HTTP {code}")
|
|
120
|
+
self.sleep(min(2 ** attempt, MAX_WAIT))
|
|
121
|
+
continue
|
|
122
|
+
if code == 401:
|
|
123
|
+
raise BriefError(Reason.GOOGLE_EXPIRED, "401")
|
|
124
|
+
if code == 403:
|
|
125
|
+
if _status(resp) == "PERMISSION_DENIED" and "scope" in _message(resp).lower():
|
|
126
|
+
raise BriefError(Reason.GOOGLE_MISSING_SCOPE, "403 scope")
|
|
127
|
+
raise FileError(Reason.NO_ACCESS)
|
|
128
|
+
if code in (404, 410):
|
|
129
|
+
raise FileError(Reason.DELETED)
|
|
130
|
+
if code == 400:
|
|
131
|
+
# a Drive file that isn't a native Doc/Sheet (an uploaded .docx/.xlsx)
|
|
132
|
+
raise FileError(Reason.NOT_DOC_OR_SHEET)
|
|
133
|
+
if code != 200:
|
|
134
|
+
raise FileError(Reason.UNREADABLE)
|
|
135
|
+
try:
|
|
136
|
+
body = resp.json()
|
|
137
|
+
except Exception:
|
|
138
|
+
raise FileError(Reason.UNREADABLE)
|
|
139
|
+
if not isinstance(body, dict):
|
|
140
|
+
raise FileError(Reason.UNREADABLE)
|
|
141
|
+
return body
|
|
142
|
+
raise BriefError(Reason.GOOGLE_UNREACHABLE, "retries exhausted") # pragma: no cover
|
|
143
|
+
|
|
144
|
+
# ── Docs ──
|
|
145
|
+
@taint_source("google_docs", "google")
|
|
146
|
+
def doc(self, file_id: str) -> DocContent:
|
|
147
|
+
body = self._get(f"{self.docs_api}/v1/documents/{quote(file_id, safe='')}",
|
|
148
|
+
[("includeTabsContent", "true")])
|
|
149
|
+
title = str(body.get("title") or "").strip()
|
|
150
|
+
texts, found = [], []
|
|
151
|
+
tabs = body.get("tabs")
|
|
152
|
+
if isinstance(tabs, list) and tabs:
|
|
153
|
+
for tab in _walk_tabs(tabs):
|
|
154
|
+
dt = tab.get("documentTab") or {}
|
|
155
|
+
_content(((dt.get("body") or {}).get("content")) or [], texts, found)
|
|
156
|
+
else:
|
|
157
|
+
_content(((body.get("body") or {}).get("content")) or [], texts, found)
|
|
158
|
+
text = "".join(texts)
|
|
159
|
+
return DocContent(title=title, text=text[:MAX_TEXT], links=_merge_links(found))
|
|
160
|
+
|
|
161
|
+
# ── Sheets ──
|
|
162
|
+
@taint_source("google_sheets", "google")
|
|
163
|
+
def sheet(self, file_id: str) -> SheetContent:
|
|
164
|
+
base = f"{self.sheets_api}/v4/spreadsheets/{quote(file_id, safe='')}"
|
|
165
|
+
meta = self._get(base, [("fields", "properties.title,sheets.properties.title")])
|
|
166
|
+
title = str((meta.get("properties") or {}).get("title") or "").strip()
|
|
167
|
+
tab_names = [str((s.get("properties") or {}).get("title") or "")
|
|
168
|
+
for s in (meta.get("sheets") or []) if isinstance(s, dict)][:MAX_TABS]
|
|
169
|
+
tabs = []
|
|
170
|
+
if tab_names:
|
|
171
|
+
ranges = [("ranges", f"'{t.replace(chr(39), chr(39) * 2)}'!A1:O{MAX_ROWS}")
|
|
172
|
+
for t in tab_names]
|
|
173
|
+
vals = self._get(f"{base}/values:batchGet",
|
|
174
|
+
ranges + [("majorDimension", "ROWS"),
|
|
175
|
+
("valueRenderOption", "FORMATTED_VALUE")])
|
|
176
|
+
for name, vr in zip(tab_names, vals.get("valueRanges") or []):
|
|
177
|
+
rows = [[str(c) for c in (r or [])][:MAX_COLS]
|
|
178
|
+
for r in (vr.get("values") or [])][:MAX_ROWS]
|
|
179
|
+
tabs.append((name, rows))
|
|
180
|
+
return SheetContent(title=title, tabs=tabs)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _walk_tabs(tabs: list):
|
|
184
|
+
for t in tabs:
|
|
185
|
+
if not isinstance(t, dict):
|
|
186
|
+
continue
|
|
187
|
+
yield t
|
|
188
|
+
yield from _walk_tabs(t.get("childTabs") or [])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _content(elements: list, texts: list, found: list) -> None:
|
|
192
|
+
for el in elements:
|
|
193
|
+
if not isinstance(el, dict):
|
|
194
|
+
continue
|
|
195
|
+
para = el.get("paragraph")
|
|
196
|
+
if para:
|
|
197
|
+
for pe in para.get("elements") or []:
|
|
198
|
+
tr = pe.get("textRun")
|
|
199
|
+
if tr:
|
|
200
|
+
content = tr.get("content") or ""
|
|
201
|
+
texts.append(content)
|
|
202
|
+
url = ((tr.get("textStyle") or {}).get("link") or {}).get("url")
|
|
203
|
+
if url:
|
|
204
|
+
found.append((url, content))
|
|
205
|
+
rl = pe.get("richLink")
|
|
206
|
+
if rl:
|
|
207
|
+
props = rl.get("richLinkProperties") or {}
|
|
208
|
+
t = props.get("title") or ""
|
|
209
|
+
texts.append(t)
|
|
210
|
+
if props.get("uri"):
|
|
211
|
+
found.append((props["uri"], t))
|
|
212
|
+
continue
|
|
213
|
+
table = el.get("table")
|
|
214
|
+
if table:
|
|
215
|
+
for row in table.get("tableRows") or []:
|
|
216
|
+
cells = []
|
|
217
|
+
for cell in row.get("tableCells") or []:
|
|
218
|
+
sub: list = []
|
|
219
|
+
_content(cell.get("content") or [], sub, found)
|
|
220
|
+
cells.append(" ".join("".join(sub).split()))
|
|
221
|
+
texts.append(" | ".join(cells) + "\n")
|
|
222
|
+
continue
|
|
223
|
+
toc = el.get("tableOfContents")
|
|
224
|
+
if toc:
|
|
225
|
+
_content(toc.get("content") or [], [], [])
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _merge_links(found: list) -> List[Link]:
|
|
229
|
+
"""Consecutive runs of one link become one Link with the joined text;
|
|
230
|
+
the same link twice is kept once (first anchor wins)."""
|
|
231
|
+
out: List[Link] = []
|
|
232
|
+
seen = set()
|
|
233
|
+
prev_url = None
|
|
234
|
+
for url, text in found:
|
|
235
|
+
if out and url == prev_url:
|
|
236
|
+
out[-1].anchor += text
|
|
237
|
+
continue
|
|
238
|
+
prev_url = url
|
|
239
|
+
if url in seen:
|
|
240
|
+
continue
|
|
241
|
+
seen.add(url)
|
|
242
|
+
out.append(Link(url=url, anchor=text))
|
|
243
|
+
for lk in out:
|
|
244
|
+
lk.anchor = " ".join(lk.anchor.split())
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _retry_after(resp) -> float:
|
|
249
|
+
try:
|
|
250
|
+
return max(0.0, min(float(resp.headers.get("Retry-After", "1")), MAX_WAIT))
|
|
251
|
+
except ValueError:
|
|
252
|
+
return 1.0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _err(resp) -> dict:
|
|
256
|
+
try:
|
|
257
|
+
b = resp.json()
|
|
258
|
+
e = b.get("error") if isinstance(b, dict) else None
|
|
259
|
+
return e if isinstance(e, dict) else {}
|
|
260
|
+
except Exception:
|
|
261
|
+
return {}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _status(resp) -> str:
|
|
265
|
+
return str(_err(resp).get("status") or "")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _message(resp) -> str:
|
|
269
|
+
return str(_err(resp).get("message") or "")
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Google sign-in for the brief: installed-app (Desktop) OAuth, loopback redirect.
|
|
2
|
+
|
|
3
|
+
- The OAuth client (client id/secret) is a file the owner keeps outside the
|
|
4
|
+
repo; config.json ``brief.google_client_file`` holds only its path.
|
|
5
|
+
- Only two read-only scopes are requested, nothing else (no Gmail, no Drive):
|
|
6
|
+
documents.readonly and spreadsheets.readonly. A grant that carries any
|
|
7
|
+
other scope is refused and nothing is stored; so is a grant missing one.
|
|
8
|
+
- PKCE (S256) + a random state; the redirect goes to 127.0.0.1 on a random
|
|
9
|
+
port. The browser is opened only when asked (``open_browser``); otherwise
|
|
10
|
+
the address is shown for the owner to open.
|
|
11
|
+
- The token is stored in $HYPERCLAW_ROOT/brief/google-token.json, 0600.
|
|
12
|
+
It is never printed, logged or sent to the model.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Callable, Optional
|
|
27
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from hyperclaw.brief import store
|
|
32
|
+
from hyperclaw.brief.errors import BriefError, Reason
|
|
33
|
+
|
|
34
|
+
log = logging.getLogger("hyperclaw.brief")
|
|
35
|
+
|
|
36
|
+
SCOPES = ("https://www.googleapis.com/auth/documents.readonly",
|
|
37
|
+
"https://www.googleapis.com/auth/spreadsheets.readonly")
|
|
38
|
+
AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
39
|
+
TOKEN_URI = "https://oauth2.googleapis.com/token"
|
|
40
|
+
TIMEOUT = 20.0
|
|
41
|
+
CONSENT_WAIT = 300.0
|
|
42
|
+
DONE_PAGE = (b"<!doctype html><meta charset=utf-8><title>HyperAgents</title>"
|
|
43
|
+
b"<p>HyperAgents has your answer from Google. You can close this tab and go "
|
|
44
|
+
b"back to the terminal.</p>")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def token_uri() -> str:
|
|
48
|
+
"""Google's token endpoint. A loopback override exists for tests only."""
|
|
49
|
+
override = os.environ.get("HYPERCLAW_BRIEF_GOOGLE_TOKEN_URI", "").strip()
|
|
50
|
+
if override:
|
|
51
|
+
host = urlsplit(override).hostname or ""
|
|
52
|
+
if host in ("127.0.0.1", "localhost", "::1"):
|
|
53
|
+
return override
|
|
54
|
+
return TOKEN_URI
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── client file ───────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def client_file() -> str:
|
|
60
|
+
return str(store.config().get("google_client_file") or "")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_client(path: Optional[str] = None) -> dict:
|
|
64
|
+
"""{"client_id", "client_secret"} from a Desktop-app client JSON file.
|
|
65
|
+
With no file configured, a team install uses the team Google client from
|
|
66
|
+
its Hyper-signed team profile (hyperclaw.teamsetup.profile)."""
|
|
67
|
+
if not (path or client_file()):
|
|
68
|
+
try:
|
|
69
|
+
from hyperclaw.teamsetup.profile import google_client
|
|
70
|
+
team = google_client()
|
|
71
|
+
except Exception:
|
|
72
|
+
team = {}
|
|
73
|
+
if team:
|
|
74
|
+
return team
|
|
75
|
+
p = Path(os.path.expanduser(path or client_file() or ""))
|
|
76
|
+
if not str(p) or not p.is_file():
|
|
77
|
+
raise BriefError(Reason.GOOGLE_CLIENT_FILE, "missing")
|
|
78
|
+
try:
|
|
79
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
80
|
+
except Exception:
|
|
81
|
+
raise BriefError(Reason.GOOGLE_CLIENT_FILE, "unparseable")
|
|
82
|
+
inst = data.get("installed") if isinstance(data, dict) else None
|
|
83
|
+
if not isinstance(inst, dict) or not inst.get("client_id") or not inst.get("client_secret"):
|
|
84
|
+
raise BriefError(Reason.GOOGLE_CLIENT_FILE, "not a desktop client")
|
|
85
|
+
return {"client_id": str(inst["client_id"]), "client_secret": str(inst["client_secret"])}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── scopes ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def check_scopes(granted: str) -> list:
|
|
91
|
+
got = sorted(set((granted or "").split()))
|
|
92
|
+
extra = [s for s in got if s not in SCOPES]
|
|
93
|
+
if extra:
|
|
94
|
+
raise BriefError(Reason.GOOGLE_EXTRA_SCOPE, f"{len(extra)} extra")
|
|
95
|
+
if set(SCOPES) - set(got):
|
|
96
|
+
raise BriefError(Reason.GOOGLE_MISSING_SCOPE)
|
|
97
|
+
return got
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── token storage ─────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def load_token() -> dict:
|
|
103
|
+
p = store.token_path()
|
|
104
|
+
if not p.exists():
|
|
105
|
+
return {}
|
|
106
|
+
if store.too_open(p):
|
|
107
|
+
raise BriefError(Reason.TOKEN_FILE_OPEN)
|
|
108
|
+
return store.read_json(p)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def save_token(tok: dict) -> None:
|
|
112
|
+
store.write_json(store.token_path(), tok)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def connected() -> bool:
|
|
116
|
+
try:
|
|
117
|
+
return bool(load_token().get("refresh_token"))
|
|
118
|
+
except BriefError:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def disconnect() -> bool:
|
|
123
|
+
try:
|
|
124
|
+
store.token_path().unlink()
|
|
125
|
+
return True
|
|
126
|
+
except FileNotFoundError:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── the consent flow ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def _pkce() -> tuple:
|
|
133
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode()
|
|
134
|
+
challenge = base64.urlsafe_b64encode(
|
|
135
|
+
hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
136
|
+
return verifier, challenge
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _Catcher:
|
|
140
|
+
"""One-shot loopback HTTP server that waits for Google's redirect."""
|
|
141
|
+
|
|
142
|
+
def __init__(self):
|
|
143
|
+
self.result: dict = {}
|
|
144
|
+
self.event = threading.Event()
|
|
145
|
+
outer = self
|
|
146
|
+
|
|
147
|
+
class H(BaseHTTPRequestHandler):
|
|
148
|
+
def log_message(self, *a): # never log the query (it holds the code)
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
def do_GET(self):
|
|
152
|
+
q = {k: v[0] for k, v in parse_qs(urlsplit(self.path).query).items()}
|
|
153
|
+
if "code" in q or "error" in q:
|
|
154
|
+
outer.result = q
|
|
155
|
+
outer.event.set()
|
|
156
|
+
self.send_response(200)
|
|
157
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
158
|
+
self.send_header("Content-Length", str(len(DONE_PAGE)))
|
|
159
|
+
self.end_headers()
|
|
160
|
+
self.wfile.write(DONE_PAGE)
|
|
161
|
+
|
|
162
|
+
self.httpd = HTTPServer(("127.0.0.1", 0), H)
|
|
163
|
+
self.port = self.httpd.server_address[1]
|
|
164
|
+
self.thread = threading.Thread(target=self._serve, daemon=True)
|
|
165
|
+
|
|
166
|
+
def _serve(self):
|
|
167
|
+
while not self.event.is_set():
|
|
168
|
+
self.httpd.handle_request()
|
|
169
|
+
|
|
170
|
+
def start(self):
|
|
171
|
+
self.httpd.timeout = 0.5
|
|
172
|
+
self.thread.start()
|
|
173
|
+
|
|
174
|
+
def close(self):
|
|
175
|
+
self.event.set()
|
|
176
|
+
try:
|
|
177
|
+
self.httpd.server_close()
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def auth_url(client_id: str, redirect_uri: str, state: str, challenge: str) -> str:
|
|
183
|
+
return AUTH_URI + "?" + urlencode({
|
|
184
|
+
"client_id": client_id,
|
|
185
|
+
"redirect_uri": redirect_uri,
|
|
186
|
+
"response_type": "code",
|
|
187
|
+
"scope": " ".join(SCOPES),
|
|
188
|
+
"access_type": "offline",
|
|
189
|
+
"prompt": "consent",
|
|
190
|
+
"state": state,
|
|
191
|
+
"code_challenge": challenge,
|
|
192
|
+
"code_challenge_method": "S256",
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def connect(*, client_path: Optional[str] = None, show: Callable[[str], None] = print,
|
|
197
|
+
open_browser: Optional[Callable[[str], object]] = None,
|
|
198
|
+
http: Optional[httpx.Client] = None, wait: float = CONSENT_WAIT,
|
|
199
|
+
on_listening: Optional[Callable[[str, str], None]] = None,
|
|
200
|
+
client: Optional[dict] = None) -> dict:
|
|
201
|
+
"""Run the consent flow, verify the scopes, store the token.
|
|
202
|
+
|
|
203
|
+
``on_listening(redirect_uri, state)`` is a test hook. Returns
|
|
204
|
+
{"scopes": [...]}; raises a named BriefError and stores nothing on failure."""
|
|
205
|
+
client = client or load_client(client_path)
|
|
206
|
+
verifier, challenge = _pkce()
|
|
207
|
+
state = secrets.token_urlsafe(24)
|
|
208
|
+
catcher = _Catcher()
|
|
209
|
+
redirect_uri = f"http://127.0.0.1:{catcher.port}"
|
|
210
|
+
catcher.start()
|
|
211
|
+
try:
|
|
212
|
+
url = auth_url(client["client_id"], redirect_uri, state, challenge)
|
|
213
|
+
show("Open this address in your browser and sign in with the Google account that can "
|
|
214
|
+
"see your Doc and Sheet. HyperAgents asks only to view Docs and Sheets "
|
|
215
|
+
"(read-only):\n\n" + url + "\n")
|
|
216
|
+
if open_browser:
|
|
217
|
+
try:
|
|
218
|
+
open_browser(url)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
if on_listening:
|
|
222
|
+
on_listening(redirect_uri, state)
|
|
223
|
+
if not catcher.event.wait(wait):
|
|
224
|
+
raise BriefError(Reason.GOOGLE_CONSENT, "timeout")
|
|
225
|
+
res = catcher.result
|
|
226
|
+
finally:
|
|
227
|
+
catcher.close()
|
|
228
|
+
if res.get("state") != state or res.get("error") or not res.get("code"):
|
|
229
|
+
raise BriefError(Reason.GOOGLE_CONSENT, "denied or state mismatch")
|
|
230
|
+
tok = _token_request({
|
|
231
|
+
"grant_type": "authorization_code",
|
|
232
|
+
"code": res["code"],
|
|
233
|
+
"redirect_uri": redirect_uri,
|
|
234
|
+
"client_id": client["client_id"],
|
|
235
|
+
"client_secret": client["client_secret"],
|
|
236
|
+
"code_verifier": verifier,
|
|
237
|
+
}, http)
|
|
238
|
+
scopes = check_scopes(tok.get("scope", ""))
|
|
239
|
+
if not tok.get("refresh_token"):
|
|
240
|
+
raise BriefError(Reason.GOOGLE_CONSENT, "no refresh token")
|
|
241
|
+
save_token({
|
|
242
|
+
"refresh_token": tok["refresh_token"],
|
|
243
|
+
"access_token": tok.get("access_token", ""),
|
|
244
|
+
"expiry": time.time() + float(tok.get("expires_in") or 0),
|
|
245
|
+
"scopes": scopes,
|
|
246
|
+
"connected_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
247
|
+
})
|
|
248
|
+
return {"scopes": scopes}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _token_request(form: dict, http: Optional[httpx.Client] = None) -> dict:
|
|
252
|
+
c = http or httpx.Client(timeout=TIMEOUT, follow_redirects=False)
|
|
253
|
+
try:
|
|
254
|
+
resp = c.post(token_uri(), data=form)
|
|
255
|
+
except httpx.HTTPError as e:
|
|
256
|
+
raise BriefError(Reason.GOOGLE_UNREACHABLE, type(e).__name__)
|
|
257
|
+
finally:
|
|
258
|
+
if http is None:
|
|
259
|
+
c.close()
|
|
260
|
+
try:
|
|
261
|
+
body = resp.json()
|
|
262
|
+
except Exception:
|
|
263
|
+
body = {}
|
|
264
|
+
if resp.status_code == 200 and isinstance(body, dict) and body.get("access_token"):
|
|
265
|
+
return body
|
|
266
|
+
err = body.get("error") if isinstance(body, dict) else ""
|
|
267
|
+
if err in ("invalid_grant", "unauthorized_client", "invalid_client"):
|
|
268
|
+
raise BriefError(Reason.GOOGLE_EXPIRED, str(err))
|
|
269
|
+
if resp.status_code >= 500:
|
|
270
|
+
raise BriefError(Reason.GOOGLE_UNREACHABLE, f"HTTP {resp.status_code}")
|
|
271
|
+
raise BriefError(Reason.GOOGLE_CONSENT, f"HTTP {resp.status_code}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def access_token(http: Optional[httpx.Client] = None, now: Optional[float] = None) -> str:
|
|
275
|
+
"""A valid access token, refreshed when needed. Scopes re-checked."""
|
|
276
|
+
tok = load_token()
|
|
277
|
+
if not tok.get("refresh_token"):
|
|
278
|
+
raise BriefError(Reason.GOOGLE_NOT_CONNECTED)
|
|
279
|
+
now = time.time() if now is None else now
|
|
280
|
+
if tok.get("access_token") and float(tok.get("expiry") or 0) - 60 > now:
|
|
281
|
+
check_scopes(" ".join(tok.get("scopes") or []))
|
|
282
|
+
return tok["access_token"]
|
|
283
|
+
client = load_client()
|
|
284
|
+
fresh = _token_request({
|
|
285
|
+
"grant_type": "refresh_token",
|
|
286
|
+
"refresh_token": tok["refresh_token"],
|
|
287
|
+
"client_id": client["client_id"],
|
|
288
|
+
"client_secret": client["client_secret"],
|
|
289
|
+
}, http)
|
|
290
|
+
scopes = check_scopes(fresh.get("scope") or " ".join(tok.get("scopes") or []))
|
|
291
|
+
tok.update({"access_token": fresh["access_token"], "scopes": scopes,
|
|
292
|
+
"expiry": now + float(fresh.get("expires_in") or 3600)})
|
|
293
|
+
save_token(tok)
|
|
294
|
+
return tok["access_token"]
|
hyperclaw/brief/links.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Links and file IDs never leave this machine's private config.
|
|
2
|
+
|
|
3
|
+
- ``parse`` turns a pasted Google link into (kind, file id). Only the id is
|
|
4
|
+
stored (owner-only sources.json), never the link.
|
|
5
|
+
- ``scrub`` removes every URL and anything shaped like a Google file id from
|
|
6
|
+
text before it reaches the model, the brief, a notice or a log line.
|
|
7
|
+
- ``link_label`` names a linked file by the text it was linked from in the
|
|
8
|
+
master Doc (read from the source); a bare pasted link gets a neutral
|
|
9
|
+
"untitled link" label instead, so the link itself is never shown.
|
|
10
|
+
- ``LinkLogFilter`` drops HTTP-client log records that carry a Google file
|
|
11
|
+
URL or a Telegram bot URL (httpx logs every request URL at INFO).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
from typing import Iterable, Optional, Tuple
|
|
19
|
+
from urllib.parse import parse_qs, urlsplit
|
|
20
|
+
|
|
21
|
+
DOC = "doc"
|
|
22
|
+
SHEET = "sheet"
|
|
23
|
+
OTHER = "other"
|
|
24
|
+
|
|
25
|
+
# Google file ids are long url-safe strings (typically 44 characters).
|
|
26
|
+
_ID = r"[A-Za-z0-9_-]{20,}"
|
|
27
|
+
ID_RE = re.compile(r"(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{25,}(?![A-Za-z0-9_-])")
|
|
28
|
+
URL_RE = re.compile(r"(?i)(?:https?://|www\.)[^\s<>\"'()\]]+")
|
|
29
|
+
_BARE_GOOGLE = re.compile(r"(?i)\b(?:docs|drive|sheets)\.google\.com/[^\s<>\"']*")
|
|
30
|
+
_PATH_ID = re.compile(rf"/d/(?:e/)?({_ID})")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse(url: str) -> Tuple[str, Optional[str]]:
|
|
34
|
+
"""(kind, file id). kind is DOC, SHEET or OTHER; id None when it can't be
|
|
35
|
+
told (OTHER is returned for every non-Google-Docs/Sheets link)."""
|
|
36
|
+
raw = (url or "").strip()
|
|
37
|
+
if not raw:
|
|
38
|
+
return OTHER, None
|
|
39
|
+
if "://" not in raw:
|
|
40
|
+
raw = "https://" + raw
|
|
41
|
+
try:
|
|
42
|
+
parts = urlsplit(raw)
|
|
43
|
+
except ValueError:
|
|
44
|
+
return OTHER, None
|
|
45
|
+
host = (parts.hostname or "").lower()
|
|
46
|
+
path = parts.path or ""
|
|
47
|
+
if host == "docs.google.com":
|
|
48
|
+
m = _PATH_ID.search(path)
|
|
49
|
+
fid = m.group(1) if m else None
|
|
50
|
+
if path.startswith("/document/") and fid and "/d/e/" not in path:
|
|
51
|
+
return DOC, fid
|
|
52
|
+
if path.startswith("/spreadsheets/") and fid and "/d/e/" not in path:
|
|
53
|
+
return SHEET, fid
|
|
54
|
+
return OTHER, fid
|
|
55
|
+
if host == "drive.google.com":
|
|
56
|
+
m = _PATH_ID.search(path)
|
|
57
|
+
fid = m.group(1) if m else (parse_qs(parts.query).get("id") or [None])[0]
|
|
58
|
+
return OTHER, fid
|
|
59
|
+
return OTHER, None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def is_link(url: str) -> bool:
|
|
63
|
+
u = (url or "").strip().lower()
|
|
64
|
+
return u.startswith(("http://", "https://"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def key_for(file_id: str) -> str:
|
|
68
|
+
"""Stable, non-reversible key for a file (snapshot keys, dedupe)."""
|
|
69
|
+
return hashlib.sha256(("hyperclaw-brief:" + (file_id or "")).encode()).hexdigest()[:16]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scrub(text: str, extra: Iterable[str] = ()) -> str:
|
|
73
|
+
"""Remove URLs, Google file ids and any of ``extra`` (known ids) from text."""
|
|
74
|
+
if not text:
|
|
75
|
+
return ""
|
|
76
|
+
out = URL_RE.sub("[link]", str(text))
|
|
77
|
+
out = _BARE_GOOGLE.sub("[link]", out)
|
|
78
|
+
for x in extra:
|
|
79
|
+
if x:
|
|
80
|
+
out = out.replace(x, "[id]")
|
|
81
|
+
return ID_RE.sub("[id]", out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def contains_link_or_id(text: str, extra: Iterable[str] = ()) -> bool:
|
|
85
|
+
t = text or ""
|
|
86
|
+
if URL_RE.search(t) or _BARE_GOOGLE.search(t) or ID_RE.search(t):
|
|
87
|
+
return True
|
|
88
|
+
return any(x and x in t for x in extra)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def link_label(anchor: str, n: int) -> str:
|
|
92
|
+
"""The label shown for a linked file: its link text from the master Doc,
|
|
93
|
+
or "untitled link n" when the text is itself a link or carries an id."""
|
|
94
|
+
a = " ".join((anchor or "").split()).strip()
|
|
95
|
+
if not a or contains_link_or_id(a):
|
|
96
|
+
return f"untitled link {n}"
|
|
97
|
+
return a[:80]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class LinkLogFilter(logging.Filter):
|
|
101
|
+
"""Drop log records whose message carries a Google file URL or a
|
|
102
|
+
Telegram bot URL. Installed on the HTTP client loggers by the brief."""
|
|
103
|
+
|
|
104
|
+
_PAT = re.compile(r"(?i)(docs\.googleapis\.com|sheets\.googleapis\.com|docs\.google\.com|"
|
|
105
|
+
r"drive\.google\.com|api\.telegram\.org/bot|oauth2\.googleapis\.com|"
|
|
106
|
+
r"/v1/documents/|/v4/spreadsheets/)")
|
|
107
|
+
|
|
108
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
109
|
+
try:
|
|
110
|
+
msg = record.getMessage()
|
|
111
|
+
except Exception:
|
|
112
|
+
return True
|
|
113
|
+
return not self._PAT.search(msg)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_FILTER = LinkLogFilter()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def install_log_filter() -> None:
|
|
120
|
+
"""Idempotent: attach the filter to the HTTP client loggers."""
|
|
121
|
+
for name in ("httpx", "httpcore", "urllib3", "urllib3.connectionpool"):
|
|
122
|
+
lg = logging.getLogger(name)
|
|
123
|
+
if _FILTER not in lg.filters:
|
|
124
|
+
lg.addFilter(_FILTER)
|