gabriel-slack 0.1.0__tar.gz

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 (66) hide show
  1. gabriel_slack-0.1.0/.gitignore +32 -0
  2. gabriel_slack-0.1.0/CHANGELOG.md +20 -0
  3. gabriel_slack-0.1.0/LICENSE +21 -0
  4. gabriel_slack-0.1.0/PKG-INFO +324 -0
  5. gabriel_slack-0.1.0/README.md +299 -0
  6. gabriel_slack-0.1.0/examples/mention_stream_reply.py +62 -0
  7. gabriel_slack-0.1.0/examples/raw_core.py +40 -0
  8. gabriel_slack-0.1.0/examples/smoke_readonly.py +126 -0
  9. gabriel_slack-0.1.0/pyproject.toml +69 -0
  10. gabriel_slack-0.1.0/src/gabriel/__init__.py +139 -0
  11. gabriel_slack-0.1.0/src/gabriel/__main__.py +7 -0
  12. gabriel_slack-0.1.0/src/gabriel/cli/__init__.py +0 -0
  13. gabriel_slack-0.1.0/src/gabriel/cli/browser.py +200 -0
  14. gabriel_slack-0.1.0/src/gabriel/cli/main.py +130 -0
  15. gabriel_slack-0.1.0/src/gabriel/cli/session_store.py +132 -0
  16. gabriel_slack-0.1.0/src/gabriel/client/__init__.py +0 -0
  17. gabriel_slack-0.1.0/src/gabriel/client/apis.py +711 -0
  18. gabriel_slack-0.1.0/src/gabriel/client/chat.py +462 -0
  19. gabriel_slack-0.1.0/src/gabriel/client/client.py +1464 -0
  20. gabriel_slack-0.1.0/src/gabriel/client/context.py +246 -0
  21. gabriel_slack-0.1.0/src/gabriel/client/executor.py +209 -0
  22. gabriel_slack-0.1.0/src/gabriel/client/files.py +273 -0
  23. gabriel_slack-0.1.0/src/gabriel/client/hydrator.py +363 -0
  24. gabriel_slack-0.1.0/src/gabriel/client/llm_context.py +66 -0
  25. gabriel_slack-0.1.0/src/gabriel/client/loader.py +142 -0
  26. gabriel_slack-0.1.0/src/gabriel/client/origin.py +137 -0
  27. gabriel_slack-0.1.0/src/gabriel/client/presence.py +498 -0
  28. gabriel_slack-0.1.0/src/gabriel/client/recovery.py +356 -0
  29. gabriel_slack-0.1.0/src/gabriel/client/scope.py +544 -0
  30. gabriel_slack-0.1.0/src/gabriel/client/stream.py +301 -0
  31. gabriel_slack-0.1.0/src/gabriel/client/unread.py +115 -0
  32. gabriel_slack-0.1.0/src/gabriel/core/__init__.py +0 -0
  33. gabriel_slack-0.1.0/src/gabriel/core/auth.py +434 -0
  34. gabriel_slack-0.1.0/src/gabriel/core/core.py +341 -0
  35. gabriel_slack-0.1.0/src/gabriel/core/http.py +416 -0
  36. gabriel_slack-0.1.0/src/gabriel/core/metadata.py +67 -0
  37. gabriel_slack-0.1.0/src/gabriel/core/types.py +63 -0
  38. gabriel_slack-0.1.0/src/gabriel/core/ws.py +763 -0
  39. gabriel_slack-0.1.0/src/gabriel/errors.py +140 -0
  40. gabriel_slack-0.1.0/src/gabriel/events/__init__.py +0 -0
  41. gabriel_slack-0.1.0/src/gabriel/events/normalize.py +1031 -0
  42. gabriel_slack-0.1.0/src/gabriel/events/types.py +616 -0
  43. gabriel_slack-0.1.0/src/gabriel/markdown/__init__.py +0 -0
  44. gabriel_slack-0.1.0/src/gabriel/markdown/blocks_to_markdown.py +403 -0
  45. gabriel_slack-0.1.0/src/gabriel/markdown/chunker.py +310 -0
  46. gabriel_slack-0.1.0/src/gabriel/py.typed +0 -0
  47. gabriel_slack-0.1.0/src/gabriel/state/__init__.py +0 -0
  48. gabriel_slack-0.1.0/src/gabriel/state/file.py +98 -0
  49. gabriel_slack-0.1.0/src/gabriel/state/store.py +91 -0
  50. gabriel_slack-0.1.0/src/gabriel/util/__init__.py +0 -0
  51. gabriel_slack-0.1.0/src/gabriel/util/aio.py +260 -0
  52. gabriel_slack-0.1.0/src/gabriel/util/logger.py +98 -0
  53. gabriel_slack-0.1.0/src/gabriel/util/lru.py +69 -0
  54. gabriel_slack-0.1.0/src/gabriel/util/redact.py +31 -0
  55. gabriel_slack-0.1.0/src/gabriel/util/semaphore.py +155 -0
  56. gabriel_slack-0.1.0/src/gabriel/util/validate.py +110 -0
  57. gabriel_slack-0.1.0/tests/conftest.py +50 -0
  58. gabriel_slack-0.1.0/tests/harness.py +406 -0
  59. gabriel_slack-0.1.0/tests/test_cli.py +82 -0
  60. gabriel_slack-0.1.0/tests/test_client.py +887 -0
  61. gabriel_slack-0.1.0/tests/test_client_primitives.py +251 -0
  62. gabriel_slack-0.1.0/tests/test_conformance.py +213 -0
  63. gabriel_slack-0.1.0/tests/test_core.py +721 -0
  64. gabriel_slack-0.1.0/tests/test_markdown.py +279 -0
  65. gabriel_slack-0.1.0/tests/test_primitives.py +89 -0
  66. gabriel_slack-0.1.0/uv.lock +820 -0
@@ -0,0 +1,32 @@
1
+ node_modules/
2
+ dist/
3
+ coverage/
4
+ *.log
5
+ .DS_Store
6
+
7
+ # Credentials and local state — never commit
8
+ .env
9
+ .env.*
10
+ !.env.example
11
+ !.env.sample
12
+ !.env.template
13
+ .gabriel/
14
+ *.pem
15
+ *.key
16
+
17
+ # Local-only leak-check word list (contains the very identifiers it guards against)
18
+ .oss-denylist.txt
19
+
20
+
21
+ # Private research repository (Easy-Ez/gabriel-slack-internal), never part of the public repo
22
+ internal/
23
+ # Legacy location of the local phase-1 credentials file
24
+ mvp-demo/
25
+
26
+ # Python
27
+ __pycache__/
28
+ *.py[cod]
29
+ .venv/
30
+ .pytest_cache/
31
+ .mypy_cache/
32
+ .ruff_cache/
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to the Python SDK are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-11-03
10
+
11
+ ### Added
12
+
13
+ - Initial Python / asyncio implementation of the Gabriel contract, at parity with the TypeScript
14
+ reference SDK: `GabrielCore` (cookie exchange, token refresh / `AUTH_DEAD`, Web API + EdgeAPI,
15
+ WebSocket state machine) and `GabrielClient` (typed events, hydration, ordering, executor,
16
+ `wait_for_reply`, chat with chunking and streams, threads, files, search, presence, gap
17
+ recovery, `scope()` sandbox, `to_llm_context()`), `MemoryStateStore` / `FileStateStore` and the
18
+ `gabriel auth` CLI.
19
+ - Every conformance fixture in `spec/fixtures/normalize` passes; every event validates against
20
+ `spec/schema/events.schema.json`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EzEasy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,324 @@
1
+ Metadata-Version: 2.5
2
+ Name: gabriel-slack
3
+ Version: 0.1.0
4
+ Summary: Typed, AI-friendly real-time SDK for the Slack Web client protocol (session token + cookie)
5
+ Project-URL: Homepage, https://github.com/Easy-Ez/gabriel-slack/tree/main/sdks/python#readme
6
+ Project-URL: Repository, https://github.com/Easy-Ez/gabriel-slack
7
+ Project-URL: Issues, https://github.com/Easy-Ez/gabriel-slack/issues
8
+ Author: EzEasy
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent,ai,llm,realtime,sdk,slack,websocket
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: httpx>=0.27
23
+ Requires-Dist: websockets>=14
24
+ Description-Content-Type: text/markdown
25
+
26
+ # gabriel-slack (Python)
27
+
28
+ Python / asyncio implementation of [Gabriel](https://github.com/Easy-Ez/gabriel-slack/blob/main/README.md): a typed,
29
+ AI-friendly real-time SDK for the Slack Web client protocol. It implements the same contract as
30
+ the TypeScript reference SDK.
31
+
32
+ > Unofficial and not affiliated with Slack. It acts as the signed-in person: use it only
33
+ > where you are authorized to automate, and treat the session cookie like a password.
34
+
35
+ * Python **≥ 3.11**, asyncio, fully typed (`py.typed`, `mypy --strict` clean).
36
+ * Runtime dependencies: [`httpx`](https://www.python-httpx.org/) and [`websockets`](https://websockets.readthedocs.io/).
37
+ * Behavior contract: [`spec/behavior.md`](https://github.com/Easy-Ez/gabriel-slack/blob/main/spec/behavior.md); every
38
+ [conformance fixture](https://github.com/Easy-Ez/gabriel-slack/tree/main/spec/fixtures/normalize) passes and every
39
+ event validates against [`events.schema.json`](https://github.com/Easy-Ez/gabriel-slack/blob/main/spec/schema/events.schema.json).
40
+
41
+ Install from PyPI (the distribution is `gabriel-slack`, the import package `gabriel`):
42
+
43
+ ```bash
44
+ pip install gabriel-slack # or: uv add gabriel-slack
45
+ ```
46
+
47
+ ## Contents
48
+
49
+ 1. [Credentials](#credentials)
50
+ 2. [Quick start](#quick-start)
51
+ 3. [Events](#events)
52
+ 4. [Handler context](#handler-context)
53
+ 5. [Actions](#actions)
54
+ 6. [AI safety: `scope()` and `to_llm_context()`](#ai-safety)
55
+ 7. [Reliability](#reliability)
56
+ 8. [Errors](#errors)
57
+ 9. [Low-level core](#low-level-core)
58
+ 10. [Configuration reference](#configuration-reference)
59
+ 11. [CLI](#cli)
60
+ 12. [Python conventions](#python-conventions)
61
+ 13. [Development](#development)
62
+
63
+ ## Credentials
64
+
65
+ The only required credential is the `d` cookie (`xoxd-…`) of a signed-in Slack web session.
66
+ The SDK exchanges it for the workspace token (`xoxc-…`), team ID and user ID, refreshes the
67
+ token on `invalid_auth` (concurrent failures share one refresh), and stops with `auth.dead`
68
+ when the cookie itself expires.
69
+
70
+ * Load it from the environment or a secret manager: `GABRIEL_COOKIE_D` (and optionally
71
+ `GABRIEL_WORKSPACE`).
72
+ * For local development, `gabriel auth parse-curl --save` stores it in
73
+ `~/.config/gabriel/auth.json` (mode 0600, shared with the Node CLI); `load_session_cookie()`
74
+ reads the environment first, then that file.
75
+ * Use `allowed_teams` to refuse connecting to any other workspace.
76
+
77
+ The SDK never logs, emits or returns tokens or cookies; reprs of credential objects are redacted.
78
+
79
+ ## Quick start
80
+
81
+ ```python
82
+ import asyncio
83
+ from gabriel import GabrielClient, MessageCreatedEvent, load_session_cookie
84
+
85
+ async def main() -> None:
86
+ session = load_session_cookie()
87
+ client = GabrielClient.from_cookie(
88
+ cookie_d=session.cookie_d, workspace=session.workspace, allowed_teams=["T0123456789"]
89
+ )
90
+
91
+ @client.on_message(mentions_me=True)
92
+ async def greet(evt: MessageCreatedEvent, ctx) -> None:
93
+ if evt.origin != "other_user":
94
+ return # skip bots and your own messages typed elsewhere
95
+ await ctx.reply(f"Hi {evt.sender.display_name or 'there'} 👋")
96
+
97
+ @client.on("auth.dead")
98
+ def dead(evt, ctx) -> None:
99
+ alert(f"Gabriel session expired: {evt.reason}")
100
+
101
+ client.on_error(lambda err, evt: print(evt and evt.event_id, err))
102
+
103
+ async with client: # start() … stop(drain=10.0)
104
+ await asyncio.Event().wait()
105
+
106
+ asyncio.run(main())
107
+ ```
108
+
109
+ More in [`examples/`](https://github.com/Easy-Ez/gabriel-slack/tree/main/sdks/python/examples): a streamed reply to mentions, a read-only live smoke test and
110
+ raw frames. Run one with `GABRIEL_COOKIE_D=… uv run python examples/mention_stream_reply.py`.
111
+
112
+ ## Events
113
+
114
+ Subscribe with `client.on(type_or_class, handler)`, `client.on_message(matcher, handler)`,
115
+ `client.on_thread_reply(handler)` — each also works as a decorator — or pull with
116
+ `async for evt in client.events()`. Handlers receive `(evt, ctx)` and may be sync or async.
117
+
118
+ Every event is a dataclass with snake_case attributes mapping 1:1 to the schema's camelCase
119
+ fields; `evt.to_dict()` / `evt.to_json()` produce the schema form. Select events by type string
120
+ (`"message.created"`) or by class (`MessageCreatedEvent`).
121
+
122
+ | Event | Class | When |
123
+ | :--- | :--- | :--- |
124
+ | `message.created` | `MessageCreatedEvent` | Any new message: channel, thread reply, broadcast, file share, bot. |
125
+ | `message.updated` | `MessageUpdatedEvent` | The body actually changed (metadata-only updates are filtered). |
126
+ | `message.deleted` | `MessageDeletedEvent` | A message was deleted (`deleted_ts`). |
127
+ | `message.thread_meta_updated` | `MessageThreadMetaUpdatedEvent` | A thread's reply count / latest reply changed. |
128
+ | `message.other` | `MessageOtherEvent` | Message subtypes without a dedicated model. |
129
+ | `ai.stream_updated` / `ai.stream_completed` | `AiStreamUpdatedEvent` / `AiStreamCompletedEvent` | Another AI app streams / finishes a reply. |
130
+ | `presence.changed` | `PresenceChangedEvent` | A watched user switched `active` ⇄ `away` (`is_initial_snapshot` for the first value). |
131
+ | `user.status_changed` / `user.dnd_changed` | `UserStatusChangedEvent` / `UserDndChangedEvent` | A watched user's custom status / DND changed. |
132
+ | `user.subteam_updated` | `UserSubteamUpdatedEvent` | A user group's members changed. |
133
+ | `interaction.typing` | `TypingEvent` | Someone is typing. |
134
+ | `interaction.reaction_added` / `_removed` | `ReactionAddedEvent` / `ReactionRemovedEvent` | Reaction on a message (`target.thread_root_ts` resolved) or file. |
135
+ | `channel.lifecycle` | `ChannelLifecycleEvent` | Joined / left / DM opened / renamed / archived. |
136
+ | `channel.marked` | `ChannelMarkedEvent` | Your read cursor moved (the SDK's own cursor changes are hidden). |
137
+ | `unread.activity` / `unread.badge_counts` | `UnreadActivityEvent` / `UnreadBadgeCountsEvent` | Activity item / badge snapshot (deduplicated). |
138
+ | `connection.ready` / `.reconnecting` / `.gap` | `Connection*Event` | Connection lifecycle and backfill reports. |
139
+ | `auth.refreshed` / `auth.dead` | `AuthRefreshedEvent` / `AuthDeadEvent` | Token refreshed / session expired (terminal). |
140
+ | `guard.loop_detected` / `.protocol_drift` / `.overflow` | `LoopDetectedEvent` / `ProtocolDriftEvent` / `OverflowEvent` | Circuit breaker / unknown frame / event dropped under load. |
141
+
142
+ Key fields of `MessageCreatedEvent`: `ts`, `channel` (`id`, `name`, `is_private`, `is_im`,
143
+ `is_mpim`), `sender` (`id`, `display_name`, `handle`, `is_bot`, `is_external`), `origin`, `text`
144
+ (raw mrkdwn), `markdown` (standard Markdown, mentions rendered as `@Name`), `thread`
145
+ (`root_ts`, `is_reply`, `broadcast` or `None`), `is_thread_reply`, `thread_ts`, `mentions`,
146
+ `is_mentioning_me`, `files`, `delivery` (`realtime` | `backfill`), `raw`.
147
+
148
+ **`origin`** distinguishes `this_client` (an echo of this SDK instance's own writes, dropped
149
+ unless `LoopGuardOptions(include_self_messages=True)`), `self_other_client`, `other_user` and `bot`.
150
+
151
+ ## Handler context
152
+
153
+ ```python
154
+ @client.on(MessageCreatedEvent)
155
+ async def handle(evt, ctx):
156
+ ctx.signal # AbortSignal: aborted on delete, timeout or stop()
157
+ await ctx.reply("text") # in the trigger's thread (in_thread=False for top level)
158
+ stream = await ctx.reply_stream() # streaming reply in the trigger's thread
159
+ await ctx.react("eyes")
160
+ answer = await ctx.wait_for_reply(lambda r: r.thread_ts == evt.ts, timeout=60) # None on timeout
161
+ await ctx.hydrate_remaining() # finish name resolution if the 300 ms budget ran out
162
+ ```
163
+
164
+ `await ctx.signal.wait()` blocks until the signal fires; `ctx.signal.aborted` checks it.
165
+ `wait_for_reply` never misses a reply that arrived before it was called, releases the handler's
166
+ concurrency slot while waiting, and (with `consume=True`, the default) keeps the reply from
167
+ reaching other handlers. Handlers for one channel run one at a time in arrival order
168
+ (`ConcurrencyOptions(executor_key="thread")` switches to per-thread). Exceptions go to
169
+ `client.on_error` and never block later events. Middleware:
170
+ `client.use(async def mw(evt, ctx, next): …; await next())`.
171
+
172
+ ## Actions
173
+
174
+ | Namespace | Methods |
175
+ | :--- | :--- |
176
+ | `client.chat` | `send(channel, markdown, *, blocks, thread_ts, broadcast, priority, preserve_unread_cursor, auto_chunk)`, `update`, `delete`, `send_typing`, `create_stream`, `stream` (async context manager) |
177
+ | `client.threads` | `get_context(channel, thread_ts, limit=50)` → messages, participants, `formatted_transcript`; `iterate_replies`, `mark_read`, `subscribe`, `unsubscribe` |
178
+ | `client.files` | `upload(filename, content, …)` (3-step, streaming), `download_stream` / `download_bytes` (`max_bytes`, sign-in page detection), `get_info`, `delete` |
179
+ | `client.search` | `messages`, `files`, `channels`, `people`, `iterate_messages`, `iterate_files` |
180
+ | `client.presence` | `watch(user_ids, track_profile_and_dnd=False)` → handle (`await h.unsubscribe()` / `async with`), `get_status(user_id)` |
181
+ | `client.reactions` | `add(channel, ts, emoji)`, `remove` (idempotent) |
182
+ | `client.users` | `resolve(id or ids)` (batched, cached), `get_dnd` |
183
+ | `client.channels` | `resolve`, `get_history`, `iterate_history` |
184
+
185
+ Streams: `append()` is synchronous; at most one update per stream is in flight and the final
186
+ message equals the buffer at `finish()`. Deleting the message in Slack aborts `stream.signal`.
187
+ Long output continues in a new message (`continuation_ts`).
188
+
189
+ ```python
190
+ async with client.chat.stream("C0123456789", thread_ts=ts) as s: # finish on exit, abort on error
191
+ async for chunk in llm.stream(prompt):
192
+ s.append(chunk)
193
+ ```
194
+
195
+ Sending behavior (chunking at 11,500 effective characters, `client_msg_id` idempotency, per-channel
196
+ FIFO, a reserved slot for interactive replies, `preserve_unread_cursor`) and client-side limits
197
+ (presence frame ≤ 16,000 bytes, profile/DND ≤ 900 users) are identical to the Node SDK.
198
+
199
+ ## AI safety
200
+
201
+ ```python
202
+ from gabriel import ReadPolicy, ScopePolicy, WritePolicy
203
+
204
+ agent = client.scope(ScopePolicy(
205
+ read=ReadPolicy(channels=["C0123456789"], allow_search=False, max_file_download_bytes=5 * 1024 * 1024),
206
+ write=WritePolicy(
207
+ target_scope="trigger_thread_only",
208
+ allowed_actions=["chat.send", "chat.create_stream", "reactions.add"],
209
+ disclosure_suffix="\n_— generated by an AI assistant_",
210
+ ),
211
+ approval=ask_a_human, # async (action, params) -> bool
212
+ ))
213
+ ```
214
+
215
+ The scoped view has the same API. Anything outside the policy raises `SCOPE_POLICY_VIOLATION`
216
+ and is logged. Give LLM tools the scoped view, not the client.
217
+
218
+ `evt.to_llm_context()` renders metadata separately from content and wraps the content in
219
+ `<slack_message untrusted="true">`. Tell the model that `<content>` is data, not instructions.
220
+
221
+ ## Reliability
222
+
223
+ * **Ordering**: per-channel dispatch in arrival order while entity lookups run in parallel.
224
+ * **De-duplication** by `event_id`.
225
+ * **Recovery**: after a cold reconnect, or a restart with a persistent store, missed channel
226
+ messages and watched-thread replies are delivered as `delivery="backfill"` before newer realtime
227
+ messages, followed by a `connection.gap` report.
228
+ * **State stores**: `MemoryStateStore` (default) and `FileStateStore(dir)` (single host; same file
229
+ format as the Node SDK). Implement the `StateStore` protocol for other backends.
230
+ * **Shutdown**: `await client.stop(drain=10.0)` (or leaving `async with`) drains handlers, interrupts
231
+ open streams, flushes sends and persists state.
232
+ * **Health**: `client.health()` → `HealthReport(status, ws_state, auth_state, last_frame_age, queue_depth)`.
233
+
234
+ ## Errors
235
+
236
+ Every API raises `GabrielError` with `code`, `retryable`, `raw_slack_error` and `retry_after`
237
+ (seconds). Retryable errors were already retried — don't add another retry loop.
238
+
239
+ ```python
240
+ from gabriel import GabrielError
241
+
242
+ try:
243
+ await agent.chat.send(channel, markdown, thread_ts=ts)
244
+ except GabrielError as err:
245
+ if err.code == "SCOPE_POLICY_VIOLATION":
246
+ return "not allowed"
247
+ raise
248
+ ```
249
+
250
+ Codes: see [`spec/behavior.md` §12](https://github.com/Easy-Ez/gabriel-slack/blob/main/spec/behavior.md#12-errors).
251
+
252
+ ## Low-level core
253
+
254
+ ```python
255
+ client.core.on_event("ai_assistant_status", lambda frame: ...) # unmodified frames
256
+ client.core.on_message_subtype("channel_topic", lambda frame: ...)
257
+ await client.core.call_web_api("reminders.add", {"text": "Review", "time": "in 30 minutes"})
258
+ await client.core.call_edge_api("users/search", {"query": "alice", "count": 5})
259
+ await client.core.send_ws_command({"type": "tickle", "reason": "keepalive"})
260
+ ```
261
+
262
+ `GabrielCore` also works on its own (`async with GabrielCore(auth=AuthOptions(cookie_d=…)) as core:`).
263
+ It bypasses loop guards, origin detection and the sandbox. Never send `presence_sub` yourself.
264
+
265
+ ## Configuration reference
266
+
267
+ ```python
268
+ GabrielClient(
269
+ auth=AuthOptions(cookie_d=..., token=None, workspace=None, allowed_teams=None),
270
+ concurrency=ConcurrencyOptions(global_concurrency=32, per_key_concurrency=1, max_queue=5000,
271
+ max_inflight_events=10_000, executor_key="channel", handler_timeout=300.0),
272
+ hydration=HydrationOptions(hydrate_budget=0.3, preload_usergroups=True),
273
+ loop_guard=LoopGuardOptions(include_self_messages=False, max_auto_replies_per_minute=5),
274
+ chat=ChatSchedulerOptions(global_send_concurrency=4, item_deadline=30.0, stream_updates_per_second=2,
275
+ stream_min_interval=0.5, interrupted_notice="\n\n_(interrupted)_"),
276
+ recovery=RecoveryOptions(max_pages_per_channel=10, watched_threads_limit=100, resync_buffer_limit=10_000),
277
+ state_store=FileStateStore("/var/lib/my-agent/gabriel"),
278
+ logger=console_logger("info"), # default: stdlib logging, logger name "gabriel"
279
+ metrics=my_metrics, # increment(name, labels) / observe(name, value, labels)
280
+ advanced=AdvancedOptions(http_client=httpx.AsyncClient(proxy=...), ws_factory=..., metadata={"user_agent": ...},
281
+ endpoints=..., gateway_server=None, start_timeout=60.0),
282
+ )
283
+ ```
284
+
285
+ `GabrielClient.from_cookie(cookie_d=…, workspace=…, allowed_teams=…, **options)` takes the same
286
+ options as keyword arguments.
287
+
288
+ ## CLI
289
+
290
+ ```bash
291
+ gabriel auth login your-workspace # sign in in a dedicated Chrome window; saved with mode 0600
292
+ gabriel auth extract --port 9222 # read from a Chrome started with --remote-debugging-port
293
+ pbpaste | gabriel auth parse-curl --save # "Copy as cURL" of any request from your signed-in Slack tab
294
+ gabriel auth status # verify and list workspaces (secrets redacted)
295
+ gabriel auth logout --yes
296
+ ```
297
+
298
+ `python -m gabriel …` works too. The session file is shared with the Node CLI.
299
+
300
+ ## Python conventions
301
+
302
+ * Durations are **float seconds** (`handler_timeout=300.0`, `retry_after`), except event payload
303
+ fields that the schema defines in milliseconds (`ConnectionReconnectingEvent.delay_ms`,
304
+ `AuthRefreshedEvent.refreshed_at`).
305
+ * Cancellation: methods accept `signal=AbortSignal`; ordinary `asyncio` task cancellation works too.
306
+ SDK-initiated cancellation surfaces as `GabrielError("CANCELLED")`.
307
+ * Logging: any object with `debug/info/warn/error(fields, msg)`; `stdlib_logger()` adapts `logging`.
308
+
309
+ ## Development
310
+
311
+ ```bash
312
+ cd sdks/python
313
+ uv sync
314
+ uv run pytest # unit, fake-Slack integration, conformance and schema tests
315
+ uv run mypy # strict
316
+ uv run ruff check src tests examples && uv run ruff format --check src tests examples
317
+ ```
318
+
319
+ Tests never touch a real workspace: [`tests/harness.py`](https://github.com/Easy-Ez/gabriel-slack/blob/main/sdks/python/tests/harness.py) is an in-memory Slack
320
+ (Web API, EdgeAPI, cookie exchange, WebSocket gateway).
321
+
322
+ ## License
323
+
324
+ MIT © 2026 EzEasy