Files
openworker/coworker/connectors/slack_directory.py
T
Rohit C Prasad 71b0df8ea2 Rename bot references from ocw to OpenWorker
Correlation token now emits [ow:id]; legacy [ocw:id] replies still parse.
Invite hints and docstrings say @OpenWorker.
2026-07-23 07:43:24 -07:00

198 lines
6.7 KiB
Python

"""Workspace rosters for the Slack pickers (people + channels).
Backs "find your name in a list" instead of the park→approve-only flow, and
channel-by-name instead of pasted IDs. Pure reads on scopes every install
already granted (`users:read`, `channels:read`, `groups:read`) — no consent
bump, and the roster never leaves this machine (in-memory cache, not the
SecretStore; names/ids are routing metadata, not content).
Slack API notes: `users.list` is Tier-2 (~20 req/min) and Slack's own guidance
is to cache it — one paginated sweep per workspace per TTL, filtered locally.
Private channels only appear where the bot is a MEMBER (API constraint — the
GUI words it honestly); public channels carry `is_member` so the picker can
hint "invite @OpenWorker in Slack" instead of silently failing to listen.
"""
from __future__ import annotations
import os
import time
from typing import Any, Optional
from ..secrets import SecretStore
_TTL = 900.0 # 15 min — rosters drift slowly; a Refresh affordance can force it
# users.list: Slack recommends ≤200/page. conversations.list allows 1000 — use it:
# the cold sweep is user-visible latency (a big workspace took ~11 s at 200/page).
_PAGE_LIMIT = 200
_CHANNEL_PAGE_LIMIT = 999
_MAX_PAGES = 25 # caps both sweeps — beyond that, type more letters
# (team_id, kind) → (fetched_at, rows). Module-level on purpose: survives
# request handlers but not the process — nothing roster-shaped is persisted.
_CACHE: dict[tuple[str, str], tuple[float, list[dict[str, Any]]]] = {}
def _api_base() -> str:
return os.environ.get("SLACK_API_URL", "https://slack.com/api/")
def _bot_token(secrets: SecretStore, team_id: str) -> str:
"""The workspace's bot token: per-team profile (managed relay) or the flat
default profile (manual Socket Mode — team_id "default")."""
if team_id and team_id != "default":
profile = secrets.get(f"slack:team:{team_id}") or {}
if profile.get("bot_token"):
return str(profile["bot_token"])
return str((secrets.get("slack:default") or {}).get("bot_token") or "")
def _get_pages(
token: str,
method: str,
params: dict[str, Any],
key: str,
page_limit: int = _PAGE_LIMIT,
) -> list[dict]:
"""Cursor-paginated GET; raises RuntimeError with Slack's error string."""
import httpx
rows: list[dict] = []
cursor = ""
for _ in range(_MAX_PAGES):
q = {**params, "limit": page_limit}
if cursor:
q["cursor"] = cursor
resp = httpx.get(
_api_base() + method,
params=q,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
)
data = resp.json()
if not data.get("ok"):
raise RuntimeError(str(data.get("error") or f"{method} failed"))
rows.extend(data.get(key) or [])
cursor = (data.get("response_metadata") or {}).get("next_cursor") or ""
if not cursor:
break
return rows
def _cached(team_id: str, kind: str, fetch, refresh: bool) -> list[dict[str, Any]]:
now = time.time()
hit = _CACHE.get((team_id, kind))
if hit and not refresh and now - hit[0] < _TTL:
return hit[1]
rows = fetch()
_CACHE[(team_id, kind)] = (now, rows)
return rows
def _rank(rows: list[dict], query: str, key: str, limit: int) -> list[dict]:
"""Case-insensitive substring filter; prefix matches first, then alpha."""
q = query.strip().lower()
if q:
rows = [
r for r in rows if q in r[key].lower() or q in r.get("handle", "").lower()
]
rows = sorted(
rows, key=lambda r: (not r[key].lower().startswith(q), r[key].lower())
)
return rows[: max(1, min(int(limit or 25), 100))]
def list_members(
secrets: SecretStore,
team_id: str,
query: str = "",
limit: int = 25,
*,
refresh: bool = False,
) -> dict[str, Any]:
"""Human members of the workspace: id, display name, @handle, guest flag.
Bots, deleted users, and Slackbot are filtered — they can't need allowing."""
token = _bot_token(secrets, team_id)
if not token:
return {"ok": False, "error": "workspace not connected"}
def fetch() -> list[dict[str, Any]]:
members = _get_pages(token, "users.list", {}, "members")
out = []
for m in members:
if m.get("deleted") or m.get("is_bot") or m.get("id") == "USLACKBOT":
continue
profile = m.get("profile") or {}
name = (
profile.get("display_name")
or profile.get("real_name")
or m.get("name")
or ""
)
out.append(
{
"id": m.get("id", ""),
"name": name,
"handle": m.get("name") or "",
"guest": bool(
m.get("is_restricted") or m.get("is_ultra_restricted")
),
}
)
return out
try:
rows = _cached(team_id, "members", fetch, refresh)
except Exception as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "members": _rank(rows, query, "name", limit)}
def list_channels(
secrets: SecretStore,
team_id: str,
query: str = "",
limit: int = 25,
*,
refresh: bool = False,
) -> dict[str, Any]:
"""Channels the token can see: all public ones, private only where the bot
is a member. `is_member` lets the GUI hint "invite @OpenWorker" for the rest."""
token = _bot_token(secrets, team_id)
if not token:
return {"ok": False, "error": "workspace not connected"}
def fetch() -> list[dict[str, Any]]:
chans = _get_pages(
token,
"conversations.list",
{"types": "public_channel,private_channel", "exclude_archived": "true"},
"channels",
page_limit=_CHANNEL_PAGE_LIMIT,
)
return [
{
"id": c.get("id", ""),
"name": c.get("name", ""),
"is_private": bool(c.get("is_private")),
"is_member": bool(c.get("is_member")),
}
for c in chans
if c.get("id") and c.get("name")
]
try:
rows = _cached(team_id, "channels", fetch, refresh)
except Exception as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "channels": _rank(rows, query, "name", limit)}
def clear_cache(team_id: Optional[str] = None) -> None:
"""Drop cached rosters (all teams, or one) — disconnect/reconnect hygiene."""
if team_id is None:
_CACHE.clear()
return
for key in [k for k in _CACHE if k[0] == team_id]:
del _CACHE[key]