From 6dce6e79af5599e655dd07102c6e666cb36f2419 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sat, 15 Aug 2026 02:50:53 +0500 Subject: [PATCH 1/2] Add slack-voice-operator ability Voice-first Slack companion with LLM-powered channel summaries, proactive @mention alerts via background daemon, natural name/channel resolution, and voice message sending with confirmation. --- community/slack-voice-operator/README.md | 98 ++++ community/slack-voice-operator/__init__.py | 0 community/slack-voice-operator/background.py | 129 +++++ community/slack-voice-operator/main.py | 525 +++++++++++++++++++ 4 files changed, 752 insertions(+) create mode 100644 community/slack-voice-operator/README.md create mode 100644 community/slack-voice-operator/__init__.py create mode 100644 community/slack-voice-operator/background.py create mode 100644 community/slack-voice-operator/main.py diff --git a/community/slack-voice-operator/README.md b/community/slack-voice-operator/README.md new file mode 100644 index 00000000..34b717d3 --- /dev/null +++ b/community/slack-voice-operator/README.md @@ -0,0 +1,98 @@ +# Slack Voice Operator + +A voice-first Slack companion for OpenHome. Read and summarise channel activity, catch @mentions, and send messages — all hands-free, with LLM-powered summaries instead of raw message dumps. + +## What makes it different from Alexa + +| Feature | Alexa Slack Skill | Slack Voice Operator | +|---------|------------------|---------------------| +| Message reading | Reads messages verbatim | LLM condenses to 2–3 sentence summary | +| @mention alerts | Reads all messages | Proactive interrupt only for @mentions, urgency-scored | +| Recipient lookup | Exact handle required | Natural name ("message Jake") fuzzy-matched | +| Channel resolution | Exact channel name | "my product channel" → LLM-matched | +| Background monitoring | None | Daemon polls every 10 min, interrupts only on new mentions | + +## Setup + +### 1. Create a Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch** +2. Choose your workspace + +### 2. Add OAuth Scopes + +Under **OAuth & Permissions → Bot Token Scopes**, add: + +| Scope | Purpose | +|-------|---------| +| `channels:history` | Read public channel messages | +| `channels:read` | List public channels | +| `groups:history` | Read private channel messages | +| `groups:read` | List private channels | +| `im:history` | Read direct messages | +| `im:read` | List direct message conversations | +| `chat:write` | Send messages | +| `users:read` | Look up workspace members | + +### 3. Install & Get Token + +1. **Install to Workspace** (button on the OAuth page) +2. Copy the **Bot User OAuth Token** (`xoxb-...`) +3. In OpenHome platform settings, add a key: `slack_bot_token` = your token + +### 4. Invite the Bot to Channels + +In each Slack channel you want the ability to read, type: +``` +/invite @your-bot-name +``` + +### 5. First Voice Run + +Say any trigger phrase — the ability walks you through a one-time setup: finding your user ID by display name, picking channels to watch for background mention alerts. + +## Trigger Phrases + +- `check my Slack` / `any Slack messages` +- `any mentions` / `did anyone ping me` +- `what's new in Slack` / `what did I miss on Slack` +- `summarize #engineering` / `what happened in product` +- `message Jake on Slack: I'll be 5 minutes late` +- `send a Slack message to #general` +- `list my Slack channels` +- `change my Slack settings` + +## Example Conversations + +**Checking mentions:** +> "Any mentions?" +> → "You have 2 mentions in the last 24 hours. Alex pinged you in #engineering asking for a review on PR 47. Sara asked in #product when the design spec will be ready." + +**Channel summary:** +> "What's happening in engineering?" +> → "Here's what's happening in #engineering: The team decided to delay the v2 release by one sprint. There's a blocker on the auth service — Ben is investigating. Three PRs are waiting for review." + +**Sending a message:** +> "Message Jake: I'll be a few minutes late to standup." +> → "Sending to Jake Smith: 'I'll be a few minutes late to standup' — shall I send it?" +> "Yes." +> → "Sent." + +**Background interrupt (no trigger needed):** +> "Heads up — you have 1 urgent Slack mention in #engineering. Say 'check my Slack' for details." + +## Storage + +All data is persisted in context storage under key `slack_voice_operator`: +- `slack_user_id` — your Slack User ID (resolved by display name on first run) +- `watch_channels` — channel IDs monitored by the background daemon +- `channel_cache` — list of all channels the bot has access to +- `user_cache` — workspace member list for name resolution +- `last_mention_ts` — timestamp of the last processed @mention + +## Notes + +- The background daemon polls every 10 minutes. It only interrupts for @mentions, never for general channel activity. +- `users.list` fetches up to 200 members. For large workspaces, name matching uses the most common names. If a name isn't found, try the exact Slack display name. +- The bot must be **invited** to each channel (`/invite @bot`) — it cannot read channels it's not a member of. +- This ability uses a **Bot Token** (`xoxb-`). User tokens (`xoxp-`) also work if you prefer to send messages as yourself. diff --git a/community/slack-voice-operator/__init__.py b/community/slack-voice-operator/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/slack-voice-operator/background.py b/community/slack-voice-operator/background.py new file mode 100644 index 00000000..5c498d14 --- /dev/null +++ b/community/slack-voice-operator/background.py @@ -0,0 +1,129 @@ +import requests +from datetime import datetime, timezone + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +STORAGE_KEY = "slack_voice_operator" +SLACK_API = "https://slack.com/api" +POLL_INTERVAL = 600.0 +ERROR_SLEEP = 120.0 + + +class SlackMentionMonitor(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + background_daemon_mode: bool = False + + # Do not change following tag of register capability + # {{register capability}} + + def does_match(self, text: str) -> bool: + return False + + def call(self, worker: AgentWorker, background_daemon_mode: bool): + self.background_daemon_mode = background_daemon_mode + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.watch_loop()) + + async def watch_loop(self): + self.capability_worker.resume_normal_flow() + while True: + try: + await self._poll_mentions() + except Exception as e: + self.worker.editor_logging_handler.error(f"[SlackMonitor] Poll error: {e}") + await self.worker.session_tasks.sleep(ERROR_SLEEP) + continue + await self.worker.session_tasks.sleep(POLL_INTERVAL) + + async def _poll_mentions(self): + token = self.capability_worker.get_api_keys("slack_bot_token") or "" + if not token: + return + + stored = self.capability_worker.get_single_key(STORAGE_KEY) + if not stored or not stored.get("slack_user_id"): + return + + user_id = stored["slack_user_id"] + watch_channels = stored.get("watch_channels", []) + last_ts = stored.get("last_mention_ts", "0") + channel_cache = stored.get("channel_cache", []) + + if not watch_channels: + return + + mentions = [] + for ch_id in watch_channels: + result = self._slack_api("conversations.history", token, params={ + "channel": ch_id, + "oldest": last_ts, + "limit": 50, + }) + if not result.get("ok"): + self.worker.editor_logging_handler.warning( + f"[SlackMonitor] history error for {ch_id}: {result.get('error', 'unknown')}" + ) + continue + for msg in result.get("messages", []): + text = msg.get("text", "") + if f"<@{user_id}>" in text: + ch_name = next((c["name"] for c in channel_cache if c["id"] == ch_id), ch_id) + mentions.append({ + "channel": ch_name, + "text": text, + "ts": msg.get("ts", "0"), + }) + + if not mentions: + return + + latest_ts = max(m["ts"] for m in mentions) + stored["last_mention_ts"] = latest_ts + try: + self.capability_worker.update_key(STORAGE_KEY, stored) + except Exception as e: + self.worker.editor_logging_handler.error(f"[SlackMonitor] Timestamp save error: {e!r}") + + mention_text = "\n".join(f"[#{m['channel']}] {m['text']}" for m in mentions) + urgency = self.capability_worker.text_to_text_response( + f"Rate the urgency of these Slack @mentions as HIGH, MEDIUM, or LOW. " + f"HIGH = action needed soon. Return only the label.\n\n{mention_text}" + ).strip().upper() + if urgency not in {"HIGH", "MEDIUM", "LOW"}: + urgency = "MEDIUM" + + count = len(mentions) + channels_hit = list(dict.fromkeys(m["channel"] for m in mentions)) + channel_str = " and ".join(f"#{c}" for c in channels_hit[:3]) + + if urgency == "HIGH": + spoken = ( + f"Heads up — you have {count} urgent Slack mention{'s' if count != 1 else ''} " + f"in {channel_str}. Say 'check my Slack' for details." + ) + else: + spoken = ( + f"You have {count} new Slack mention{'s' if count != 1 else ''} in {channel_str}. " + "Say 'check my Slack' whenever you're ready." + ) + + await self.capability_worker.send_interrupt_signal() + await self.capability_worker.speak(spoken) + + def _slack_api(self, endpoint: str, token: str, params: dict = None, + json_body: dict = None, method: str = "GET") -> dict: + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + url = f"{SLACK_API}/{endpoint}" + try: + if method == "POST": + resp = requests.post(url, headers=headers, json=json_body, timeout=10) + else: + resp = requests.get(url, headers=headers, params=params, timeout=10) + return resp.json() + except Exception as e: + self.worker.editor_logging_handler.error(f"[SlackMonitor] API {endpoint}: {e}") + return {} diff --git a/community/slack-voice-operator/main.py b/community/slack-voice-operator/main.py new file mode 100644 index 00000000..12e81c1c --- /dev/null +++ b/community/slack-voice-operator/main.py @@ -0,0 +1,525 @@ +import json +import requests +from datetime import datetime, timezone + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +STORAGE_KEY = "slack_voice_operator" +SLACK_API = "https://slack.com/api" + +HOTWORDS = { + "check my slack", "slack update", "slack messages", "open slack", + "what's on slack", "any slack", "slack notification", "slack notifications", + "any mentions", "did anyone ping me", "check my pings", "new in slack", + "message on slack", "send a slack", "slack message to", "send slack", + "message via slack", "dm on slack", "slack message", + "list my slack channels", "my slack channels", "slack channels", + "what's in slack", "what happened in slack", "what did i miss on slack", + "change my slack", "slack settings", "update my slack", +} + +EXIT_WORDS = {"stop", "done", "exit", "quit", "bye", "cancel", "nothing"} +EXIT_PHRASES = {"that's all", "thats all", "no thanks", "never mind"} + +INTENTS = {"MENTIONS", "SUMMARY", "SEND", "CHANNELS", "SETUP", "EXIT"} + + +class SlackVoiceOperator(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # Do not change following tag of register capability + # {{register capability}} + + def does_match(self, text: str) -> bool: + text_lower = text.lower() + return any(h in text_lower for h in HOTWORDS) + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self._run()) + + async def _run(self): + try: + trigger = await self.capability_worker.wait_for_complete_transcription() + + token = self.capability_worker.get_api_keys("slack_bot_token") or "" + if not token: + await self.capability_worker.speak( + "I need a Slack bot token to get started. " + "Create a Slack app at api dot slack dot com, add the required scopes, " + "install it to your workspace, then save the bot token in OpenHome settings as slack underscore bot underscore token." + ) + return + + config = self._load_config() + fresh_setup = not (config and config.get("slack_user_id")) + + if fresh_setup: + config = await self._handle_setup(config or {}, token) + if not config: + return + else: + if trigger and not self._is_exit(trigger): + intent = self._classify_intent(trigger, config) + if intent == "EXIT": + await self.capability_worker.speak("Sure, I'll let you know if anything important comes in.") + return + await self._dispatch(intent, trigger, config, token) + + while True: + reply = await self.capability_worker.user_response() + if not reply or self._is_exit(reply): + await self.capability_worker.speak("Got it. I'll ping you if any important mentions come in.") + break + intent = self._classify_intent(reply, config) + if intent == "EXIT": + await self.capability_worker.speak("Got it. I'll ping you if any important mentions come in.") + break + await self._dispatch(intent, reply, config, token) + + except Exception as e: + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] Error: {e}") + finally: + self.capability_worker.resume_normal_flow() + + # ── Setup ────────────────────────────────────────────────────────────── + + async def _handle_setup(self, config: dict, token: str) -> dict: + user_name = await self._load_user_name() + if not user_name: + await self.capability_worker.speak("What's your name?") + reply = await self.capability_worker.user_response() + if reply: + user_name = self.capability_worker.text_to_text_response( + f"Extract the first name from: '{reply}'. Return only the name." + ).strip() + config["user_name"] = user_name + + greeting = f"Hi {user_name}! " if user_name else "" + await self.capability_worker.speak( + f"{greeting}Let me connect to your Slack. " + "What's your display name — the name shown on your Slack profile?" + ) + name_reply = await self.capability_worker.user_response() + if not name_reply: + await self.capability_worker.speak("I didn't catch that. Try saying 'open Slack' to start again.") + return None + + await self.capability_worker.speak("One moment while I look you up.") + users = self._fetch_users(token) + matched_user = self._fuzzy_match_user(name_reply, users) + if not matched_user: + await self.capability_worker.speak( + f"I couldn't find anyone named {name_reply} in your workspace. " + "Make sure the name matches your Slack profile exactly, then try again." + ) + return None + + config["slack_user_id"] = matched_user["id"] + config["user_cache"] = users + + channels = self._fetch_all_channels(token) + config["channel_cache"] = channels + + if not channels: + await self.capability_worker.speak( + "I connected to Slack but couldn't find any channels. " + "Make sure the bot is invited to at least one channel, then try again." + ) + return None + + sample = ", ".join(f"#{c['name']}" for c in channels[:5]) + suffix = f" and {len(channels) - 5} more" if len(channels) > 5 else "" + await self.capability_worker.speak( + f"Found {len(channels)} channel{'s' if len(channels) != 1 else ''} — " + f"{sample}{suffix}. " + "Which channels should I watch for mentions in the background? " + "Say the names, or say 'all' to watch everything." + ) + watch_reply = await self.capability_worker.user_response() + if not watch_reply or "all" in (watch_reply or "").lower(): + watch = channels[:10] + else: + watch = self._match_channels_from_utterance(watch_reply, channels) + if not watch: + watch = channels[:3] + + config["watch_channels"] = [c["id"] for c in watch] + config["watch_channel_names"] = [c["name"] for c in watch] + config["last_mention_ts"] = str(datetime.now(timezone.utc).timestamp()) + + watch_names = ", ".join(f"#{n}" for n in config["watch_channel_names"]) + await self.capability_worker.speak( + f"All set. I'll watch {watch_names} for mentions. What would you like to check?" + ) + + self._save_config(config) + return config + + # ── Intent dispatch ──────────────────────────────────────────────────── + + async def _dispatch(self, intent: str, utterance: str, config: dict, token: str): + if intent == "MENTIONS": + await self._handle_mentions(config, token) + elif intent == "SUMMARY": + await self._handle_summary(utterance, config, token) + elif intent == "SEND": + await self._handle_send(utterance, config, token) + elif intent == "CHANNELS": + await self._handle_channels(config, token) + elif intent == "SETUP": + updated = await self._handle_setup(config, token) + if updated: + config.update(updated) + else: + await self.capability_worker.speak( + "I can check your mentions, summarize a channel, send a message, or list your channels." + ) + + # ── Mentions ─────────────────────────────────────────────────────────── + + async def _handle_mentions(self, config: dict, token: str): + user_id = config.get("slack_user_id", "") + watch_channels = config.get("watch_channels", []) + channel_cache = config.get("channel_cache", []) + + if not watch_channels: + await self.capability_worker.speak( + "You don't have any channels configured yet. Say 'change my Slack settings' to set them up." + ) + return + + lookback = str(datetime.now(timezone.utc).timestamp() - 86400) + mentions = [] + for ch_id in watch_channels: + result = self._slack_api("conversations.history", token, params={ + "channel": ch_id, "oldest": lookback, "limit": 50 + }) + if not result.get("ok"): + continue + for msg in result.get("messages", []): + text = msg.get("text", "") + if f"<@{user_id}>" in text: + ch_name = next((c["name"] for c in channel_cache if c["id"] == ch_id), ch_id) + mentions.append({"channel": ch_name, "text": text}) + + if not mentions: + await self.capability_worker.speak("No mentions in the last 24 hours. You're all clear.") + return + + raw = "\n".join(f"[#{m['channel']}] {m['text']}" for m in mentions) + summary = self.capability_worker.text_to_text_response( + f"Summarize these Slack @mentions for the user. " + f"Group by channel. Note who mentioned them and what it's about. " + f"Be concise — 2 to 4 sentences max. Omit raw Slack user IDs.\n\n{raw}" + ) + count = len(mentions) + await self.capability_worker.speak( + f"You have {count} mention{'s' if count != 1 else ''} in the last 24 hours. {summary}" + ) + + # ── Summary ──────────────────────────────────────────────────────────── + + async def _handle_summary(self, utterance: str, config: dict, token: str): + channels = config.get("channel_cache", []) + if not channels: + await self.capability_worker.speak("Let me refresh your channel list first.") + channels = self._fetch_all_channels(token) + config["channel_cache"] = channels + self._save_config(config) + + if not channels: + await self.capability_worker.speak( + "I couldn't find any channels. Check that your bot is invited to at least one channel." + ) + return + + channel_list = ", ".join(c["name"] for c in channels) + target_name = self.capability_worker.text_to_text_response( + f"From this request: '{utterance}', identify which Slack channel the user wants to summarize. " + f"Available channels: {channel_list}. " + f"Return only the exact channel name from the list, or NONE if not mentioned." + ).strip().lower().lstrip("#") + + if target_name == "none" or not target_name: + await self.capability_worker.speak("Which channel would you like me to summarize?") + reply = await self.capability_worker.user_response() + if not reply: + return + target_name = reply.lower().strip().lstrip("#") + + channel = self._fuzzy_match_channel(target_name, channels) + if not channel: + await self.capability_worker.speak( + f"I couldn't match '{target_name}' to a channel. Try using the full channel name." + ) + return + + result = self._slack_api("conversations.history", token, params={ + "channel": channel["id"], "limit": 25 + }) + if not result.get("ok"): + err = result.get("error", "unknown") + await self.capability_worker.speak( + f"I couldn't read #{channel['name']}. " + "Make sure the bot is invited to that channel." + ) + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] history error: {err}") + return + + messages = [m for m in result.get("messages", []) if m.get("text")] + if not messages: + await self.capability_worker.speak(f"#{channel['name']} has been quiet — no recent messages.") + return + + raw = "\n".join(f"- {m['text']}" for m in reversed(messages)) + summary = self.capability_worker.text_to_text_response( + f"Summarize this Slack channel activity in 2 to 3 sentences. " + f"Focus on decisions made, blockers raised, and action items. " + f"Skip small talk and greetings. Omit raw Slack user IDs.\n\n{raw}" + ) + await self.capability_worker.speak(f"Here's what's happening in #{channel['name']}: {summary}") + + # ── Send ─────────────────────────────────────────────────────────────── + + async def _handle_send(self, utterance: str, config: dict, token: str): + channels = config.get("channel_cache", []) + + extracted = self.capability_worker.text_to_text_response( + f"From this request, extract the Slack recipient and the message to send. " + f"Recipient can be a person's name or a channel (e.g. #general). " + f"Return JSON only — no markdown: {{\"recipient\": \"...\", \"message\": \"...\"}}\n" + f"Request: {utterance}" + ).strip() + + try: + parsed = json.loads(extracted) + recipient = parsed.get("recipient", "").strip() + message = parsed.get("message", "").strip() + except Exception: + recipient = "" + message = "" + + if not recipient or not message: + await self.capability_worker.speak("Who should I message, and what should I say?") + follow_up = await self.capability_worker.user_response() + if not follow_up: + return + extracted2 = self.capability_worker.text_to_text_response( + f"Extract Slack recipient and message from: '{follow_up}'. " + f"Return JSON only: {{\"recipient\": \"...\", \"message\": \"...\"}}" + ).strip() + try: + parsed2 = json.loads(extracted2) + recipient = parsed2.get("recipient", "").strip() + message = parsed2.get("message", "").strip() + except Exception: + pass + + if not recipient or not message: + await self.capability_worker.speak("I couldn't work out who to message or what to say. Please try again.") + return + + target_id = None + display_name = recipient + + if recipient.startswith("#"): + clean = recipient.lstrip("#").lower() + matched_ch = self._fuzzy_match_channel(clean, channels) + if matched_ch: + target_id = matched_ch["id"] + display_name = f"#{matched_ch['name']}" + else: + user_cache = config.get("user_cache") or [] + if not user_cache: + user_cache = self._fetch_users(token) + config["user_cache"] = user_cache + self._save_config(config) + matched_user = self._fuzzy_match_user(recipient, user_cache) + if matched_user: + target_id = matched_user["id"] + display_name = matched_user["name"] + + if not target_id: + await self.capability_worker.speak( + f"I couldn't find '{recipient}' in your workspace. " + "Try using their exact Slack display name or say the channel name." + ) + return + + confirmed = await self.capability_worker.run_confirmation_loop( + f"Sending to {display_name}: '{message}' — shall I send it?" + ) + if not confirmed: + await self.capability_worker.speak("Message cancelled.") + return + + result = self._slack_api("chat.postMessage", token, method="POST", json_body={ + "channel": target_id, + "text": message, + }) + if result.get("ok"): + await self.capability_worker.speak("Sent.") + else: + err = result.get("error", "unknown error") + await self.capability_worker.speak(f"Couldn't send the message: {err}.") + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] postMessage error: {err}") + + # ── Channels ─────────────────────────────────────────────────────────── + + async def _handle_channels(self, config: dict, token: str): + channels = self._fetch_all_channels(token) + if channels: + config["channel_cache"] = channels + self._save_config(config) + + if not channels: + await self.capability_worker.speak("I couldn't retrieve your channel list. Check that your bot token is valid.") + return + + names = ", ".join(f"#{c['name']}" for c in channels[:10]) + suffix = f" — and {len(channels) - 10} more" if len(channels) > 10 else "" + await self.capability_worker.speak( + f"You're in {len(channels)} channel{'s' if len(channels) != 1 else ''}: {names}{suffix}." + ) + + # ── Helpers ──────────────────────────────────────────────────────────── + + def _classify_intent(self, text: str, config: dict) -> str: + result = self.capability_worker.text_to_text_response( + "Classify this Slack assistant request. Return ONLY one label.\n" + "MENTIONS = checking @mentions or pings\n" + "SUMMARY = summarize a channel's recent messages\n" + "SEND = compose or send a message to someone\n" + "CHANNELS = list or refresh available channels\n" + "SETUP = change settings or re-configure\n" + "EXIT = done, stop, quit, nothing\n" + f"Request: {text}" + ).strip().upper().split()[0] + return result if result in INTENTS else "MENTIONS" + + def _is_exit(self, text: str) -> bool: + text_lower = text.lower().strip() + if any(p in text_lower for p in EXIT_PHRASES): + return True + return bool(set(text_lower.split()) & EXIT_WORDS) + + async def _load_user_name(self) -> str: + for filename in ("user_profile.md", "user_summary.md"): + try: + content = await self.capability_worker.read_file(filename, in_ability_directory=False) + if content: + name = self.capability_worker.text_to_text_response( + f"Extract the person's first name from this profile. " + f"Return only the name, or UNKNOWN if not found.\n{content}" + ).strip() + if name and name != "UNKNOWN": + return name + except Exception: + pass + return "" + + def _slack_api(self, endpoint: str, token: str, params: dict = None, + json_body: dict = None, method: str = "GET") -> dict: + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + url = f"{SLACK_API}/{endpoint}" + try: + if method == "POST": + resp = requests.post(url, headers=headers, json=json_body, timeout=10) + else: + resp = requests.get(url, headers=headers, params=params, timeout=10) + return resp.json() + except Exception as e: + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] API {endpoint}: {e}") + return {} + + def _fetch_all_channels(self, token: str) -> list: + result = self._slack_api("conversations.list", token, params={ + "types": "public_channel,private_channel", + "exclude_archived": "true", + "limit": 200, + }) + return [{"id": c["id"], "name": c["name"]} for c in result.get("channels", [])] + + def _fetch_users(self, token: str) -> list: + result = self._slack_api("users.list", token, params={"limit": 200}) + return [ + { + "id": m["id"], + "name": m.get("profile", {}).get("real_name") or m.get("name", ""), + } + for m in result.get("members", []) + if not m.get("is_bot") and not m.get("deleted") + ] + + def _resolve_user_id_by_name(self, display_name: str, token: str) -> str: + users = self._fetch_users(token) + matched = self._fuzzy_match_user(display_name, users) + return matched["id"] if matched else "" + + def _fuzzy_match_channel(self, query: str, channels: list) -> dict: + query = query.lower().strip().lstrip("#") + for c in channels: + if c["name"].lower() == query: + return c + for c in channels: + if query in c["name"].lower() or c["name"].lower() in query: + return c + if channels: + name_list = ", ".join(c["name"] for c in channels) + best = self.capability_worker.text_to_text_response( + f"Match '{query}' to the closest channel from: {name_list}. " + f"Return only the exact channel name, or NONE." + ).strip().lower() + for c in channels: + if c["name"].lower() == best: + return c + return None + + def _fuzzy_match_user(self, query: str, users: list) -> dict: + query_lower = query.lower().strip() + for u in users: + if u["name"].lower() == query_lower: + return u + for u in users: + if query_lower in u["name"].lower(): + return u + if users: + name_list = ", ".join(u["name"] for u in users[:60]) + best = self.capability_worker.text_to_text_response( + f"Match '{query}' to the closest person from: {name_list}. " + f"Return only the exact name, or NONE." + ).strip() + for u in users: + if u["name"].lower() == best.lower(): + return u + return None + + def _match_channels_from_utterance(self, utterance: str, channels: list) -> list: + name_list = ", ".join(c["name"] for c in channels) + result = self.capability_worker.text_to_text_response( + f"From this request: '{utterance}', extract all channel names the user wants to watch. " + f"Match against: {name_list}. " + f"Return a comma-separated list of exact channel names, or NONE." + ).strip() + if result.upper() == "NONE": + return [] + selected = {n.strip().lstrip("#").lower() for n in result.split(",")} + return [c for c in channels if c["name"].lower() in selected] + + def _load_config(self) -> dict: + stored = self.capability_worker.get_single_key(STORAGE_KEY) + return stored if stored else {} + + def _save_config(self, config: dict): + try: + self.capability_worker.create_key(STORAGE_KEY, config) + except Exception: + try: + self.capability_worker.update_key(STORAGE_KEY, config) + except Exception as e: + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] Save error: {e!r}") From 7bf48a384d4195bd1fc3f15204e023dd9a702c11 Mon Sep 17 00:00:00 2001 From: Muhammad Hassan Date: Sun, 23 Aug 2026 17:07:48 +0500 Subject: [PATCH 2/2] refactor: migrate slack-voice-operator to official SDK patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace get_api_keys("slack_bot_token") with get_slack_key() — platform OAuth - Replace requests HTTP calls with slack_sdk WebClient method calls - Use auth_test() to resolve user ID directly — removes display name setup question - Catch SlackApiError instead of checking result.get("ok") - Simplify setup flow and update README to reflect platform-linked auth --- community/slack-voice-operator/README.md | 41 +--- community/slack-voice-operator/background.py | 56 +++--- community/slack-voice-operator/main.py | 201 +++++++++---------- 3 files changed, 122 insertions(+), 176 deletions(-) diff --git a/community/slack-voice-operator/README.md b/community/slack-voice-operator/README.md index 34b717d3..eabc1877 100644 --- a/community/slack-voice-operator/README.md +++ b/community/slack-voice-operator/README.md @@ -14,42 +14,16 @@ A voice-first Slack companion for OpenHome. Read and summarise channel activity, ## Setup -### 1. Create a Slack App +### 1. Link Your Slack Account -1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch** -2. Choose your workspace +1. Go to [OpenHome Dashboard → Settings](https://app.openhome.com/dashboard/settings) +2. Link your Slack account -### 2. Add OAuth Scopes +That's it. No bot app to create, no scopes to configure, no token to copy. The platform handles OAuth and provides the token to the ability at runtime via `get_slack_key()`. -Under **OAuth & Permissions → Bot Token Scopes**, add: +### 2. First Voice Run -| Scope | Purpose | -|-------|---------| -| `channels:history` | Read public channel messages | -| `channels:read` | List public channels | -| `groups:history` | Read private channel messages | -| `groups:read` | List private channels | -| `im:history` | Read direct messages | -| `im:read` | List direct message conversations | -| `chat:write` | Send messages | -| `users:read` | Look up workspace members | - -### 3. Install & Get Token - -1. **Install to Workspace** (button on the OAuth page) -2. Copy the **Bot User OAuth Token** (`xoxb-...`) -3. In OpenHome platform settings, add a key: `slack_bot_token` = your token - -### 4. Invite the Bot to Channels - -In each Slack channel you want the ability to read, type: -``` -/invite @your-bot-name -``` - -### 5. First Voice Run - -Say any trigger phrase — the ability walks you through a one-time setup: finding your user ID by display name, picking channels to watch for background mention alerts. +Say any trigger phrase — the ability connects automatically, confirms the workspace name, and asks which channels to watch for background mention alerts. ## Trigger Phrases @@ -94,5 +68,4 @@ All data is persisted in context storage under key `slack_voice_operator`: - The background daemon polls every 10 minutes. It only interrupts for @mentions, never for general channel activity. - `users.list` fetches up to 200 members. For large workspaces, name matching uses the most common names. If a name isn't found, try the exact Slack display name. -- The bot must be **invited** to each channel (`/invite @bot`) — it cannot read channels it's not a member of. -- This ability uses a **Bot Token** (`xoxb-`). User tokens (`xoxp-`) also work if you prefer to send messages as yourself. +- Channels are fetched from your linked account — you'll only see channels you're already a member of. diff --git a/community/slack-voice-operator/background.py b/community/slack-voice-operator/background.py index 5c498d14..bcec3856 100644 --- a/community/slack-voice-operator/background.py +++ b/community/slack-voice-operator/background.py @@ -1,12 +1,13 @@ -import requests from datetime import datetime, timezone +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + from src.agent.capability import MatchingCapability from src.agent.capability_worker import CapabilityWorker from src.main import AgentWorker STORAGE_KEY = "slack_voice_operator" -SLACK_API = "https://slack.com/api" POLL_INTERVAL = 600.0 ERROR_SLEEP = 120.0 @@ -40,7 +41,7 @@ async def watch_loop(self): await self.worker.session_tasks.sleep(POLL_INTERVAL) async def _poll_mentions(self): - token = self.capability_worker.get_api_keys("slack_bot_token") or "" + token = self.capability_worker.get_slack_key() or "" if not token: return @@ -56,27 +57,30 @@ async def _poll_mentions(self): if not watch_channels: return + slack_client = WebClient(token=token) mentions = [] + for ch_id in watch_channels: - result = self._slack_api("conversations.history", token, params={ - "channel": ch_id, - "oldest": last_ts, - "limit": 50, - }) - if not result.get("ok"): + try: + result = slack_client.conversations_history( + channel=ch_id, + oldest=last_ts, + limit=50, + ) + for msg in result["messages"]: + text = msg.get("text", "") + if f"<@{user_id}>" in text: + ch_name = next((c["name"] for c in channel_cache if c["id"] == ch_id), ch_id) + mentions.append({ + "channel": ch_name, + "text": text, + "ts": msg.get("ts", "0"), + }) + except SlackApiError as e: self.worker.editor_logging_handler.warning( - f"[SlackMonitor] history error for {ch_id}: {result.get('error', 'unknown')}" + f"[SlackMonitor] history error for {ch_id}: {e.response.get('error', 'unknown')}" ) continue - for msg in result.get("messages", []): - text = msg.get("text", "") - if f"<@{user_id}>" in text: - ch_name = next((c["name"] for c in channel_cache if c["id"] == ch_id), ch_id) - mentions.append({ - "channel": ch_name, - "text": text, - "ts": msg.get("ts", "0"), - }) if not mentions: return @@ -113,17 +117,3 @@ async def _poll_mentions(self): await self.capability_worker.send_interrupt_signal() await self.capability_worker.speak(spoken) - - def _slack_api(self, endpoint: str, token: str, params: dict = None, - json_body: dict = None, method: str = "GET") -> dict: - headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - url = f"{SLACK_API}/{endpoint}" - try: - if method == "POST": - resp = requests.post(url, headers=headers, json=json_body, timeout=10) - else: - resp = requests.get(url, headers=headers, params=params, timeout=10) - return resp.json() - except Exception as e: - self.worker.editor_logging_handler.error(f"[SlackMonitor] API {endpoint}: {e}") - return {} diff --git a/community/slack-voice-operator/main.py b/community/slack-voice-operator/main.py index 12e81c1c..dfa0d4a3 100644 --- a/community/slack-voice-operator/main.py +++ b/community/slack-voice-operator/main.py @@ -1,13 +1,14 @@ import json -import requests from datetime import datetime, timezone +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + from src.agent.capability import MatchingCapability from src.agent.capability_worker import CapabilityWorker from src.main import AgentWorker STORAGE_KEY = "slack_voice_operator" -SLACK_API = "https://slack.com/api" HOTWORDS = { "check my slack", "slack update", "slack messages", "open slack", @@ -29,6 +30,7 @@ class SlackVoiceOperator(MatchingCapability): worker: AgentWorker = None capability_worker: CapabilityWorker = None + slack_client: WebClient = None # Do not change following tag of register capability # {{register capability}} @@ -46,40 +48,41 @@ async def _run(self): try: trigger = await self.capability_worker.wait_for_complete_transcription() - token = self.capability_worker.get_api_keys("slack_bot_token") or "" + token = self.capability_worker.get_slack_key() or "" if not token: await self.capability_worker.speak( - "I need a Slack bot token to get started. " - "Create a Slack app at api dot slack dot com, add the required scopes, " - "install it to your workspace, then save the bot token in OpenHome settings as slack underscore bot underscore token." + "Your Slack account isn't linked yet. " + "Go to app dot openhome dot com, open Settings, and link your Slack account to get started." ) return + self.slack_client = WebClient(token=token) + config = self._load_config() fresh_setup = not (config and config.get("slack_user_id")) if fresh_setup: - config = await self._handle_setup(config or {}, token) + config = await self._handle_setup(config or {}) if not config: return else: if trigger and not self._is_exit(trigger): - intent = self._classify_intent(trigger, config) + intent = self._classify_intent(trigger) if intent == "EXIT": await self.capability_worker.speak("Sure, I'll let you know if anything important comes in.") return - await self._dispatch(intent, trigger, config, token) + await self._dispatch(intent, trigger, config) while True: reply = await self.capability_worker.user_response() if not reply or self._is_exit(reply): await self.capability_worker.speak("Got it. I'll ping you if any important mentions come in.") break - intent = self._classify_intent(reply, config) + intent = self._classify_intent(reply) if intent == "EXIT": await self.capability_worker.speak("Got it. I'll ping you if any important mentions come in.") break - await self._dispatch(intent, reply, config, token) + await self._dispatch(intent, reply, config) except Exception as e: self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] Error: {e}") @@ -88,7 +91,7 @@ async def _run(self): # ── Setup ────────────────────────────────────────────────────────────── - async def _handle_setup(self, config: dict, token: str) -> dict: + async def _handle_setup(self, config: dict) -> dict: user_name = await self._load_user_name() if not user_name: await self.capability_worker.speak("What's your name?") @@ -99,36 +102,30 @@ async def _handle_setup(self, config: dict, token: str) -> dict: ).strip() config["user_name"] = user_name - greeting = f"Hi {user_name}! " if user_name else "" - await self.capability_worker.speak( - f"{greeting}Let me connect to your Slack. " - "What's your display name — the name shown on your Slack profile?" - ) - name_reply = await self.capability_worker.user_response() - if not name_reply: - await self.capability_worker.speak("I didn't catch that. Try saying 'open Slack' to start again.") - return None - - await self.capability_worker.speak("One moment while I look you up.") - users = self._fetch_users(token) - matched_user = self._fuzzy_match_user(name_reply, users) - if not matched_user: + try: + auth = self.slack_client.auth_test() + config["slack_user_id"] = auth["user_id"] + workspace = auth.get("team", "your workspace") + except SlackApiError as e: await self.capability_worker.speak( - f"I couldn't find anyone named {name_reply} in your workspace. " - "Make sure the name matches your Slack profile exactly, then try again." + "I couldn't connect to Slack. " + "Check that your account is properly linked in OpenHome settings." ) + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] auth_test: {e}") return None - config["slack_user_id"] = matched_user["id"] - config["user_cache"] = users + greeting = f"Hi {user_name}! " if user_name else "" + await self.capability_worker.speak( + f"{greeting}Connected to {workspace}. Let me fetch your channels." + ) - channels = self._fetch_all_channels(token) + channels = self._fetch_all_channels() config["channel_cache"] = channels if not channels: await self.capability_worker.speak( "I connected to Slack but couldn't find any channels. " - "Make sure the bot is invited to at least one channel, then try again." + "Make sure you're a member of at least one channel, then try again." ) return None @@ -162,17 +159,17 @@ async def _handle_setup(self, config: dict, token: str) -> dict: # ── Intent dispatch ──────────────────────────────────────────────────── - async def _dispatch(self, intent: str, utterance: str, config: dict, token: str): + async def _dispatch(self, intent: str, utterance: str, config: dict): if intent == "MENTIONS": - await self._handle_mentions(config, token) + await self._handle_mentions(config) elif intent == "SUMMARY": - await self._handle_summary(utterance, config, token) + await self._handle_summary(utterance, config) elif intent == "SEND": - await self._handle_send(utterance, config, token) + await self._handle_send(utterance, config) elif intent == "CHANNELS": - await self._handle_channels(config, token) + await self._handle_channels(config) elif intent == "SETUP": - updated = await self._handle_setup(config, token) + updated = await self._handle_setup(config) if updated: config.update(updated) else: @@ -182,7 +179,7 @@ async def _dispatch(self, intent: str, utterance: str, config: dict, token: str) # ── Mentions ─────────────────────────────────────────────────────────── - async def _handle_mentions(self, config: dict, token: str): + async def _handle_mentions(self, config: dict): user_id = config.get("slack_user_id", "") watch_channels = config.get("watch_channels", []) channel_cache = config.get("channel_cache", []) @@ -196,16 +193,18 @@ async def _handle_mentions(self, config: dict, token: str): lookback = str(datetime.now(timezone.utc).timestamp() - 86400) mentions = [] for ch_id in watch_channels: - result = self._slack_api("conversations.history", token, params={ - "channel": ch_id, "oldest": lookback, "limit": 50 - }) - if not result.get("ok"): + try: + result = self.slack_client.conversations_history( + channel=ch_id, oldest=lookback, limit=50 + ) + for msg in result["messages"]: + text = msg.get("text", "") + if f"<@{user_id}>" in text: + ch_name = next((c["name"] for c in channel_cache if c["id"] == ch_id), ch_id) + mentions.append({"channel": ch_name, "text": text}) + except SlackApiError as e: + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] history {ch_id}: {e}") continue - for msg in result.get("messages", []): - text = msg.get("text", "") - if f"<@{user_id}>" in text: - ch_name = next((c["name"] for c in channel_cache if c["id"] == ch_id), ch_id) - mentions.append({"channel": ch_name, "text": text}) if not mentions: await self.capability_worker.speak("No mentions in the last 24 hours. You're all clear.") @@ -224,17 +223,17 @@ async def _handle_mentions(self, config: dict, token: str): # ── Summary ──────────────────────────────────────────────────────────── - async def _handle_summary(self, utterance: str, config: dict, token: str): + async def _handle_summary(self, utterance: str, config: dict): channels = config.get("channel_cache", []) if not channels: await self.capability_worker.speak("Let me refresh your channel list first.") - channels = self._fetch_all_channels(token) + channels = self._fetch_all_channels() config["channel_cache"] = channels self._save_config(config) if not channels: await self.capability_worker.speak( - "I couldn't find any channels. Check that your bot is invited to at least one channel." + "I couldn't find any channels. Check that your Slack account is properly linked." ) return @@ -259,19 +258,17 @@ async def _handle_summary(self, utterance: str, config: dict, token: str): ) return - result = self._slack_api("conversations.history", token, params={ - "channel": channel["id"], "limit": 25 - }) - if not result.get("ok"): - err = result.get("error", "unknown") + try: + result = self.slack_client.conversations_history(channel=channel["id"], limit=25) + messages = [m for m in result["messages"] if m.get("text")] + except SlackApiError as e: await self.capability_worker.speak( f"I couldn't read #{channel['name']}. " - "Make sure the bot is invited to that channel." + "Make sure you're a member of that channel." ) - self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] history error: {err}") + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] history: {e}") return - messages = [m for m in result.get("messages", []) if m.get("text")] if not messages: await self.capability_worker.speak(f"#{channel['name']} has been quiet — no recent messages.") return @@ -286,7 +283,7 @@ async def _handle_summary(self, utterance: str, config: dict, token: str): # ── Send ─────────────────────────────────────────────────────────────── - async def _handle_send(self, utterance: str, config: dict, token: str): + async def _handle_send(self, utterance: str, config: dict): channels = config.get("channel_cache", []) extracted = self.capability_worker.text_to_text_response( @@ -336,7 +333,7 @@ async def _handle_send(self, utterance: str, config: dict, token: str): else: user_cache = config.get("user_cache") or [] if not user_cache: - user_cache = self._fetch_users(token) + user_cache = self._fetch_users() config["user_cache"] = user_cache self._save_config(config) matched_user = self._fuzzy_match_user(recipient, user_cache) @@ -358,27 +355,24 @@ async def _handle_send(self, utterance: str, config: dict, token: str): await self.capability_worker.speak("Message cancelled.") return - result = self._slack_api("chat.postMessage", token, method="POST", json_body={ - "channel": target_id, - "text": message, - }) - if result.get("ok"): + try: + self.slack_client.chat_postMessage(channel=target_id, text=message) await self.capability_worker.speak("Sent.") - else: - err = result.get("error", "unknown error") + except SlackApiError as e: + err = e.response.get("error", "unknown error") await self.capability_worker.speak(f"Couldn't send the message: {err}.") - self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] postMessage error: {err}") + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] postMessage: {e}") # ── Channels ─────────────────────────────────────────────────────────── - async def _handle_channels(self, config: dict, token: str): - channels = self._fetch_all_channels(token) + async def _handle_channels(self, config: dict): + channels = self._fetch_all_channels() if channels: config["channel_cache"] = channels self._save_config(config) if not channels: - await self.capability_worker.speak("I couldn't retrieve your channel list. Check that your bot token is valid.") + await self.capability_worker.speak("I couldn't retrieve your channel list. Check that your Slack account is linked.") return names = ", ".join(f"#{c['name']}" for c in channels[:10]) @@ -389,7 +383,7 @@ async def _handle_channels(self, config: dict, token: str): # ── Helpers ──────────────────────────────────────────────────────────── - def _classify_intent(self, text: str, config: dict) -> str: + def _classify_intent(self, text: str) -> str: result = self.capability_worker.text_to_text_response( "Classify this Slack assistant request. Return ONLY one label.\n" "MENTIONS = checking @mentions or pings\n" @@ -423,43 +417,32 @@ async def _load_user_name(self) -> str: pass return "" - def _slack_api(self, endpoint: str, token: str, params: dict = None, - json_body: dict = None, method: str = "GET") -> dict: - headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - url = f"{SLACK_API}/{endpoint}" + def _fetch_all_channels(self) -> list: try: - if method == "POST": - resp = requests.post(url, headers=headers, json=json_body, timeout=10) - else: - resp = requests.get(url, headers=headers, params=params, timeout=10) - return resp.json() - except Exception as e: - self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] API {endpoint}: {e}") - return {} - - def _fetch_all_channels(self, token: str) -> list: - result = self._slack_api("conversations.list", token, params={ - "types": "public_channel,private_channel", - "exclude_archived": "true", - "limit": 200, - }) - return [{"id": c["id"], "name": c["name"]} for c in result.get("channels", [])] - - def _fetch_users(self, token: str) -> list: - result = self._slack_api("users.list", token, params={"limit": 200}) - return [ - { - "id": m["id"], - "name": m.get("profile", {}).get("real_name") or m.get("name", ""), - } - for m in result.get("members", []) - if not m.get("is_bot") and not m.get("deleted") - ] - - def _resolve_user_id_by_name(self, display_name: str, token: str) -> str: - users = self._fetch_users(token) - matched = self._fuzzy_match_user(display_name, users) - return matched["id"] if matched else "" + result = self.slack_client.conversations_list( + types="public_channel,private_channel", + exclude_archived=True, + limit=200, + ) + return [{"id": c["id"], "name": c["name"]} for c in result["channels"]] + except SlackApiError as e: + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] conversations_list: {e}") + return [] + + def _fetch_users(self) -> list: + try: + result = self.slack_client.users_list(limit=200) + return [ + { + "id": m["id"], + "name": m.get("profile", {}).get("real_name") or m.get("name", ""), + } + for m in result["members"] + if not m.get("is_bot") and not m.get("deleted") + ] + except SlackApiError as e: + self.worker.editor_logging_handler.error(f"[SlackVoiceOperator] users_list: {e}") + return [] def _fuzzy_match_channel(self, query: str, channels: list) -> dict: query = query.lower().strip().lstrip("#")