Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions community/slack-voice-operator/README.md
Original file line number Diff line number Diff line change
@@ -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.
Empty file.
129 changes: 129 additions & 0 deletions community/slack-voice-operator/background.py
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading