world-model-optimizer 0.2.0__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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/connect/slack.py
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
"""Slack context connector: paste-token auth (default) or BYO OAuth, channel history pulls.
|
|
2
|
+
|
|
3
|
+
The default connect path prompts for a pasted user token (xoxp-...) created by installing a
|
|
4
|
+
workspace app; the caller supplies that user token. When `WMO_SLACK_CLIENT_ID` and
|
|
5
|
+
`WMO_SLACK_CLIENT_SECRET` resolve a complete OAuth app, connect runs the browser loopback flow
|
|
6
|
+
instead (advanced: Slack only accepts HTTPS redirect URLs, so a plain localhost callback needs
|
|
7
|
+
extra setup on the app side). Pulls read one channel's history, fold each thread (parent plus
|
|
8
|
+
replies) into a single item, and normalize everything into `ContextItem`s.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from typing import cast
|
|
17
|
+
from urllib.parse import urlsplit
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from wmo.connect.apps import get_app
|
|
22
|
+
from wmo.connect.connector import ConnectUI, register_connector
|
|
23
|
+
from wmo.connect.oauth import OAuthApp, run_loopback_flow
|
|
24
|
+
from wmo.connect.types import (
|
|
25
|
+
ConnectError,
|
|
26
|
+
ConnectorAuth,
|
|
27
|
+
ContextItem,
|
|
28
|
+
ItemKind,
|
|
29
|
+
PullQuery,
|
|
30
|
+
transport_errors,
|
|
31
|
+
)
|
|
32
|
+
from wmo.core.types import JsonObject, JsonValue
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
_API_BASE = "https://slack.com/api"
|
|
37
|
+
_API_HOST = "slack.com"
|
|
38
|
+
|
|
39
|
+
_TIMEOUT_SECONDS = 30.0
|
|
40
|
+
|
|
41
|
+
# Slack's page-size ceiling for cursor-paginated list endpoints.
|
|
42
|
+
_PAGE_SIZE = 200
|
|
43
|
+
|
|
44
|
+
# User scopes the connector needs: read public/private channel history and resolve user names.
|
|
45
|
+
USER_SCOPES = (
|
|
46
|
+
"channels:history",
|
|
47
|
+
"channels:read",
|
|
48
|
+
"groups:history",
|
|
49
|
+
"groups:read",
|
|
50
|
+
"users:read",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_TITLE_SNIPPET_CHARS = 60
|
|
54
|
+
|
|
55
|
+
# Slack error codes that mean the credential itself is bad (the 200-with-ok:false 401 analogs).
|
|
56
|
+
_AUTH_ERRORS = frozenset(
|
|
57
|
+
{"invalid_auth", "not_authed", "account_inactive", "token_revoked", "token_expired"}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_MENTION_RE = re.compile(r"<@([A-Z0-9]+)(?:\|[^>]*)?>")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SlackConnector:
|
|
64
|
+
"""Pulls Slack channel history (threads folded into single items) as context.
|
|
65
|
+
|
|
66
|
+
Auth paths, in order of preference at connect time:
|
|
67
|
+
1. Pasted user token (default): the caller supplies a user (xoxp-) token from a
|
|
68
|
+
workspace-internal app (or injects it via `WMO_SLACK_TOKEN`); see
|
|
69
|
+
docs/reference/connect-library.md.
|
|
70
|
+
2. BYO OAuth app (advanced): when `WMO_SLACK_CLIENT_ID` and `WMO_SLACK_CLIENT_SECRET`
|
|
71
|
+
are both set, connect runs the browser loopback flow requesting `USER_SCOPES` via
|
|
72
|
+
Slack's `user_scope` parameter.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name = "slack"
|
|
76
|
+
label = "Slack"
|
|
77
|
+
|
|
78
|
+
def __init__(self, transport: httpx.BaseTransport | None = None) -> None:
|
|
79
|
+
"""`transport` is injected into every httpx call; None means the real network."""
|
|
80
|
+
self._transport = transport
|
|
81
|
+
self._users_cache: dict[str, dict[str, str]] = {}
|
|
82
|
+
|
|
83
|
+
# -- auth -----------------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def connect(self, ui: ConnectUI) -> ConnectorAuth:
|
|
86
|
+
"""Run the paste-token flow (default) or the BYO OAuth loopback flow.
|
|
87
|
+
|
|
88
|
+
Either way the credential is validated with `auth.test` and enriched with the human
|
|
89
|
+
identity (`account`) plus the team id/domain (`extra`), which `pull` needs to build
|
|
90
|
+
message permalinks.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ConnectError: When no token is entered or Slack rejects the credential.
|
|
94
|
+
"""
|
|
95
|
+
app = self._byo_app()
|
|
96
|
+
auth = self._connect_oauth(app, ui) if app is not None else self._connect_paste(ui)
|
|
97
|
+
with self._client() as client:
|
|
98
|
+
identity = self._auth_test(client, auth.access_token)
|
|
99
|
+
account, extra = _identity_fields(identity)
|
|
100
|
+
return auth.model_copy(update={"account": account, "extra": {**auth.extra, **extra}})
|
|
101
|
+
|
|
102
|
+
def verify(self, auth: ConnectorAuth) -> str:
|
|
103
|
+
"""Check the credential with `auth.test` and return "user @ team".
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ConnectError: When Slack rejects the credential (with re-connect guidance) or the
|
|
107
|
+
call is missing a scope (naming the scope).
|
|
108
|
+
"""
|
|
109
|
+
with self._client() as client:
|
|
110
|
+
payload = self._auth_test(client, auth.access_token)
|
|
111
|
+
account, _ = _identity_fields(payload)
|
|
112
|
+
return account
|
|
113
|
+
|
|
114
|
+
def _byo_app(self) -> OAuthApp | None:
|
|
115
|
+
"""The user's own OAuth app, only when both a client id and secret resolve."""
|
|
116
|
+
try:
|
|
117
|
+
app = get_app(self.name)
|
|
118
|
+
except ConnectError:
|
|
119
|
+
return None
|
|
120
|
+
return app if app.client_secret else None
|
|
121
|
+
|
|
122
|
+
def _connect_paste(self, ui: ConnectUI) -> ConnectorAuth:
|
|
123
|
+
"""Prompt for a pasted user token (the default path)."""
|
|
124
|
+
token = ui.prompt_secret(
|
|
125
|
+
"Slack user token (xoxp-...): create and install a workspace app, then paste its "
|
|
126
|
+
"User OAuth Token (see docs/reference/connect-library.md)"
|
|
127
|
+
).strip()
|
|
128
|
+
if not token:
|
|
129
|
+
raise ConnectError(
|
|
130
|
+
"no Slack token entered; a user OAuth token must be supplied by the caller "
|
|
131
|
+
"(see docs/reference/connect-library.md)"
|
|
132
|
+
)
|
|
133
|
+
return ConnectorAuth(kind="token", access_token=token)
|
|
134
|
+
|
|
135
|
+
def _connect_oauth(self, app: OAuthApp, ui: ConnectUI) -> ConnectorAuth:
|
|
136
|
+
"""Run the loopback flow against the user's own Slack OAuth app (advanced).
|
|
137
|
+
|
|
138
|
+
Slack quirks handled here: user scopes travel in the `user_scope` authorize parameter
|
|
139
|
+
(never `scope`), and the token response nests the user token under
|
|
140
|
+
`authed_user.access_token`, which `_UserTokenTransport` hoists into the standard shape
|
|
141
|
+
the shared oauth helpers expect.
|
|
142
|
+
"""
|
|
143
|
+
ui.info(
|
|
144
|
+
"Using your Slack OAuth app (WMO_SLACK_CLIENT_ID is set). Slack only accepts "
|
|
145
|
+
"HTTPS redirect URLs, so this browser flow is advanced setup; the pasted-token "
|
|
146
|
+
"path in docs/reference/connect-library.md is the supported default."
|
|
147
|
+
)
|
|
148
|
+
flow_app = app.model_copy(
|
|
149
|
+
update={
|
|
150
|
+
"extra_auth_params": {**app.extra_auth_params, "user_scope": ",".join(USER_SCOPES)}
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
transport = _UserTokenTransport(self._transport, token_url=app.token_url)
|
|
154
|
+
return run_loopback_flow(flow_app, scopes=[], open_url=ui.open_url, transport=transport)
|
|
155
|
+
|
|
156
|
+
# -- pull -----------------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def pull(self, auth: ConnectorAuth, query: PullQuery) -> list[ContextItem]:
|
|
159
|
+
"""Pull one channel's history, folding threads into single items.
|
|
160
|
+
|
|
161
|
+
`query.target` names the channel (with or without `#`) or gives its id; `query.since`
|
|
162
|
+
and `query.until` become the history `oldest`/`latest` epoch bounds; `query.limit`
|
|
163
|
+
caps the item count (a thread and its replies count as one item).
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ConnectError: On a missing/unknown target, an unparseable since/until, a rejected
|
|
167
|
+
credential, a missing scope, or a rate limit.
|
|
168
|
+
"""
|
|
169
|
+
if not query.target:
|
|
170
|
+
raise ConnectError(
|
|
171
|
+
"slack pull needs a channel: pass a target like '#general' or a channel id "
|
|
172
|
+
"(e.g. C0123456789)"
|
|
173
|
+
)
|
|
174
|
+
if query.limit <= 0:
|
|
175
|
+
return []
|
|
176
|
+
oldest = _epoch_param(query.since, field="since") if query.since else None
|
|
177
|
+
latest = _epoch_param(query.until, field="until") if query.until else None
|
|
178
|
+
with self._client() as client:
|
|
179
|
+
token = auth.access_token
|
|
180
|
+
channel_id, channel_name = self._resolve_channel(client, token, query.target)
|
|
181
|
+
users = self._user_names(client, token)
|
|
182
|
+
items: list[ContextItem] = []
|
|
183
|
+
cursor: str | None = None
|
|
184
|
+
while len(items) < query.limit:
|
|
185
|
+
params = {
|
|
186
|
+
"channel": channel_id,
|
|
187
|
+
"limit": str(min(_PAGE_SIZE, query.limit - len(items))),
|
|
188
|
+
}
|
|
189
|
+
if oldest:
|
|
190
|
+
params["oldest"] = oldest
|
|
191
|
+
if latest:
|
|
192
|
+
params["latest"] = latest
|
|
193
|
+
if cursor:
|
|
194
|
+
params["cursor"] = cursor
|
|
195
|
+
payload = self._api(client, token, "conversations.history", params)
|
|
196
|
+
for value in _as_list(payload.get("messages")):
|
|
197
|
+
if len(items) >= query.limit:
|
|
198
|
+
break
|
|
199
|
+
message = _as_object(value)
|
|
200
|
+
item = self._normalize_message(
|
|
201
|
+
client, token, auth, channel_id, channel_name, message, users
|
|
202
|
+
)
|
|
203
|
+
if item is not None:
|
|
204
|
+
items.append(item)
|
|
205
|
+
cursor = _next_cursor(payload)
|
|
206
|
+
if not cursor:
|
|
207
|
+
break
|
|
208
|
+
logger.debug("pulled %d slack items from #%s", len(items), channel_name)
|
|
209
|
+
return items
|
|
210
|
+
|
|
211
|
+
def _resolve_channel(self, client: httpx.Client, token: str, target: str) -> tuple[str, str]:
|
|
212
|
+
"""Resolve a channel name (with or without `#`) or id to `(id, name)`.
|
|
213
|
+
|
|
214
|
+
Paginates `conversations.list` over public and private channels; a miss raises with a
|
|
215
|
+
sample of the names the token can actually see.
|
|
216
|
+
"""
|
|
217
|
+
wanted = target.lstrip("#")
|
|
218
|
+
available: list[str] = []
|
|
219
|
+
total = 0
|
|
220
|
+
cursor: str | None = None
|
|
221
|
+
while True:
|
|
222
|
+
params = {"types": "public_channel,private_channel", "limit": str(_PAGE_SIZE)}
|
|
223
|
+
if cursor:
|
|
224
|
+
params["cursor"] = cursor
|
|
225
|
+
payload = self._api(client, token, "conversations.list", params)
|
|
226
|
+
for value in _as_list(payload.get("channels")):
|
|
227
|
+
channel = _as_object(value)
|
|
228
|
+
channel_id = _as_str(channel.get("id"))
|
|
229
|
+
channel_name = _as_str(channel.get("name"))
|
|
230
|
+
if not channel_id:
|
|
231
|
+
continue
|
|
232
|
+
total += 1
|
|
233
|
+
if channel_id == target or (channel_name and channel_name == wanted):
|
|
234
|
+
return channel_id, channel_name or channel_id
|
|
235
|
+
if channel_name and len(available) < 5:
|
|
236
|
+
available.append(f"#{channel_name}")
|
|
237
|
+
cursor = _next_cursor(payload)
|
|
238
|
+
if not cursor:
|
|
239
|
+
break
|
|
240
|
+
listing = ", ".join(available) if available else "none visible to this token"
|
|
241
|
+
more = f" (and {total - len(available)} more)" if total > len(available) else ""
|
|
242
|
+
raise ConnectError(
|
|
243
|
+
f"no slack channel matching {target!r}; channels visible to this token include: "
|
|
244
|
+
f"{listing}{more}; pass the exact #name or channel id"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def _user_names(self, client: httpx.Client, token: str) -> dict[str, str]:
|
|
248
|
+
"""User id -> display name for the whole workspace (one pass, cached per token)."""
|
|
249
|
+
cached = self._users_cache.get(token)
|
|
250
|
+
if cached is not None:
|
|
251
|
+
return cached
|
|
252
|
+
names: dict[str, str] = {}
|
|
253
|
+
cursor: str | None = None
|
|
254
|
+
while True:
|
|
255
|
+
params = {"limit": str(_PAGE_SIZE)}
|
|
256
|
+
if cursor:
|
|
257
|
+
params["cursor"] = cursor
|
|
258
|
+
payload = self._api(client, token, "users.list", params)
|
|
259
|
+
for value in _as_list(payload.get("members")):
|
|
260
|
+
member = _as_object(value)
|
|
261
|
+
user_id = _as_str(member.get("id"))
|
|
262
|
+
if not user_id:
|
|
263
|
+
continue
|
|
264
|
+
profile = _as_object(member.get("profile"))
|
|
265
|
+
names[user_id] = (
|
|
266
|
+
_as_str(profile.get("display_name"))
|
|
267
|
+
or _as_str(profile.get("real_name"))
|
|
268
|
+
or _as_str(member.get("real_name"))
|
|
269
|
+
or _as_str(member.get("name"))
|
|
270
|
+
or user_id
|
|
271
|
+
)
|
|
272
|
+
cursor = _next_cursor(payload)
|
|
273
|
+
if not cursor:
|
|
274
|
+
break
|
|
275
|
+
self._users_cache[token] = names
|
|
276
|
+
return names
|
|
277
|
+
|
|
278
|
+
def _normalize_message(
|
|
279
|
+
self,
|
|
280
|
+
client: httpx.Client,
|
|
281
|
+
token: str,
|
|
282
|
+
auth: ConnectorAuth,
|
|
283
|
+
channel_id: str,
|
|
284
|
+
channel_name: str,
|
|
285
|
+
message: JsonObject,
|
|
286
|
+
users: dict[str, str],
|
|
287
|
+
) -> ContextItem | None:
|
|
288
|
+
"""One history message -> a thread item (parent + replies) or a standalone MESSAGE."""
|
|
289
|
+
ts = _as_str(message.get("ts"))
|
|
290
|
+
if not ts:
|
|
291
|
+
return None
|
|
292
|
+
text = _humanize(_as_str(message.get("text")), users)
|
|
293
|
+
title = f"#{channel_name}: {_snippet(text)}"
|
|
294
|
+
url = _permalink(auth.extra, channel_id, ts)
|
|
295
|
+
reply_count = message.get("reply_count")
|
|
296
|
+
if isinstance(reply_count, int) and not isinstance(reply_count, bool) and reply_count > 0:
|
|
297
|
+
lines = self._thread_lines(client, token, channel_id, message, users)
|
|
298
|
+
latest_reply = _as_str(message.get("latest_reply"))
|
|
299
|
+
return ContextItem(
|
|
300
|
+
id=f"{channel_id}:{ts}",
|
|
301
|
+
source=self.name,
|
|
302
|
+
kind=ItemKind.THREAD,
|
|
303
|
+
title=title,
|
|
304
|
+
body="\n".join(lines),
|
|
305
|
+
url=url,
|
|
306
|
+
created_at=_iso(ts),
|
|
307
|
+
updated_at=_iso(latest_reply) if latest_reply else None,
|
|
308
|
+
metadata={"channel": channel_name, "reply_count": reply_count},
|
|
309
|
+
)
|
|
310
|
+
return ContextItem(
|
|
311
|
+
id=f"{channel_id}:{ts}",
|
|
312
|
+
source=self.name,
|
|
313
|
+
kind=ItemKind.MESSAGE,
|
|
314
|
+
title=title,
|
|
315
|
+
body=self._line(message, users),
|
|
316
|
+
url=url,
|
|
317
|
+
created_at=_iso(ts),
|
|
318
|
+
metadata={"channel": channel_name},
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def _thread_lines(
|
|
322
|
+
self,
|
|
323
|
+
client: httpx.Client,
|
|
324
|
+
token: str,
|
|
325
|
+
channel_id: str,
|
|
326
|
+
parent: JsonObject,
|
|
327
|
+
users: dict[str, str],
|
|
328
|
+
) -> list[str]:
|
|
329
|
+
"""The parent line plus one line per reply (first `_PAGE_SIZE` replies)."""
|
|
330
|
+
parent_ts = _as_str(parent.get("ts"))
|
|
331
|
+
lines = [self._line(parent, users)]
|
|
332
|
+
params = {"channel": channel_id, "ts": parent_ts, "limit": str(_PAGE_SIZE)}
|
|
333
|
+
payload = self._api(client, token, "conversations.replies", params)
|
|
334
|
+
for value in _as_list(payload.get("messages")):
|
|
335
|
+
message = _as_object(value)
|
|
336
|
+
if _as_str(message.get("ts")) == parent_ts:
|
|
337
|
+
continue # the replies listing repeats the parent first
|
|
338
|
+
lines.append(self._line(message, users))
|
|
339
|
+
return lines
|
|
340
|
+
|
|
341
|
+
def _line(self, message: JsonObject, users: dict[str, str]) -> str:
|
|
342
|
+
"""Render one message as a "[@name at HH:MM] text" line."""
|
|
343
|
+
ts = _as_str(message.get("ts"))
|
|
344
|
+
stamp = _hhmm(ts) if ts else "??:??"
|
|
345
|
+
user_id = _as_str(message.get("user"))
|
|
346
|
+
name = users.get(user_id) or _as_str(message.get("username")) or user_id or "unknown"
|
|
347
|
+
text = _humanize(_as_str(message.get("text")), users)
|
|
348
|
+
return f"[@{name} at {stamp}] {text}"
|
|
349
|
+
|
|
350
|
+
# -- transport ------------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def _client(self) -> httpx.Client:
|
|
353
|
+
return httpx.Client(base_url=_API_BASE, timeout=_TIMEOUT_SECONDS, transport=self._transport)
|
|
354
|
+
|
|
355
|
+
def _auth_test(self, client: httpx.Client, token: str) -> JsonObject:
|
|
356
|
+
"""POST `auth.test` and return its payload (the cheapest identity/credential check)."""
|
|
357
|
+
with transport_errors(_API_HOST):
|
|
358
|
+
response = client.post("/auth.test", headers=_bearer(token))
|
|
359
|
+
return _payload_or_raise("auth.test", response)
|
|
360
|
+
|
|
361
|
+
def _api(
|
|
362
|
+
self, client: httpx.Client, token: str, method: str, params: dict[str, str]
|
|
363
|
+
) -> JsonObject:
|
|
364
|
+
"""GET one Web API method and return its ok payload."""
|
|
365
|
+
with transport_errors(_API_HOST):
|
|
366
|
+
response = client.get(f"/{method}", params=params, headers=_bearer(token))
|
|
367
|
+
return _payload_or_raise(method, response)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class _UserTokenTransport(httpx.BaseTransport):
|
|
371
|
+
"""Hoists Slack's nested user token so the shared oauth helpers see a standard response.
|
|
372
|
+
|
|
373
|
+
Slack's `oauth.v2.access` puts the user token at `authed_user.access_token` (the top level
|
|
374
|
+
carries the bot token, absent for user-scope-only installs), while the generic
|
|
375
|
+
`run_loopback_flow` expects a flat RFC 6749 payload. This transport wraps the real (or
|
|
376
|
+
injected) transport and flattens only the token-endpoint response.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
def __init__(self, inner: httpx.BaseTransport | None, *, token_url: str) -> None:
|
|
380
|
+
self._inner = inner if inner is not None else httpx.HTTPTransport()
|
|
381
|
+
self._token_url = token_url
|
|
382
|
+
|
|
383
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
384
|
+
response = self._inner.handle_request(request)
|
|
385
|
+
if str(request.url) != self._token_url:
|
|
386
|
+
return response
|
|
387
|
+
response.read()
|
|
388
|
+
try:
|
|
389
|
+
raw = response.json()
|
|
390
|
+
except ValueError:
|
|
391
|
+
return response
|
|
392
|
+
if not isinstance(raw, dict):
|
|
393
|
+
return response
|
|
394
|
+
payload = cast(JsonObject, raw)
|
|
395
|
+
authed_user = payload.get("authed_user")
|
|
396
|
+
if not isinstance(authed_user, dict):
|
|
397
|
+
return response
|
|
398
|
+
token = authed_user.get("access_token")
|
|
399
|
+
if not isinstance(token, str) or not token:
|
|
400
|
+
return response
|
|
401
|
+
flattened: JsonObject = {**payload, "access_token": token}
|
|
402
|
+
for key in ("scope", "refresh_token", "expires_in", "token_type"):
|
|
403
|
+
value = authed_user.get(key)
|
|
404
|
+
if value is not None:
|
|
405
|
+
flattened[key] = value
|
|
406
|
+
return httpx.Response(response.status_code, json=flattened, request=request)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _bearer(token: str) -> dict[str, str]:
|
|
410
|
+
return {"Authorization": f"Bearer {token}"}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _payload_or_raise(method: str, response: httpx.Response) -> JsonObject:
|
|
414
|
+
"""Parse one Web API response, turning every Slack failure mode into a ConnectError.
|
|
415
|
+
|
|
416
|
+
Slack returns HTTP 200 with `ok: false` plus an error code on most failures, HTTP 429 with
|
|
417
|
+
a Retry-After header on rate limits, and plain 401/403 on rejected transport auth.
|
|
418
|
+
"""
|
|
419
|
+
if response.status_code == 429:
|
|
420
|
+
wait = response.headers.get("Retry-After", "60")
|
|
421
|
+
raise ConnectError(
|
|
422
|
+
f"slack rate-limited {method} (HTTP 429): wait {wait}s, then re-run the command; "
|
|
423
|
+
"note commercially distributed non-Marketplace apps are capped at 1 request/min "
|
|
424
|
+
"on history endpoints (a workspace-internal pasted-token app avoids that cap)"
|
|
425
|
+
)
|
|
426
|
+
if response.status_code in (401, 403):
|
|
427
|
+
raise ConnectError(
|
|
428
|
+
f"slack rejected the credential on {method} (HTTP {response.status_code}); "
|
|
429
|
+
"the token is invalid or expired and the connection must be reauthorized"
|
|
430
|
+
)
|
|
431
|
+
if response.status_code != 200:
|
|
432
|
+
raise ConnectError(
|
|
433
|
+
f"slack {method} returned HTTP {response.status_code}: {response.text[:200]}; "
|
|
434
|
+
"retry, and check https://status.slack.com if it persists"
|
|
435
|
+
)
|
|
436
|
+
try:
|
|
437
|
+
raw = response.json()
|
|
438
|
+
except ValueError:
|
|
439
|
+
raw = None
|
|
440
|
+
if not isinstance(raw, dict):
|
|
441
|
+
raise ConnectError(
|
|
442
|
+
f"slack {method} returned a non-JSON body: {response.text[:200]}; "
|
|
443
|
+
"retry, and check https://status.slack.com if it persists"
|
|
444
|
+
)
|
|
445
|
+
payload = cast(JsonObject, raw)
|
|
446
|
+
if payload.get("ok") is not True:
|
|
447
|
+
raise _slack_error(method, payload)
|
|
448
|
+
return payload
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _slack_error(method: str, payload: JsonObject) -> ConnectError:
|
|
452
|
+
"""An `ok: false` payload -> a ConnectError with per-code guidance."""
|
|
453
|
+
error = _as_str(payload.get("error")) or "unknown_error"
|
|
454
|
+
if error in _AUTH_ERRORS:
|
|
455
|
+
return ConnectError(
|
|
456
|
+
f"slack rejected the credential on {method} ({error}); "
|
|
457
|
+
"the token is invalid or expired and the connection must be reauthorized"
|
|
458
|
+
)
|
|
459
|
+
if error == "missing_scope":
|
|
460
|
+
needed = _as_str(payload.get("needed")) or "an additional"
|
|
461
|
+
return ConnectError(
|
|
462
|
+
f"slack {method} is missing the {needed!r} scope; add it to the app's user scopes, "
|
|
463
|
+
"re-install the app to the workspace, then supply a token with that scope"
|
|
464
|
+
)
|
|
465
|
+
if error == "not_in_channel":
|
|
466
|
+
return ConnectError(
|
|
467
|
+
f"slack {method} failed: the connected user is not a member of that channel; "
|
|
468
|
+
"join the channel in Slack and re-run the pull"
|
|
469
|
+
)
|
|
470
|
+
return ConnectError(
|
|
471
|
+
f"slack {method} failed: {error}; see https://api.slack.com/methods/{method}#errors"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _identity_fields(payload: JsonObject) -> tuple[str, JsonObject]:
|
|
476
|
+
"""`auth.test` payload -> ("user @ team", extras with the team id and domain)."""
|
|
477
|
+
user = _as_str(payload.get("user")) or "unknown"
|
|
478
|
+
team = _as_str(payload.get("team")) or "unknown team"
|
|
479
|
+
extra: JsonObject = {}
|
|
480
|
+
team_id = _as_str(payload.get("team_id"))
|
|
481
|
+
if team_id:
|
|
482
|
+
extra["team_id"] = team_id
|
|
483
|
+
host = urlsplit(_as_str(payload.get("url"))).hostname
|
|
484
|
+
if host and host.endswith(".slack.com"):
|
|
485
|
+
extra["team_domain"] = host.split(".", 1)[0]
|
|
486
|
+
return f"{user} @ {team}", extra
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _permalink(extra: JsonObject, channel_id: str, ts: str) -> str | None:
|
|
490
|
+
"""The archives permalink, when the team domain is known from the stored auth."""
|
|
491
|
+
domain = extra.get("team_domain")
|
|
492
|
+
if not isinstance(domain, str) or not domain:
|
|
493
|
+
return None
|
|
494
|
+
return f"https://{domain}.slack.com/archives/{channel_id}/p{ts.replace('.', '')}"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _humanize(text: str, users: dict[str, str]) -> str:
|
|
498
|
+
"""Replace `<@U...>` mention encodings with the users' display names."""
|
|
499
|
+
|
|
500
|
+
def replace(match: re.Match[str]) -> str:
|
|
501
|
+
name = users.get(match.group(1))
|
|
502
|
+
return f"@{name}" if name else match.group(0)
|
|
503
|
+
|
|
504
|
+
return _MENTION_RE.sub(replace, text)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _snippet(text: str) -> str:
|
|
508
|
+
"""The title snippet: whitespace-flattened first `_TITLE_SNIPPET_CHARS` chars."""
|
|
509
|
+
flattened = " ".join(text.split())
|
|
510
|
+
return flattened[:_TITLE_SNIPPET_CHARS] if flattened else "(no text)"
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _iso(ts: str) -> str:
|
|
514
|
+
"""A Slack epoch ts string ("1512085950.000216") as ISO-8601 UTC."""
|
|
515
|
+
return datetime.fromtimestamp(float(ts), UTC).isoformat(timespec="seconds")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _hhmm(ts: str) -> str:
|
|
519
|
+
"""A Slack epoch ts string as an HH:MM (UTC) stamp for message lines."""
|
|
520
|
+
return datetime.fromtimestamp(float(ts), UTC).strftime("%H:%M")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _epoch_param(value: str, *, field: str) -> str:
|
|
524
|
+
"""An ISO-8601 date/datetime -> the epoch-seconds string Slack's history bounds expect."""
|
|
525
|
+
try:
|
|
526
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
527
|
+
except ValueError as exc:
|
|
528
|
+
raise ConnectError(
|
|
529
|
+
f"could not parse {field}={value!r} as an ISO-8601 date or datetime; "
|
|
530
|
+
"use e.g. 2026-07-01 or 2026-07-01T12:00:00Z"
|
|
531
|
+
) from exc
|
|
532
|
+
if parsed.tzinfo is None:
|
|
533
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
534
|
+
return f"{parsed.timestamp():.6f}"
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _next_cursor(payload: JsonObject) -> str | None:
|
|
538
|
+
"""The `response_metadata.next_cursor` of a paginated response ("" and absent -> None)."""
|
|
539
|
+
cursor = _as_str(_as_object(payload.get("response_metadata")).get("next_cursor"))
|
|
540
|
+
return cursor or None
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _as_object(value: JsonValue | None) -> JsonObject:
|
|
544
|
+
return value if isinstance(value, dict) else {}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _as_list(value: JsonValue | None) -> list[JsonValue]:
|
|
548
|
+
return value if isinstance(value, list) else []
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _as_str(value: JsonValue | None) -> str:
|
|
552
|
+
return value if isinstance(value, str) else ""
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
register_connector(SlackConnector())
|