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
hyperclaw/digest/http.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Read-only HTTP for the digest collectors: GET only, bounded retries.
|
|
2
|
+
|
|
3
|
+
- 429: wait Retry-After (capped), retry a few times, then the source's
|
|
4
|
+
named rate-limit failure.
|
|
5
|
+
- 5xx / network errors: short retries, then "<source> couldn't be reached".
|
|
6
|
+
The token goes only into the Authorization header of requests to the
|
|
7
|
+
source's own API; it is never logged.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Callable, Optional
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from hyperclaw.digest.errors import DigestError, Reason
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger("hyperclaw.digest")
|
|
23
|
+
|
|
24
|
+
MAX_RETRIES = 3
|
|
25
|
+
MAX_WAIT = 30.0
|
|
26
|
+
TIMEOUT = 20.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class SourceResult:
|
|
31
|
+
source: str
|
|
32
|
+
ok: bool = True
|
|
33
|
+
reason: Optional[Reason] = None
|
|
34
|
+
account: str = ""
|
|
35
|
+
items: list = field(default_factory=list)
|
|
36
|
+
notes: list = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ReadOnlyHttp:
|
|
40
|
+
def __init__(self, base_url: str, headers: dict, *, rate_limited: Reason,
|
|
41
|
+
unreachable: Reason, client: Optional[httpx.Client] = None,
|
|
42
|
+
sleep: Callable[[float], None] = time.sleep):
|
|
43
|
+
self.base = require_https(base_url).rstrip("/")
|
|
44
|
+
self._headers = headers
|
|
45
|
+
self.rate_limited = rate_limited
|
|
46
|
+
self.unreachable = unreachable
|
|
47
|
+
self.client = client or httpx.Client(timeout=TIMEOUT, follow_redirects=False)
|
|
48
|
+
self.sleep = sleep
|
|
49
|
+
|
|
50
|
+
def get(self, path: str, params: Optional[dict] = None) -> httpx.Response:
|
|
51
|
+
url = f"{self.base}/{path.lstrip('/')}"
|
|
52
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
53
|
+
try:
|
|
54
|
+
resp = self.client.get(url, params=params or {}, headers=self._headers)
|
|
55
|
+
except httpx.HTTPError as e:
|
|
56
|
+
if attempt >= MAX_RETRIES:
|
|
57
|
+
raise DigestError(self.unreachable, type(e).__name__)
|
|
58
|
+
self.sleep(min(2 ** attempt, MAX_WAIT))
|
|
59
|
+
continue
|
|
60
|
+
if resp.status_code == 429:
|
|
61
|
+
if attempt >= MAX_RETRIES:
|
|
62
|
+
raise DigestError(self.rate_limited, "429 after retries")
|
|
63
|
+
self.sleep(_retry_after(resp))
|
|
64
|
+
continue
|
|
65
|
+
if resp.status_code >= 500:
|
|
66
|
+
if attempt >= MAX_RETRIES:
|
|
67
|
+
raise DigestError(self.unreachable, f"HTTP {resp.status_code}")
|
|
68
|
+
self.sleep(min(2 ** attempt, MAX_WAIT))
|
|
69
|
+
continue
|
|
70
|
+
return resp
|
|
71
|
+
raise DigestError(self.unreachable, "retries exhausted") # pragma: no cover
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
try:
|
|
75
|
+
self.client.close()
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def require_https(url: str) -> str:
|
|
81
|
+
"""Tokens go only over https (plain http only to this machine, for tests)."""
|
|
82
|
+
parts = urlsplit(url or "")
|
|
83
|
+
if parts.scheme == "https" and parts.hostname:
|
|
84
|
+
return url
|
|
85
|
+
if parts.scheme == "http" and parts.hostname in ("127.0.0.1", "localhost", "::1"):
|
|
86
|
+
return url
|
|
87
|
+
raise DigestError(Reason.INSECURE_URL, parts.scheme)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _retry_after(resp: httpx.Response) -> float:
|
|
91
|
+
try:
|
|
92
|
+
return max(0.0, min(float(resp.headers.get("Retry-After", "1")), MAX_WAIT))
|
|
93
|
+
except ValueError:
|
|
94
|
+
return 1.0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_time(value) -> Optional[datetime]:
|
|
98
|
+
"""ISO-8601 (GitLab, with Z) or a Slack ts ('1696930000.000100')."""
|
|
99
|
+
if value is None or value == "":
|
|
100
|
+
return None
|
|
101
|
+
try:
|
|
102
|
+
if isinstance(value, (int, float)) or str(value).replace(".", "", 1).isdigit():
|
|
103
|
+
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
|
104
|
+
s = str(value).replace("Z", "+00:00")
|
|
105
|
+
dt = datetime.fromisoformat(s)
|
|
106
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
107
|
+
except (ValueError, OSError, OverflowError):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def clip(text: str, n: int) -> str:
|
|
112
|
+
text = " ".join(str(text or "").split())
|
|
113
|
+
return text if len(text) <= n else text[: n - 1] + "…"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Secrets are redacted from collected content before anything else sees it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from hyperclaw.truth_guard import redact_secrets as _base_redact
|
|
7
|
+
|
|
8
|
+
_PATTERNS = [
|
|
9
|
+
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)",
|
|
10
|
+
re.S), "[REDACTED-private-key]"),
|
|
11
|
+
(re.compile(r"\bxox[abeoprsc]-[A-Za-z0-9-]{8,}"), "[REDACTED-slack-token]"),
|
|
12
|
+
(re.compile(r"\bxoxe\.xox[a-z]-[A-Za-z0-9.-]{8,}"), "[REDACTED-slack-token]"),
|
|
13
|
+
(re.compile(r"\bxapp-[A-Za-z0-9-]{8,}"), "[REDACTED-slack-token]"),
|
|
14
|
+
(re.compile(r"\bgl(?:pat|oas|dt|rt|ptt|cbt|ft|imt|soat|agent|ffct|wt)-[A-Za-z0-9_.-]{16,}"),
|
|
15
|
+
"[REDACTED-gitlab-token]"),
|
|
16
|
+
(re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), "[REDACTED-aws-key]"),
|
|
17
|
+
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "[REDACTED-jwt]"),
|
|
18
|
+
(re.compile(r"(?i)\b(bearer\s+)[A-Za-z0-9._~+/=-]{12,}"), r"\1[REDACTED]"),
|
|
19
|
+
(re.compile(r"(?i)\b((?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|token)"
|
|
20
|
+
r"\s*[:=]\s*)(\"[^\"]+\"|'[^']+'|\S+)"), r"\1[REDACTED]"),
|
|
21
|
+
(re.compile(r"\b([a-z][a-z0-9+.-]*://[^:/\s@]+:)[^@\s]+(@)"), r"\1[REDACTED]\2"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def redact(text: str) -> str:
|
|
26
|
+
"""Remove tokens, keys and passwords from a piece of collected text."""
|
|
27
|
+
if not text:
|
|
28
|
+
return ""
|
|
29
|
+
out = _base_redact(str(text))
|
|
30
|
+
for pat, repl in _PATTERNS:
|
|
31
|
+
out = pat.sub(repl, out)
|
|
32
|
+
return out
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Connect, run, deliver: the digest pipeline (docs/CONNECTED-DIGEST.md).
|
|
2
|
+
|
|
3
|
+
Owner only: nothing here is reachable from a chat. ``run_digest`` refuses
|
|
4
|
+
any scope but OWNER, delivery goes to the owner's Telegram chat(s) only, and
|
|
5
|
+
every failure is reported with a fixed, named reason.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
|
|
16
|
+
from hyperclaw.chat_scope import OWNER, owner_ids
|
|
17
|
+
from hyperclaw.digest import state, tokens
|
|
18
|
+
from hyperclaw.digest.errors import FIX, TEXT, DigestError, Reason
|
|
19
|
+
from hyperclaw.digest.gitlab import GitLabCollector
|
|
20
|
+
from hyperclaw.digest.http import SourceResult
|
|
21
|
+
from hyperclaw.digest.redact import redact
|
|
22
|
+
from hyperclaw.digest.slack import SlackCollector
|
|
23
|
+
from hyperclaw.digest.summarize import summarize, v2_route
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("hyperclaw.digest")
|
|
26
|
+
|
|
27
|
+
LABEL = {"slack": "Slack", "gitlab": "GitLab"}
|
|
28
|
+
NOT_CONNECTED = {"slack": Reason.SLACK_NOT_CONNECTED, "gitlab": Reason.GITLAB_NOT_CONNECTED}
|
|
29
|
+
# Failure notices follow the notice standard (hyperclaw.notices): they name
|
|
30
|
+
# the action, never imply success and never carry technical detail.
|
|
31
|
+
GENERIC_FAILURE = ("Sorry, I couldn't build today's digest. Something went wrong on my side; "
|
|
32
|
+
"details are in the HyperClaw log.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_collector(source: str, token: str, *, gitlab_url: str = "", client=None, sleep=None):
|
|
36
|
+
if source == "slack":
|
|
37
|
+
return SlackCollector(token, client=client, sleep=sleep)
|
|
38
|
+
if source == "gitlab":
|
|
39
|
+
return GitLabCollector(token, url=gitlab_url, client=client, sleep=sleep)
|
|
40
|
+
raise ValueError(f"unknown source {source!r}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── connect / disconnect ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def connect(source: str, token: str, *, gitlab_url: str = "", store=None,
|
|
46
|
+
client=None, sleep=None) -> dict:
|
|
47
|
+
"""Verify read-only scopes, then store the token. Returns the non-secret
|
|
48
|
+
connection info. Raises a named DigestError; nothing is stored then."""
|
|
49
|
+
token = tokens.check_format(token)
|
|
50
|
+
col = make_collector(source, token, gitlab_url=gitlab_url, client=client, sleep=sleep)
|
|
51
|
+
try:
|
|
52
|
+
info = col.verify()
|
|
53
|
+
finally:
|
|
54
|
+
col.close()
|
|
55
|
+
st = store or tokens.store()
|
|
56
|
+
st.set(source, token)
|
|
57
|
+
meta = {k: v for k, v in info.items() if k in ("account", "workspace", "name", "scopes",
|
|
58
|
+
"url", "expires_at", "user_id")}
|
|
59
|
+
meta["store"] = st.name
|
|
60
|
+
meta["connected_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
61
|
+
state.set_connection(source, meta)
|
|
62
|
+
return meta
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def disconnect(source: str) -> bool:
|
|
66
|
+
meta = state.connections().get(source) or {}
|
|
67
|
+
removed = False
|
|
68
|
+
for name in {meta.get("store") or tokens.backend_name(), "file"}:
|
|
69
|
+
try:
|
|
70
|
+
removed = tokens.store(name).delete(source) or removed
|
|
71
|
+
except DigestError:
|
|
72
|
+
pass
|
|
73
|
+
state.set_connection(source, None)
|
|
74
|
+
return removed or bool(meta)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── run ───────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Outcome:
|
|
81
|
+
ok: bool
|
|
82
|
+
text: str # what is delivered (digest or failure notice)
|
|
83
|
+
reason: str = "" # fixed reason text on failure
|
|
84
|
+
sources: dict = field(default_factory=dict) # source -> "ok" | reason code
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def failure_notice(reasons: list) -> str:
|
|
88
|
+
"""'Sorry, I couldn't build today's digest. <why>. <what to do>'
|
|
89
|
+
(hyperclaw.notices standard)."""
|
|
90
|
+
from hyperclaw.notices import failure_notice as standard
|
|
91
|
+
texts = [TEXT[r] for r in reasons]
|
|
92
|
+
fixes = [FIX[r] for r in reasons if r in FIX]
|
|
93
|
+
why = "; ".join(texts)
|
|
94
|
+
detail = (why[:1].upper() + why[1:] + ".") if why else ""
|
|
95
|
+
if fixes:
|
|
96
|
+
detail += " " + " ".join(dict.fromkeys(fixes))
|
|
97
|
+
return standard(what="build today's digest", detail=detail, retry=False)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fmt_day(dt: datetime) -> str:
|
|
101
|
+
local = dt.astimezone()
|
|
102
|
+
return local.strftime("%a %d %b").replace(" 0", " ")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fmt_since(dt: datetime) -> str:
|
|
106
|
+
local = dt.astimezone()
|
|
107
|
+
return local.strftime("%a %d %b %H:%M").replace(" 0", " ", 1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def sources_line(results: dict) -> str:
|
|
111
|
+
parts = []
|
|
112
|
+
for name, res in results.items():
|
|
113
|
+
if res.ok:
|
|
114
|
+
parts.append(f"{LABEL[name]} ok ({len(res.items)} items)")
|
|
115
|
+
else:
|
|
116
|
+
parts.append(f"{LABEL[name]} not read: {TEXT[res.reason]}")
|
|
117
|
+
return "Sources: " + " · ".join(parts)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _read_token(source: str, meta: dict) -> str:
|
|
121
|
+
return tokens.store(meta.get("store") or tokens.backend_name()).get(source)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def collect(now: datetime, since: datetime, *, client=None, sleep=None) -> dict:
|
|
125
|
+
"""source -> SourceResult for every connected source. Never raises for a
|
|
126
|
+
source failure: the result carries the named reason instead."""
|
|
127
|
+
results = {}
|
|
128
|
+
for source, meta in state.connections().items():
|
|
129
|
+
if source not in LABEL:
|
|
130
|
+
continue
|
|
131
|
+
col = None
|
|
132
|
+
try:
|
|
133
|
+
token = _read_token(source, meta)
|
|
134
|
+
if not token:
|
|
135
|
+
raise DigestError(NOT_CONNECTED[source])
|
|
136
|
+
col = make_collector(source, token, gitlab_url=meta.get("url", ""),
|
|
137
|
+
client=client, sleep=sleep)
|
|
138
|
+
col.verify()
|
|
139
|
+
results[source] = col.collect(since)
|
|
140
|
+
except DigestError as e:
|
|
141
|
+
log.warning("digest: %s not read: %s (%s)", source, e.reason.value, e.detail)
|
|
142
|
+
results[source] = SourceResult(source, ok=False, reason=e.reason)
|
|
143
|
+
finally:
|
|
144
|
+
if col is not None:
|
|
145
|
+
col.close()
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def guard_results(results: dict, provider: str) -> dict:
|
|
150
|
+
"""Restricted-client guard at the Slack/GitLab reader boundary: under an
|
|
151
|
+
outside model, every item that involves a restricted client (or can't be
|
|
152
|
+
checked) is withheld before the summary request is built, and a note
|
|
153
|
+
says so. The digest writes with HyperSpeed V2 only, so nothing is
|
|
154
|
+
withheld today; the boundary stays in place if that ever changes."""
|
|
155
|
+
from hyperclaw import client_guard
|
|
156
|
+
with client_guard.model_scope(provider):
|
|
157
|
+
for name, res in results.items():
|
|
158
|
+
if not res.ok:
|
|
159
|
+
continue
|
|
160
|
+
kept, n = client_guard.filter_items(res.items, name)
|
|
161
|
+
res.items = kept
|
|
162
|
+
if n:
|
|
163
|
+
res.notes.append(client_guard.message_note(n, name))
|
|
164
|
+
return results
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def run_digest(*, now: Optional[datetime] = None, scope: str = OWNER, registry=None,
|
|
168
|
+
client_factory: Optional[Callable] = None, http_client=None,
|
|
169
|
+
sleep=None) -> Outcome:
|
|
170
|
+
"""Collect, summarize with V2, save as latest. Never raises a DigestError."""
|
|
171
|
+
if scope != OWNER:
|
|
172
|
+
return Outcome(False, failure_notice([Reason.NOT_OWNER]), TEXT[Reason.NOT_OWNER])
|
|
173
|
+
now = now or datetime.now(timezone.utc)
|
|
174
|
+
since = state.window_start(now)
|
|
175
|
+
|
|
176
|
+
def fail(reasons: list, sources: Optional[dict] = None) -> Outcome:
|
|
177
|
+
text = failure_notice(reasons)
|
|
178
|
+
state.record_run(now, False, text, reason=reasons[0].value, sources=sources)
|
|
179
|
+
return Outcome(False, text, "; ".join(TEXT[r] for r in reasons), sources or {})
|
|
180
|
+
|
|
181
|
+
if not state.connections():
|
|
182
|
+
return fail([Reason.NO_SOURCES])
|
|
183
|
+
try:
|
|
184
|
+
route = v2_route(registry) # refuse before reading anything if V2 can't write it
|
|
185
|
+
except DigestError as e:
|
|
186
|
+
return fail([e.reason])
|
|
187
|
+
|
|
188
|
+
# v2_route only returns for HyperSpeed V2 (it raises for anything else).
|
|
189
|
+
prov = route[0] if isinstance(route, tuple) and route else None
|
|
190
|
+
results = guard_results(collect(now, since, client=http_client, sleep=sleep),
|
|
191
|
+
getattr(prov, "name", "") or "hyperspeed")
|
|
192
|
+
sources = {n: ("ok" if r.ok else r.reason.value) for n, r in results.items()}
|
|
193
|
+
if not results or not any(r.ok for r in results.values()):
|
|
194
|
+
return fail([r.reason for r in results.values()] or [Reason.NO_SOURCES], sources)
|
|
195
|
+
try:
|
|
196
|
+
summary = summarize(results, f"the period since {_fmt_since(since)}",
|
|
197
|
+
registry=registry, client_factory=client_factory)
|
|
198
|
+
except DigestError as e:
|
|
199
|
+
return fail([e.reason], sources)
|
|
200
|
+
text = "\n\n".join([f"Daily digest, {_fmt_day(now)} (since {_fmt_since(since)})",
|
|
201
|
+
redact(summary), sources_line(results)])
|
|
202
|
+
state.record_run(now, True, text, sources=sources)
|
|
203
|
+
return Outcome(True, text, "", sources)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── delivery ──────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
async def deliver(outcome: Outcome, send: Optional[Callable]) -> list:
|
|
209
|
+
"""Send to the owner's Telegram chat(s) only. Returns the chat ids sent to.
|
|
210
|
+
The terminal copy is $HYPERCLAW_ROOT/digest/latest.txt (record_run)."""
|
|
211
|
+
sent = []
|
|
212
|
+
if not send or not (outcome.text or "").strip():
|
|
213
|
+
return sent
|
|
214
|
+
for chat_id in sorted(owner_ids("telegram")):
|
|
215
|
+
try:
|
|
216
|
+
if await send(int(chat_id), outcome.text):
|
|
217
|
+
sent.append(int(chat_id))
|
|
218
|
+
except Exception as e:
|
|
219
|
+
log.error("digest: Telegram delivery failed (%s)", type(e).__name__)
|
|
220
|
+
if not sent:
|
|
221
|
+
log.info("digest: no owner Telegram chat reached; saved to %s", state.latest_path())
|
|
222
|
+
return sent
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def scheduled_run(send: Optional[Callable], **kw) -> Outcome:
|
|
226
|
+
"""The connected_digest job body. Unexpected errors become the generic
|
|
227
|
+
notice; exception text never reaches a chat."""
|
|
228
|
+
try:
|
|
229
|
+
outcome = await asyncio.to_thread(lambda: run_digest(**kw))
|
|
230
|
+
except Exception as e:
|
|
231
|
+
log.error("digest: job failed (%s)", type(e).__name__, exc_info=True)
|
|
232
|
+
outcome = Outcome(False, GENERIC_FAILURE, "internal error")
|
|
233
|
+
await deliver(outcome, send)
|
|
234
|
+
return outcome
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ── schedule gate / readiness / status ────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
def named_in_env() -> bool:
|
|
240
|
+
raw = os.environ.get("HYPERCLAW_SCHEDULER_JOBS", "")
|
|
241
|
+
return state.JOB_ID in {n.strip().lower() for n in raw.split(",")}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def opted_in() -> bool:
|
|
245
|
+
return named_in_env() or state.opted_in_by_config()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def ready(registry=None) -> tuple:
|
|
249
|
+
"""(runnable, reason) for the scheduler: V2 configured + a source connected."""
|
|
250
|
+
try:
|
|
251
|
+
v2_route(registry)
|
|
252
|
+
except DigestError as e:
|
|
253
|
+
return False, e.text
|
|
254
|
+
if not state.connections():
|
|
255
|
+
return False, TEXT[Reason.NO_SOURCES]
|
|
256
|
+
return True, ""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def status() -> dict:
|
|
260
|
+
from hyperclaw import unattended
|
|
261
|
+
data = state.load()
|
|
262
|
+
h, m = state.run_time()
|
|
263
|
+
return {
|
|
264
|
+
"connections": state.connections(),
|
|
265
|
+
"unattended": unattended.enabled(),
|
|
266
|
+
"opted_in": opted_in(),
|
|
267
|
+
"time": f"{h:02d}:{m:02d}",
|
|
268
|
+
"last_run": data.get("last_run", ""),
|
|
269
|
+
"last_result": data.get("last_result") or {},
|
|
270
|
+
"latest_path": str(state.latest_path()),
|
|
271
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Slack collector: the owner's @mentions and DMs, read-only, own user token.
|
|
2
|
+
|
|
3
|
+
Calls (GET): auth.test, search.messages, conversations.list,
|
|
4
|
+
conversations.history, users.info (only when users:read is granted).
|
|
5
|
+
See docs/CONNECTED-DIGEST.md §1 for the scope choice.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from hyperclaw.digest.errors import DigestError, Reason
|
|
14
|
+
from hyperclaw.digest.http import ReadOnlyHttp, SourceResult, clip, log, parse_time
|
|
15
|
+
from hyperclaw.digest.redact import redact
|
|
16
|
+
from hyperclaw.taint import mark
|
|
17
|
+
|
|
18
|
+
DEFAULT_API = "https://slack.com/api"
|
|
19
|
+
API_ENV = "HYPERCLAW_DIGEST_SLACK_API" # tests / Slack GovSlack only
|
|
20
|
+
|
|
21
|
+
REQUIRED_SCOPES = frozenset({"search:read", "im:read", "im:history"})
|
|
22
|
+
OPTIONAL_SCOPES = frozenset({"mpim:read", "mpim:history", "users:read"})
|
|
23
|
+
|
|
24
|
+
MAX_MENTIONS = 50
|
|
25
|
+
MAX_DM_CONVERSATIONS = 60
|
|
26
|
+
MAX_DM_MESSAGES = 100
|
|
27
|
+
MAX_PAGES = 10
|
|
28
|
+
TEXT_CHARS = 400
|
|
29
|
+
|
|
30
|
+
_EXPIRED = {"invalid_auth", "not_authed", "token_expired", "token_revoked",
|
|
31
|
+
"account_inactive"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def write_scopes(scopes) -> list:
|
|
35
|
+
"""Scopes that are not read-only. Read-only = ends with ':read' or
|
|
36
|
+
':history', or is 'identify'."""
|
|
37
|
+
bad = []
|
|
38
|
+
for s in scopes:
|
|
39
|
+
s = s.strip()
|
|
40
|
+
if not s:
|
|
41
|
+
continue
|
|
42
|
+
if s == "identify" or s.endswith(":read") or s.endswith(":history"):
|
|
43
|
+
continue
|
|
44
|
+
bad.append(s)
|
|
45
|
+
return sorted(bad)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_user_token(token: str) -> bool:
|
|
49
|
+
t = token or ""
|
|
50
|
+
return t.startswith("xoxp-") or t.startswith("xoxe.xoxp-")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SlackCollector:
|
|
54
|
+
source = "slack"
|
|
55
|
+
|
|
56
|
+
def __init__(self, token: str, *, base_url: str = "", client=None, sleep=None):
|
|
57
|
+
if not is_user_token(token):
|
|
58
|
+
raise DigestError(Reason.SLACK_NOT_USER_TOKEN)
|
|
59
|
+
kw = {"client": client}
|
|
60
|
+
if sleep is not None:
|
|
61
|
+
kw["sleep"] = sleep
|
|
62
|
+
self.http = ReadOnlyHttp(base_url or os.environ.get(API_ENV, "") or DEFAULT_API,
|
|
63
|
+
{"Authorization": f"Bearer {token}"},
|
|
64
|
+
rate_limited=Reason.SLACK_RATE_LIMITED,
|
|
65
|
+
unreachable=Reason.SLACK_UNREACHABLE, **kw)
|
|
66
|
+
self.info: dict = {}
|
|
67
|
+
self._names: dict = {}
|
|
68
|
+
|
|
69
|
+
# ── API ───────────────────────────────────────────────────────────────
|
|
70
|
+
def _call(self, method: str, params: Optional[dict] = None):
|
|
71
|
+
resp = self.http.get(method, params)
|
|
72
|
+
if resp.status_code in (401, 403):
|
|
73
|
+
raise DigestError(Reason.SLACK_TOKEN_EXPIRED, f"HTTP {resp.status_code}")
|
|
74
|
+
try:
|
|
75
|
+
data = resp.json()
|
|
76
|
+
except ValueError:
|
|
77
|
+
raise DigestError(Reason.SLACK_UNREACHABLE, f"{method}: not JSON")
|
|
78
|
+
if not data.get("ok"):
|
|
79
|
+
err = str(data.get("error") or "")
|
|
80
|
+
if err in _EXPIRED:
|
|
81
|
+
raise DigestError(Reason.SLACK_TOKEN_EXPIRED, err)
|
|
82
|
+
if err == "missing_scope":
|
|
83
|
+
raise DigestError(Reason.SLACK_MISSING_SCOPE, f"{method}: {data.get('needed', '')}")
|
|
84
|
+
if err == "ratelimited":
|
|
85
|
+
raise DigestError(Reason.SLACK_RATE_LIMITED, method)
|
|
86
|
+
raise DigestError(Reason.SLACK_UNREACHABLE, f"{method}: {err}")
|
|
87
|
+
return data, resp
|
|
88
|
+
|
|
89
|
+
def verify(self) -> dict:
|
|
90
|
+
"""auth.test + read-only scope check. Raises a named DigestError."""
|
|
91
|
+
data, resp = self._call("auth.test")
|
|
92
|
+
scopes = sorted({s.strip() for s in resp.headers.get("x-oauth-scopes", "").split(",")
|
|
93
|
+
if s.strip()})
|
|
94
|
+
bad = write_scopes(scopes)
|
|
95
|
+
if bad:
|
|
96
|
+
raise DigestError(Reason.SLACK_WRITE_SCOPE, ",".join(bad))
|
|
97
|
+
missing = sorted(REQUIRED_SCOPES - set(scopes))
|
|
98
|
+
if missing:
|
|
99
|
+
raise DigestError(Reason.SLACK_MISSING_SCOPE, ",".join(missing))
|
|
100
|
+
self.info = {
|
|
101
|
+
"user_id": str(data.get("user_id") or ""),
|
|
102
|
+
"account": str(data.get("user") or ""),
|
|
103
|
+
"workspace": str(data.get("team") or ""),
|
|
104
|
+
"scopes": scopes,
|
|
105
|
+
}
|
|
106
|
+
return self.info
|
|
107
|
+
|
|
108
|
+
# ── collection ────────────────────────────────────────────────────────
|
|
109
|
+
def collect(self, since: datetime) -> SourceResult:
|
|
110
|
+
info = self.info or self.verify()
|
|
111
|
+
res = SourceResult(self.source, account=f"{info['account']} ({info['workspace']})")
|
|
112
|
+
self._mentions(info["user_id"], since, res)
|
|
113
|
+
self._dms(info["user_id"], since, set(info["scopes"]), res)
|
|
114
|
+
res.items = mark(res.items, "slack", "slack") # connector content (hyperclaw.taint)
|
|
115
|
+
return res
|
|
116
|
+
|
|
117
|
+
def _name(self, user_id: str) -> str:
|
|
118
|
+
if not user_id:
|
|
119
|
+
return "someone"
|
|
120
|
+
if "users:read" not in set(self.info.get("scopes", ())):
|
|
121
|
+
return user_id
|
|
122
|
+
if user_id not in self._names:
|
|
123
|
+
try:
|
|
124
|
+
data, _ = self._call("users.info", {"user": user_id})
|
|
125
|
+
u = data.get("user") or {}
|
|
126
|
+
prof = u.get("profile") or {}
|
|
127
|
+
self._names[user_id] = (prof.get("display_name") or u.get("real_name")
|
|
128
|
+
or u.get("name") or user_id)
|
|
129
|
+
except DigestError as e:
|
|
130
|
+
if e.reason in (Reason.SLACK_TOKEN_EXPIRED, Reason.SLACK_RATE_LIMITED):
|
|
131
|
+
raise
|
|
132
|
+
self._names[user_id] = user_id
|
|
133
|
+
return self._names[user_id]
|
|
134
|
+
|
|
135
|
+
def _mentions(self, uid: str, since: datetime, res: SourceResult) -> None:
|
|
136
|
+
day = (since - timedelta(days=1)).date().isoformat()
|
|
137
|
+
query = f"<@{uid}> after:{day}"
|
|
138
|
+
found = 0
|
|
139
|
+
page, pages = 1, 1
|
|
140
|
+
while page <= pages and page <= MAX_PAGES:
|
|
141
|
+
data, _ = self._call("search.messages", {"query": query, "sort": "timestamp",
|
|
142
|
+
"sort_dir": "desc", "count": 100,
|
|
143
|
+
"page": page})
|
|
144
|
+
msgs = data.get("messages") or {}
|
|
145
|
+
pages = int((msgs.get("paging") or {}).get("pages") or 1)
|
|
146
|
+
for m in msgs.get("matches") or []:
|
|
147
|
+
when = parse_time(m.get("ts"))
|
|
148
|
+
if not when or when < since:
|
|
149
|
+
continue
|
|
150
|
+
if found >= MAX_MENTIONS:
|
|
151
|
+
res.notes.append(f"More than {MAX_MENTIONS} mentions; the oldest are not shown.")
|
|
152
|
+
return
|
|
153
|
+
ch = m.get("channel") or {}
|
|
154
|
+
where = "a DM" if ch.get("is_im") else (f"#{ch.get('name')}" if ch.get("name")
|
|
155
|
+
else "a conversation")
|
|
156
|
+
res.items.append({
|
|
157
|
+
"kind": "mention", "where": where,
|
|
158
|
+
"from": m.get("username") or self._name(m.get("user", "")),
|
|
159
|
+
"when": when.isoformat(timespec="minutes"),
|
|
160
|
+
"text": redact(clip(m.get("text", ""), TEXT_CHARS)),
|
|
161
|
+
"link": m.get("permalink", ""),
|
|
162
|
+
})
|
|
163
|
+
found += 1
|
|
164
|
+
page += 1
|
|
165
|
+
|
|
166
|
+
def _conversations(self, types: str) -> list:
|
|
167
|
+
out, cursor = [], ""
|
|
168
|
+
for _ in range(MAX_PAGES):
|
|
169
|
+
params = {"types": types, "limit": 200, "exclude_archived": "true"}
|
|
170
|
+
if cursor:
|
|
171
|
+
params["cursor"] = cursor
|
|
172
|
+
data, _ = self._call("conversations.list", params)
|
|
173
|
+
out.extend(data.get("channels") or [])
|
|
174
|
+
cursor = ((data.get("response_metadata") or {}).get("next_cursor") or "").strip()
|
|
175
|
+
if not cursor or len(out) >= MAX_DM_CONVERSATIONS:
|
|
176
|
+
break
|
|
177
|
+
if len(out) > MAX_DM_CONVERSATIONS:
|
|
178
|
+
out = out[:MAX_DM_CONVERSATIONS]
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
def _dms(self, uid: str, since: datetime, scopes: set, res: SourceResult) -> None:
|
|
182
|
+
types = "im"
|
|
183
|
+
if {"mpim:read", "mpim:history"} <= scopes:
|
|
184
|
+
types = "im,mpim"
|
|
185
|
+
else:
|
|
186
|
+
res.notes.append("Group DMs were not read (the token has no mpim:read/mpim:history).")
|
|
187
|
+
convs = self._conversations(types)
|
|
188
|
+
if len(convs) >= MAX_DM_CONVERSATIONS:
|
|
189
|
+
res.notes.append(f"Only the first {MAX_DM_CONVERSATIONS} DM conversations were checked.")
|
|
190
|
+
count = 0
|
|
191
|
+
for conv in convs:
|
|
192
|
+
cid = conv.get("id")
|
|
193
|
+
if not cid:
|
|
194
|
+
continue
|
|
195
|
+
is_group = bool(conv.get("is_mpim"))
|
|
196
|
+
cursor = ""
|
|
197
|
+
for _ in range(MAX_PAGES):
|
|
198
|
+
params = {"channel": cid, "oldest": f"{since.timestamp():.6f}", "limit": 100}
|
|
199
|
+
if cursor:
|
|
200
|
+
params["cursor"] = cursor
|
|
201
|
+
data, _ = self._call("conversations.history", params)
|
|
202
|
+
for m in data.get("messages") or []:
|
|
203
|
+
if m.get("subtype") or m.get("user") == uid:
|
|
204
|
+
continue # joins/bots/edits, and the owner's own messages
|
|
205
|
+
when = parse_time(m.get("ts"))
|
|
206
|
+
if not when or when < since:
|
|
207
|
+
continue
|
|
208
|
+
if count >= MAX_DM_MESSAGES:
|
|
209
|
+
res.notes.append(f"More than {MAX_DM_MESSAGES} DM messages; the rest are not shown.")
|
|
210
|
+
return
|
|
211
|
+
res.items.append({
|
|
212
|
+
"kind": "group_dm" if is_group else "dm",
|
|
213
|
+
"from": self._name(m.get("user", "")),
|
|
214
|
+
"when": when.isoformat(timespec="minutes"),
|
|
215
|
+
"text": redact(clip(m.get("text", ""), TEXT_CHARS)),
|
|
216
|
+
})
|
|
217
|
+
count += 1
|
|
218
|
+
cursor = ((data.get("response_metadata") or {}).get("next_cursor") or "").strip()
|
|
219
|
+
if not cursor or not data.get("has_more", bool(cursor)):
|
|
220
|
+
break
|
|
221
|
+
|
|
222
|
+
def close(self) -> None:
|
|
223
|
+
self.http.close()
|