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.
Files changed (361) hide show
  1. agents/__init__.py +31 -0
  2. agents/background_monitor.py +453 -0
  3. agents/learning.py +460 -0
  4. agents/planner.py +421 -0
  5. agents/transcriber.py +341 -0
  6. civilization/__init__.py +51 -0
  7. civilization/api/__init__.py +7 -0
  8. civilization/api/civilization_api.py +361 -0
  9. civilization/graph/__init__.py +19 -0
  10. civilization/graph/knowledge_linker.py +348 -0
  11. civilization/graph/org_graph.py +348 -0
  12. civilization/graph/process_graph.py +386 -0
  13. civilization/ingestion/__init__.py +20 -0
  14. civilization/ingestion/chunker.py +273 -0
  15. civilization/ingestion/document_ingestor.py +358 -0
  16. civilization/ingestion/embedder.py +262 -0
  17. civilization/ingestion/extractor.py +224 -0
  18. civilization/interview/__init__.py +19 -0
  19. civilization/interview/gap_detector.py +380 -0
  20. civilization/interview/interviewer.py +323 -0
  21. civilization/interview/questioner.py +363 -0
  22. civilization/retrieval/__init__.py +17 -0
  23. civilization/retrieval/civilization_rag.py +316 -0
  24. civilization/retrieval/context_injector.py +342 -0
  25. civilization/retrieval/relevance_ranker.py +287 -0
  26. civilization/schema.py +299 -0
  27. civilization/store.py +177 -0
  28. civilization/sync/__init__.py +16 -0
  29. civilization/sync/confluence_sync.py +317 -0
  30. civilization/sync/gdrive_sync.py +262 -0
  31. civilization/sync/notion_sync.py +237 -0
  32. civilization/versioning/__init__.py +19 -0
  33. civilization/versioning/diff_engine.py +351 -0
  34. civilization/versioning/staleness_detector.py +303 -0
  35. civilization/versioning/version_manager.py +293 -0
  36. cli/__init__.py +1 -0
  37. cli/doctor.py +334 -0
  38. cli/hyperclaw.py +1357 -0
  39. cli/onboarding.py +469 -0
  40. core/__init__.py +1 -0
  41. core/connectors/__init__.py +119 -0
  42. core/connectors/infrastructure.py +772 -0
  43. core/connectors/messaging.py +586 -0
  44. core/connectors/productivity.py +764 -0
  45. core/connectors/smarthome.py +528 -0
  46. core/connectors/social.py +730 -0
  47. core/hyperrouter/__init__.py +23 -0
  48. core/hyperrouter/bandit.py +218 -0
  49. core/hyperrouter/fast_loop.py +68 -0
  50. core/hyperrouter/slow_loop.py +143 -0
  51. core/hyperstate/__init__.py +24 -0
  52. core/hyperstate/certifier.py +154 -0
  53. core/hyperstate/schema.py +160 -0
  54. core/hyperstate/state_manager.py +103 -0
  55. core/hyperstate/store.py +163 -0
  56. core/providers/__init__.py +121 -0
  57. core/providers/embeddings.py +389 -0
  58. core/providers/image.py +534 -0
  59. core/providers/llm.py +1935 -0
  60. core/providers/search.py +608 -0
  61. core/providers/voice.py +474 -0
  62. hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
  63. hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
  64. hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
  65. hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
  66. hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
  67. hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
  68. hyperclaw/__init__.py +67 -0
  69. hyperclaw/__main__.py +220 -0
  70. hyperclaw/agent.py +775 -0
  71. hyperclaw/agent_coordinator.py +577 -0
  72. hyperclaw/anthropic_params.py +225 -0
  73. hyperclaw/api_utils.py +85 -0
  74. hyperclaw/approval_telegram.py +209 -0
  75. hyperclaw/approval_terminal.py +80 -0
  76. hyperclaw/attachments.py +177 -0
  77. hyperclaw/branding.py +55 -0
  78. hyperclaw/brief/__init__.py +11 -0
  79. hyperclaw/brief/cli.py +204 -0
  80. hyperclaw/brief/collect.py +146 -0
  81. hyperclaw/brief/compose.py +333 -0
  82. hyperclaw/brief/diff.py +110 -0
  83. hyperclaw/brief/errors.py +99 -0
  84. hyperclaw/brief/google.py +269 -0
  85. hyperclaw/brief/google_auth.py +294 -0
  86. hyperclaw/brief/links.py +124 -0
  87. hyperclaw/brief/service.py +424 -0
  88. hyperclaw/brief/store.py +205 -0
  89. hyperclaw/brief/tasks.py +200 -0
  90. hyperclaw/chat_scope.py +115 -0
  91. hyperclaw/cli_tui.py +497 -0
  92. hyperclaw/client_guard.py +717 -0
  93. hyperclaw/connection_check.py +113 -0
  94. hyperclaw/cost_guard.py +1728 -0
  95. hyperclaw/default_config/agents.yaml +534 -0
  96. hyperclaw/default_config/hyperclaw.yaml +27 -0
  97. hyperclaw/default_config/models.yaml +161 -0
  98. hyperclaw/deployment.py +39 -0
  99. hyperclaw/digest/__init__.py +9 -0
  100. hyperclaw/digest/cli.py +117 -0
  101. hyperclaw/digest/errors.py +100 -0
  102. hyperclaw/digest/gitlab.py +187 -0
  103. hyperclaw/digest/http.py +113 -0
  104. hyperclaw/digest/redact.py +32 -0
  105. hyperclaw/digest/service.py +271 -0
  106. hyperclaw/digest/slack.py +223 -0
  107. hyperclaw/digest/state.py +124 -0
  108. hyperclaw/digest/summarize.py +198 -0
  109. hyperclaw/digest/tokens.py +143 -0
  110. hyperclaw/distribution.py +46 -0
  111. hyperclaw/documents.py +351 -0
  112. hyperclaw/feeds.py +339 -0
  113. hyperclaw/google_workspace/__init__.py +5 -0
  114. hyperclaw/google_workspace/api.py +151 -0
  115. hyperclaw/google_workspace/auth.py +397 -0
  116. hyperclaw/google_workspace/calendar.py +157 -0
  117. hyperclaw/google_workspace/cli.py +72 -0
  118. hyperclaw/google_workspace/drive.py +236 -0
  119. hyperclaw/google_workspace/errors.py +58 -0
  120. hyperclaw/google_workspace/gmail.py +295 -0
  121. hyperclaw/google_workspace/meet.py +122 -0
  122. hyperclaw/google_workspace/tools.py +319 -0
  123. hyperclaw/hyperspeed.py +57 -0
  124. hyperclaw/integrations_layer.py +1427 -0
  125. hyperclaw/live_state.py +188 -0
  126. hyperclaw/managed_config.py +245 -0
  127. hyperclaw/media_hub.py +179 -0
  128. hyperclaw/memory_manager.py +584 -0
  129. hyperclaw/model_catalog.py +551 -0
  130. hyperclaw/model_policy.py +211 -0
  131. hyperclaw/model_router.py +595 -0
  132. hyperclaw/model_selector.py +72 -0
  133. hyperclaw/naming.py +284 -0
  134. hyperclaw/notices.py +65 -0
  135. hyperclaw/oai_stream.py +106 -0
  136. hyperclaw/orchestrator.py +591 -0
  137. hyperclaw/outbox.py +82 -0
  138. hyperclaw/persona.py +367 -0
  139. hyperclaw/prometheus.py +219 -0
  140. hyperclaw/prompt_assembly.py +157 -0
  141. hyperclaw/provider_setup.py +146 -0
  142. hyperclaw/providers.py +340 -0
  143. hyperclaw/qualification.py +341 -0
  144. hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
  145. hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
  146. hyperclaw/reasoning_display.py +35 -0
  147. hyperclaw/scheduler.py +454 -0
  148. hyperclaw/server.py +847 -0
  149. hyperclaw/setup.py +627 -0
  150. hyperclaw/skills.py +717 -0
  151. hyperclaw/solomon.py +442 -0
  152. hyperclaw/swarm.py +533 -0
  153. hyperclaw/taint.py +832 -0
  154. hyperclaw/team/__init__.py +28 -0
  155. hyperclaw/team/dispatcher.py +431 -0
  156. hyperclaw/team/genesis.py +106 -0
  157. hyperclaw/team/lead.py +110 -0
  158. hyperclaw/team/model.py +135 -0
  159. hyperclaw/team/morning.py +238 -0
  160. hyperclaw/team/repos.py +66 -0
  161. hyperclaw/team/roster.py +264 -0
  162. hyperclaw/team/specialist.py +235 -0
  163. hyperclaw/team/status.py +163 -0
  164. hyperclaw/team/watchers.py +112 -0
  165. hyperclaw/teamsetup/__init__.py +17 -0
  166. hyperclaw/teamsetup/checks.py +208 -0
  167. hyperclaw/teamsetup/item.py +391 -0
  168. hyperclaw/teamsetup/oauth_loopback.py +84 -0
  169. hyperclaw/teamsetup/onboard.py +550 -0
  170. hyperclaw/teamsetup/profile.py +82 -0
  171. hyperclaw/teamsetup/secrets.py +313 -0
  172. hyperclaw/teamsetup/selfcheck.py +295 -0
  173. hyperclaw/teamsetup/service.py +495 -0
  174. hyperclaw/teamsetup/slack_oauth.py +442 -0
  175. hyperclaw/teamsetup/tty.py +62 -0
  176. hyperclaw/telegram_bot.py +1106 -0
  177. hyperclaw/telegram_intake.py +126 -0
  178. hyperclaw/telegram_pairing.py +360 -0
  179. hyperclaw/terminal_provider.py +323 -0
  180. hyperclaw/tools/__init__.py +1 -0
  181. hyperclaw/tools/approval.py +949 -0
  182. hyperclaw/tools/args.py +212 -0
  183. hyperclaw/tools/catalog.py +244 -0
  184. hyperclaw/tools/classify.py +266 -0
  185. hyperclaw/tools/effects.py +724 -0
  186. hyperclaw/tools/egress.py +210 -0
  187. hyperclaw/tools/envelope.py +188 -0
  188. hyperclaw/tools/guard.py +367 -0
  189. hyperclaw/tools/ledger.py +339 -0
  190. hyperclaw/tools/receipts.py +70 -0
  191. hyperclaw/tools/registry.py +318 -0
  192. hyperclaw/tools/service_guard.py +151 -0
  193. hyperclaw/tools/surfaces.py +54 -0
  194. hyperclaw/tools/workspace.py +87 -0
  195. hyperclaw/trading/__init__.py +77 -0
  196. hyperclaw/truth_guard.py +791 -0
  197. hyperclaw/tts.py +124 -0
  198. hyperclaw/tui.py +4255 -0
  199. hyperclaw/tui_bridge.py +938 -0
  200. hyperclaw/unattended.py +60 -0
  201. hyperclaw/updates/__init__.py +54 -0
  202. hyperclaw/updates/check.py +131 -0
  203. hyperclaw/updates/index.py +178 -0
  204. hyperclaw/updates/install.py +110 -0
  205. hyperclaw/updates/managed.py +161 -0
  206. hyperclaw/updates/manifest.py +138 -0
  207. hyperclaw/updates/notices.py +86 -0
  208. hyperclaw/updates/paths.py +53 -0
  209. hyperclaw/updates/release.py +146 -0
  210. hyperclaw/updates/upgrade.py +253 -0
  211. hyperclaw/updates/versioning.py +104 -0
  212. hyperclaw/userconfig.py +119 -0
  213. hyperclaw/xai.py +83 -0
  214. integrations/__init__.py +31 -0
  215. integrations/automation/__init__.py +1 -0
  216. integrations/automation/make.py +87 -0
  217. integrations/automation/zapier.py +87 -0
  218. integrations/base.py +476 -0
  219. integrations/communication/__init__.py +1 -0
  220. integrations/communication/twilio.py +279 -0
  221. integrations/communication/zoom.py +290 -0
  222. integrations/data/__init__.py +1 -0
  223. integrations/data/google_analytics.py +135 -0
  224. integrations/data/postgres.py +182 -0
  225. integrations/data/supabase_connector.py +202 -0
  226. integrations/data/weather_feeds.py +450 -0
  227. integrations/developer/__init__.py +9 -0
  228. integrations/developer/github.py +406 -0
  229. integrations/developer/gitlab.py +298 -0
  230. integrations/enterprise/__init__.py +13 -0
  231. integrations/enterprise/confluence.py +266 -0
  232. integrations/enterprise/hubspot.py +293 -0
  233. integrations/enterprise/jira.py +314 -0
  234. integrations/enterprise/salesforce.py +286 -0
  235. integrations/finance/__init__.py +1 -0
  236. integrations/finance/polymarket_client.py +247 -0
  237. integrations/finance/quickbooks.py +281 -0
  238. integrations/finance/stripe.py +261 -0
  239. integrations/gateway.py +238 -0
  240. integrations/messaging/__init__.py +32 -0
  241. integrations/messaging/discord.py +159 -0
  242. integrations/messaging/email.py +401 -0
  243. integrations/messaging/imessage.py +443 -0
  244. integrations/messaging/signal.py +175 -0
  245. integrations/messaging/slack.py +217 -0
  246. integrations/messaging/sms.py +175 -0
  247. integrations/messaging/teams.py +208 -0
  248. integrations/messaging/telegram.py +260 -0
  249. integrations/messaging/whatsapp.py +203 -0
  250. integrations/productivity/__init__.py +17 -0
  251. integrations/productivity/airtable.py +217 -0
  252. integrations/productivity/asana.py +248 -0
  253. integrations/productivity/google/__init__.py +19 -0
  254. integrations/productivity/google/base.py +89 -0
  255. integrations/productivity/google/calendar.py +272 -0
  256. integrations/productivity/google/docs.py +202 -0
  257. integrations/productivity/google/drive.py +268 -0
  258. integrations/productivity/google/gmail.py +279 -0
  259. integrations/productivity/google/meet.py +218 -0
  260. integrations/productivity/google/sheets.py +222 -0
  261. integrations/productivity/google/tasks.py +199 -0
  262. integrations/productivity/linear.py +279 -0
  263. integrations/productivity/microsoft/__init__.py +13 -0
  264. integrations/productivity/microsoft/base.py +93 -0
  265. integrations/productivity/microsoft/calendar.py +207 -0
  266. integrations/productivity/microsoft/onedrive.py +222 -0
  267. integrations/productivity/microsoft/outlook.py +241 -0
  268. integrations/productivity/microsoft/sharepoint.py +211 -0
  269. integrations/productivity/notion.py +243 -0
  270. integrations/productivity/todoist.py +250 -0
  271. integrations/productivity/trello.py +244 -0
  272. integrations/storage/__init__.py +1 -0
  273. integrations/storage/box.py +279 -0
  274. integrations/storage/dropbox.py +215 -0
  275. integrations/telegram_inbound.py +179 -0
  276. integrations/telegram_poller.py +151 -0
  277. memory/__init__.py +1 -0
  278. memory/causal_graph.py +125 -0
  279. memory/impact_tracker.py +88 -0
  280. memory/vector_memory.py +436 -0
  281. models/__init__.py +12 -0
  282. models/chatjimmy_client.py +210 -0
  283. models/claude_client.py +235 -0
  284. models/claude_code_subagent.py +192 -0
  285. models/openai_client.py +120 -0
  286. models/router.py +162 -0
  287. recursive/discovery_loop.py +118 -0
  288. recursive/optimization_loop.py +135 -0
  289. recursive/validation_loop.py +38 -0
  290. security/__init__.py +1 -0
  291. security/audit_logger.py +106 -0
  292. security/filesystem_guard.py +100 -0
  293. security/hypershield.py +99 -0
  294. security/network_guard.py +97 -0
  295. security/policy_engine.py +198 -0
  296. swarm/__init__.py +1 -0
  297. swarm/agents/__init__.py +0 -0
  298. swarm/agents/base.py +230 -0
  299. swarm/agents/business/__init__.py +0 -0
  300. swarm/agents/business/counsel.py +31 -0
  301. swarm/agents/business/herald.py +32 -0
  302. swarm/agents/business/ledger.py +26 -0
  303. swarm/agents/business/nexus.py +30 -0
  304. swarm/agents/business/ops.py +30 -0
  305. swarm/agents/business/pipeline.py +28 -0
  306. swarm/agents/business/revenue.py +30 -0
  307. swarm/agents/business/sovereign.py +31 -0
  308. swarm/agents/business/strategos.py +27 -0
  309. swarm/agents/business/talent.py +27 -0
  310. swarm/agents/business/venture.py +32 -0
  311. swarm/agents/comms/__init__.py +1 -0
  312. swarm/agents/comms/cipher.py +32 -0
  313. swarm/agents/comms/echo_comms.py +31 -0
  314. swarm/agents/comms/envoy.py +31 -0
  315. swarm/agents/comms/herald_scheduler.py +33 -0
  316. swarm/agents/comms/pulse.py +29 -0
  317. swarm/agents/creative/__init__.py +0 -0
  318. swarm/agents/creative/author.py +27 -0
  319. swarm/agents/creative/lens.py +25 -0
  320. swarm/agents/executive/__init__.py +1 -0
  321. swarm/agents/intelligence/__init__.py +1 -0
  322. swarm/agents/intelligence/arbiter.py +30 -0
  323. swarm/agents/intelligence/sentinel.py +30 -0
  324. swarm/agents/media/__init__.py +0 -0
  325. swarm/agents/personal/__init__.py +0 -0
  326. swarm/agents/personal/atlas.py +27 -0
  327. swarm/agents/personal/hearth.py +26 -0
  328. swarm/agents/personal/midas.py +27 -0
  329. swarm/agents/personal/navigator.py +27 -0
  330. swarm/agents/personal/nourish.py +31 -0
  331. swarm/agents/personal/vitals.py +33 -0
  332. swarm/agents/recursive/__init__.py +0 -0
  333. swarm/agents/recursive/alchemist.py +73 -0
  334. swarm/agents/recursive/calibrator.py +45 -0
  335. swarm/agents/recursive/scout.py +97 -0
  336. swarm/agents/scientific/__init__.py +0 -0
  337. swarm/agents/scientific/cosmos.py +36 -0
  338. swarm/agents/scientific/gaia.py +26 -0
  339. swarm/agents/scientific/medicus.py +33 -0
  340. swarm/agents/scientific/oracle_agent.py +39 -0
  341. swarm/agents/scientific/scribe.py +39 -0
  342. swarm/agents/talent/__init__.py +0 -0
  343. swarm/agents/talent/deal.py +28 -0
  344. swarm/agents/talent/roster.py +29 -0
  345. swarm/agents/talent/scout.py +29 -0
  346. swarm/agents/talent/stage.py +27 -0
  347. swarm/agents/tech/__init__.py +0 -0
  348. swarm/agents/tech/aegis.py +28 -0
  349. swarm/agents/tech/bridge.py +28 -0
  350. swarm/agents/tech/forge.py +28 -0
  351. swarm/agents/trading/__init__.py +1 -0
  352. swarm/agents/trading/global_prediction_engine.py +611 -0
  353. swarm/agents/trading/polymarket_trader.py +165 -0
  354. swarm/agents/trading/prediction_strategist.py +218 -0
  355. swarm/autogen_bridge.py +68 -0
  356. swarm/bid_protocol.py +196 -0
  357. swarm/dispatcher.py +527 -0
  358. swarm/nexus.py +181 -0
  359. swarm/registry.py +112 -0
  360. telegram_listener.py +403 -0
  361. ui/tui/app.py +214 -0
@@ -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()