mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-05 00:56:40 +00:00
OpenWorker: initial import
Imported from andrewyng/aisuite@1b4bbf303e (contents of its platform/ directory, hoisted to the repo root). Development history prior to this commit lives in that repository. Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""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 @ocw 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 @ocw" 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]
|
||||
Reference in New Issue
Block a user