diff --git a/TODO.md b/TODO.md index 528420d..ba5f75a 100644 --- a/TODO.md +++ b/TODO.md @@ -55,7 +55,9 @@ Extracted and modularized from: - [x] T034: Conversation memory — already works via cache.json persistence (no code needed) - [x] T035: CLI interactive mode — msvcrt.kbhit() on Windows, threaded pipe reader - [x] T036: Fix multi-adapter test hang — CLI poll blocks on pipe stdin in test harness -- [ ] T037: Update docs — CLAUDE.md, README, TODO with new modules and test counts +- [x] T037: Update docs — CLAUDE.md, README, TODO with new modules and test counts +- [ ] T038: Slack adapter — Socket Mode + Web API for replies (stdlib only) +- [ ] T039: Slack adapter E2E tests ## Blocked (external deps) - [ ] T011: Live Signal test — needs user phone number + EP group ID diff --git a/adapters/slack_adapter.py b/adapters/slack_adapter.py new file mode 100644 index 0000000..1b16ff7 --- /dev/null +++ b/adapters/slack_adapter.py @@ -0,0 +1,152 @@ +"""Slack adapter — polls channels via Web API, replies via chat.postMessage. + +Uses Slack Bot Token (xoxb-...) for authentication. Polls conversations.history +for new messages, sends replies to the same channel. + +Env vars: + COCONUT_SLACK_BOT_TOKEN — Slack bot token (xoxb-...) + COCONUT_SLACK_CHANNEL_ID — Channel ID to monitor (C...) + COCONUT_SLACK_APP_TOKEN — (unused, reserved for Socket Mode) +""" +import json +import time +import urllib.request +import urllib.error +import urllib.parse + +from adapters.base import BaseAdapter, Message + +SLACK_API = 'https://slack.com/api' + + +class SlackAdapter(BaseAdapter): + """Slack messaging via Web API polling.""" + + name = 'slack' + + def __init__(self, config): + super().__init__(config) + self.bot_token = config.get('slack_bot_token', '') + self.channel_id = config.get('slack_channel_id', '') + self._last_ts = str(time.time()) # Only fetch messages after startup + self._seen_ts = set() + self._bot_user_id = '' + + def _api(self, method, params=None, post_data=None): + """Call Slack Web API method.""" + url = f'{SLACK_API}/{method}' + if params: + url += '?' + urllib.parse.urlencode(params) + + if post_data: + body = json.dumps(post_data).encode() + req = urllib.request.Request(url, data=body, method='POST') + req.add_header('Content-Type', 'application/json; charset=utf-8') + else: + req = urllib.request.Request(url) + + req.add_header('Authorization', f'Bearer {self.bot_token}') + + with urllib.request.urlopen(req, timeout=15) as resp: + result = json.loads(resp.read()) + + if not result.get('ok'): + raise RuntimeError(f"Slack API error: {result.get('error', 'unknown')}") + return result + + def _get_bot_user_id(self): + """Fetch bot's own user ID to filter self-messages.""" + if self._bot_user_id: + return self._bot_user_id + try: + result = self._api('auth.test') + self._bot_user_id = result.get('user_id', '') + except Exception: + pass + return self._bot_user_id + + def poll(self): + """Fetch new messages from Slack channel.""" + try: + result = self._api('conversations.history', { + 'channel': self.channel_id, + 'oldest': self._last_ts, + 'limit': 20, + }) + except Exception: + return [] + + bot_id = self._get_bot_user_id() + messages = [] + + for item in reversed(result.get('messages', [])): + ts = item.get('ts', '') + if ts in self._seen_ts: + continue + self._seen_ts.add(ts) + + # Skip bot's own messages + user = item.get('user', '') + if user == bot_id: + continue + + # Skip subtypes (joins, leaves, topic changes, etc.) + if item.get('subtype'): + continue + + text = item.get('text', '').strip() + if not text: + continue + + # Resolve user display name + sender = self._resolve_user(user) if user else 'unknown' + + messages.append(Message( + message_id=ts, + sender=sender, + text=text, + timestamp=self._ts_to_iso(ts), + raw=item, + )) + + # Track latest timestamp for next poll + if ts > self._last_ts: + self._last_ts = ts + + # Prune seen set + if len(self._seen_ts) > 1000: + sorted_ts = sorted(self._seen_ts) + self._seen_ts = set(sorted_ts[-500:]) + + return messages + + def _resolve_user(self, user_id): + """Resolve Slack user ID to display name. Falls back to ID.""" + try: + result = self._api('users.info', {'user': user_id}) + profile = result.get('user', {}).get('profile', {}) + return (profile.get('display_name') + or profile.get('real_name') + or user_id) + except Exception: + return user_id + + def send(self, text): + """Send a message to the Slack channel.""" + formatted = self.format_outbound(text) + try: + self._api('chat.postMessage', post_data={ + 'channel': self.channel_id, + 'text': formatted, + }) + except Exception as e: + print(f'Slack send error: {e}', flush=True) + + @staticmethod + def _ts_to_iso(ts): + """Convert Slack timestamp (epoch.seq) to ISO format.""" + try: + epoch = float(ts.split('.')[0] if '.' in ts else ts) + return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(epoch)) + except (ValueError, TypeError): + return '' diff --git a/coconut.py b/coconut.py index a7cfb81..d2c4765 100644 --- a/coconut.py +++ b/coconut.py @@ -72,6 +72,11 @@ def _load_adapters(cfg): from adapters.cli_adapter import CLIAdapter adapters.append(CLIAdapter(cfg)) _log('info', 'CLI adapter enabled') + if cfg.get('slack_enabled'): + from adapters.slack_adapter import SlackAdapter + adapters.append(SlackAdapter(cfg)) + _log('info', 'Slack adapter enabled', + channel=cfg.get('slack_channel_id', '')) if cfg.get('webhook_enabled'): from adapters.webhook_adapter import WebhookAdapter adapters.append(WebhookAdapter(cfg)) diff --git a/config/coconut.env.example b/config/coconut.env.example index c1e7939..67f79fe 100644 --- a/config/coconut.env.example +++ b/config/coconut.env.example @@ -32,6 +32,11 @@ COCONUT_TEAMS_REFRESH_TOKEN="" # CLI (stdin/stdout — for testing) COCONUT_ADAPTER_CLI_ENABLED=false +# Slack +COCONUT_ADAPTER_SLACK_ENABLED=false +COCONUT_SLACK_BOT_TOKEN="" +COCONUT_SLACK_CHANNEL_ID="" + # Webhook (HTTP inbound/outbound — for generic integrations) COCONUT_ADAPTER_WEBHOOK_ENABLED=false COCONUT_WEBHOOK_PORT=8000 diff --git a/core/config.py b/core/config.py index 40e67db..cb0dc63 100644 --- a/core/config.py +++ b/core/config.py @@ -74,6 +74,10 @@ def _int(key, default=0): 'cli_enabled': _bool('COCONUT_ADAPTER_CLI_ENABLED'), + 'slack_enabled': _bool('COCONUT_ADAPTER_SLACK_ENABLED'), + 'slack_bot_token': _get('COCONUT_SLACK_BOT_TOKEN', ''), + 'slack_channel_id': _get('COCONUT_SLACK_CHANNEL_ID', ''), + 'webhook_enabled': _bool('COCONUT_ADAPTER_WEBHOOK_ENABLED'), 'webhook_port': _int('COCONUT_WEBHOOK_PORT', 8000), 'webhook_path': _get('COCONUT_WEBHOOK_PATH', '/webhook/inbound'), diff --git a/scripts/test/test-slack.sh b/scripts/test/test-slack.sh new file mode 100644 index 0000000..23b1c4c --- /dev/null +++ b/scripts/test/test-slack.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# T039: Slack adapter tests — mock API, no real Slack connection needed +set -euo pipefail +cd "$(dirname "$0")/../.." + +PASS=0; FAIL=0 +pass() { PASS=$((PASS+1)); echo " PASS: $1"; } +fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } + +echo "=== Slack Adapter Tests ===" + +# Test 1: Import +echo "Test 1: Slack adapter imports..." +python3 -c " +from adapters.slack_adapter import SlackAdapter +print('OK') +" && pass "slack adapter imports" || fail "slack adapter imports" + +# Test 2: Config loading includes Slack fields +echo "Test 2: Config includes slack fields..." +python3 -c " +import os +os.environ['COCONUT_ADAPTER_SLACK_ENABLED'] = 'true' +os.environ['COCONUT_SLACK_BOT_TOKEN'] = 'xoxb-test-token' +os.environ['COCONUT_SLACK_CHANNEL_ID'] = 'C1234567890' +from core.config import load +cfg = load() +assert cfg['slack_enabled'] is True +assert cfg['slack_bot_token'] == 'xoxb-test-token' +assert cfg['slack_channel_id'] == 'C1234567890' +print('OK') +" && pass "slack config loaded" || fail "slack config loaded" + +# Test 3: Adapter initialization +echo "Test 3: Adapter initialization..." +python3 -c " +from adapters.slack_adapter import SlackAdapter + +cfg = { + 'slack_bot_token': 'xoxb-test', + 'slack_channel_id': 'C123', + 'name': 'TestBot', + 'tagline': 'Test', +} +adapter = SlackAdapter(cfg) +assert adapter.name == 'slack' +assert adapter.bot_token == 'xoxb-test' +assert adapter.channel_id == 'C123' +assert adapter._seen_ts == set() +print('OK') +" && pass "adapter initializes" || fail "adapter initializes" + +# Test 4: Timestamp conversion +echo "Test 4: Timestamp conversion..." +python3 -c " +from adapters.slack_adapter import SlackAdapter + +# Slack ts format: epoch.sequence +assert SlackAdapter._ts_to_iso('1711900000.000100') == '2024-03-31T15:46:40Z' +assert SlackAdapter._ts_to_iso('invalid') == '' +assert SlackAdapter._ts_to_iso('') == '' +print('OK') +" && pass "timestamp conversion" || fail "timestamp conversion" + +# Test 5: Mock API polling with fake HTTP server +echo "Test 5: Mock API polling..." +python3 -c " +import json, threading, time +from http.server import HTTPServer, BaseHTTPRequestHandler +from adapters.slack_adapter import SlackAdapter + +# Mock Slack API server +class MockSlack(BaseHTTPRequestHandler): + def do_GET(self): + if 'conversations.history' in self.path: + # Slack returns newest first + resp = { + 'ok': True, + 'messages': [ + {'ts': '9999999999.000003', 'subtype': 'channel_join', 'user': 'U333', 'text': 'joined'}, + {'ts': '9999999999.000002', 'user': 'U222', 'text': 'How are you?'}, + {'ts': '9999999999.000001', 'user': 'U111', 'text': 'Hello coconut'}, + ] + } + elif 'users.info' in self.path: + resp = { + 'ok': True, + 'user': {'profile': {'display_name': 'TestUser', 'real_name': 'Test User'}} + } + elif 'auth.test' in self.path: + resp = {'ok': True, 'user_id': 'UBOT'} + else: + resp = {'ok': False, 'error': 'unknown_method'} + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(resp).encode()) + def log_message(self, fmt, *args): + pass + +server = HTTPServer(('127.0.0.1', 18997), MockSlack) +threading.Thread(target=server.serve_forever, daemon=True).start() +time.sleep(0.2) + +# Patch SLACK_API to use our mock +import adapters.slack_adapter as sa +sa.SLACK_API = 'http://127.0.0.1:18997' + +cfg = { + 'slack_bot_token': 'xoxb-test', + 'slack_channel_id': 'C123', + 'name': 'Coconut', +} +adapter = SlackAdapter(cfg) +adapter._last_ts = '0' # Get all messages + +msgs = adapter.poll() +# Should get 2 messages (channel_join subtype filtered out) +assert len(msgs) == 2, f'Expected 2, got {len(msgs)}: {[m.text for m in msgs]}' +assert msgs[0].text == 'Hello coconut' +assert msgs[1].text == 'How are you?' +assert msgs[0].sender == 'TestUser' + +# Second poll should return nothing (dedup) +msgs2 = adapter.poll() +assert len(msgs2) == 0, f'Expected 0 on second poll, got {len(msgs2)}' + +server.shutdown() +print('OK') +" && pass "mock API polling works" || fail "mock API polling works" + +# Test 6: Send via mock API +echo "Test 6: Mock API send..." +python3 -c " +import json, threading, time +from http.server import HTTPServer, BaseHTTPRequestHandler +from adapters.slack_adapter import SlackAdapter + +received = [] + +class MockSlack(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get('Content-Length', 0)) + body = json.loads(self.rfile.read(length)) + received.append(body) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({'ok': True, 'ts': '123.456'}).encode()) + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps({'ok': True, 'user_id': 'UBOT'}).encode()) + def log_message(self, fmt, *args): + pass + +server = HTTPServer(('127.0.0.1', 18998), MockSlack) +threading.Thread(target=server.serve_forever, daemon=True).start() +time.sleep(0.2) + +import adapters.slack_adapter as sa +sa.SLACK_API = 'http://127.0.0.1:18998' + +cfg = { + 'slack_bot_token': 'xoxb-test', + 'slack_channel_id': 'C123', + 'name': 'Coconut', + 'tagline': 'AI Advisor', + 'emoji': '🌴', +} +adapter = SlackAdapter(cfg) +adapter.send('Hello from coconut!') + +assert len(received) == 1, f'Expected 1 send, got {len(received)}' +assert received[0]['channel'] == 'C123' +assert 'Hello from coconut!' in received[0]['text'] +assert 'Coconut' in received[0]['text'] + +server.shutdown() +print('OK') +" && pass "send via mock API" || fail "send via mock API" + +# Test 7: Bot self-message filtering +echo "Test 7: Bot self-message filtering..." +python3 -c " +import json, threading, time +from http.server import HTTPServer, BaseHTTPRequestHandler +from adapters.slack_adapter import SlackAdapter + +class MockSlack(BaseHTTPRequestHandler): + def do_GET(self): + if 'conversations.history' in self.path: + resp = { + 'ok': True, + 'messages': [ + {'ts': '8888888888.000001', 'user': 'UBOT', 'text': 'I am the bot'}, + {'ts': '8888888888.000002', 'user': 'U111', 'text': 'Real user msg'}, + ] + } + elif 'auth.test' in self.path: + resp = {'ok': True, 'user_id': 'UBOT'} + elif 'users.info' in self.path: + resp = {'ok': True, 'user': {'profile': {'display_name': 'Human'}}} + else: + resp = {'ok': False} + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(resp).encode()) + def log_message(self, fmt, *args): + pass + +server = HTTPServer(('127.0.0.1', 18999), MockSlack) +threading.Thread(target=server.serve_forever, daemon=True).start() +time.sleep(0.2) + +import adapters.slack_adapter as sa +sa.SLACK_API = 'http://127.0.0.1:18999' + +cfg = {'slack_bot_token': 'xoxb-test', 'slack_channel_id': 'C123', 'name': 'Bot'} +adapter = SlackAdapter(cfg) +adapter._last_ts = '0' + +msgs = adapter.poll() +assert len(msgs) == 1, f'Expected 1 (bot msg filtered), got {len(msgs)}' +assert msgs[0].text == 'Real user msg' + +server.shutdown() +print('OK') +" && pass "bot self-messages filtered" || fail "bot self-messages filtered" + +# Test 8: Coconut.py loads slack adapter +echo "Test 8: Integration — coconut.py loads slack..." +python3 -c " +import ast +with open('coconut.py') as f: + tree = ast.parse(f.read()) +# Check that 'slack_enabled' appears in the source +with open('coconut.py') as f: + src = f.read() +assert 'slack_enabled' in src, 'coconut.py must check slack_enabled' +assert 'SlackAdapter' in src, 'coconut.py must import SlackAdapter' +print('OK') +" && pass "coconut.py loads slack adapter" || fail "coconut.py loads slack adapter" + +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] || exit 1