Skip to content
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 into
feature/phase2-upgradesfrom
feat/kora-KR-FEAT-SLACK-DM-ST2
May 22, 2026
Merged

feat(kora): KR-FEAT-SLACK-DM ST2 — outbound chat.postMessage + echo reply#122
rafe-walker merged 1 commit into
feature/phase2-upgradesfrom
feat/kora-KR-FEAT-SLACK-DM-ST2

Conversation

@rafe-walker

Copy link
Copy Markdown
Owner

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):

  • `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 daemon's existing outbound HTTP patterns; drops one extra requirement from daemon deploys
  • 10s per-call timeout
  • Retry policy (max 2 attempts):
    • HTTP 429 → respect `Retry-After` (default 1s if missing); 1 retry
    • HTTP 5xx → 0.5s backoff; 1 retry
    • HTTP 2xx + `ok: false` → NO retry; `SlackAPIError`
    • HTTP 4xx non-429 → NO retry; `SlackTransportError`
    • Transport exception on attempt 1 → 1 retry

Error hierarchy: `SlackClientError` (base) → `SlackClientNotConfigured` / `SlackAPIError` / `SlackTransportError`. Handler maps to stable JSONL `failure_reason` codes.

Handler integration (+204 lines in slack_dm_handler.py)

  • New optional `slack_client` ctor 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"`; None CACHED so subsequent inbound events don't re-log
  • After inbound `received` JSONL + chain emit, calls `_send_echo_reply()`:
    • Echo text LOCKED: `Kora received: {text[:200]}`
    • `thread_ts` = `event.thread_ts` (already in-thread) OR `event.ts` (new thread)
    • Catches ALL exceptions (SlackAPIError, SlackTransportError, unexpected types) → outbound JSONL `failed` + `[kora.slack_dm.reply_failed]` log → 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?}` — operator log-analysis branches 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 dedicated tests.

Tests (28 new, 233 total all passing)

`test_slack_client.py` (14 tests):

  • Fail-CLOSED on missing / whitespace token
  • Constructor reads token at construction (not lazy)
  • post_dm success returns response dict; thread_ts in/out of payload
  • Auth header is `Bearer xoxb-...`; Content-Type JSON
  • SlackAPIError on ok:false (NO retry)
  • 429 → respect Retry-After → 1 retry → success
  • 429 on BOTH attempts → SlackTransportError(last_status=429)
  • 429 missing Retry-After → default value used
  • 5xx → retry → success; 5xx on both → SlackTransportError; 503 retryable
  • 4xx non-429 (401/403/404) → NO retry
  • Timeout on attempt 1 → 1 retry; timeout on both → SlackTransportError(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 + 200-char truncation
  • thread_ts uses event.thread_ts if present, else event.ts
  • JSONL: inbound first, outbound second; distinct schemas
  • Missing bot token → outbound `failed` with `slack_client_not_configured` + reply_failed log
  • SlackTransportError 429 / 500 / timeout → stable `transport:` / `transport:<exc_name>` reasons
  • SlackAPIError → `slack_api:` 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

ST1 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

  • Base `feature/phase2-upgrades`
  • Title format `feat(kora): KR-FEAT-SLACK-DM STn — `
  • §4 defaults locked (echo, retry policy, dead-letter)
  • Bot token NEVER logged (asserted at both SlackClient + handler JSONL layers)
  • Reply failure does NOT crash handler (3 distinct exception classes + unexpected-type guard)
  • Slack always gets 200 OK
  • 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, 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

…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>
@rafe-walker rafe-walker merged commit 000bc32 into feature/phase2-upgrades May 22, 2026
@rafe-walker rafe-walker deleted the feat/kora-KR-FEAT-SLACK-DM-ST2 branch May 22, 2026 17:07
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>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant