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,397 @@
|
|
|
1
|
+
"""Google sign-in for the Workspace tools: one sign-in per person, in their own
|
|
2
|
+
browser, through the team's INTERNAL Google app (installed-app OAuth,
|
|
3
|
+
loopback redirect, PKCE).
|
|
4
|
+
|
|
5
|
+
- The OAuth client file (Desktop app, ``{"installed": {...}}``) arrives in
|
|
6
|
+
each person's team profile and is never part of the package or the repo:
|
|
7
|
+
``HYPERCLAW_GOOGLE_CLIENT_FILE``, else ``$HYPERCLAW_ROOT/profile/google-client.json``.
|
|
8
|
+
A client file inside the installed package is refused.
|
|
9
|
+
- Scopes (Vaughn, 12 Oct): Gmail read, compose and send; read-only Calendar,
|
|
10
|
+
Meet (records and transcripts), Drive, Docs, Sheets and Slides. Calendar
|
|
11
|
+
write (``calendar.events``) is asked for only when Hyper's managed config
|
|
12
|
+
sets ``"google_calendar_write": true``. A grant with any other scope is
|
|
13
|
+
refused and nothing is stored; a grant missing some scopes is kept, and
|
|
14
|
+
the tools that need a missing scope say so.
|
|
15
|
+
- The token lives in the OS secret store when one is available (``keyring``),
|
|
16
|
+
else in ``$HYPERCLAW_ROOT/google/token.json`` (0600, directory 0700). Either
|
|
17
|
+
way it is keyed by this install's ``HYPERCLAW_ROOT``: every person's agent
|
|
18
|
+
has its own token and there is no shared store, so nothing passes between
|
|
19
|
+
people's agents. The token is never printed, logged or sent to a model.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import secrets
|
|
28
|
+
import stat
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Callable, Dict, List, Optional
|
|
32
|
+
from urllib.parse import urlencode, urlsplit
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
from .errors import GoogleError, Reason
|
|
37
|
+
|
|
38
|
+
log = logging.getLogger("hyperclaw.google")
|
|
39
|
+
|
|
40
|
+
_G = "https://www.googleapis.com/auth/"
|
|
41
|
+
GMAIL_READ = _G + "gmail.readonly"
|
|
42
|
+
GMAIL_COMPOSE = _G + "gmail.compose"
|
|
43
|
+
GMAIL_SEND = _G + "gmail.send"
|
|
44
|
+
CALENDAR_READ = _G + "calendar.readonly"
|
|
45
|
+
CALENDAR_WRITE = _G + "calendar.events"
|
|
46
|
+
MEET_READ = _G + "meetings.space.readonly"
|
|
47
|
+
DRIVE_READ = _G + "drive.readonly"
|
|
48
|
+
DOCS_READ = _G + "documents.readonly"
|
|
49
|
+
SHEETS_READ = _G + "spreadsheets.readonly"
|
|
50
|
+
SLIDES_READ = _G + "presentations.readonly"
|
|
51
|
+
|
|
52
|
+
SCOPES = (GMAIL_READ, GMAIL_COMPOSE, GMAIL_SEND, CALENDAR_READ, MEET_READ, DRIVE_READ,
|
|
53
|
+
DOCS_READ, SHEETS_READ, SLIDES_READ)
|
|
54
|
+
OPTIONAL_SCOPES = (CALENDAR_WRITE,)
|
|
55
|
+
|
|
56
|
+
AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
57
|
+
TOKEN_URI = "https://oauth2.googleapis.com/token"
|
|
58
|
+
TIMEOUT = 20.0
|
|
59
|
+
CONSENT_WAIT = 300.0
|
|
60
|
+
KEYRING_SERVICE = "HyperAgents Google"
|
|
61
|
+
STORE_ENV = "HYPERCLAW_GOOGLE_TOKEN_STORE" # "file" forces the file store
|
|
62
|
+
CLIENT_ENV = "HYPERCLAW_GOOGLE_CLIENT_FILE"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _root() -> Path:
|
|
66
|
+
return Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw")).expanduser()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _loopback(url: str) -> bool:
|
|
70
|
+
return (urlsplit(url).hostname or "") in ("127.0.0.1", "localhost", "::1")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def token_uri() -> str:
|
|
74
|
+
"""Google's token endpoint. A loopback override exists for tests only."""
|
|
75
|
+
o = os.environ.get("HYPERCLAW_GOOGLE_TOKEN_URI", "").strip()
|
|
76
|
+
return o if o and _loopback(o) else TOKEN_URI
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ───────────────────────── scopes ─────────────────────────
|
|
80
|
+
|
|
81
|
+
def calendar_write_allowed() -> bool:
|
|
82
|
+
"""Hyper's managed config turns on Calendar write (off by default)."""
|
|
83
|
+
try:
|
|
84
|
+
from hyperclaw import managed_config
|
|
85
|
+
data, _ok = managed_config.raw() # system file, else the verified team profile
|
|
86
|
+
return isinstance(data, dict) and data.get("google_calendar_write") is True
|
|
87
|
+
except Exception:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def requested_scopes() -> tuple:
|
|
92
|
+
return SCOPES + (OPTIONAL_SCOPES if calendar_write_allowed() else ())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_scopes(granted: str) -> List[str]:
|
|
96
|
+
"""The granted scopes, or GoogleError when any was not asked for."""
|
|
97
|
+
got = sorted(set((granted or "").split()))
|
|
98
|
+
allowed = set(requested_scopes())
|
|
99
|
+
extra = [s for s in got if s not in allowed]
|
|
100
|
+
if extra:
|
|
101
|
+
raise GoogleError(Reason.EXTRA_SCOPE, f"{len(extra)} extra")
|
|
102
|
+
if not got:
|
|
103
|
+
raise GoogleError(Reason.CONSENT, "no scopes granted")
|
|
104
|
+
return got
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ───────────────────────── the client file ─────────────────────────
|
|
108
|
+
|
|
109
|
+
def client_path() -> Path:
|
|
110
|
+
explicit = os.environ.get(CLIENT_ENV, "").strip()
|
|
111
|
+
if explicit:
|
|
112
|
+
return Path(explicit).expanduser()
|
|
113
|
+
return _root() / "profile" / "google-client.json"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _inside_package(p: Path) -> bool:
|
|
117
|
+
pkg = Path(__file__).resolve().parents[2]
|
|
118
|
+
try:
|
|
119
|
+
p.resolve().relative_to(pkg)
|
|
120
|
+
return (pkg / "hyperclaw").is_dir()
|
|
121
|
+
except ValueError:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_client(path: Optional[Path] = None) -> Dict[str, str]:
|
|
126
|
+
p = Path(path) if path else client_path()
|
|
127
|
+
if not p.is_file():
|
|
128
|
+
raise GoogleError(Reason.CLIENT_FILE, "missing")
|
|
129
|
+
if _inside_package(p):
|
|
130
|
+
raise GoogleError(Reason.CLIENT_FILE, "inside the package")
|
|
131
|
+
try:
|
|
132
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
133
|
+
except Exception:
|
|
134
|
+
raise GoogleError(Reason.CLIENT_FILE, "unparseable")
|
|
135
|
+
inst = data.get("installed") if isinstance(data, dict) else None
|
|
136
|
+
if not isinstance(inst, dict) or not inst.get("client_id") or not inst.get("client_secret"):
|
|
137
|
+
raise GoogleError(Reason.CLIENT_FILE, "not a desktop client")
|
|
138
|
+
return {"client_id": str(inst["client_id"]), "client_secret": str(inst["client_secret"])}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ───────────────────────── token storage (per install) ─────────────────────────
|
|
142
|
+
|
|
143
|
+
def install_key() -> str:
|
|
144
|
+
"""This install's identity for the secret store: a hash of its root."""
|
|
145
|
+
return hashlib.sha256(str(_root().resolve()).encode()).hexdigest()[:24]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def token_file() -> Path:
|
|
149
|
+
return _root() / "google" / "token.json"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _keyring():
|
|
153
|
+
if os.environ.get(STORE_ENV, "").strip().lower() == "file":
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
import keyring # optional: the OS secret store
|
|
157
|
+
from keyring.backends import fail
|
|
158
|
+
kr = keyring.get_keyring()
|
|
159
|
+
if isinstance(kr, fail.Keyring) or "fail" in type(kr).__module__:
|
|
160
|
+
return None
|
|
161
|
+
return keyring
|
|
162
|
+
except Exception:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def store_name() -> str:
|
|
167
|
+
return "os-secret-store" if _keyring() is not None else "file"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _too_open(p: Path) -> bool:
|
|
171
|
+
try:
|
|
172
|
+
return bool(p.stat().st_mode & (stat.S_IRWXG | stat.S_IRWXO))
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_token() -> dict:
|
|
178
|
+
kr = _keyring()
|
|
179
|
+
if kr is not None:
|
|
180
|
+
try:
|
|
181
|
+
raw = kr.get_password(KEYRING_SERVICE, install_key())
|
|
182
|
+
except Exception:
|
|
183
|
+
raw = None
|
|
184
|
+
if raw:
|
|
185
|
+
try:
|
|
186
|
+
d = json.loads(raw)
|
|
187
|
+
return d if isinstance(d, dict) else {}
|
|
188
|
+
except ValueError:
|
|
189
|
+
return {}
|
|
190
|
+
p = token_file()
|
|
191
|
+
if not p.exists():
|
|
192
|
+
return {}
|
|
193
|
+
if _too_open(p):
|
|
194
|
+
raise GoogleError(Reason.TOKEN_FILE_OPEN)
|
|
195
|
+
try:
|
|
196
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
197
|
+
return d if isinstance(d, dict) else {}
|
|
198
|
+
except Exception:
|
|
199
|
+
return {}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def save_token(tok: dict) -> str:
|
|
203
|
+
"""Store the token; returns where ("os-secret-store" | "file")."""
|
|
204
|
+
text = json.dumps(tok)
|
|
205
|
+
kr = _keyring()
|
|
206
|
+
if kr is not None:
|
|
207
|
+
try:
|
|
208
|
+
kr.set_password(KEYRING_SERVICE, install_key(), text)
|
|
209
|
+
_remove_file()
|
|
210
|
+
return "os-secret-store"
|
|
211
|
+
except Exception:
|
|
212
|
+
log.warning("google: OS secret store unavailable; using the private file")
|
|
213
|
+
p = token_file()
|
|
214
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
try:
|
|
216
|
+
os.chmod(p.parent, 0o700)
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
tmp = p.with_name(p.name + ".tmp")
|
|
220
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
221
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
222
|
+
f.write(text)
|
|
223
|
+
os.chmod(tmp, 0o600)
|
|
224
|
+
os.replace(tmp, p)
|
|
225
|
+
return "file"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _remove_file() -> None:
|
|
229
|
+
try:
|
|
230
|
+
token_file().unlink()
|
|
231
|
+
except FileNotFoundError:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def disconnect() -> bool:
|
|
236
|
+
had = False
|
|
237
|
+
kr = _keyring()
|
|
238
|
+
if kr is not None:
|
|
239
|
+
try:
|
|
240
|
+
if kr.get_password(KEYRING_SERVICE, install_key()):
|
|
241
|
+
kr.delete_password(KEYRING_SERVICE, install_key())
|
|
242
|
+
had = True
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
if token_file().exists():
|
|
246
|
+
_remove_file()
|
|
247
|
+
had = True
|
|
248
|
+
return had
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def connected() -> bool:
|
|
252
|
+
try:
|
|
253
|
+
return bool(load_token().get("refresh_token"))
|
|
254
|
+
except GoogleError:
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def granted_scopes() -> List[str]:
|
|
259
|
+
try:
|
|
260
|
+
return list(load_token().get("scopes") or [])
|
|
261
|
+
except GoogleError:
|
|
262
|
+
return []
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def has_scope(scope: str) -> bool:
|
|
266
|
+
return scope in granted_scopes()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def account() -> str:
|
|
270
|
+
try:
|
|
271
|
+
return str(load_token().get("account") or "")
|
|
272
|
+
except GoogleError:
|
|
273
|
+
return ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ───────────────────────── consent flow ─────────────────────────
|
|
277
|
+
|
|
278
|
+
def auth_url(client_id: str, redirect_uri: str, state: str, challenge: str) -> str:
|
|
279
|
+
return AUTH_URI + "?" + urlencode({
|
|
280
|
+
"client_id": client_id,
|
|
281
|
+
"redirect_uri": redirect_uri,
|
|
282
|
+
"response_type": "code",
|
|
283
|
+
"scope": " ".join(requested_scopes()),
|
|
284
|
+
"access_type": "offline",
|
|
285
|
+
"prompt": "consent",
|
|
286
|
+
"state": state,
|
|
287
|
+
"code_challenge": challenge,
|
|
288
|
+
"code_challenge_method": "S256",
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def connect(*, client_file: Optional[Path] = None, show: Callable[[str], None] = print,
|
|
293
|
+
open_browser: Optional[Callable[[str], object]] = None,
|
|
294
|
+
http: Optional[httpx.Client] = None, wait: float = CONSENT_WAIT,
|
|
295
|
+
on_listening: Optional[Callable[[str, str], None]] = None,
|
|
296
|
+
whoami: Optional[Callable[[str], str]] = None) -> dict:
|
|
297
|
+
"""Run the consent flow in the person's own browser (loopback), verify the
|
|
298
|
+
scopes, store the token. Raises a named GoogleError; nothing is stored
|
|
299
|
+
on failure. ``on_listening(redirect_uri, state)`` is a test hook."""
|
|
300
|
+
from hyperclaw.brief.google_auth import _Catcher, _pkce
|
|
301
|
+
client = load_client(client_file)
|
|
302
|
+
verifier, challenge = _pkce()
|
|
303
|
+
state = secrets.token_urlsafe(24)
|
|
304
|
+
catcher = _Catcher()
|
|
305
|
+
redirect_uri = f"http://127.0.0.1:{catcher.port}"
|
|
306
|
+
catcher.start()
|
|
307
|
+
try:
|
|
308
|
+
url = auth_url(client["client_id"], redirect_uri, state, challenge)
|
|
309
|
+
show("Open this address in your own browser and sign in with your Google account. "
|
|
310
|
+
"HyperAgents asks to read Gmail, write drafts and send mail you approve, and to "
|
|
311
|
+
"read Calendar, Meet records and transcripts, Drive, Docs, Sheets and Slides:\n\n"
|
|
312
|
+
+ url + "\n")
|
|
313
|
+
if open_browser:
|
|
314
|
+
try:
|
|
315
|
+
open_browser(url)
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
if on_listening:
|
|
319
|
+
on_listening(redirect_uri, state)
|
|
320
|
+
if not catcher.event.wait(wait):
|
|
321
|
+
raise GoogleError(Reason.CONSENT, "timeout")
|
|
322
|
+
res = catcher.result
|
|
323
|
+
finally:
|
|
324
|
+
catcher.close()
|
|
325
|
+
if res.get("state") != state or res.get("error") or not res.get("code"):
|
|
326
|
+
raise GoogleError(Reason.CONSENT, "denied or state mismatch")
|
|
327
|
+
tok = token_request({
|
|
328
|
+
"grant_type": "authorization_code", "code": res["code"],
|
|
329
|
+
"redirect_uri": redirect_uri, "client_id": client["client_id"],
|
|
330
|
+
"client_secret": client["client_secret"], "code_verifier": verifier,
|
|
331
|
+
}, http)
|
|
332
|
+
scopes = check_scopes(tok.get("scope", ""))
|
|
333
|
+
if not tok.get("refresh_token"):
|
|
334
|
+
raise GoogleError(Reason.CONSENT, "no refresh token")
|
|
335
|
+
acct = ""
|
|
336
|
+
if whoami is not None and GMAIL_READ in scopes:
|
|
337
|
+
try:
|
|
338
|
+
acct = whoami(tok["access_token"])
|
|
339
|
+
except Exception:
|
|
340
|
+
acct = ""
|
|
341
|
+
where = save_token({
|
|
342
|
+
"refresh_token": tok["refresh_token"], "access_token": tok.get("access_token", ""),
|
|
343
|
+
"expiry": time.time() + float(tok.get("expires_in") or 0), "scopes": scopes,
|
|
344
|
+
"account": acct, "install": install_key(),
|
|
345
|
+
"connected_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
346
|
+
})
|
|
347
|
+
missing = [s for s in SCOPES if s not in scopes]
|
|
348
|
+
return {"scopes": scopes, "missing": missing, "store": where, "account": acct}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def token_request(form: dict, http: Optional[httpx.Client] = None) -> dict:
|
|
352
|
+
c = http or httpx.Client(timeout=TIMEOUT, follow_redirects=False)
|
|
353
|
+
try:
|
|
354
|
+
resp = c.post(token_uri(), data=form)
|
|
355
|
+
except httpx.HTTPError as e:
|
|
356
|
+
raise GoogleError(Reason.UNREACHABLE, type(e).__name__)
|
|
357
|
+
finally:
|
|
358
|
+
if http is None:
|
|
359
|
+
c.close()
|
|
360
|
+
try:
|
|
361
|
+
body = resp.json()
|
|
362
|
+
except Exception:
|
|
363
|
+
body = {}
|
|
364
|
+
if resp.status_code == 200 and isinstance(body, dict) and body.get("access_token"):
|
|
365
|
+
return body
|
|
366
|
+
err = body.get("error") if isinstance(body, dict) else ""
|
|
367
|
+
if err in ("invalid_grant", "unauthorized_client", "invalid_client"):
|
|
368
|
+
raise GoogleError(Reason.EXPIRED, str(err))
|
|
369
|
+
if resp.status_code >= 500:
|
|
370
|
+
raise GoogleError(Reason.UNREACHABLE, f"HTTP {resp.status_code}")
|
|
371
|
+
raise GoogleError(Reason.CONSENT, f"HTTP {resp.status_code}")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def access_token(http: Optional[httpx.Client] = None, now: Optional[float] = None,
|
|
375
|
+
force_refresh: bool = False) -> str:
|
|
376
|
+
"""A valid access token for THIS install, refreshed when needed (and
|
|
377
|
+
reloaded from the store each time, so a re-sign-in takes effect at once).
|
|
378
|
+
A revoked grant raises EXPIRED: sign in again."""
|
|
379
|
+
tok = load_token()
|
|
380
|
+
if not tok.get("refresh_token"):
|
|
381
|
+
raise GoogleError(Reason.NOT_CONNECTED)
|
|
382
|
+
if tok.get("install") and tok["install"] != install_key():
|
|
383
|
+
raise GoogleError(Reason.NOT_CONNECTED, "token belongs to another install")
|
|
384
|
+
now = time.time() if now is None else now
|
|
385
|
+
if not force_refresh and tok.get("access_token") and float(tok.get("expiry") or 0) - 60 > now:
|
|
386
|
+
check_scopes(" ".join(tok.get("scopes") or []))
|
|
387
|
+
return tok["access_token"]
|
|
388
|
+
client = load_client()
|
|
389
|
+
fresh = token_request({
|
|
390
|
+
"grant_type": "refresh_token", "refresh_token": tok["refresh_token"],
|
|
391
|
+
"client_id": client["client_id"], "client_secret": client["client_secret"],
|
|
392
|
+
}, http)
|
|
393
|
+
scopes = check_scopes(fresh.get("scope") or " ".join(tok.get("scopes") or []))
|
|
394
|
+
tok.update({"access_token": fresh["access_token"], "scopes": scopes,
|
|
395
|
+
"expiry": now + float(fresh.get("expires_in") or 3600)})
|
|
396
|
+
save_token(tok)
|
|
397
|
+
return tok["access_token"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Google Calendar: list events and free/busy (reads, GET only); create or
|
|
2
|
+
change an event (ask tier, and only when Hyper's managed config grants
|
|
3
|
+
Calendar write; the default sign-in is read-only).
|
|
4
|
+
|
|
5
|
+
Free/busy is worked out from the events list (GET), so a read never sends a
|
|
6
|
+
request body anywhere. Restricted-client guard: each event (title,
|
|
7
|
+
description, location, attendees, organizer) is checked on its own.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import datetime as _dt
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
from hyperclaw import client_guard
|
|
16
|
+
|
|
17
|
+
from . import auth
|
|
18
|
+
from .api import GoogleApi
|
|
19
|
+
from .errors import GoogleError, Reason
|
|
20
|
+
|
|
21
|
+
MAX_EVENTS = 100
|
|
22
|
+
MAX_DESC = 2000
|
|
23
|
+
SOURCE = "google_calendar"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _iso(value: str, *, field: str) -> str:
|
|
27
|
+
"""RFC 3339 from 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM' or an RFC 3339 string (UTC if no zone)."""
|
|
28
|
+
v = (value or "").strip()
|
|
29
|
+
if not v:
|
|
30
|
+
raise ValueError(f"{field} is required")
|
|
31
|
+
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y-%m-%d"):
|
|
32
|
+
try:
|
|
33
|
+
d = _dt.datetime.strptime(v, fmt).replace(tzinfo=_dt.timezone.utc)
|
|
34
|
+
return d.isoformat().replace("+00:00", "Z")
|
|
35
|
+
except ValueError:
|
|
36
|
+
pass
|
|
37
|
+
try:
|
|
38
|
+
d = _dt.datetime.fromisoformat(v.replace("Z", "+00:00"))
|
|
39
|
+
except ValueError:
|
|
40
|
+
raise ValueError(f"{field} must be a date and time like 2026-10-14 15:00")
|
|
41
|
+
if d.tzinfo is None:
|
|
42
|
+
d = d.replace(tzinfo=_dt.timezone.utc)
|
|
43
|
+
return d.isoformat()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _window(start: str, end: str, days: int) -> tuple:
|
|
47
|
+
now = _dt.datetime.now(_dt.timezone.utc)
|
|
48
|
+
t0 = _iso(start, field="start") if start else now.isoformat()
|
|
49
|
+
if end:
|
|
50
|
+
t1 = _iso(end, field="end")
|
|
51
|
+
else:
|
|
52
|
+
base = _dt.datetime.fromisoformat(t0.replace("Z", "+00:00"))
|
|
53
|
+
t1 = (base + _dt.timedelta(days=max(1, min(int(days or 7), 62)))).isoformat()
|
|
54
|
+
return t0, t1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _event(e: dict) -> dict:
|
|
58
|
+
def when(x):
|
|
59
|
+
x = x or {}
|
|
60
|
+
return str(x.get("dateTime") or x.get("date") or "")
|
|
61
|
+
desc = str(e.get("description") or "")
|
|
62
|
+
return {
|
|
63
|
+
"id": str(e.get("id") or ""), "summary": str(e.get("summary") or ""),
|
|
64
|
+
"start": when(e.get("start")), "end": when(e.get("end")),
|
|
65
|
+
"location": str(e.get("location") or ""),
|
|
66
|
+
"description": desc[:MAX_DESC] + ("... [clipped]" if len(desc) > MAX_DESC else ""),
|
|
67
|
+
"attendees": [str(a.get("email") or a.get("displayName") or "")
|
|
68
|
+
for a in (e.get("attendees") or []) if isinstance(a, dict)],
|
|
69
|
+
"organizer": str((e.get("organizer") or {}).get("email") or ""),
|
|
70
|
+
"status": str(e.get("status") or ""),
|
|
71
|
+
"transparency": str(e.get("transparency") or "opaque"),
|
|
72
|
+
"meet_link": str(e.get("hangoutLink") or ""),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _events(api: GoogleApi, calendar_id: str, t0: str, t1: str, query: str = "",
|
|
77
|
+
limit: int = MAX_EVENTS) -> List[dict]:
|
|
78
|
+
params = {"timeMin": t0, "timeMax": t1, "singleEvents": "true", "orderBy": "startTime",
|
|
79
|
+
"maxResults": max(1, min(int(limit or MAX_EVENTS), MAX_EVENTS))}
|
|
80
|
+
if query:
|
|
81
|
+
params["q"] = query
|
|
82
|
+
body = api.get("calendar", f"calendars/{quote(calendar_id or 'primary', safe='@.')}/events",
|
|
83
|
+
params)
|
|
84
|
+
return [_event(e) for e in (body.get("items") or []) if isinstance(e, dict)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def list_events(api: GoogleApi, *, start: str = "", end: str = "", days: int = 7,
|
|
88
|
+
query: str = "", calendar_id: str = "primary", max_results: int = 25,
|
|
89
|
+
outside: Optional[bool] = None) -> dict:
|
|
90
|
+
t0, t1 = _window(start, end, days)
|
|
91
|
+
items = _events(api, calendar_id, t0, t1, query, max_results)
|
|
92
|
+
kept, withheld = client_guard.filter_items(items, SOURCE, outside=outside)
|
|
93
|
+
return {"calendar": calendar_id, "from": t0, "to": t1, "items": kept,
|
|
94
|
+
"withheld": withheld}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def free_busy(api: GoogleApi, *, start: str = "", end: str = "", days: int = 1,
|
|
98
|
+
calendar_id: str = "primary", outside: Optional[bool] = None) -> dict:
|
|
99
|
+
"""Busy blocks only (no titles): times of events that block time."""
|
|
100
|
+
t0, t1 = _window(start, end, days)
|
|
101
|
+
busy = []
|
|
102
|
+
for e in _events(api, calendar_id, t0, t1):
|
|
103
|
+
if e["status"] == "cancelled" or e["transparency"] == "transparent":
|
|
104
|
+
continue
|
|
105
|
+
busy.append({"start": e["start"], "end": e["end"]})
|
|
106
|
+
return {"calendar": calendar_id, "from": t0, "to": t1, "busy": busy}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ───────────────────────── writes (ask tier only) ─────────────────────────
|
|
110
|
+
|
|
111
|
+
def _need_write() -> None:
|
|
112
|
+
if not auth.calendar_write_allowed() or not auth.has_scope(auth.CALENDAR_WRITE):
|
|
113
|
+
raise GoogleError(Reason.MISSING_SCOPE, "calendar write")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _event_body(title: str, start: str, end: str, location: str = "", description: str = "",
|
|
117
|
+
attendees: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
118
|
+
body: Dict[str, Any] = {}
|
|
119
|
+
if title:
|
|
120
|
+
body["summary"] = title
|
|
121
|
+
if start:
|
|
122
|
+
body["start"] = {"dateTime": _iso(start, field="start")}
|
|
123
|
+
if end:
|
|
124
|
+
body["end"] = {"dateTime": _iso(end, field="end")}
|
|
125
|
+
if location:
|
|
126
|
+
body["location"] = location
|
|
127
|
+
if description:
|
|
128
|
+
body["description"] = description
|
|
129
|
+
if attendees:
|
|
130
|
+
body["attendees"] = [{"email": a} for a in attendees]
|
|
131
|
+
return body
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def create_event(api: GoogleApi, *, title: str, start: str, end: str, location: str = "",
|
|
135
|
+
description: str = "", attendees: Optional[List[str]] = None,
|
|
136
|
+
calendar_id: str = "primary") -> dict:
|
|
137
|
+
_need_write()
|
|
138
|
+
body = _event_body(title, start, end, location, description, attendees)
|
|
139
|
+
res = api.post("calendar", f"calendars/{quote(calendar_id, safe='@.')}/events"
|
|
140
|
+
f"?sendUpdates=none", body)
|
|
141
|
+
if not res.get("id"):
|
|
142
|
+
raise GoogleError(Reason.UNREADABLE, "no event id")
|
|
143
|
+
return {"event_id": str(res["id"])}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def update_event(api: GoogleApi, *, event_id: str, title: str = "", start: str = "",
|
|
147
|
+
end: str = "", location: str = "", description: str = "",
|
|
148
|
+
calendar_id: str = "primary") -> dict:
|
|
149
|
+
_need_write()
|
|
150
|
+
body = _event_body(title, start, end, location, description)
|
|
151
|
+
if not body:
|
|
152
|
+
raise ValueError("nothing to change")
|
|
153
|
+
res = api.patch("calendar", f"calendars/{quote(calendar_id, safe='@.')}/events/"
|
|
154
|
+
f"{quote(event_id, safe='')}?sendUpdates=none", body)
|
|
155
|
+
if not res.get("id"):
|
|
156
|
+
raise GoogleError(Reason.UNREADABLE, "no event id")
|
|
157
|
+
return {"event_id": str(res["id"])}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""`hyperclaw google ...` (local terminal = the owner): connect, status, disconnect.
|
|
2
|
+
|
|
3
|
+
One sign-in per person, in their own browser. The token is never shown.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
google_app = typer.Typer(help="Google Workspace for HyperAgents: Gmail, Calendar, Drive, Docs, "
|
|
10
|
+
"Sheets, Slides and Meet (sends and changes need your approval)")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _env() -> None:
|
|
14
|
+
try:
|
|
15
|
+
from hyperclaw.userconfig import load_env_into_process
|
|
16
|
+
load_env_into_process()
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _whoami(token: str) -> str:
|
|
22
|
+
from .api import GoogleApi
|
|
23
|
+
from .gmail import profile_email
|
|
24
|
+
with GoogleApi(token_fn=lambda **_: token) as api:
|
|
25
|
+
return profile_email(api)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@google_app.command("connect")
|
|
29
|
+
def connect_cmd(open_browser: bool = typer.Option(
|
|
30
|
+
False, "--open", help="Open the sign-in page in your default browser")) -> None:
|
|
31
|
+
"""Sign in to Google in your own browser (loopback; nothing is shared with other installs)."""
|
|
32
|
+
_env()
|
|
33
|
+
from . import auth
|
|
34
|
+
from .errors import GoogleError
|
|
35
|
+
opener = None
|
|
36
|
+
if open_browser:
|
|
37
|
+
import webbrowser
|
|
38
|
+
opener = webbrowser.open
|
|
39
|
+
try:
|
|
40
|
+
res = auth.connect(show=typer.echo, open_browser=opener, whoami=_whoami)
|
|
41
|
+
except GoogleError as e:
|
|
42
|
+
typer.echo(e.text)
|
|
43
|
+
raise typer.Exit(1)
|
|
44
|
+
who = f" as {res['account']}" if res.get("account") else ""
|
|
45
|
+
typer.echo(f"✓ Google connected{who}. Token kept in "
|
|
46
|
+
f"{'the system keychain' if res['store'] == 'os-secret-store' else 'a private file'}"
|
|
47
|
+
f" for this install only.")
|
|
48
|
+
if res.get("missing"):
|
|
49
|
+
typer.echo("Not granted at sign-in (the tools that need these will say so): "
|
|
50
|
+
+ ", ".join(s.rsplit("/", 1)[-1] for s in res["missing"]))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@google_app.command("status")
|
|
54
|
+
def status_cmd() -> None:
|
|
55
|
+
"""Is Google connected on this install, and with which access."""
|
|
56
|
+
_env()
|
|
57
|
+
from . import auth
|
|
58
|
+
if not auth.connected():
|
|
59
|
+
typer.echo("Google: not connected. Run `hyperclaw google connect`.")
|
|
60
|
+
return
|
|
61
|
+
acct = auth.account()
|
|
62
|
+
typer.echo(f"Google: connected{(' as ' + acct) if acct else ''} "
|
|
63
|
+
f"({'system keychain' if auth.store_name() == 'os-secret-store' else 'private file'}).")
|
|
64
|
+
typer.echo("Access: " + ", ".join(s.rsplit("/", 1)[-1] for s in auth.granted_scopes()))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@google_app.command("disconnect")
|
|
68
|
+
def disconnect_cmd() -> None:
|
|
69
|
+
"""Remove this install's Google token."""
|
|
70
|
+
_env()
|
|
71
|
+
from . import auth
|
|
72
|
+
typer.echo("Google disconnected." if auth.disconnect() else "Google wasn't connected.")
|