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,1427 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Assistant Integration Layer
|
|
3
|
+
Unified interface for Gmail, Calendar, iMessage, Supabase, Telegram
|
|
4
|
+
All wired into HyperClaw agent tool execution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import logging
|
|
15
|
+
from datetime import datetime, timezone, timedelta
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from dotenv import load_dotenv
|
|
21
|
+
|
|
22
|
+
load_dotenv(str(Path.home() / '.hyperclaw/.env'))
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("hyperclaw.integrations_layer")
|
|
25
|
+
|
|
26
|
+
# workspace/secrets/ or the secrets/ dir onboarding creates (realpath-resolved)
|
|
27
|
+
from hyperclaw.api_utils import gmail_token_path
|
|
28
|
+
from hyperclaw.taint import source as _taint_source, training_sink
|
|
29
|
+
GMAIL_TOKEN_FILE = gmail_token_path()
|
|
30
|
+
GOOGLE_CREDS_FILE = Path(os.getenv('GOOGLE_CREDENTIALS_FILE', ''))
|
|
31
|
+
|
|
32
|
+
# ─────────────────────────────────────────────
|
|
33
|
+
# TOKEN MANAGER
|
|
34
|
+
# ─────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
class GoogleTokenManager:
|
|
37
|
+
"""Manages Google OAuth tokens with auto-refresh.
|
|
38
|
+
|
|
39
|
+
Hardened version: always checks expiry, auto-refreshes on 401,
|
|
40
|
+
reloads from disk every 30s (not 5min), and retries with env fallbacks.
|
|
41
|
+
Self-heals by syncing refresh_token from .env if token file's is revoked.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_token_data: dict = {}
|
|
45
|
+
_last_loaded: float = 0
|
|
46
|
+
_refresh_failures: int = 0
|
|
47
|
+
# Circuit breaker: stop hammering Google once the grant is dead/revoked.
|
|
48
|
+
MAX_FAILURES: int = 5
|
|
49
|
+
_circuit_open_until: float = 0.0 # epoch seconds; while now < this, _refresh() is a no-op
|
|
50
|
+
_alerted: bool = False # ensures only ONE reauth alert per outage
|
|
51
|
+
_auth_revoked: bool = False # terminal state: refresh token returned invalid_grant
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def load(cls, force: bool = False) -> dict:
|
|
55
|
+
"""Load token from disk. Reloads every 30s or on force."""
|
|
56
|
+
import time
|
|
57
|
+
if force or (time.time() - cls._last_loaded > 30):
|
|
58
|
+
if GMAIL_TOKEN_FILE.exists():
|
|
59
|
+
try:
|
|
60
|
+
with open(GMAIL_TOKEN_FILE) as f:
|
|
61
|
+
cls._token_data = json.load(f)
|
|
62
|
+
cls._last_loaded = time.time()
|
|
63
|
+
logger.debug(f"Loaded token from {GMAIL_TOKEN_FILE} (refresh: {cls._token_data.get('refresh_token','')[:20]}...)")
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error(f"Failed to load token file {GMAIL_TOKEN_FILE}: {e}")
|
|
66
|
+
else:
|
|
67
|
+
logger.warning(f"Token file not found: {GMAIL_TOKEN_FILE}")
|
|
68
|
+
return cls._token_data
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def _is_expired(cls, data: dict) -> bool:
|
|
72
|
+
"""Check if token is expired or will expire within 10 minutes."""
|
|
73
|
+
expiry_str = data.get('expiry')
|
|
74
|
+
if not expiry_str:
|
|
75
|
+
return True # No expiry = assume expired
|
|
76
|
+
try:
|
|
77
|
+
expiry = datetime.fromisoformat(expiry_str.replace('Z', '+00:00'))
|
|
78
|
+
if expiry.tzinfo is None:
|
|
79
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
80
|
+
now = datetime.now(timezone.utc)
|
|
81
|
+
return now >= expiry - timedelta(minutes=10)
|
|
82
|
+
except Exception:
|
|
83
|
+
return True # Can't parse = assume expired
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def get_access_token(cls) -> str:
|
|
87
|
+
"""Get valid access token, refreshing if needed."""
|
|
88
|
+
data = cls.load()
|
|
89
|
+
|
|
90
|
+
# Support both 'token' and 'access_token' keys
|
|
91
|
+
token = data.get('token', '') or data.get('access_token', '')
|
|
92
|
+
|
|
93
|
+
# If token is empty, force reload from disk
|
|
94
|
+
if not token:
|
|
95
|
+
data = cls.load(force=True)
|
|
96
|
+
token = data.get('token', '') or data.get('access_token', '')
|
|
97
|
+
|
|
98
|
+
# If still empty or expired, refresh
|
|
99
|
+
if not token or cls._is_expired(data):
|
|
100
|
+
cls._refresh()
|
|
101
|
+
data = cls._token_data
|
|
102
|
+
token = data.get('token', '') or data.get('access_token', '')
|
|
103
|
+
|
|
104
|
+
return token
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def handle_401(cls) -> str:
|
|
108
|
+
"""Called when a Gmail API call returns 401. Force refresh and return new token."""
|
|
109
|
+
logger.warning("Got 401 from Gmail API, force-refreshing token...")
|
|
110
|
+
cls._last_loaded = 0 # Force disk reload first
|
|
111
|
+
cls.load(force=True)
|
|
112
|
+
cls._refresh()
|
|
113
|
+
data = cls._token_data
|
|
114
|
+
return data.get('token', '') or data.get('access_token', '')
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def is_healthy(cls) -> bool:
|
|
118
|
+
"""True when Gmail/Calendar auth is usable. Used to gate email send/bump/escalation so
|
|
119
|
+
The assistant does not generate work (bumps, SLA breaches) it cannot deliver while the token is dead."""
|
|
120
|
+
if cls._auth_revoked:
|
|
121
|
+
return False
|
|
122
|
+
try:
|
|
123
|
+
tok = cls.get_access_token()
|
|
124
|
+
return bool(tok) and cls._refresh_failures == 0 and not cls._is_expired(cls._token_data)
|
|
125
|
+
except Exception:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def _refresh(cls) -> None:
|
|
130
|
+
"""Refresh the access token. Tries token file creds, then env var fallbacks.
|
|
131
|
+
|
|
132
|
+
Self-healing: if the token file's refresh_token is revoked but the env var's works,
|
|
133
|
+
automatically update the token file with the working refresh_token.
|
|
134
|
+
"""
|
|
135
|
+
import time
|
|
136
|
+
# Circuit breaker: after repeated failures (or a revoked grant) we back off instead of
|
|
137
|
+
# retrying on every single Gmail/Calendar call. This is what stopped the 8000+ retries.
|
|
138
|
+
if time.time() < cls._circuit_open_until:
|
|
139
|
+
return
|
|
140
|
+
data = cls._token_data
|
|
141
|
+
|
|
142
|
+
# Build credential pairs to try (token file first, then env vars)
|
|
143
|
+
cred_sets = []
|
|
144
|
+
file_refresh = data.get('refresh_token', '')
|
|
145
|
+
|
|
146
|
+
# From token file
|
|
147
|
+
if data.get('client_id') and file_refresh:
|
|
148
|
+
cred_sets.append({
|
|
149
|
+
'client_id': data['client_id'],
|
|
150
|
+
'client_secret': data.get('client_secret', ''),
|
|
151
|
+
'refresh_token': file_refresh,
|
|
152
|
+
'source': 'token_file',
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
# From env vars (as fallback)
|
|
156
|
+
env_client_id = os.getenv('GMAIL_CLIENT_ID', '')
|
|
157
|
+
env_client_secret = os.getenv('GMAIL_CLIENT_SECRET', '')
|
|
158
|
+
env_refresh = os.getenv('GMAIL_REFRESH_TOKEN', '')
|
|
159
|
+
if env_client_id and env_refresh and env_refresh != file_refresh:
|
|
160
|
+
cred_sets.append({
|
|
161
|
+
'client_id': env_client_id,
|
|
162
|
+
'client_secret': env_client_secret,
|
|
163
|
+
'refresh_token': env_refresh,
|
|
164
|
+
'source': 'env_vars',
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
for i, creds in enumerate(cred_sets):
|
|
168
|
+
source = creds.pop('source', f'set_{i}')
|
|
169
|
+
try:
|
|
170
|
+
params = {**creds, 'grant_type': 'refresh_token'}
|
|
171
|
+
with httpx.Client(timeout=15) as client:
|
|
172
|
+
resp = client.post('https://oauth2.googleapis.com/token', data=params)
|
|
173
|
+
result = resp.json()
|
|
174
|
+
|
|
175
|
+
if 'access_token' in result:
|
|
176
|
+
new_token = result['access_token']
|
|
177
|
+
expires_in = result.get('expires_in', 3600)
|
|
178
|
+
expiry = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
179
|
+
|
|
180
|
+
cls._token_data['token'] = new_token
|
|
181
|
+
cls._token_data['access_token'] = new_token
|
|
182
|
+
cls._token_data['expiry'] = expiry.isoformat()
|
|
183
|
+
|
|
184
|
+
# Self-heal: if env var worked but file didn't, sync the working token to file
|
|
185
|
+
if source == 'env_vars' and creds['refresh_token'] != file_refresh:
|
|
186
|
+
logger.warning("Self-healing: updating token file with working refresh_token from env")
|
|
187
|
+
cls._token_data['refresh_token'] = creds['refresh_token']
|
|
188
|
+
cls._token_data['client_id'] = creds['client_id']
|
|
189
|
+
cls._token_data['client_secret'] = creds['client_secret']
|
|
190
|
+
|
|
191
|
+
# Handle token rotation (Google may issue new refresh tokens)
|
|
192
|
+
if result.get('refresh_token'):
|
|
193
|
+
cls._token_data['refresh_token'] = result['refresh_token']
|
|
194
|
+
logger.info("Google returned a rotated refresh_token, saved.")
|
|
195
|
+
|
|
196
|
+
# Persist to disk
|
|
197
|
+
try:
|
|
198
|
+
with open(GMAIL_TOKEN_FILE, 'w') as f:
|
|
199
|
+
json.dump(cls._token_data, f, indent=2)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.error(f"Failed to persist token to disk: {e}")
|
|
202
|
+
|
|
203
|
+
cls._last_loaded = 0 # Force reload next time
|
|
204
|
+
cls._refresh_failures = 0
|
|
205
|
+
cls._alerted = False
|
|
206
|
+
cls._circuit_open_until = 0.0
|
|
207
|
+
cls._auth_revoked = False
|
|
208
|
+
logger.info(f"Google token refreshed (source: {source}), expires {expiry.isoformat()}")
|
|
209
|
+
return
|
|
210
|
+
else:
|
|
211
|
+
error_code = str(result.get('error', '')).lower()
|
|
212
|
+
error_desc = result.get('error_description', result.get('error', 'unknown'))
|
|
213
|
+
logger.warning(f"Refresh attempt {i} ({source}) failed: {error_desc}")
|
|
214
|
+
# invalid_grant / unauthorized_client are PERMANENT (revoked refresh token or
|
|
215
|
+
# wrong client). Trying more creds or retrying later is futile until reauth.
|
|
216
|
+
if error_code in ('invalid_grant', 'unauthorized_client'):
|
|
217
|
+
cls._auth_revoked = True
|
|
218
|
+
break
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.warning(f"Token refresh attempt {i} ({source}) failed: {e}")
|
|
221
|
+
|
|
222
|
+
cls._refresh_failures += 1
|
|
223
|
+
logger.error(f"All token refresh attempts failed (failure #{cls._refresh_failures})")
|
|
224
|
+
# Trip the circuit once we hit the ceiling or the grant is terminally revoked, and alert
|
|
225
|
+
# the owner exactly once (instead of thousands of silent failures filling the logs).
|
|
226
|
+
if cls._auth_revoked or cls._refresh_failures >= cls.MAX_FAILURES:
|
|
227
|
+
cooldown = 3600 if cls._auth_revoked else 900 # revoked needs human; transient backs off
|
|
228
|
+
cls._circuit_open_until = time.time() + cooldown
|
|
229
|
+
if not cls._alerted:
|
|
230
|
+
cls._alerted = True
|
|
231
|
+
_send_gmail_reauth_alert(revoked=cls._auth_revoked)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _send_gmail_reauth_alert(revoked: bool = True) -> None:
|
|
235
|
+
"""Post a Gmail-reauth Telegram alert, at most once per hour ACROSS ALL processes.
|
|
236
|
+
Best-effort, never raises. The file-based cooldown prevents every daemon (hermes, briefing,
|
|
237
|
+
server, CLI) from each firing its own alert during the same outage."""
|
|
238
|
+
try:
|
|
239
|
+
import time as _t
|
|
240
|
+
stamp = Path.home() / '.hyperclaw/data/.gmail_alert_sent'
|
|
241
|
+
try:
|
|
242
|
+
if stamp.exists() and (_t.time() - stamp.stat().st_mtime) < 3600:
|
|
243
|
+
return # another process already alerted within the last hour
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
token = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
|
248
|
+
chat_id = os.getenv('TELEGRAM_CHAT_ID', '')
|
|
249
|
+
if not token or not chat_id:
|
|
250
|
+
return
|
|
251
|
+
if revoked:
|
|
252
|
+
text = ("Gmail auth is down. The OAuth refresh token is revoked (invalid_grant). "
|
|
253
|
+
"Email send and follow-ups are paused until reauth.\n"
|
|
254
|
+
"Fix: run python3 ~/.hyperclaw/scripts/gmail_reauth.py on the Mac, then publish "
|
|
255
|
+
"the OAuth app to Production in Google Cloud so it stops expiring weekly.")
|
|
256
|
+
else:
|
|
257
|
+
text = ("Gmail token refresh has failed repeatedly; email send/follow-ups may be "
|
|
258
|
+
"degraded. If it persists, run python3 ~/.hyperclaw/scripts/gmail_reauth.py")
|
|
259
|
+
with httpx.Client(timeout=10) as client:
|
|
260
|
+
from hyperclaw.tools.egress import telegram_payload
|
|
261
|
+
client.post(f'https://api.telegram.org/bot{token}/sendMessage',
|
|
262
|
+
json=telegram_payload({'chat_id': chat_id, 'text': text}))
|
|
263
|
+
try:
|
|
264
|
+
stamp.parent.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
stamp.write_text(str(int(_t.time())))
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
logger.error("Sent one-time Gmail reauth alert to Telegram")
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.warning(f"Failed to send Gmail reauth alert: {e}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ─────────────────────────────────────────────
|
|
274
|
+
# GMAIL
|
|
275
|
+
# ─────────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
def _gmail_request(method: str, url: str, **kwargs) -> httpx.Response:
|
|
278
|
+
"""Make a Gmail API request with automatic 401 retry."""
|
|
279
|
+
token = GoogleTokenManager.get_access_token()
|
|
280
|
+
headers = kwargs.pop('headers', {})
|
|
281
|
+
headers['Authorization'] = f'Bearer {token}'
|
|
282
|
+
|
|
283
|
+
with httpx.Client(timeout=30) as client:
|
|
284
|
+
r = getattr(client, method)(url, headers=headers, **kwargs)
|
|
285
|
+
|
|
286
|
+
# Auto-retry on 401
|
|
287
|
+
if r.status_code == 401:
|
|
288
|
+
logger.warning("Gmail 401 - auto-refreshing token and retrying...")
|
|
289
|
+
new_token = GoogleTokenManager.handle_401()
|
|
290
|
+
if new_token:
|
|
291
|
+
headers['Authorization'] = f'Bearer {new_token}'
|
|
292
|
+
with httpx.Client(timeout=30) as client:
|
|
293
|
+
r = getattr(client, method)(url, headers=headers, **kwargs)
|
|
294
|
+
|
|
295
|
+
return r
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@_taint_source("gmail", "google")
|
|
299
|
+
def gmail_list_inbox(max_results: int = 20, query: str = 'in:inbox') -> dict:
|
|
300
|
+
"""List inbox messages."""
|
|
301
|
+
r = _gmail_request(
|
|
302
|
+
'get',
|
|
303
|
+
'https://gmail.googleapis.com/gmail/v1/users/me/messages',
|
|
304
|
+
params={'q': query, 'maxResults': max_results}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if r.status_code != 200:
|
|
308
|
+
return {'error': f'Gmail list failed: {r.status_code} {r.text[:200]}'}
|
|
309
|
+
|
|
310
|
+
return r.json()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@_taint_source("gmail", "google")
|
|
314
|
+
def gmail_get_message(message_id: str, format: str = 'full') -> dict:
|
|
315
|
+
"""Get a specific message."""
|
|
316
|
+
r = _gmail_request(
|
|
317
|
+
'get',
|
|
318
|
+
f'https://gmail.googleapis.com/gmail/v1/users/me/messages/{message_id}',
|
|
319
|
+
params={'format': format}
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return r.json() if r.status_code == 200 else {'error': r.text[:200]}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
import re as _re_attach
|
|
326
|
+
# Forward-looking phrases that mean "a file is attached" (not benign uses of 'attached').
|
|
327
|
+
_ATTACH_PROMISE_RE = _re_attach.compile(
|
|
328
|
+
r"(please find attached|see attached|find attached|attached (is|are|please|herewith|you will find)|"
|
|
329
|
+
r"i[' ]?ve attached|i have attached|i am attaching|i'm attaching|attaching the|the attached (file|document|"
|
|
330
|
+
r"deck|report|spreadsheet|pdf|doc)|enclosed (is|are|please)|\bpfa\b|attachment:)",
|
|
331
|
+
_re_attach.IGNORECASE,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _body_promises_attachment(body: str) -> bool:
|
|
336
|
+
"""True if the body clearly promises an attached file."""
|
|
337
|
+
return bool(_ATTACH_PROMISE_RE.search(body or ""))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def gmail_send(to: str, subject: str, body: str, cc: str = '', reply_to_id: str = '', attachments: list = None, in_reply_to: str = None, references: str = None, bcc: str = '', allow_unattached: bool = False, body_html: str = None) -> dict:
|
|
341
|
+
"""Send an email via Gmail with optional attachments.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
to: Recipient email address(es), comma-separated
|
|
345
|
+
subject: Email subject
|
|
346
|
+
body: Email body text
|
|
347
|
+
cc: Additional CC recipients (comma-separated)
|
|
348
|
+
bcc: BCC recipients (comma-separated) - use for a private side-channel to yourself
|
|
349
|
+
reply_to_id: Thread ID to reply to (keeps email in same thread)
|
|
350
|
+
attachments: List of file paths to attach
|
|
351
|
+
in_reply_to: Message-ID to reply to (for In-Reply-To header)
|
|
352
|
+
references: Full References header chain (defaults to in_reply_to)
|
|
353
|
+
allow_unattached: bypass the attachment-promise guard (only if intentionally no file)
|
|
354
|
+
|
|
355
|
+
Returns: {status, id, threadId, to, cc, bcc} on success or {error} on failure
|
|
356
|
+
|
|
357
|
+
Threading: when reply_to_id (a Gmail threadId) is given but in_reply_to is not, this
|
|
358
|
+
auto-resolves the parent Message-ID + References chain from the thread and forces the subject
|
|
359
|
+
to match the thread. Without those headers replies fork into a NEW thread - which was the bug.
|
|
360
|
+
"""
|
|
361
|
+
import base64
|
|
362
|
+
import mimetypes
|
|
363
|
+
from email.mime.text import MIMEText
|
|
364
|
+
from email.mime.multipart import MIMEMultipart
|
|
365
|
+
from email.mime.base import MIMEBase
|
|
366
|
+
from email import encoders
|
|
367
|
+
|
|
368
|
+
token = GoogleTokenManager.get_access_token()
|
|
369
|
+
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
|
|
370
|
+
|
|
371
|
+
# --- Make replies thread correctly for ALL callers (the new-thread-on-reply fix) ---
|
|
372
|
+
if reply_to_id and not in_reply_to:
|
|
373
|
+
try:
|
|
374
|
+
tr = _gmail_request(
|
|
375
|
+
'get', f'https://gmail.googleapis.com/gmail/v1/users/me/threads/{reply_to_id}',
|
|
376
|
+
params={'format': 'metadata', 'metadataHeaders': ['Message-ID', 'References', 'Subject']}
|
|
377
|
+
)
|
|
378
|
+
if tr.status_code == 200:
|
|
379
|
+
tmsgs = tr.json().get('messages', [])
|
|
380
|
+
if tmsgs:
|
|
381
|
+
lh = {h['name'].lower(): h['value'] for h in tmsgs[-1].get('payload', {}).get('headers', [])}
|
|
382
|
+
in_reply_to = lh.get('message-id', '') or in_reply_to
|
|
383
|
+
parent_refs = lh.get('references', '')
|
|
384
|
+
if in_reply_to and not references:
|
|
385
|
+
references = (parent_refs + ' ' + in_reply_to).strip() if parent_refs else in_reply_to
|
|
386
|
+
# Gmail requires the subject to match the thread for a threadId send to stick.
|
|
387
|
+
tsubj = lh.get('subject', '')
|
|
388
|
+
if tsubj:
|
|
389
|
+
import re as _re
|
|
390
|
+
_norm = lambda s: _re.sub(r'^\s*(re|fwd|fw)\s*:\s*', '', (s or '').strip(),
|
|
391
|
+
flags=_re.IGNORECASE).strip().lower()
|
|
392
|
+
if _norm(subject) != _norm(tsubj):
|
|
393
|
+
subject = tsubj if tsubj.lower().startswith('re:') else f"Re: {tsubj}"
|
|
394
|
+
except Exception as e:
|
|
395
|
+
logger.debug(f"Reply threading resolve failed: {e}")
|
|
396
|
+
|
|
397
|
+
# Pre-send attachment guard: never send a message that promises a file it doesn't carry.
|
|
398
|
+
if not attachments and not allow_unattached and _body_promises_attachment(body):
|
|
399
|
+
return {'error': "BLOCKED_NO_ATTACHMENT: the body references an attached file but no file is "
|
|
400
|
+
"attached. Attach the file (attachments=[...] or email_send_with_attachment), "
|
|
401
|
+
"or reword the body, then resend. If intentional, pass allow_unattached=True."}
|
|
402
|
+
|
|
403
|
+
# Optionally CC a configured address on every outbound email (CC_EMAIL env). Empty = no auto-CC.
|
|
404
|
+
_auto_cc = os.environ.get('CC_EMAIL', '').strip()
|
|
405
|
+
cc_list = [_auto_cc] if _auto_cc else []
|
|
406
|
+
if cc and cc != _auto_cc:
|
|
407
|
+
cc_list.append(cc)
|
|
408
|
+
bcc_list = [b.strip() for b in (bcc or '').split(',') if b.strip()]
|
|
409
|
+
|
|
410
|
+
# Use mixed multipart for attachments/html, alternative for plain email
|
|
411
|
+
if attachments or body_html:
|
|
412
|
+
msg = MIMEMultipart('mixed')
|
|
413
|
+
else:
|
|
414
|
+
msg = MIMEMultipart('alternative')
|
|
415
|
+
|
|
416
|
+
msg['to'] = to
|
|
417
|
+
msg['subject'] = subject
|
|
418
|
+
msg['cc'] = ', '.join(cc_list)
|
|
419
|
+
if bcc_list:
|
|
420
|
+
msg['bcc'] = ', '.join(bcc_list)
|
|
421
|
+
|
|
422
|
+
# Add threading headers for replies
|
|
423
|
+
if in_reply_to:
|
|
424
|
+
msg['In-Reply-To'] = in_reply_to
|
|
425
|
+
msg['References'] = references or in_reply_to
|
|
426
|
+
|
|
427
|
+
# Body: plain always; when body_html is given, send a proper multipart/alternative
|
|
428
|
+
# so modern clients render rich formatting and plain-text clients still work.
|
|
429
|
+
if body_html:
|
|
430
|
+
alt = MIMEMultipart('alternative')
|
|
431
|
+
alt.attach(MIMEText(body, 'plain'))
|
|
432
|
+
alt.attach(MIMEText(body_html, 'html'))
|
|
433
|
+
msg.attach(alt)
|
|
434
|
+
else:
|
|
435
|
+
msg.attach(MIMEText(body, 'plain'))
|
|
436
|
+
|
|
437
|
+
# Attach files
|
|
438
|
+
if attachments:
|
|
439
|
+
for file_path in attachments:
|
|
440
|
+
path = Path(file_path)
|
|
441
|
+
if not path.exists():
|
|
442
|
+
return {'error': f'Attachment not found: {file_path}'}
|
|
443
|
+
|
|
444
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
445
|
+
if mime_type is None:
|
|
446
|
+
mime_type = 'application/octet-stream'
|
|
447
|
+
|
|
448
|
+
main_type, sub_type = mime_type.split('/', 1)
|
|
449
|
+
|
|
450
|
+
with open(path, 'rb') as f:
|
|
451
|
+
file_data = f.read()
|
|
452
|
+
|
|
453
|
+
part = MIMEBase(main_type, sub_type)
|
|
454
|
+
part.set_payload(file_data)
|
|
455
|
+
encoders.encode_base64(part)
|
|
456
|
+
part.add_header('Content-Disposition', 'attachment', filename=path.name)
|
|
457
|
+
msg.attach(part)
|
|
458
|
+
|
|
459
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
|
|
460
|
+
payload = {'raw': raw}
|
|
461
|
+
if reply_to_id:
|
|
462
|
+
payload['threadId'] = reply_to_id
|
|
463
|
+
|
|
464
|
+
with httpx.Client(timeout=60) as client:
|
|
465
|
+
r = client.post(
|
|
466
|
+
'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
|
|
467
|
+
headers=headers,
|
|
468
|
+
json=payload
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Auto-retry on 401
|
|
472
|
+
if r.status_code == 401:
|
|
473
|
+
logger.warning("Gmail send 401 - auto-refreshing token and retrying...")
|
|
474
|
+
new_token = GoogleTokenManager.handle_401()
|
|
475
|
+
if new_token:
|
|
476
|
+
headers['Authorization'] = f'Bearer {new_token}'
|
|
477
|
+
with httpx.Client(timeout=60) as client:
|
|
478
|
+
r = client.post(
|
|
479
|
+
'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
|
|
480
|
+
headers=headers,
|
|
481
|
+
json=payload
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
if r.status_code in (200, 201):
|
|
485
|
+
j = r.json()
|
|
486
|
+
return {'status': 'sent', 'id': j.get('id', ''), 'threadId': j.get('threadId', ''),
|
|
487
|
+
'attachments': len(attachments or []),
|
|
488
|
+
'to': to, 'cc': ', '.join(cc_list), 'bcc': ', '.join(bcc_list)}
|
|
489
|
+
return {'error': f'Send failed: {r.status_code} {r.text[:200]}'}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def gmail_reply(message_id: str, body: str, cc: str = '', attachments: list = None,
|
|
493
|
+
reply_all: bool = False) -> dict:
|
|
494
|
+
"""Reply to a specific message, guaranteed in-thread.
|
|
495
|
+
|
|
496
|
+
Resolves the recipient, subject (Re:), threadId and In-Reply-To/References from the original
|
|
497
|
+
message, so callers only need the message_id + body. Use this for replies instead of gmail_send.
|
|
498
|
+
"""
|
|
499
|
+
orig = gmail_get_message(message_id, format='metadata')
|
|
500
|
+
if 'error' in orig:
|
|
501
|
+
orig = gmail_get_message(message_id, format='full')
|
|
502
|
+
if 'error' in orig:
|
|
503
|
+
return {'error': f"Could not load message {message_id}: {orig.get('error')}"}
|
|
504
|
+
|
|
505
|
+
hdrs = {h['name'].lower(): h['value'] for h in orig.get('payload', {}).get('headers', [])}
|
|
506
|
+
thread_id = orig.get('threadId', '')
|
|
507
|
+
|
|
508
|
+
def _bare(addr: str) -> str:
|
|
509
|
+
addr = addr or ''
|
|
510
|
+
if '<' in addr and '>' in addr:
|
|
511
|
+
return addr[addr.find('<') + 1:addr.find('>')].strip()
|
|
512
|
+
return addr.strip()
|
|
513
|
+
|
|
514
|
+
to_addr = _bare(hdrs.get('reply-to') or hdrs.get('from', ''))
|
|
515
|
+
cc_arg = cc
|
|
516
|
+
if reply_all:
|
|
517
|
+
extras = [hdrs.get('to', ''), hdrs.get('cc', '')]
|
|
518
|
+
cc_arg = ', '.join([x for x in ([cc] + extras) if x])
|
|
519
|
+
|
|
520
|
+
# gmail_send auto-resolves In-Reply-To/References/subject from the threadId.
|
|
521
|
+
return gmail_send(to=to_addr, subject=hdrs.get('subject', ''), body=body, cc=cc_arg,
|
|
522
|
+
reply_to_id=thread_id, attachments=attachments)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _html_to_text(html: str) -> str:
|
|
526
|
+
"""Convert HTML email to clean readable text."""
|
|
527
|
+
try:
|
|
528
|
+
from bs4 import BeautifulSoup
|
|
529
|
+
soup = BeautifulSoup(html, 'lxml')
|
|
530
|
+
|
|
531
|
+
# Remove script/style elements
|
|
532
|
+
for tag in soup(['script', 'style', 'head', 'meta', 'link']):
|
|
533
|
+
tag.decompose()
|
|
534
|
+
|
|
535
|
+
# Get text and clean up whitespace
|
|
536
|
+
text = soup.get_text(separator='\n')
|
|
537
|
+
lines = [line.strip() for line in text.splitlines()]
|
|
538
|
+
text = '\n'.join(line for line in lines if line)
|
|
539
|
+
return text
|
|
540
|
+
except ImportError:
|
|
541
|
+
# Fallback: basic regex strip
|
|
542
|
+
import re
|
|
543
|
+
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
|
544
|
+
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
|
545
|
+
text = re.sub(r'<[^>]+>', ' ', text)
|
|
546
|
+
text = re.sub(r'\s+', ' ', text)
|
|
547
|
+
return text.strip()
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _extract_body_enhanced(payload: dict, message_id: str = None) -> str:
|
|
551
|
+
"""Extract readable text from an email payload.
|
|
552
|
+
|
|
553
|
+
Robust to the things that used to return a blank body: incorrect base64 padding, bodies
|
|
554
|
+
delivered via attachmentId (large/forwarded emails - the #1 cause of "forwarded message is
|
|
555
|
+
blank"), deeply nested multipart, and forwarded message/rfc822 parts. Accumulates ALL text
|
|
556
|
+
parts so forwarded/quoted content is preserved. Prefers text/plain, falls back to HTML->text.
|
|
557
|
+
"""
|
|
558
|
+
import base64
|
|
559
|
+
|
|
560
|
+
def _b64(data: str) -> str:
|
|
561
|
+
# Pad to a multiple of 4 with the CORRECT count of '=' (the old '+ ==' hack corrupted some).
|
|
562
|
+
return base64.urlsafe_b64decode(data + '=' * (-len(data) % 4)).decode('utf-8', errors='replace')
|
|
563
|
+
|
|
564
|
+
def _fetch_attachment_body(att_id: str) -> str:
|
|
565
|
+
if not message_id or not att_id:
|
|
566
|
+
return ''
|
|
567
|
+
try:
|
|
568
|
+
r = _gmail_request(
|
|
569
|
+
'get',
|
|
570
|
+
f'https://gmail.googleapis.com/gmail/v1/users/me/messages/{message_id}/attachments/{att_id}'
|
|
571
|
+
)
|
|
572
|
+
if r.status_code == 200:
|
|
573
|
+
return _b64(r.json().get('data', ''))
|
|
574
|
+
except Exception as e:
|
|
575
|
+
logger.debug(f"Body attachment fetch failed: {e}")
|
|
576
|
+
return ''
|
|
577
|
+
|
|
578
|
+
plain_parts, html_parts = [], []
|
|
579
|
+
|
|
580
|
+
def walk(p):
|
|
581
|
+
mime = (p.get('mimeType', '') or '')
|
|
582
|
+
body = p.get('body', {}) or {}
|
|
583
|
+
data = body.get('data', '')
|
|
584
|
+
att_id = body.get('attachmentId', '')
|
|
585
|
+
text = ''
|
|
586
|
+
if data:
|
|
587
|
+
try:
|
|
588
|
+
text = _b64(data)
|
|
589
|
+
except Exception as e:
|
|
590
|
+
logger.debug(f"Body base64 decode failed for {mime}: {e}")
|
|
591
|
+
elif att_id and mime.startswith('text/'):
|
|
592
|
+
# Gmail returns large/forwarded bodies as attachments, not inline data.
|
|
593
|
+
text = _fetch_attachment_body(att_id)
|
|
594
|
+
if text:
|
|
595
|
+
if mime.startswith('text/plain'):
|
|
596
|
+
plain_parts.append(text)
|
|
597
|
+
elif mime.startswith('text/html'):
|
|
598
|
+
html_parts.append(text)
|
|
599
|
+
# Recurse: multipart/* and forwarded message/rfc822 both carry nested parts.
|
|
600
|
+
for part in (p.get('parts', []) or []):
|
|
601
|
+
walk(part)
|
|
602
|
+
|
|
603
|
+
walk(payload)
|
|
604
|
+
|
|
605
|
+
if plain_parts:
|
|
606
|
+
return '\n'.join(plain_parts).strip()
|
|
607
|
+
if html_parts:
|
|
608
|
+
return _html_to_text('\n'.join(html_parts)).strip()
|
|
609
|
+
return ''
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@_taint_source("gmail", "google")
|
|
613
|
+
def gmail_read_message_text(message_id: str) -> str:
|
|
614
|
+
"""Get full readable text of a message with thread info for replies."""
|
|
615
|
+
msg = gmail_get_message(message_id, format='full')
|
|
616
|
+
if 'error' in msg:
|
|
617
|
+
return f"Error: {msg['error']}"
|
|
618
|
+
|
|
619
|
+
payload = msg.get('payload', {})
|
|
620
|
+
headers = {h['name']: h['value'] for h in payload.get('headers', [])}
|
|
621
|
+
body = _extract_body_enhanced(payload, message_id)
|
|
622
|
+
|
|
623
|
+
# Include IDs needed for replying
|
|
624
|
+
thread_id = msg.get('threadId', '')
|
|
625
|
+
msg_id_header = headers.get('Message-ID', headers.get('Message-Id', ''))
|
|
626
|
+
|
|
627
|
+
return f"""From: {headers.get('From', 'Unknown')}
|
|
628
|
+
To: {headers.get('To', 'Unknown')}
|
|
629
|
+
Date: {headers.get('Date', 'Unknown')}
|
|
630
|
+
Subject: {headers.get('Subject', 'No subject')}
|
|
631
|
+
[Thread-ID: {thread_id}] [Message-ID: {message_id}]
|
|
632
|
+
|
|
633
|
+
{body[:5000]}"""
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@_taint_source("gmail", "google")
|
|
637
|
+
def gmail_list_sent(max_results: int = 20) -> dict:
|
|
638
|
+
"""List sent emails."""
|
|
639
|
+
return gmail_list_inbox(max_results=max_results, query='in:sent')
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@_taint_source("gmail", "google")
|
|
643
|
+
def gmail_list_all(max_results: int = 20, query: str = '') -> dict:
|
|
644
|
+
"""List all emails (inbox + sent + all labels).
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
max_results: Maximum number of results
|
|
648
|
+
query: Gmail search query (e.g., 'from:someone@example.com', 'subject:hello', 'after:2024/01/01')
|
|
649
|
+
"""
|
|
650
|
+
params = {'maxResults': max_results}
|
|
651
|
+
if query:
|
|
652
|
+
params['q'] = query
|
|
653
|
+
|
|
654
|
+
r = _gmail_request(
|
|
655
|
+
'get',
|
|
656
|
+
'https://gmail.googleapis.com/gmail/v1/users/me/messages',
|
|
657
|
+
params=params
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
if r.status_code != 200:
|
|
661
|
+
return {'error': f'Gmail list failed: {r.status_code} {r.text[:200]}'}
|
|
662
|
+
|
|
663
|
+
return r.json()
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@_taint_source("gmail", "google")
|
|
667
|
+
def gmail_search(query: str, max_results: int = 20) -> list:
|
|
668
|
+
"""Search all emails with a query.
|
|
669
|
+
|
|
670
|
+
Query examples:
|
|
671
|
+
'from:john@example.com' - emails from John
|
|
672
|
+
'to:jane@example.com' - emails to Jane
|
|
673
|
+
'subject:meeting' - emails with 'meeting' in subject
|
|
674
|
+
'in:sent' - sent emails
|
|
675
|
+
'in:inbox' - inbox emails
|
|
676
|
+
'is:unread' - unread emails
|
|
677
|
+
'after:2024/01/01 before:2024/12/31' - date range
|
|
678
|
+
'has:attachment' - emails with attachments
|
|
679
|
+
'larger:5M' - emails larger than 5MB
|
|
680
|
+
|
|
681
|
+
Returns list of parsed email summaries.
|
|
682
|
+
"""
|
|
683
|
+
result = gmail_list_all(max_results=max_results, query=query)
|
|
684
|
+
|
|
685
|
+
if 'error' in result:
|
|
686
|
+
return [{'error': result['error']}]
|
|
687
|
+
|
|
688
|
+
messages = result.get('messages', [])
|
|
689
|
+
emails = []
|
|
690
|
+
|
|
691
|
+
for msg_ref in messages[:max_results]:
|
|
692
|
+
msg = gmail_get_message(msg_ref['id'], format='metadata')
|
|
693
|
+
if 'error' in msg:
|
|
694
|
+
continue
|
|
695
|
+
|
|
696
|
+
headers = {h['name']: h['value'] for h in msg.get('payload', {}).get('headers', [])}
|
|
697
|
+
labels = msg.get('labelIds', [])
|
|
698
|
+
|
|
699
|
+
emails.append({
|
|
700
|
+
'id': msg.get('id'),
|
|
701
|
+
'thread_id': msg.get('threadId'),
|
|
702
|
+
'from': headers.get('From', ''),
|
|
703
|
+
'to': headers.get('To', ''),
|
|
704
|
+
'subject': headers.get('Subject', '(no subject)'),
|
|
705
|
+
'date': headers.get('Date', ''),
|
|
706
|
+
'snippet': msg.get('snippet', '')[:100],
|
|
707
|
+
'labels': labels,
|
|
708
|
+
'is_sent': 'SENT' in labels,
|
|
709
|
+
'is_unread': 'UNREAD' in labels,
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
return emails
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@_taint_source("gmail", "google")
|
|
716
|
+
def gmail_get_thread(thread_id: str, parse_quotes: bool = True) -> dict:
|
|
717
|
+
"""Get all messages in an email thread with enhanced parsing.
|
|
718
|
+
|
|
719
|
+
Args:
|
|
720
|
+
thread_id: The Gmail thread ID
|
|
721
|
+
parse_quotes: If True, separate new content from quoted replies
|
|
722
|
+
|
|
723
|
+
Returns dict with:
|
|
724
|
+
- messages: list of messages in chronological order
|
|
725
|
+
- participants: unique list of all participants
|
|
726
|
+
- subject: thread subject
|
|
727
|
+
- summary: quick overview
|
|
728
|
+
"""
|
|
729
|
+
r = _gmail_request(
|
|
730
|
+
'get',
|
|
731
|
+
f'https://gmail.googleapis.com/gmail/v1/users/me/threads/{thread_id}',
|
|
732
|
+
params={'format': 'full'}
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
if r.status_code != 200:
|
|
736
|
+
return {'error': f'Thread fetch failed: {r.status_code}', 'messages': []}
|
|
737
|
+
|
|
738
|
+
thread = r.json()
|
|
739
|
+
messages = []
|
|
740
|
+
participants = set()
|
|
741
|
+
subject = ''
|
|
742
|
+
|
|
743
|
+
for msg in thread.get('messages', []):
|
|
744
|
+
payload = msg.get('payload', {})
|
|
745
|
+
hdrs = {h['name']: h['value'] for h in payload.get('headers', [])}
|
|
746
|
+
|
|
747
|
+
# Track participants
|
|
748
|
+
from_addr = hdrs.get('From', '')
|
|
749
|
+
to_addr = hdrs.get('To', '')
|
|
750
|
+
participants.add(from_addr)
|
|
751
|
+
if to_addr:
|
|
752
|
+
for addr in to_addr.split(','):
|
|
753
|
+
participants.add(addr.strip())
|
|
754
|
+
|
|
755
|
+
# Get subject from first message
|
|
756
|
+
if not subject:
|
|
757
|
+
subject = hdrs.get('Subject', '')
|
|
758
|
+
|
|
759
|
+
# Extract body with HTML support
|
|
760
|
+
body = _extract_body_enhanced(payload, msg.get('id'))
|
|
761
|
+
labels = msg.get('labelIds', [])
|
|
762
|
+
|
|
763
|
+
# Parse quotes if requested
|
|
764
|
+
if parse_quotes and body:
|
|
765
|
+
parsed = parse_email_quotes(body)
|
|
766
|
+
new_content = parsed['new_content']
|
|
767
|
+
quoted = parsed['quoted']
|
|
768
|
+
else:
|
|
769
|
+
new_content = body
|
|
770
|
+
quoted = []
|
|
771
|
+
|
|
772
|
+
# Check for attachments
|
|
773
|
+
attachments = gmail_list_attachments(msg.get('id', ''))
|
|
774
|
+
has_attachments = bool(attachments and 'error' not in attachments[0])
|
|
775
|
+
|
|
776
|
+
messages.append({
|
|
777
|
+
'id': msg.get('id'),
|
|
778
|
+
'from': from_addr,
|
|
779
|
+
'to': to_addr,
|
|
780
|
+
'cc': hdrs.get('Cc', ''),
|
|
781
|
+
'date': hdrs.get('Date', ''),
|
|
782
|
+
'subject': hdrs.get('Subject', ''),
|
|
783
|
+
'new_content': new_content[:3000] if parse_quotes else None,
|
|
784
|
+
'body': body[:3000] if not parse_quotes else None,
|
|
785
|
+
'quoted_sections': len(quoted),
|
|
786
|
+
'is_sent': 'SENT' in labels,
|
|
787
|
+
'has_attachments': has_attachments,
|
|
788
|
+
'attachment_count': len(attachments) if has_attachments else 0,
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
return {
|
|
792
|
+
'thread_id': thread_id,
|
|
793
|
+
'subject': subject,
|
|
794
|
+
'message_count': len(messages),
|
|
795
|
+
'participants': list(participants),
|
|
796
|
+
'messages': messages,
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
@_taint_source("gmail", "google")
|
|
801
|
+
def gmail_get_recent_activity(hours: int = 24, max_results: int = 50) -> dict:
|
|
802
|
+
"""Get recent email activity (sent and received) in the last N hours.
|
|
803
|
+
|
|
804
|
+
Returns summary of recent emails grouped by sent/received.
|
|
805
|
+
"""
|
|
806
|
+
from datetime import datetime, timedelta
|
|
807
|
+
|
|
808
|
+
# Calculate date for query
|
|
809
|
+
after_date = (datetime.now() - timedelta(hours=hours)).strftime('%Y/%m/%d')
|
|
810
|
+
|
|
811
|
+
# Get all recent emails
|
|
812
|
+
all_emails = gmail_search(f'after:{after_date}', max_results=max_results)
|
|
813
|
+
|
|
814
|
+
if all_emails and 'error' in all_emails[0]:
|
|
815
|
+
return {'error': all_emails[0]['error']}
|
|
816
|
+
|
|
817
|
+
sent = [e for e in all_emails if e.get('is_sent')]
|
|
818
|
+
received = [e for e in all_emails if not e.get('is_sent')]
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
'period_hours': hours,
|
|
822
|
+
'total': len(all_emails),
|
|
823
|
+
'sent_count': len(sent),
|
|
824
|
+
'received_count': len(received),
|
|
825
|
+
'sent': sent[:10], # Last 10 sent
|
|
826
|
+
'received': received[:10], # Last 10 received
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
# ─────────────────────────────────────────────
|
|
831
|
+
# EMAIL ATTACHMENTS
|
|
832
|
+
# ─────────────────────────────────────────────
|
|
833
|
+
|
|
834
|
+
DOWNLOADS_DIR = Path.home() / '.hyperclaw' / 'downloads'
|
|
835
|
+
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
@_taint_source("gmail", "google")
|
|
839
|
+
def gmail_list_attachments(message_id: str) -> list:
|
|
840
|
+
"""List all attachments in an email.
|
|
841
|
+
|
|
842
|
+
Returns list of {filename, mime_type, size, attachment_id, part_id}
|
|
843
|
+
"""
|
|
844
|
+
msg = gmail_get_message(message_id, format='full')
|
|
845
|
+
if 'error' in msg:
|
|
846
|
+
return [{'error': msg['error']}]
|
|
847
|
+
|
|
848
|
+
attachments = []
|
|
849
|
+
|
|
850
|
+
def find_attachments(part, part_path=''):
|
|
851
|
+
filename = part.get('filename', '')
|
|
852
|
+
attachment_id = part.get('body', {}).get('attachmentId')
|
|
853
|
+
|
|
854
|
+
if filename and attachment_id:
|
|
855
|
+
attachments.append({
|
|
856
|
+
'filename': filename,
|
|
857
|
+
'mime_type': part.get('mimeType', 'application/octet-stream'),
|
|
858
|
+
'size': part.get('body', {}).get('size', 0),
|
|
859
|
+
'attachment_id': attachment_id,
|
|
860
|
+
'part_id': part_path,
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
for i, sub_part in enumerate(part.get('parts', [])):
|
|
864
|
+
find_attachments(sub_part, f"{part_path}.{i}" if part_path else str(i))
|
|
865
|
+
|
|
866
|
+
find_attachments(msg.get('payload', {}))
|
|
867
|
+
return attachments
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
@_taint_source("gmail", "google")
|
|
871
|
+
def gmail_download_attachment(message_id: str, attachment_id: str, filename: str = None) -> dict:
|
|
872
|
+
"""Download an email attachment.
|
|
873
|
+
|
|
874
|
+
Args:
|
|
875
|
+
message_id: The email message ID
|
|
876
|
+
attachment_id: The attachment ID from gmail_list_attachments()
|
|
877
|
+
filename: Optional filename to save as (defaults to original)
|
|
878
|
+
|
|
879
|
+
Returns: {path, size, filename} on success or {error} on failure
|
|
880
|
+
"""
|
|
881
|
+
import base64
|
|
882
|
+
|
|
883
|
+
r = _gmail_request(
|
|
884
|
+
'get',
|
|
885
|
+
f'https://gmail.googleapis.com/gmail/v1/users/me/messages/{message_id}/attachments/{attachment_id}',
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
if r.status_code != 200:
|
|
889
|
+
return {'error': f'Download failed: {r.status_code} {r.text[:200]}'}
|
|
890
|
+
|
|
891
|
+
data = r.json().get('data', '')
|
|
892
|
+
if not data:
|
|
893
|
+
return {'error': 'No attachment data returned'}
|
|
894
|
+
|
|
895
|
+
# Decode base64
|
|
896
|
+
file_data = base64.urlsafe_b64decode(data + '==')
|
|
897
|
+
|
|
898
|
+
# Determine filename
|
|
899
|
+
if not filename:
|
|
900
|
+
# Try to get from attachment list
|
|
901
|
+
attachments = gmail_list_attachments(message_id)
|
|
902
|
+
for att in attachments:
|
|
903
|
+
if att.get('attachment_id') == attachment_id:
|
|
904
|
+
filename = att.get('filename', f'attachment_{attachment_id[:8]}')
|
|
905
|
+
break
|
|
906
|
+
else:
|
|
907
|
+
filename = f'attachment_{attachment_id[:8]}'
|
|
908
|
+
|
|
909
|
+
# Save file
|
|
910
|
+
save_path = DOWNLOADS_DIR / filename
|
|
911
|
+
save_path.write_bytes(file_data)
|
|
912
|
+
|
|
913
|
+
return {
|
|
914
|
+
'path': str(save_path),
|
|
915
|
+
'size': len(file_data),
|
|
916
|
+
'filename': filename,
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@_taint_source("gmail", "google")
|
|
921
|
+
def gmail_download_all_attachments(message_id: str) -> list:
|
|
922
|
+
"""Download all attachments from an email.
|
|
923
|
+
|
|
924
|
+
Returns list of download results.
|
|
925
|
+
"""
|
|
926
|
+
attachments = gmail_list_attachments(message_id)
|
|
927
|
+
if attachments and 'error' in attachments[0]:
|
|
928
|
+
return attachments
|
|
929
|
+
|
|
930
|
+
results = []
|
|
931
|
+
for att in attachments:
|
|
932
|
+
result = gmail_download_attachment(
|
|
933
|
+
message_id,
|
|
934
|
+
att['attachment_id'],
|
|
935
|
+
att['filename']
|
|
936
|
+
)
|
|
937
|
+
result['original_filename'] = att['filename']
|
|
938
|
+
results.append(result)
|
|
939
|
+
|
|
940
|
+
return results
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def gmail_forward(message_id: str, to: str, note: str = '', include_attachments: bool = True) -> dict:
|
|
945
|
+
"""Forward an email (body + attachments) to someone, with an optional note on top."""
|
|
946
|
+
meta = gmail_get_message(message_id)
|
|
947
|
+
if 'error' in meta:
|
|
948
|
+
return meta
|
|
949
|
+
headers = {h['name'].lower(): h['value'] for h in meta.get('payload', {}).get('headers', [])}
|
|
950
|
+
orig_subject = headers.get('subject', '(no subject)')
|
|
951
|
+
orig_from = headers.get('from', 'unknown')
|
|
952
|
+
orig_date = headers.get('date', '')
|
|
953
|
+
body_text = gmail_read_message_text(message_id)
|
|
954
|
+
|
|
955
|
+
fwd_body = ''
|
|
956
|
+
if note:
|
|
957
|
+
fwd_body += note + '\n\n'
|
|
958
|
+
fwd_body += f"---------- Forwarded message ----------\nFrom: {orig_from}\nDate: {orig_date}\nSubject: {orig_subject}\n\n{body_text}"
|
|
959
|
+
|
|
960
|
+
att_paths = []
|
|
961
|
+
if include_attachments:
|
|
962
|
+
try:
|
|
963
|
+
for saved in gmail_download_all_attachments(message_id):
|
|
964
|
+
path = saved.get('path') if isinstance(saved, dict) else saved
|
|
965
|
+
if path:
|
|
966
|
+
att_paths.append(path)
|
|
967
|
+
except Exception as e:
|
|
968
|
+
logger.warning(f"Forward: attachment download failed: {e}")
|
|
969
|
+
|
|
970
|
+
subject = orig_subject if orig_subject.lower().startswith('fwd:') else f'Fwd: {orig_subject}'
|
|
971
|
+
return gmail_send(to=to, subject=subject, body=fwd_body,
|
|
972
|
+
attachments=att_paths or None, allow_unattached=True)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def gmail_create_draft(to: str, subject: str, body: str, cc: str = '',
|
|
976
|
+
attachments: list = None, body_html: str = None,
|
|
977
|
+
thread_id: str = '') -> dict:
|
|
978
|
+
"""Create a Gmail DRAFT (visible in the account's Drafts folder) instead of sending.
|
|
979
|
+
The approval-queue path: the assistant prepares, the owner hits send."""
|
|
980
|
+
from email.mime.text import MIMEText
|
|
981
|
+
from email.mime.multipart import MIMEMultipart
|
|
982
|
+
from email.mime.base import MIMEBase
|
|
983
|
+
from email import encoders
|
|
984
|
+
import mimetypes
|
|
985
|
+
import base64
|
|
986
|
+
|
|
987
|
+
token = GoogleTokenManager.get_access_token()
|
|
988
|
+
if not token:
|
|
989
|
+
return {'error': 'Gmail auth unavailable'}
|
|
990
|
+
|
|
991
|
+
msg = MIMEMultipart('mixed') if (attachments or body_html) else MIMEMultipart('alternative')
|
|
992
|
+
msg['to'] = to
|
|
993
|
+
msg['subject'] = subject
|
|
994
|
+
if cc:
|
|
995
|
+
msg['cc'] = cc
|
|
996
|
+
if body_html:
|
|
997
|
+
alt = MIMEMultipart('alternative')
|
|
998
|
+
alt.attach(MIMEText(body, 'plain'))
|
|
999
|
+
alt.attach(MIMEText(body_html, 'html'))
|
|
1000
|
+
msg.attach(alt)
|
|
1001
|
+
else:
|
|
1002
|
+
msg.attach(MIMEText(body, 'plain'))
|
|
1003
|
+
for file_path in (attachments or []):
|
|
1004
|
+
path = Path(file_path)
|
|
1005
|
+
if not path.exists():
|
|
1006
|
+
return {'error': f'Attachment not found: {file_path}'}
|
|
1007
|
+
mime_type, _ = mimetypes.guess_type(str(path))
|
|
1008
|
+
main_type, sub_type = (mime_type or 'application/octet-stream').split('/', 1)
|
|
1009
|
+
part = MIMEBase(main_type, sub_type)
|
|
1010
|
+
part.set_payload(path.read_bytes())
|
|
1011
|
+
encoders.encode_base64(part)
|
|
1012
|
+
part.add_header('Content-Disposition', 'attachment', filename=path.name)
|
|
1013
|
+
msg.attach(part)
|
|
1014
|
+
|
|
1015
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
|
|
1016
|
+
payload = {'message': {'raw': raw}}
|
|
1017
|
+
if thread_id:
|
|
1018
|
+
payload['message']['threadId'] = thread_id
|
|
1019
|
+
r = _gmail_request('post', 'https://gmail.googleapis.com/gmail/v1/users/me/drafts', json=payload)
|
|
1020
|
+
if r.status_code in (200, 201):
|
|
1021
|
+
j = r.json()
|
|
1022
|
+
return {'status': 'draft_created', 'draft_id': j.get('id', ''), 'to': to, 'subject': subject,
|
|
1023
|
+
'note': 'Draft is in the Drafts folder awaiting review/send.'}
|
|
1024
|
+
return {'error': f'Draft create failed: {r.status_code} {r.text[:200]}'}
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def gmail_modify(message_id: str, mark_read: bool = None, archive: bool = None,
|
|
1028
|
+
star: bool = None, add_labels: list = None, remove_labels: list = None) -> dict:
|
|
1029
|
+
"""Inbox management: mark read/unread, archive, star/unstar, arbitrary labels."""
|
|
1030
|
+
add, remove = list(add_labels or []), list(remove_labels or [])
|
|
1031
|
+
if mark_read is True:
|
|
1032
|
+
remove.append('UNREAD')
|
|
1033
|
+
elif mark_read is False:
|
|
1034
|
+
add.append('UNREAD')
|
|
1035
|
+
if archive is True:
|
|
1036
|
+
remove.append('INBOX')
|
|
1037
|
+
elif archive is False:
|
|
1038
|
+
add.append('INBOX')
|
|
1039
|
+
if star is True:
|
|
1040
|
+
add.append('STARRED')
|
|
1041
|
+
elif star is False:
|
|
1042
|
+
remove.append('STARRED')
|
|
1043
|
+
if not add and not remove:
|
|
1044
|
+
return {'error': 'Nothing to modify'}
|
|
1045
|
+
r = _gmail_request(
|
|
1046
|
+
'post', f'https://gmail.googleapis.com/gmail/v1/users/me/messages/{message_id}/modify',
|
|
1047
|
+
json={'addLabelIds': add, 'removeLabelIds': remove})
|
|
1048
|
+
if r.status_code == 200:
|
|
1049
|
+
return {'status': 'modified', 'added': add, 'removed': remove}
|
|
1050
|
+
return {'error': f'Modify failed: {r.status_code} {r.text[:200]}'}
|
|
1051
|
+
|
|
1052
|
+
def strip_email_signature(body: str) -> dict:
|
|
1053
|
+
"""Detect and strip email signature from body.
|
|
1054
|
+
|
|
1055
|
+
Returns: {content: str, signature: str}
|
|
1056
|
+
"""
|
|
1057
|
+
import re
|
|
1058
|
+
|
|
1059
|
+
# Common signature patterns
|
|
1060
|
+
sig_patterns = [
|
|
1061
|
+
r'\n--\s*\n', # Standard -- delimiter
|
|
1062
|
+
r'\nBest,?\s*\n', # Best,
|
|
1063
|
+
r'\nRegards,?\s*\n', # Regards,
|
|
1064
|
+
r'\nThanks,?\s*\n', # Thanks,
|
|
1065
|
+
r'\nCheers,?\s*\n', # Cheers,
|
|
1066
|
+
r'\nSincerely,?\s*\n', # Sincerely,
|
|
1067
|
+
r'\nSent from my iPhone', # iOS
|
|
1068
|
+
r'\nSent from my iPad', # iPad
|
|
1069
|
+
r'\nGet Outlook for', # Outlook mobile
|
|
1070
|
+
r'\n_{3,}', # ___ separator
|
|
1071
|
+
r'\n-{3,}', # --- separator
|
|
1072
|
+
]
|
|
1073
|
+
|
|
1074
|
+
earliest_match = len(body)
|
|
1075
|
+
matched_pattern = None
|
|
1076
|
+
|
|
1077
|
+
for pattern in sig_patterns:
|
|
1078
|
+
match = re.search(pattern, body, re.IGNORECASE)
|
|
1079
|
+
if match and match.start() < earliest_match:
|
|
1080
|
+
earliest_match = match.start()
|
|
1081
|
+
matched_pattern = pattern
|
|
1082
|
+
|
|
1083
|
+
if matched_pattern and earliest_match < len(body) - 50:
|
|
1084
|
+
return {
|
|
1085
|
+
'content': body[:earliest_match].strip(),
|
|
1086
|
+
'signature': body[earliest_match:].strip()
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return {'content': body.strip(), 'signature': ''}
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def parse_email_quotes(body: str) -> dict:
|
|
1093
|
+
"""Parse email body to separate new content from quoted replies.
|
|
1094
|
+
|
|
1095
|
+
Returns: {new_content: str, quoted: list[{from, content}]}
|
|
1096
|
+
"""
|
|
1097
|
+
import re
|
|
1098
|
+
|
|
1099
|
+
lines = body.split('\n')
|
|
1100
|
+
new_content = []
|
|
1101
|
+
quoted_sections = []
|
|
1102
|
+
current_quote = []
|
|
1103
|
+
quote_attribution = ''
|
|
1104
|
+
in_quote = False
|
|
1105
|
+
|
|
1106
|
+
for line in lines:
|
|
1107
|
+
# Check for quote attribution line
|
|
1108
|
+
quote_start = re.match(r'^On .+ wrote:$', line.strip())
|
|
1109
|
+
if quote_start:
|
|
1110
|
+
if current_quote:
|
|
1111
|
+
quoted_sections.append({
|
|
1112
|
+
'from': quote_attribution,
|
|
1113
|
+
'content': '\n'.join(current_quote)
|
|
1114
|
+
})
|
|
1115
|
+
quote_attribution = line.strip()
|
|
1116
|
+
current_quote = []
|
|
1117
|
+
in_quote = True
|
|
1118
|
+
continue
|
|
1119
|
+
|
|
1120
|
+
# Check for quoted line (starts with >)
|
|
1121
|
+
if line.strip().startswith('>'):
|
|
1122
|
+
in_quote = True
|
|
1123
|
+
current_quote.append(line.lstrip('> ').strip())
|
|
1124
|
+
elif in_quote and line.strip() == '':
|
|
1125
|
+
current_quote.append('')
|
|
1126
|
+
elif in_quote:
|
|
1127
|
+
# End of quoted section
|
|
1128
|
+
if current_quote:
|
|
1129
|
+
quoted_sections.append({
|
|
1130
|
+
'from': quote_attribution,
|
|
1131
|
+
'content': '\n'.join(current_quote)
|
|
1132
|
+
})
|
|
1133
|
+
current_quote = []
|
|
1134
|
+
in_quote = False
|
|
1135
|
+
quote_attribution = ''
|
|
1136
|
+
new_content.append(line)
|
|
1137
|
+
else:
|
|
1138
|
+
new_content.append(line)
|
|
1139
|
+
|
|
1140
|
+
# Handle remaining quote
|
|
1141
|
+
if current_quote:
|
|
1142
|
+
quoted_sections.append({
|
|
1143
|
+
'from': quote_attribution,
|
|
1144
|
+
'content': '\n'.join(current_quote)
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
return {
|
|
1148
|
+
'new_content': '\n'.join(new_content).strip(),
|
|
1149
|
+
'quoted': quoted_sections
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
# ─────────────────────────────────────────────
|
|
1154
|
+
# CALENDAR
|
|
1155
|
+
# ─────────────────────────────────────────────
|
|
1156
|
+
|
|
1157
|
+
@_taint_source("google_calendar", "google")
|
|
1158
|
+
def calendar_get_events(days_ahead: int = 7) -> list:
|
|
1159
|
+
"""Get upcoming calendar events."""
|
|
1160
|
+
now = datetime.now(timezone.utc)
|
|
1161
|
+
end = now + timedelta(days=days_ahead)
|
|
1162
|
+
|
|
1163
|
+
r = _gmail_request(
|
|
1164
|
+
'get',
|
|
1165
|
+
'https://www.googleapis.com/calendar/v3/calendars/primary/events',
|
|
1166
|
+
params={
|
|
1167
|
+
'timeMin': now.isoformat(),
|
|
1168
|
+
'timeMax': end.isoformat(),
|
|
1169
|
+
'maxResults': 25,
|
|
1170
|
+
'singleEvents': True,
|
|
1171
|
+
'orderBy': 'startTime'
|
|
1172
|
+
}
|
|
1173
|
+
)
|
|
1174
|
+
|
|
1175
|
+
if r.status_code != 200:
|
|
1176
|
+
return [{'error': f'Calendar failed: {r.status_code}'}]
|
|
1177
|
+
|
|
1178
|
+
events = r.json().get('items', [])
|
|
1179
|
+
result = []
|
|
1180
|
+
for e in events:
|
|
1181
|
+
start = e.get('start', {})
|
|
1182
|
+
result.append({
|
|
1183
|
+
'summary': e.get('summary', 'Untitled'),
|
|
1184
|
+
'start': start.get('dateTime', start.get('date', '')),
|
|
1185
|
+
'end': e.get('end', {}).get('dateTime', ''),
|
|
1186
|
+
'location': e.get('location', ''),
|
|
1187
|
+
'description': e.get('description', '')[:200],
|
|
1188
|
+
'attendees': [a.get('email') for a in e.get('attendees', [])],
|
|
1189
|
+
})
|
|
1190
|
+
return result
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
def calendar_create_event(title: str, start_iso: str, end_iso: str,
|
|
1194
|
+
description: str = '', attendees: list = None) -> dict:
|
|
1195
|
+
"""Create a calendar event."""
|
|
1196
|
+
body = {
|
|
1197
|
+
'summary': title,
|
|
1198
|
+
'start': {'dateTime': start_iso, 'timeZone': 'America/Los_Angeles'},
|
|
1199
|
+
'end': {'dateTime': end_iso, 'timeZone': 'America/Los_Angeles'},
|
|
1200
|
+
'description': description,
|
|
1201
|
+
}
|
|
1202
|
+
if attendees:
|
|
1203
|
+
body['attendees'] = [{'email': a} for a in attendees]
|
|
1204
|
+
|
|
1205
|
+
r = _gmail_request(
|
|
1206
|
+
'post',
|
|
1207
|
+
'https://www.googleapis.com/calendar/v3/calendars/primary/events',
|
|
1208
|
+
headers={'Content-Type': 'application/json'},
|
|
1209
|
+
json=body
|
|
1210
|
+
)
|
|
1211
|
+
|
|
1212
|
+
if r.status_code in (200, 201):
|
|
1213
|
+
event = r.json()
|
|
1214
|
+
return {'status': 'created', 'id': event.get('id'), 'link': event.get('htmlLink')}
|
|
1215
|
+
return {'error': f'Create failed: {r.status_code} {r.text[:200]}'}
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
# ─────────────────────────────────────────────
|
|
1219
|
+
# iMESSAGE (Native AppleScript)
|
|
1220
|
+
# ─────────────────────────────────────────────
|
|
1221
|
+
|
|
1222
|
+
def imessage_send(recipient: str, message: str) -> dict:
|
|
1223
|
+
"""Send an iMessage via AppleScript."""
|
|
1224
|
+
if sys.platform != 'darwin':
|
|
1225
|
+
return {'error': 'iMessage requires macOS'}
|
|
1226
|
+
|
|
1227
|
+
# Escape for AppleScript
|
|
1228
|
+
content = message.replace('\\', '\\\\').replace('"', '\\"')
|
|
1229
|
+
recipient_clean = recipient.replace('"', '\\"')
|
|
1230
|
+
|
|
1231
|
+
script = f'''
|
|
1232
|
+
tell application "Messages"
|
|
1233
|
+
set targetService to first service whose service type = iMessage
|
|
1234
|
+
set targetBuddy to buddy "{recipient_clean}" of targetService
|
|
1235
|
+
send "{content}" to targetBuddy
|
|
1236
|
+
end tell
|
|
1237
|
+
'''
|
|
1238
|
+
|
|
1239
|
+
try:
|
|
1240
|
+
result = subprocess.run(
|
|
1241
|
+
['osascript', '-e', script],
|
|
1242
|
+
capture_output=True, text=True, timeout=15
|
|
1243
|
+
)
|
|
1244
|
+
if result.returncode == 0:
|
|
1245
|
+
return {'status': 'sent', 'recipient': recipient}
|
|
1246
|
+
else:
|
|
1247
|
+
# Try SMS fallback
|
|
1248
|
+
script_sms = f'''
|
|
1249
|
+
tell application "Messages"
|
|
1250
|
+
set targetService to first service whose service type = SMS
|
|
1251
|
+
set targetBuddy to buddy "{recipient_clean}" of targetService
|
|
1252
|
+
send "{content}" to targetBuddy
|
|
1253
|
+
end tell
|
|
1254
|
+
'''
|
|
1255
|
+
result2 = subprocess.run(
|
|
1256
|
+
['osascript', '-e', script_sms],
|
|
1257
|
+
capture_output=True, text=True, timeout=15
|
|
1258
|
+
)
|
|
1259
|
+
if result2.returncode == 0:
|
|
1260
|
+
return {'status': 'sent_sms', 'recipient': recipient}
|
|
1261
|
+
return {'error': f'AppleScript error: {result.stderr.strip()}'}
|
|
1262
|
+
except subprocess.TimeoutExpired:
|
|
1263
|
+
return {'error': 'iMessage send timed out'}
|
|
1264
|
+
except Exception as e:
|
|
1265
|
+
return {'error': str(e)}
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
@_taint_source("imessage", "imessage")
|
|
1269
|
+
def imessage_get_recent(contact: str = '', limit: int = 10) -> list:
|
|
1270
|
+
"""Get recent iMessages (requires Full Disk Access for Messages DB)."""
|
|
1271
|
+
db_path = Path.home() / 'Library/Messages/chat.db'
|
|
1272
|
+
|
|
1273
|
+
if not db_path.exists():
|
|
1274
|
+
return [{'error': 'Messages DB not accessible — Full Disk Access required'}]
|
|
1275
|
+
|
|
1276
|
+
try:
|
|
1277
|
+
import sqlite3
|
|
1278
|
+
conn = sqlite3.connect(str(db_path))
|
|
1279
|
+
conn.row_factory = sqlite3.Row
|
|
1280
|
+
|
|
1281
|
+
query = '''
|
|
1282
|
+
SELECT
|
|
1283
|
+
m.rowid,
|
|
1284
|
+
m.text,
|
|
1285
|
+
m.date / 1000000000 + 978307200 as unix_time,
|
|
1286
|
+
m.is_from_me,
|
|
1287
|
+
h.id as handle
|
|
1288
|
+
FROM message m
|
|
1289
|
+
LEFT JOIN handle h ON m.handle_id = h.rowid
|
|
1290
|
+
WHERE m.text IS NOT NULL
|
|
1291
|
+
'''
|
|
1292
|
+
|
|
1293
|
+
if contact:
|
|
1294
|
+
query += f" AND h.id LIKE '%{contact}%'"
|
|
1295
|
+
|
|
1296
|
+
query += f" ORDER BY m.date DESC LIMIT {limit}"
|
|
1297
|
+
|
|
1298
|
+
rows = conn.execute(query).fetchall()
|
|
1299
|
+
conn.close()
|
|
1300
|
+
|
|
1301
|
+
messages = []
|
|
1302
|
+
for row in rows:
|
|
1303
|
+
messages.append({
|
|
1304
|
+
'text': row['text'],
|
|
1305
|
+
'from_me': bool(row['is_from_me']),
|
|
1306
|
+
'handle': row['handle'] or 'Unknown',
|
|
1307
|
+
'time': datetime.fromtimestamp(row['unix_time']).strftime('%Y-%m-%d %H:%M'),
|
|
1308
|
+
})
|
|
1309
|
+
return messages
|
|
1310
|
+
except Exception as e:
|
|
1311
|
+
return [{'error': f'DB read failed: {e}'}]
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
# ─────────────────────────────────────────────
|
|
1315
|
+
# SUPABASE
|
|
1316
|
+
# ─────────────────────────────────────────────
|
|
1317
|
+
|
|
1318
|
+
SUPABASE_URL = os.getenv('SUPABASE_URL', '')
|
|
1319
|
+
SUPABASE_KEY = os.getenv('SUPABASE_SERVICE_KEY', '')
|
|
1320
|
+
|
|
1321
|
+
def supabase_query(table: str, limit: int = 50, filters: dict = None) -> list:
|
|
1322
|
+
"""Query a Supabase table."""
|
|
1323
|
+
headers = {
|
|
1324
|
+
'apikey': SUPABASE_KEY,
|
|
1325
|
+
'Authorization': f'Bearer {SUPABASE_KEY}',
|
|
1326
|
+
}
|
|
1327
|
+
params = {'limit': limit}
|
|
1328
|
+
if filters:
|
|
1329
|
+
for k, v in filters.items():
|
|
1330
|
+
params[f'eq.{k}'] = v
|
|
1331
|
+
|
|
1332
|
+
with httpx.Client(timeout=30) as client:
|
|
1333
|
+
r = client.get(f'{SUPABASE_URL}/rest/v1/{table}', headers=headers, params=params)
|
|
1334
|
+
|
|
1335
|
+
return r.json() if r.status_code == 200 else [{'error': r.text[:200]}]
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def supabase_insert(table: str, data: dict) -> dict:
|
|
1339
|
+
"""Insert a row into a Supabase table."""
|
|
1340
|
+
headers = {
|
|
1341
|
+
'apikey': SUPABASE_KEY,
|
|
1342
|
+
'Authorization': f'Bearer {SUPABASE_KEY}',
|
|
1343
|
+
'Content-Type': 'application/json',
|
|
1344
|
+
'Prefer': 'return=representation'
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
with httpx.Client(timeout=30) as client:
|
|
1348
|
+
r = client.post(f'{SUPABASE_URL}/rest/v1/{table}', headers=headers, json=data)
|
|
1349
|
+
|
|
1350
|
+
if r.status_code in (200, 201):
|
|
1351
|
+
return {'status': 'inserted', 'data': r.json()}
|
|
1352
|
+
return {'error': f'{r.status_code} {r.text[:200]}'}
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
@training_sink("integrations.supabase_store_memory", own_words=True)
|
|
1356
|
+
def supabase_store_memory(content: str, memory_type: str = 'episodic',
|
|
1357
|
+
tags: list = None, importance: float = 0.5) -> dict:
|
|
1358
|
+
"""Store a memory in Supabase episodic_memories table."""
|
|
1359
|
+
return supabase_insert('episodic_memories', {
|
|
1360
|
+
'content': content,
|
|
1361
|
+
'memory_type': memory_type,
|
|
1362
|
+
'tags': tags or [],
|
|
1363
|
+
'importance': importance,
|
|
1364
|
+
'created_at': datetime.now(timezone.utc).isoformat(),
|
|
1365
|
+
})
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
# ─────────────────────────────────────────────
|
|
1369
|
+
# INTEGRATION STATUS
|
|
1370
|
+
# ─────────────────────────────────────────────
|
|
1371
|
+
|
|
1372
|
+
def get_integration_status() -> dict:
|
|
1373
|
+
"""Get status of all integrations."""
|
|
1374
|
+
status = {}
|
|
1375
|
+
|
|
1376
|
+
# Gmail / Google
|
|
1377
|
+
try:
|
|
1378
|
+
token = GoogleTokenManager.get_access_token()
|
|
1379
|
+
status['gmail'] = 'connected' if token else 'no token'
|
|
1380
|
+
status['calendar'] = 'connected' if token else 'no token'
|
|
1381
|
+
except Exception as e:
|
|
1382
|
+
status['gmail'] = f'error: {e}'
|
|
1383
|
+
status['calendar'] = f'error: {e}'
|
|
1384
|
+
|
|
1385
|
+
# iMessage
|
|
1386
|
+
status['imessage'] = 'native AppleScript (macOS)' if sys.platform == 'darwin' else 'not macOS'
|
|
1387
|
+
|
|
1388
|
+
# Supabase
|
|
1389
|
+
try:
|
|
1390
|
+
with httpx.Client(timeout=10) as client:
|
|
1391
|
+
r = client.get(
|
|
1392
|
+
f'{SUPABASE_URL}/rest/v1/episodic_memories?limit=1',
|
|
1393
|
+
headers={'apikey': SUPABASE_KEY, 'Authorization': f'Bearer {SUPABASE_KEY}'}
|
|
1394
|
+
)
|
|
1395
|
+
status['supabase'] = 'connected' if r.status_code == 200 else f'error: {r.status_code}'
|
|
1396
|
+
except Exception as e:
|
|
1397
|
+
status['supabase'] = f'error: {e}'
|
|
1398
|
+
|
|
1399
|
+
# Telegram (check .env)
|
|
1400
|
+
tg_token = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
|
1401
|
+
status['telegram'] = 'configured' if tg_token else 'no token'
|
|
1402
|
+
|
|
1403
|
+
# WhatsApp
|
|
1404
|
+
wa_token = os.getenv('WHATSAPP_ACCESS_TOKEN', '')
|
|
1405
|
+
status['whatsapp'] = 'configured' if wa_token else 'credentials needed'
|
|
1406
|
+
|
|
1407
|
+
return status
|
|
1408
|
+
|
|
1409
|
+
|
|
1410
|
+
if __name__ == '__main__':
|
|
1411
|
+
print("Integration Status Check:")
|
|
1412
|
+
status = get_integration_status()
|
|
1413
|
+
for k, v in status.items():
|
|
1414
|
+
print(f" {k:15} {v}")
|
|
1415
|
+
|
|
1416
|
+
print("\nCalendar Events (next 7 days):")
|
|
1417
|
+
events = calendar_get_events(7)
|
|
1418
|
+
if events:
|
|
1419
|
+
for e in events:
|
|
1420
|
+
print(f" - {e.get('summary')} @ {e.get('start')}")
|
|
1421
|
+
else:
|
|
1422
|
+
print(" (none)")
|
|
1423
|
+
|
|
1424
|
+
print("\nGmail Inbox (5 most recent):")
|
|
1425
|
+
inbox = gmail_list_inbox(5)
|
|
1426
|
+
msgs = inbox.get('messages', [])
|
|
1427
|
+
print(f" {len(msgs)} messages retrieved")
|