This repository was archived by the owner on May 26, 2026. It is now read-only.
feat(kora): KR-FEAT-SLACK-DM ST2 — outbound chat.postMessage + echo reply#122
Merged
rafe-walker merged 1 commit intoMay 22, 2026
Merged
Conversation
…eply
Second ST of Feature 5. Joshua DMs → Kora replies. Reply content is
the LOCKED echo "Kora received: {text[:200]}" — real AI-driven
response generation is the KR-FEAT-SLACK-DM-AI follow-on.
## New module
**`kora_cli/clients/slack_client.py`** (~240 lines) — minimal async
Slack Web API wrapper:
- `SlackClient` reads `KORA_SLACK_BOT_TOKEN` at `__init__`;
**fail-CLOSED** with `SlackClientNotConfigured` if unset/whitespace.
- `post_dm(channel_id, text, thread_ts)` posts to
`https://slack.com/api/chat.postMessage`.
- httpx (core dep; `httpx[socks]==0.28.1`) over aiohttp (slack
extra only) — consistent with the daemon's existing outbound HTTP
patterns (`heartbeat_probes/`, `auth.py`, `web_server.py`); drops
one extra requirement from daemon deploys.
- 10s per-call timeout.
- **Retry policy** (max 2 attempts):
- HTTP 429: respect `Retry-After` header (default 1s if missing);
1 retry.
- HTTP 5xx: 0.5s backoff; 1 retry.
- HTTP 2xx + `ok: false` (e.g. `invalid_auth`,
`channel_not_found`): NO retry; raise `SlackAPIError`.
- HTTP 4xx other than 429: NO retry; raise `SlackTransportError`.
- Transport exception on attempt 1: 1 retry.
- Constant-time bearer compare not needed (token is the daemon's
own, not user-presented); but the token is NEVER logged, NEVER
in error messages, NEVER in `__repr__` — asserted by
diverse-failure-mode test.
## Error hierarchy
- `SlackClientError` (base)
- `SlackClientNotConfigured` — missing env
- `SlackAPIError` — Slack returned ok:false; carries `slack_error`
- `SlackTransportError` — network failure / retry exhaustion;
carries `last_status` when applicable
Handler maps these to stable `failure_reason` codes in outbound JSONL
(`slack_client_not_configured` / `slack_api:<error>` /
`transport:<status>` / `transport:<exc_name>`).
## Handler integration
**`kora_cli/handlers/slack_dm_handler.py`** (+204 lines):
- New optional `slack_client` constructor arg. Production code
lazy-constructs on first reply via `_get_or_create_slack_client`;
`SlackClientNotConfigured` → returns None → outbound JSONL
entry with `failure_reason: "slack_client_not_configured"`. The
None is CACHED so subsequent inbound events don't re-log the
same failure.
- After the inbound `received` JSONL entry + chain emit, calls
`_send_echo_reply()` which:
- Builds `Kora received: {text[:200]}` (echo format LOCKED).
- thread_ts = event.thread_ts (already in-thread) OR event.ts
(new thread under the originating DM) — Kora threads under
Joshua's message every time.
- Catches all exceptions (SlackAPIError, SlackTransportError,
and even unexpected types) — writes outbound JSONL entry +
`[kora.slack_dm.reply_failed]` structured-log emit + returns
cleanly. Inbound handler always returns ok to Slack.
- New `_append_outbound_log_entry()` — distinct JSONL schema:
`{sent_at, channel_id, thread_ts, text, slack_message_ts,
send_status, failure_reason?}` so operator log-analysis can
branch on `received_at` (inbound) vs `sent_at` (outbound) key
presence.
## Filtered events do NOT trigger reply
Only the all-filters-passed identified-Joshua path calls
`_send_echo_reply()`. Non-Joshua / bot / subtype / non-IM /
PAUSED-state events stop at their respective filter without an
outbound attempt — asserted by 4 tests in `test_slack_dm_reply.py`.
## Tests (28 new, 233 total all passing)
**`test_slack_client.py`** (14 tests):
- Fail-CLOSED on missing / whitespace token
- Constructor reads token from env at construction (not lazy)
- Successful post_dm returns response dict; thread_ts in payload
when present + omitted when None
- Auth header is `Bearer xoxb-...`; Content-Type JSON
- SlackAPIError on ok:false (channel_not_found, invalid_auth) —
NO retry
- 429 → respect Retry-After → 1 retry → success
- 429 on BOTH attempts → SlackTransportError(last_status=429)
- 429 missing Retry-After header → default value used
- 5xx → retry → success; 5xx on both → SlackTransportError
- 503 treated as 5xx (retryable)
- 4xx non-429 (401/403/404) → NO retry; SlackTransportError
- Timeout on attempt 1 → 1 retry; timeout on both → SlackTransportError
with last_status=None
- **SECURITY**: Bot token NEVER in error messages / repr / log
output after diverse failure-mode sequence
**`test_slack_dm_reply.py`** (14 tests):
- Echo format LOCKED `Kora received: {text[:200]}`
- thread_ts uses event.thread_ts when present, falls back to
event.ts otherwise
- Echo text truncated at 200 chars even for 5000-char input
- JSONL: inbound `received` entry first, outbound `ok` entry
second; distinct schemas
- Missing bot token → outbound `failed` +
`failure_reason: slack_client_not_configured` + reply_failed log
- SlackTransportError 429 / 500 / timeout → outbound `failed` with
stable `transport:<code>` / `transport:<exc_name>` reasons
- SlackAPIError → outbound `failed` with
`slack_api:<error_code>` reason
- Unexpected exception (RuntimeError) does NOT crash inbound handler
- Filtered events (non-Joshua / bot / subtype / PAUSED) do NOT
trigger reply
- **SECURITY**: Bot token NEVER appears in JSONL after diverse
paths (success / transport / api / filtered)
**ST1 test fixture updated**: `_read_log_lines` filters to inbound
entries (`handled_status` key), so existing ST1 assertions stay
sharp; happy-path test that previously asserted "3 lines" now
asserts "5 lines (3 inbound + 2 outbound from 2 Joshua DMs)" with
schema-branched validation.
## §5 ship checklist
- [x] Base `feature/phase2-upgrades`
- [x] Title format `feat(kora): KR-FEAT-SLACK-DM STn — <scope>`
- [x] §4 defaults locked (echo format, retry policy, dead-letter)
- [x] Bot token NEVER logged (asserted at both SlackClient layer +
handler JSONL layer)
- [x] Reply failure does NOT crash handler (3 distinct exception-
class tests + 1 unexpected-type test)
- [x] Slack always gets 200 OK (handler returns ok regardless of
outbound disposition)
- [x] Tests pass locally (**233/233** across full daemon + listener
+ handler + client + docker suite)
## What's next
**ST3** — `kora_docs/15_status_and_roadmap/slack_app_setup_runbook.md`
covering Slack app creation, OAuth scopes (`chat:write`, `im:history`,
`im:read`, `im:write` for bot; `message.im` for events), Event
Subscriptions wire-up, Doppler secret setup for the 3 envs
(`KORA_SLACK_SIGNING_SECRET`, `KORA_SLACK_BOT_TOKEN`,
`KORA_SLACK_JOSHUA_USER_ID`), smoke test, troubleshooting, the dual
signing-secret env transition note (legacy `SLACK_SIGNING_SECRET`
used by `gateway/platforms/slack.py` Bolt path; new env covers the
daemon listener).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 22, 2026
rafe-walker
pushed a commit
that referenced
this pull request
May 23, 2026
Bridges CC#3's SlackClient (#122) + CC#1's PurelymailClient (#124) onto the agent-facing /mcp surface. Other agents (other PMs, drone tickets, future Kora-driven multi-agent workflows) can now drive Kora-initiated sends. `kora__send_slack_dm`: - Args: channel_id (D-prefix or KORA_SLACK_JOSHUA_USER_ID), text (≤4000 chars), thread_ts (optional) - requires_cap_gate=True (-32001 capability_denied if absent) - Channel-broadcast defense: U-prefix + C-prefix rejected at MCP layer (operator pre-resolves to DM channel) - SlackClient-unavailable surface: -32001 with "slack_client_unavailable" prefix (KORA_SLACK_BOT_TOKEN unset or daemon not running with slack_client listener) - Bot identity = Kora (NOT caller-controllable) `kora__send_email`: - Args: to (≤10), subject, body_text, body_html (optional), in_reply_to (optional) - requires_cap_gate=True - from_addr derived from KORA_PUREMAIL_SMTP_USERNAME — NOT caller-controllable (prevents sender impersonation) - NO attachments in this bucket (deferred to KR-MCP-SEND-TOOLS-ATTACHMENTS; file-upload semantics aren't trivially MCP-compatible) - PurelymailClient-unavailable surface: -32001 with "purelymail_client_unavailable" prefix Both tools added to ST2_TOOL_DESCRIPTORS + ST2_TOOL_DISPATCH; schema validation surfaces -32602 invalid_params for malformed inputs before any client invocation. `kora_cli/listeners/slack_client_listener.py` (NEW): - SlackClientListener: startup constructs SlackClient if KORA_SLACK_BOT_TOKEN set; FAIL-SOFT if unset (daemon boot continues; outbound disabled with INFO log) - current_slack_client() module-level accessor mirroring the current_pool() / current_coordinator() pattern `kora_cli/listeners/purelymail_client_listener.py` (NEW): - PurelymailClientListener: startup constructs PurelymailClient if SMTP envs set; FAIL-SOFT if missing - current_purelymail_client() accessor Both registered at module-import time via the listeners package __init__; both fail-soft so outbound capabilities never block daemon boot. `kora_cli/handlers/slack_dm_handler.py:_get_or_create_slack_client` updated: - Order: cached client → listener accessor → lazy-construct - Lazy-construct fallback PRESERVED so standalone-handler tests (no daemon listeners) keep passing - Production daemon paths get the shared listener instance - Existing reasoning-engine reply path (CC#3 KR-FEAT-AI-RESPONSE- LOOP) goes through this helper → automatically picks up the listener instance once daemon's running `caller_actor_kind` field added to outbound entries in BOTH log files: - slack_dm_log.jsonl: handler's `_append_outbound_log_entry` gains optional caller_actor_kind kwarg (omitted when None to preserve back-compat with consumers expecting absence on handler-driven sends) - email_outbound_log.jsonl: PurelymailClient's send_email gains caller_actor_kind kwarg, propagated to `_append_outbound_log` MCP tool dispatchers pass caller.actor_kind through; handler / runtime-driven sends omit the field (None). Tool 1 rejects channel_ids that don't start with `D` OR match KORA_SLACK_JOSHUA_USER_ID. U... user-IDs are rejected at MCP layer (would require an extra Slack API call to resolve to DM channel; operator/agent must pre-resolve). C... public/private channels also rejected — channel broadcast is a separate threat model + would need its own bucket. - SlackClient bot token never in error envelope or JSONL (sanitized via type-name-only re-raise on exception) - PurelymailClient SMTP password never in error envelope or JSONL (sanitized via same pattern + existing PurelymailClient _sanitize_error) - Diverse-failure-mode tests pin both invariants 36 new tests across 2 files: `tests/kora_cli/test_listeners/test_send_client_listeners.py` (11): - Both listeners registered at import time - Slack listener fail-soft on missing token + WARN log - Slack listener constructs cleanly when token set - PurelymailClient listener fail-soft on missing username OR password - PurelymailClient listener constructs with full env - Both listeners clear singleton on shutdown - Both listeners handle unexpected construction errors `tests/kora_cli/test_listeners/test_mcp_send_tools.py` (25): - 6 registry-side wiring tests (descriptors present, requires_cap_gate=True, dispatch registered, schema caps) - 5 slack input validation tests (empty channel/text; >4000 chars; non-DM channel rejected; Joshua's user ID OK) - 1 SlackClient unavailable - 2 slack happy-path tests (SendResult shape + JSONL entry with caller_actor_kind) - 1 slack token-absence-in-error test - 4 email input validation tests - 2 email client-unavailable tests (listener None + env unset) - 1 email happy-path test (from_addr from env; caller_actor_kind threaded) - 1 email password-absence-in-error test - 1 caller_actor_kind end-to-end propagation test 36/36 pass; 341/341 cross-bucket regression (test_listeners/ + test_heartbeat_probes/ + test_clients/ + test_handlers/). Ruff clean. Agent-facing /mcp surface gains 2 mutating tools (total now: 5 mutating + 8 read = 13 tools). Other agents have a complete operational surface on Kora — read state, request transitions, create Sea_Tickets, send Slack DMs, send emails. The remaining follow-on (attachment support in send_email) ships when the file-upload semantics get a design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Second ST of Feature 5. Joshua DMs → Kora replies. Reply content is the LOCKED echo `"Kora received: {text[:200]}"` — real AI-driven response generation is the KR-FEAT-SLACK-DM-AI follow-on.
Bucket spec: `kora_docs/17_cc_bucket_prompts/KR-FEAT-SLACK-DM_kora_joshua_dm.md`.
Base: `feature/phase2-upgrades` — NOT main.
New module
`kora_cli/clients/slack_client.py` (~240 lines):
Error hierarchy: `SlackClientError` (base) → `SlackClientNotConfigured` / `SlackAPIError` / `SlackTransportError`. Handler maps to stable JSONL `failure_reason` codes.
Handler integration (+204 lines in slack_dm_handler.py)
Filtered events do NOT trigger reply
Only the all-filters-passed identified-Joshua path calls `_send_echo_reply()`. Non-Joshua / bot / subtype / non-IM / PAUSED-state events stop at their respective filter without an outbound attempt — asserted by 4 dedicated tests.
Tests (28 new, 233 total all passing)
`test_slack_client.py` (14 tests):
`test_slack_dm_reply.py` (14 tests):
` / `transport:<exc_name>` reasonsST1 test fixture updated: `_read_log_lines` filters to inbound entries; happy-path test asserting "3 lines" now correctly expects "5 lines (3 inbound + 2 outbound from 2 Joshua DMs)" with schema-branched validation.
§5 ship checklist
What's next
ST3 — `kora_docs/15_status_and_roadmap/slack_app_setup_runbook.md` covering Slack app creation, OAuth scopes, Event Subscriptions wire-up, Doppler secret setup for the 3 envs (`KORA_SLACK_SIGNING_SECRET`, `KORA_SLACK_BOT_TOKEN`, `KORA_SLACK_JOSHUA_USER_ID`), smoke test, troubleshooting, the dual signing-secret env transition note.
🤖 Generated with Claude Code