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,442 @@
|
|
|
1
|
+
"""Slack sign-in for a team install: OAuth v2 with PKCE, user token: reads, and posts only on the owner's Send.
|
|
2
|
+
|
|
3
|
+
- The Slack app (``app_id``, ``client_id``) comes from the Hyper-signed team
|
|
4
|
+
profile; no client secret is used (PKCE, S256).
|
|
5
|
+
- The redirect is ``http://localhost:<port><path>`` (team profile; port 8766
|
|
6
|
+
by default), caught by a one-shot loopback listener. The person signs in in
|
|
7
|
+
their own browser.
|
|
8
|
+
- Exactly the app's user scopes are requested (``SLACK_USER_SCOPES``: read
|
|
9
|
+
mentions, DMs and channels, plus ``chat:write`` to post on tap; every post
|
|
10
|
+
still waits for the owner's Send). A grant that differs in any way is
|
|
11
|
+
refused and nothing is stored.
|
|
12
|
+
- The token bundle (access token, refresh token, expiries) is stored in the
|
|
13
|
+
OS secret store (hyperclaw.teamsetup.secrets, name ``slack_user_token``).
|
|
14
|
+
- **Refresh** (the app: PKCE on, token rotation off; the main service checks
|
|
15
|
+
hourly, docs/TEAM-ONBOARDING.md):
|
|
16
|
+
- a grant with no ``expires_in`` and no refresh token is a long-lived user
|
|
17
|
+
token: nothing to refresh; a new sign-in is needed only if it is revoked;
|
|
18
|
+
- a grant with a refresh token: the access token is refreshed 10 minutes
|
|
19
|
+
before it expires. Slack's PKCE refresh tokens expire 30 days after they
|
|
20
|
+
are issued. A refresh that returns a new refresh token restarts the 30
|
|
21
|
+
days; one that doesn't leaves the original date. Three days before that
|
|
22
|
+
date the owner is told once on Telegram (``SIGN_IN_SOON``); a refused
|
|
23
|
+
refresh is told once (``SIGN_IN_AGAIN``).
|
|
24
|
+
- A connection is proven with one authenticated call, ``auth.test``.
|
|
25
|
+
- **One workspace only** (fail-closed). The signed team profile names the
|
|
26
|
+
Slack workspace (``slack_team_id``; and ``slack_enterprise_id`` only for an
|
|
27
|
+
Enterprise Grid org). After the OAuth exchange, on every refresh and on
|
|
28
|
+
every connection check, the token's team id (and its enterprise id, when
|
|
29
|
+
Slack reports one) must match exactly. On a mismatch the token is
|
|
30
|
+
discarded (revoked with Slack, best effort, and nothing is stored or the
|
|
31
|
+
stored bundle is removed) and the person is told plainly
|
|
32
|
+
(``TEXT["wrong_workspace"]``). A profile with no ``slack_team_id`` refuses
|
|
33
|
+
Slack sign-in, and stored tokens are not handed out, with the fix shown.
|
|
34
|
+
|
|
35
|
+
Nothing here prints, logs or raises with a token, a code or a verifier.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import base64
|
|
40
|
+
import hashlib
|
|
41
|
+
import json
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import secrets as _secrets
|
|
45
|
+
import time
|
|
46
|
+
from dataclasses import dataclass
|
|
47
|
+
from typing import Callable, Optional
|
|
48
|
+
from urllib.parse import urlencode, urlsplit
|
|
49
|
+
|
|
50
|
+
import httpx
|
|
51
|
+
|
|
52
|
+
from hyperclaw.teamsetup import secrets
|
|
53
|
+
from hyperclaw.teamsetup.item import SLACK_USER_SCOPES
|
|
54
|
+
from hyperclaw.teamsetup.oauth_loopback import Catcher, PortBusy
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger("hyperclaw.teamsetup.slack")
|
|
57
|
+
|
|
58
|
+
AUTH_URL = "https://slack.com/oauth/v2/authorize"
|
|
59
|
+
API = "https://slack.com/api"
|
|
60
|
+
API_ENV = "HYPERCLAW_TEAM_SLACK_API" # tests only: must be loopback
|
|
61
|
+
SECRET = "slack_user_token"
|
|
62
|
+
TIMEOUT = 20.0
|
|
63
|
+
CONSENT_WAIT = 300.0
|
|
64
|
+
REFRESH_LIMIT = 30 * 86400 # a PKCE refresh token's life from when it is issued
|
|
65
|
+
WARN_BEFORE = 3 * 86400 # tell the owner this long before it runs out
|
|
66
|
+
EARLY = 600 # refresh an access token 10 min before expiry
|
|
67
|
+
|
|
68
|
+
SIGN_IN_AGAIN = ("Slack needs you to sign in again (your Slack sign-in has expired or was "
|
|
69
|
+
"removed). Run: hyperclaw onboard --team --step slack")
|
|
70
|
+
SIGN_IN_SOON = ("Slack will sign HyperAgents out on {date}. To stay connected, sign in again "
|
|
71
|
+
"before then: hyperclaw onboard --team --step slack")
|
|
72
|
+
|
|
73
|
+
TEXT = {
|
|
74
|
+
"no_app": "the team profile has no Slack app, so Slack sign-in isn't set up for you",
|
|
75
|
+
"port_busy": ("another program on this computer is using port {port}, which Slack sign-in "
|
|
76
|
+
"needs. Close it and try again"),
|
|
77
|
+
"denied": "Slack sign-in was cancelled or didn't finish",
|
|
78
|
+
"timeout": "Slack sign-in didn't finish within 5 minutes",
|
|
79
|
+
"scopes": ("Slack granted different access than HyperAgents asks for, so nothing was "
|
|
80
|
+
"saved"),
|
|
81
|
+
"rejected": "Slack didn't accept the sign-in",
|
|
82
|
+
"unreachable": "Slack couldn't be reached",
|
|
83
|
+
"expired": "your Slack sign-in has expired or was removed",
|
|
84
|
+
"not_connected": "Slack isn't connected",
|
|
85
|
+
"wrong_workspace": ("This Slack sign-in is for a different workspace than the "
|
|
86
|
+
"workspace your team profile names, so it was discarded and nothing was "
|
|
87
|
+
"saved. Sign in again and choose your team's workspace"),
|
|
88
|
+
"no_workspace": ("your team profile doesn't name the Slack workspace (slack_team_id), so "
|
|
89
|
+
"Slack sign-in is refused"),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
RERUN = "hyperclaw onboard --team --step slack"
|
|
93
|
+
FIX = {
|
|
94
|
+
"no_workspace": ("ask Vaughn for a new 1Password item whose signed team profile includes "
|
|
95
|
+
"slack_team_id, then run: hyperclaw onboard --team --step item"),
|
|
96
|
+
}
|
|
97
|
+
WRONG_WORKSPACE = TEXT["wrong_workspace"] + ": " + RERUN
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SlackAuthError(Exception):
|
|
101
|
+
def __init__(self, code: str, **fmt):
|
|
102
|
+
self.code = code
|
|
103
|
+
super().__init__(TEXT[code].format(**fmt))
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def text(self) -> str:
|
|
107
|
+
return str(self)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def fix(self) -> str:
|
|
111
|
+
return FIX.get(self.code, RERUN)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def api_base() -> str:
|
|
115
|
+
override = os.environ.get(API_ENV, "").strip()
|
|
116
|
+
if override and (urlsplit(override).hostname or "") in ("127.0.0.1", "localhost", "::1"):
|
|
117
|
+
return override.rstrip("/")
|
|
118
|
+
return API
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _pkce() -> tuple:
|
|
122
|
+
verifier = base64.urlsafe_b64encode(_secrets.token_bytes(48)).rstrip(b"=").decode()
|
|
123
|
+
challenge = base64.urlsafe_b64encode(
|
|
124
|
+
hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
|
125
|
+
return verifier, challenge
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def redirect_uri(cfg: dict) -> str:
|
|
129
|
+
return f"http://localhost:{int(cfg.get('redirect_port') or 8766)}" \
|
|
130
|
+
f"{cfg.get('redirect_path') or '/slack/callback'}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def authorize_url(cfg: dict, state: str, challenge: str) -> str:
|
|
134
|
+
return AUTH_URL + "?" + urlencode({
|
|
135
|
+
"client_id": cfg["client_id"],
|
|
136
|
+
"user_scope": ",".join(SLACK_USER_SCOPES),
|
|
137
|
+
"redirect_uri": redirect_uri(cfg),
|
|
138
|
+
"state": state,
|
|
139
|
+
"code_challenge": challenge,
|
|
140
|
+
"code_challenge_method": "S256",
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── token bundle ──────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def load_bundle() -> dict:
|
|
147
|
+
raw = secrets.get(SECRET)
|
|
148
|
+
try:
|
|
149
|
+
data = json.loads(raw) if raw else {}
|
|
150
|
+
except ValueError:
|
|
151
|
+
data = {}
|
|
152
|
+
return data if isinstance(data, dict) else {}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def save_bundle(b: dict) -> str:
|
|
156
|
+
return secrets.put(SECRET, json.dumps(b, separators=(",", ":")))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def connected() -> bool:
|
|
160
|
+
return bool(load_bundle().get("access_token"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _bundle_from(resp: dict, now: float, old: Optional[dict] = None) -> dict:
|
|
164
|
+
"""The stored bundle from an oauth.v2.access answer (first grant or a
|
|
165
|
+
refresh; Slack nests user tokens under authed_user on the first grant)."""
|
|
166
|
+
u = resp.get("authed_user") if isinstance(resp.get("authed_user"), dict) else {}
|
|
167
|
+
src = u if u.get("access_token") else resp
|
|
168
|
+
old = old or {}
|
|
169
|
+
exp = src.get("expires_in")
|
|
170
|
+
team = resp.get("team") if isinstance(resp.get("team"), dict) else {}
|
|
171
|
+
ent = resp.get("enterprise") if isinstance(resp.get("enterprise"), dict) else {}
|
|
172
|
+
return {
|
|
173
|
+
"access_token": str(src.get("access_token") or ""),
|
|
174
|
+
"refresh_token": str(src.get("refresh_token") or old.get("refresh_token") or ""),
|
|
175
|
+
# a new refresh token restarts its 30 days; otherwise the old date stands
|
|
176
|
+
"refresh_issued_at": now if src.get("refresh_token") else
|
|
177
|
+
float(old.get("refresh_issued_at") or old.get("refreshed_at") or now),
|
|
178
|
+
"expires_at": (now + float(exp)) if exp else 0,
|
|
179
|
+
"refreshed_at": now,
|
|
180
|
+
"scope": str(src.get("scope") or old.get("scope") or ""),
|
|
181
|
+
"team_id": str(team.get("id") or old.get("team_id") or ""),
|
|
182
|
+
"team_name": str(team.get("name") or old.get("team_name") or ""),
|
|
183
|
+
"enterprise_id": str(ent.get("id") or old.get("enterprise_id") or ""),
|
|
184
|
+
"user_id": str(u.get("id") or src.get("user_id") or old.get("user_id") or ""),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _post(path: str, data: dict, http: Optional[httpx.Client], headers=None) -> dict:
|
|
189
|
+
c = http or httpx.Client(timeout=TIMEOUT, follow_redirects=False)
|
|
190
|
+
try:
|
|
191
|
+
r = c.post(f"{api_base()}/{path}", data=data, headers=headers or {})
|
|
192
|
+
except httpx.HTTPError as e:
|
|
193
|
+
log.warning("slack %s: network error (%s)", path, type(e).__name__)
|
|
194
|
+
raise SlackAuthError("unreachable") from None
|
|
195
|
+
finally:
|
|
196
|
+
if http is None:
|
|
197
|
+
c.close()
|
|
198
|
+
if r.status_code >= 500 or r.status_code == 429:
|
|
199
|
+
raise SlackAuthError("unreachable")
|
|
200
|
+
try:
|
|
201
|
+
body = r.json()
|
|
202
|
+
except ValueError:
|
|
203
|
+
body = {}
|
|
204
|
+
return body if isinstance(body, dict) else {}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
_EXPIRED = {"invalid_refresh_token", "invalid_grant", "token_revoked", "token_expired",
|
|
208
|
+
"invalid_auth", "not_authed", "account_inactive"}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _check_scopes(scope: str) -> None:
|
|
212
|
+
got = sorted({s.strip() for s in (scope or "").split(",") if s.strip()})
|
|
213
|
+
if got != sorted(SLACK_USER_SCOPES):
|
|
214
|
+
raise SlackAuthError("scopes")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── the one workspace ─────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
def expected_workspace(cfg: dict) -> tuple:
|
|
220
|
+
"""(team_id, enterprise_id) the profile allows. Raises
|
|
221
|
+
SlackAuthError('no_workspace') when the profile names no workspace."""
|
|
222
|
+
team = str(cfg.get("team_id") or "").strip()
|
|
223
|
+
if not team:
|
|
224
|
+
raise SlackAuthError("no_workspace")
|
|
225
|
+
return team, str(cfg.get("enterprise_id") or "").strip()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def workspace_matches(cfg: dict, team_id: str, enterprise_id: str = "") -> bool:
|
|
229
|
+
"""Exact match on the team id; an enterprise id, when either side has
|
|
230
|
+
one, must match too (a Grid org the profile doesn't name is refused)."""
|
|
231
|
+
want_team, want_ent = expected_workspace(cfg)
|
|
232
|
+
return bool(team_id) and team_id == want_team and (enterprise_id or "") == want_ent
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _revoke(token: str, http: Optional[httpx.Client]) -> None:
|
|
236
|
+
"""Best effort: tell Slack to drop a token we won't keep."""
|
|
237
|
+
if not token:
|
|
238
|
+
return
|
|
239
|
+
try:
|
|
240
|
+
_post("auth.revoke", {}, http, headers={"Authorization": f"Bearer {token}"})
|
|
241
|
+
except SlackAuthError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _discard(token: str, http: Optional[httpx.Client], *, stored: bool) -> None:
|
|
246
|
+
_revoke(token, http)
|
|
247
|
+
if stored and not secrets.remove(SECRET):
|
|
248
|
+
save_bundle({}) # the store wouldn't delete: overwrite it empty
|
|
249
|
+
log.warning("slack: sign-in for a different workspace discarded")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _auth_test(token: str, http: Optional[httpx.Client]) -> dict:
|
|
253
|
+
body = _post("auth.test", {}, http, headers={"Authorization": f"Bearer {token}"})
|
|
254
|
+
if not body.get("ok"):
|
|
255
|
+
if str(body.get("error") or "") in _EXPIRED:
|
|
256
|
+
raise SlackAuthError("expired")
|
|
257
|
+
raise SlackAuthError("rejected")
|
|
258
|
+
return body
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── sign-in ───────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
def sign_in(cfg: dict, *, show: Callable[[str], None] = print,
|
|
264
|
+
open_browser: Optional[Callable[[str], object]] = None,
|
|
265
|
+
http: Optional[httpx.Client] = None, wait: float = CONSENT_WAIT,
|
|
266
|
+
on_listening: Optional[Callable[[str, str], None]] = None,
|
|
267
|
+
now: Callable[[], float] = time.time) -> dict:
|
|
268
|
+
"""Run the PKCE sign-in and store the token bundle. Returns the
|
|
269
|
+
non-secret {"team_name", "user_id", "scope", "store"}."""
|
|
270
|
+
if not cfg.get("client_id"):
|
|
271
|
+
raise SlackAuthError("no_app")
|
|
272
|
+
expected_workspace(cfg) # no named workspace: refuse before any sign-in
|
|
273
|
+
port = int(cfg.get("redirect_port") or 8766)
|
|
274
|
+
verifier, challenge = _pkce()
|
|
275
|
+
state = _secrets.token_urlsafe(24)
|
|
276
|
+
try:
|
|
277
|
+
catcher = Catcher(port, cfg.get("redirect_path") or "/slack/callback")
|
|
278
|
+
except PortBusy:
|
|
279
|
+
raise SlackAuthError("port_busy", port=port) from None
|
|
280
|
+
catcher.start()
|
|
281
|
+
try:
|
|
282
|
+
url = authorize_url(cfg, state, challenge)
|
|
283
|
+
show("Your browser will open Slack. Sign in to the Hyper workspace and choose Allow. "
|
|
284
|
+
"HyperAgents asks to read your messages and channels, and to post only when "
|
|
285
|
+
"you press Send. If the browser didn't open, copy this address "
|
|
286
|
+
"into it:\n\n" + url + "\n")
|
|
287
|
+
if open_browser:
|
|
288
|
+
try:
|
|
289
|
+
open_browser(url)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
if on_listening:
|
|
293
|
+
on_listening(redirect_uri(cfg), state)
|
|
294
|
+
if not catcher.wait(wait):
|
|
295
|
+
raise SlackAuthError("timeout")
|
|
296
|
+
res = catcher.result
|
|
297
|
+
finally:
|
|
298
|
+
catcher.close()
|
|
299
|
+
if res.get("state") != state or res.get("error") or not res.get("code"):
|
|
300
|
+
raise SlackAuthError("denied")
|
|
301
|
+
body = _post("oauth.v2.access", {
|
|
302
|
+
"client_id": cfg["client_id"], "code": res["code"], "code_verifier": verifier,
|
|
303
|
+
"redirect_uri": redirect_uri(cfg), "grant_type": "authorization_code"}, http)
|
|
304
|
+
if not body.get("ok"):
|
|
305
|
+
raise SlackAuthError("rejected")
|
|
306
|
+
bundle = _bundle_from(body, now())
|
|
307
|
+
if not bundle["access_token"]:
|
|
308
|
+
raise SlackAuthError("rejected")
|
|
309
|
+
if not workspace_matches(cfg, bundle["team_id"], bundle["enterprise_id"]):
|
|
310
|
+
_discard(bundle["access_token"], http, stored=False)
|
|
311
|
+
raise SlackAuthError("wrong_workspace")
|
|
312
|
+
_check_scopes(bundle["scope"])
|
|
313
|
+
where = save_bundle(bundle)
|
|
314
|
+
return {"team_name": bundle["team_name"], "user_id": bundle["user_id"],
|
|
315
|
+
"scope": bundle["scope"], "store": where}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ── refresh ───────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
@dataclass
|
|
321
|
+
class RefreshResult:
|
|
322
|
+
status: str # "fresh" | "refreshed" | "sign_in_again" | "retry_later" |
|
|
323
|
+
# "not_connected" | "wrong_workspace" | "no_workspace"
|
|
324
|
+
message: str = ""
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def needs_refresh(b: dict, now: float) -> bool:
|
|
328
|
+
"""Only an access token about to expire is refreshed (a long-lived token
|
|
329
|
+
without a refresh token never is)."""
|
|
330
|
+
if not b.get("refresh_token"):
|
|
331
|
+
return False
|
|
332
|
+
exp = float(b.get("expires_at") or 0)
|
|
333
|
+
return bool(exp) and exp - EARLY <= now
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def sign_in_by(b: dict) -> float:
|
|
337
|
+
"""When a new sign-in is needed (the refresh token's 30 days), or 0 when
|
|
338
|
+
the token doesn't expire."""
|
|
339
|
+
if not b.get("refresh_token"):
|
|
340
|
+
return 0.0
|
|
341
|
+
return float(b.get("refresh_issued_at") or b.get("refreshed_at") or 0) + REFRESH_LIMIT
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def expiry_warning(b: dict, now: float) -> str:
|
|
345
|
+
"""SIGN_IN_SOON when the sign-in runs out within WARN_BEFORE, else ''."""
|
|
346
|
+
by = sign_in_by(b)
|
|
347
|
+
if not by or by - now > WARN_BEFORE:
|
|
348
|
+
return ""
|
|
349
|
+
import datetime as _dt
|
|
350
|
+
date = _dt.datetime.fromtimestamp(by).strftime("%a %d %b")
|
|
351
|
+
return SIGN_IN_SOON.format(date=date) if by > now else SIGN_IN_AGAIN
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def refresh(cfg: dict, *, http: Optional[httpx.Client] = None, force: bool = False,
|
|
355
|
+
now: Callable[[], float] = time.time) -> RefreshResult:
|
|
356
|
+
b = load_bundle()
|
|
357
|
+
if not b.get("access_token"):
|
|
358
|
+
return RefreshResult("not_connected", TEXT["not_connected"])
|
|
359
|
+
try:
|
|
360
|
+
expected_workspace(cfg)
|
|
361
|
+
except SlackAuthError as e:
|
|
362
|
+
return RefreshResult("no_workspace", f"Slack is off: {e.text}. Fix: {e.fix}")
|
|
363
|
+
if b.get("team_id") and not workspace_matches(cfg, b["team_id"], b.get("enterprise_id", "")):
|
|
364
|
+
_discard(b["access_token"], http, stored=True)
|
|
365
|
+
return RefreshResult("wrong_workspace", WRONG_WORKSPACE)
|
|
366
|
+
t = now()
|
|
367
|
+
if not force and not needs_refresh(b, t):
|
|
368
|
+
return RefreshResult("fresh", expiry_warning(b, t))
|
|
369
|
+
if not b.get("refresh_token"):
|
|
370
|
+
return RefreshResult("fresh")
|
|
371
|
+
try:
|
|
372
|
+
body = _post("oauth.v2.access", {"client_id": cfg.get("client_id") or "",
|
|
373
|
+
"grant_type": "refresh_token",
|
|
374
|
+
"refresh_token": b["refresh_token"]}, http)
|
|
375
|
+
except SlackAuthError:
|
|
376
|
+
return RefreshResult("retry_later", expiry_warning(b, t))
|
|
377
|
+
if not body.get("ok"):
|
|
378
|
+
if str(body.get("error") or "") in _EXPIRED:
|
|
379
|
+
return RefreshResult("sign_in_again", SIGN_IN_AGAIN)
|
|
380
|
+
return RefreshResult("retry_later")
|
|
381
|
+
nb = _bundle_from(body, t, old=b)
|
|
382
|
+
if not nb["access_token"]:
|
|
383
|
+
return RefreshResult("retry_later")
|
|
384
|
+
team = body.get("team") if isinstance(body.get("team"), dict) else {}
|
|
385
|
+
ent = body.get("enterprise") if isinstance(body.get("enterprise"), dict) else {}
|
|
386
|
+
if team.get("id"):
|
|
387
|
+
got = (str(team["id"]), str(ent.get("id") or ""))
|
|
388
|
+
else: # Slack didn't say: ask with the new token
|
|
389
|
+
try:
|
|
390
|
+
who = _auth_test(nb["access_token"], http)
|
|
391
|
+
except SlackAuthError:
|
|
392
|
+
return RefreshResult("retry_later")
|
|
393
|
+
got = (str(who.get("team_id") or ""), str(who.get("enterprise_id") or ""))
|
|
394
|
+
if not workspace_matches(cfg, *got):
|
|
395
|
+
_revoke(nb["access_token"], http)
|
|
396
|
+
_discard(b["access_token"], http, stored=True)
|
|
397
|
+
return RefreshResult("wrong_workspace", WRONG_WORKSPACE)
|
|
398
|
+
nb["team_id"], nb["enterprise_id"] = got
|
|
399
|
+
save_bundle(nb)
|
|
400
|
+
return RefreshResult("refreshed", expiry_warning(nb, t))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _current(cfg: dict, http: Optional[httpx.Client]) -> dict:
|
|
404
|
+
r = refresh(cfg, http=http)
|
|
405
|
+
if r.status in ("not_connected", "no_workspace", "wrong_workspace"):
|
|
406
|
+
raise SlackAuthError(r.status)
|
|
407
|
+
if r.status == "sign_in_again":
|
|
408
|
+
raise SlackAuthError("expired")
|
|
409
|
+
return load_bundle()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def access_token(cfg: dict, *, http: Optional[httpx.Client] = None) -> str:
|
|
413
|
+
"""A current user token for other Slack code (refreshed when due), only
|
|
414
|
+
for the profile's workspace. Raises SlackAuthError('expired' /
|
|
415
|
+
'not_connected' / 'no_workspace' / 'wrong_workspace')."""
|
|
416
|
+
b = _current(cfg, http)
|
|
417
|
+
if not b.get("team_id"): # never confirmed: confirm before handing it out
|
|
418
|
+
_confirm(cfg, b, http)
|
|
419
|
+
return load_bundle().get("access_token", "")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _confirm(cfg: dict, b: dict, http: Optional[httpx.Client]) -> dict:
|
|
423
|
+
"""auth.test with the stored token; the workspace must be the profile's.
|
|
424
|
+
A mismatch discards the stored token and raises 'wrong_workspace'."""
|
|
425
|
+
body = _auth_test(b["access_token"], http)
|
|
426
|
+
team, ent = str(body.get("team_id") or ""), str(body.get("enterprise_id") or "")
|
|
427
|
+
if not workspace_matches(cfg, team, ent):
|
|
428
|
+
_discard(b["access_token"], http, stored=True)
|
|
429
|
+
raise SlackAuthError("wrong_workspace")
|
|
430
|
+
if b.get("team_id") != team or b.get("enterprise_id", "") != ent:
|
|
431
|
+
save_bundle(dict(b, team_id=team, enterprise_id=ent))
|
|
432
|
+
return body
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ── verify ────────────────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
def verify(cfg: dict, *, http: Optional[httpx.Client] = None) -> dict:
|
|
438
|
+
"""One authenticated call (auth.test), which must answer for the
|
|
439
|
+
profile's workspace (else the token is discarded: 'wrong_workspace').
|
|
440
|
+
Returns {"team", "user_id"}."""
|
|
441
|
+
body = _confirm(cfg, _current(cfg, http), http)
|
|
442
|
+
return {"team": str(body.get("team") or ""), "user_id": str(body.get("user_id") or "")}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Hidden input that takes a long paste.
|
|
2
|
+
|
|
3
|
+
``getpass`` reads in the terminal's line mode, which on macOS cuts a line at
|
|
4
|
+
1024 characters, shorter than some 1Password items. This reader turns echo
|
|
5
|
+
and line mode off and reads characters itself until Enter, so nothing shows
|
|
6
|
+
on screen and nothing is cut. Backspace works; Ctrl-C raises
|
|
7
|
+
KeyboardInterrupt. Without a terminal it falls back to ``getpass``.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import getpass
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
MAX_CHARS = 16384
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def read_hidden(prompt: str) -> str:
|
|
19
|
+
try:
|
|
20
|
+
import termios
|
|
21
|
+
fd = sys.stdin.fileno()
|
|
22
|
+
if not os.isatty(fd):
|
|
23
|
+
raise OSError
|
|
24
|
+
old = termios.tcgetattr(fd)
|
|
25
|
+
except (ImportError, OSError, ValueError, AttributeError):
|
|
26
|
+
return getpass.getpass(prompt)
|
|
27
|
+
sys.stdout.write(prompt)
|
|
28
|
+
sys.stdout.flush()
|
|
29
|
+
new = termios.tcgetattr(fd)
|
|
30
|
+
new[3] &= ~(termios.ECHO | termios.ICANON) # lflags: no echo, no line mode
|
|
31
|
+
new[6][termios.VMIN] = 1
|
|
32
|
+
new[6][termios.VTIME] = 0
|
|
33
|
+
chars = []
|
|
34
|
+
try:
|
|
35
|
+
termios.tcsetattr(fd, termios.TCSAFLUSH, new)
|
|
36
|
+
while True:
|
|
37
|
+
b = os.read(fd, 4096)
|
|
38
|
+
if not b:
|
|
39
|
+
break
|
|
40
|
+
done = False
|
|
41
|
+
for ch in b.decode("utf-8", errors="ignore"):
|
|
42
|
+
if ch in ("\r", "\n"):
|
|
43
|
+
done = True
|
|
44
|
+
break
|
|
45
|
+
if ch == "\x03":
|
|
46
|
+
raise KeyboardInterrupt
|
|
47
|
+
if ch == "\x04" and not chars:
|
|
48
|
+
done = True
|
|
49
|
+
break
|
|
50
|
+
if ch in ("\x7f", "\x08"):
|
|
51
|
+
if chars:
|
|
52
|
+
chars.pop()
|
|
53
|
+
continue
|
|
54
|
+
if len(chars) < MAX_CHARS:
|
|
55
|
+
chars.append(ch)
|
|
56
|
+
if done:
|
|
57
|
+
break
|
|
58
|
+
finally:
|
|
59
|
+
termios.tcsetattr(fd, termios.TCSAFLUSH, old)
|
|
60
|
+
sys.stdout.write("\n")
|
|
61
|
+
sys.stdout.flush()
|
|
62
|
+
return "".join(chars)
|