mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-04 16:42:35 +00:00
Correlation token now emits [ow:id]; legacy [ocw:id] replies still parse. Invite hints and docstrings say @OpenWorker.
198 lines
6.7 KiB
Python
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]
|