Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ ENC_DEC_KEY="DISABLED"
SLACK_BOT_TOKEN=AKEYTODO
SLACK_WEBHOOK=WEBHOOKTHINGTODO

# Shared secret the praise-bot uses to read /api/praise-bot/config
# (falls back to BACKEND_PRAISE_TOKEN if unset)
BACKEND_BOT_CONFIG_TOKEN=CHANGEME

6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,9 @@ Read-only admin aggregation for the frontend `/admin/feedback` dashboard. New bl
- `GET /api/admin/feedback/peer` — peer-to-peer `feedback` collection, newest first, giver/receiver names resolved via a one-shot `fetch_users()` directory (giver hidden when `is_anonymous`); light `by_relationship`/`by_role` summary.
- `GET /api/admin/feedback/onboarding` — `onboarding_feedbacks` collection, newest first, + rating & ease distributions. Keeps `clientInfo.userAgent`, drops the IP. **Field shape (load-bearing for the admin UI)**: maps `contactForFollowup` → `contact: {willing, firstName, email}` (the form stores `firstName`+`willing`, NOT `name` — don't revert to `name`); `overallRating` 0 = unrated (skipped in the average); timestamps are normalized to ISO, stripping a `__Timestamp__` export sentinel (see `scripts/sync_hackathons_from_csv.py`) if present.
Event surveys reuse the `api/surveys` admin routes (`/responses`, `/summary`, `/overview`) — not duplicated here.

## Praise-bot remote config (`api/praisebot/`, `praise_bot_config` collection)
Config store for the Slack praise-bot (repo `ohack-slack-bot/praise-bot`): the bot polls it every ~60s so channels/repos/crons/toggles are managed from `/admin/praise-bot` instead of Fly.io env vars. Blueprint `api/praisebot/praisebot_views.py` + `praisebot_service.py`.
- Doc types in one collection: fixed doc id `global` (dry_run/llm_enabled/timezone), `github_watcher` (source `mode: hackathon|repos`, digest + optional rollup crons), `calendar_reminder`, and singleton `community` (intro matchmaker + weekly digest). Audit fields `created_at/updated_at/updated_by` on every doc.
- `GET /api/praise-bot/config` — bot-facing, authed via `X-Api-Key` against `BACKEND_BOT_CONFIG_TOKEN` (falls back to `BACKEND_PRAISE_TOKEN`) through the shared `common/utils/api_key.py:check_api_key` (hmac.compare_digest; new code should use this instead of the inline checks in messages_views). Returns `configured: false` when the collection is empty → bot uses its env defaults.
- `GET/POST /api/praise-bot/admin/config`, `PATCH/DELETE /api/praise-bot/admin/config/<doc_id>` — `volunteer.admin`-gated. Validation is whitelist-per-type (`_ALLOWED_KEYS` — the enforcement point that keeps secrets out of docs); crons validated as 5 fields (bot re-validates with `cron.validate()`); repos normalized to `owner/repo`; `global` is upsert-only (no DELETE); `community` is a singleton (POST 400s if one exists). `source.orgs` is accepted/stored but ignored by the bot until org-watching ships. 15s TTL cache on the assembled config, cleared on mutation. Tests: `api/praisebot/tests/` (mockfirestore, run with `ENVIRONMENT=test`).
2 changes: 2 additions & 0 deletions api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ def add_headers(response):
from api.email_templates import email_templates_views
from api.surveys import surveys_views
from api.feedback import feedback_views
from api.praisebot import praisebot_views

app.register_blueprint(messages_views.bp)
app.register_blueprint(exception_views.bp)
Expand All @@ -213,5 +214,6 @@ def add_headers(response):
app.register_blueprint(email_templates_views.bp)
app.register_blueprint(surveys_views.bp)
app.register_blueprint(feedback_views.bp)
app.register_blueprint(praisebot_views.bp)

return app
Empty file added api/praisebot/__init__.py
Empty file.
325 changes: 325 additions & 0 deletions api/praisebot/praisebot_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
"""Service layer for praise-bot configuration.

Stores bot behavior (channels, repos, cron schedules, feature toggles) in the
`praise_bot_config` Firestore collection so the Slack bot can be reconfigured
from /admin without touching Fly.io env vars. Secrets never live here — the
per-type key whitelists below are the enforcement point.

Doc types:
- global (fixed doc id "global": dry_run, llm_enabled, timezone)
- github_watcher (repo-tpm digest/rollup config)
- calendar_reminder (Google Calendar ICS reminder config)
- community (#introductions matchmaker + weekly community digest)
"""
import re
import threading
import time
import uuid
from datetime import datetime, timezone

from db.db import get_db
from common.log import get_logger

logger = get_logger(__name__)

COLLECTION = "praise_bot_config"
GLOBAL_DOC_ID = "global"

# 5 whitespace-separated cron fields; the bot re-validates with cron.validate()
_CRON_RE = re.compile(r"^\s*\S+\s+\S+\s+\S+\s+\S+\s+\S+\s*$")
_REPO_SHORTHAND_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
_REPO_URL_RE = re.compile(r"github\.com/([\w.-]+)/([\w.-]+)", re.IGNORECASE)

_ALLOWED_KEYS = {
"global": {"dry_run", "llm_enabled", "timezone"},
"github_watcher": {"name", "enabled", "source", "digest", "rollup", "dry_run"},
"calendar_reminder": {
"name", "enabled", "calendar_id", "channels", "lead_minutes",
"events_page_url", "poll_cron",
},
"community": {
"name", "enabled", "intro_channel", "matchmaker", "digest",
"lookback_days", "dry_run",
},
}
DOC_TYPES = set(_ALLOWED_KEYS.keys())

# Tiny last-good cache so the bot's 60s poll doesn't hammer Firestore
_cache_lock = threading.Lock()
_cache = {"value": None, "at": 0.0}
_CACHE_TTL_SECONDS = 15


def _now_iso():
return datetime.now(timezone.utc).isoformat()


def _filter_payload(doc_type, json_in):
allowed = _ALLOWED_KEYS[doc_type]
return {k: v for k, v in (json_in or {}).items() if k in allowed}


def _normalize_repo(repo):
"""Accept 'owner/repo' or a full GitHub URL; return 'owner/repo' or None."""
if not isinstance(repo, str):
return None
repo = repo.strip()
if _REPO_SHORTHAND_RE.match(repo):
return repo
m = _REPO_URL_RE.search(repo)
if m:
return f"{m.group(1)}/{m.group(2).removesuffix('.git')}"
return None


def _normalize_channels(channels):
"""Accept a list or comma-separated string of channel names/IDs."""
if isinstance(channels, str):
channels = channels.split(",")
if not isinstance(channels, list):
return []
return [c.strip().lstrip("#") for c in channels if isinstance(c, str) and c.strip()]


def _validate_cron(expr, field, errors, required=False):
if expr in (None, ""):
if required:
errors.append(f"{field} is required")
return
if not isinstance(expr, str) or not _CRON_RE.match(expr):
errors.append(f"{field} must be a 5-field cron expression")


def _validate_doc(doc_type, payload):
"""Validate + normalize a filtered payload in place. Returns error list."""
errors = []

if doc_type == "global":
for key in ("dry_run", "llm_enabled"):
if key in payload and not isinstance(payload[key], bool):
errors.append(f"{key} must be a boolean")
if "timezone" in payload and not isinstance(payload["timezone"], str):
errors.append("timezone must be a string")

elif doc_type == "github_watcher":
source = payload.get("source")
if source is not None:
if not isinstance(source, dict):
errors.append("source must be an object")
else:
mode = source.get("mode")
if mode not in ("hackathon", "repos"):
errors.append("source.mode must be 'hackathon' or 'repos'")
elif mode == "hackathon":
if not source.get("event_id"):
errors.append("source.event_id is required in hackathon mode")
else:
repos = [_normalize_repo(r) for r in source.get("repos") or []]
if not repos or None in repos:
errors.append(
"source.repos must be a non-empty list of owner/repo or GitHub URLs")
else:
source["repos"] = repos
channels = _normalize_channels(source.get("channels"))
if not channels:
errors.append("source.channels is required in repos mode")
else:
source["channels"] = channels
# Accepted and stored now, ignored by the bot until org
# watching ships — avoids a schema migration later.
if "orgs" in source and not isinstance(source["orgs"], list):
errors.append("source.orgs must be a list")
for section, cron_required in (("digest", True), ("rollup", False)):
block = payload.get(section)
if block is None:
continue
if not isinstance(block, dict):
errors.append(f"{section} must be an object")
continue
if block.get("enabled"):
_validate_cron(block.get("cron"), f"{section}.cron", errors, required=cron_required)
elif block.get("cron"):
_validate_cron(block.get("cron"), f"{section}.cron", errors)
rollup = payload.get("rollup")
if isinstance(rollup, dict) and rollup.get("enabled") and not rollup.get("channel"):
errors.append("rollup.channel is required when rollup is enabled")

elif doc_type == "calendar_reminder":
if "channels" in payload:
channels = _normalize_channels(payload["channels"])
if not channels:
errors.append("channels must be a non-empty list")
else:
payload["channels"] = channels
if "lead_minutes" in payload:
lead = payload["lead_minutes"]
if not isinstance(lead, int) or not 1 <= lead <= 240:
errors.append("lead_minutes must be an integer between 1 and 240")
_validate_cron(payload.get("poll_cron"), "poll_cron", errors)

elif doc_type == "community":
for section in ("matchmaker", "digest"):
block = payload.get(section)
if block is not None and not isinstance(block, dict):
errors.append(f"{section} must be an object")
digest = payload.get("digest")
if isinstance(digest, dict) and digest.get("enabled"):
_validate_cron(digest.get("cron"), "digest.cron", errors, required=True)
if not digest.get("channel"):
errors.append("digest.channel is required when the community digest is enabled")
matchmaker = payload.get("matchmaker")
if isinstance(matchmaker, dict) and "max_matches" in matchmaker:
mm = matchmaker["max_matches"]
if not isinstance(mm, int) or not 1 <= mm <= 10:
errors.append("matchmaker.max_matches must be an integer between 1 and 10")
if "lookback_days" in payload:
lb = payload["lookback_days"]
if not isinstance(lb, int) or not 1 <= lb <= 3650:
errors.append("lookback_days must be an integer between 1 and 3650")

return errors


def _clear_cache():
with _cache_lock:
_cache["value"] = None
_cache["at"] = 0.0


def _all_docs():
docs = []
for doc in get_db().collection(COLLECTION).stream():
adict = doc.to_dict() or {}
adict["id"] = doc.id
docs.append(adict)
return docs


def get_full_config(include_audit=False):
"""Assemble the config payload for the bot (and the admin UI).

include_audit=True keeps created_at/updated_at/updated_by on each doc.
"""
if not include_audit:
with _cache_lock:
if _cache["value"] is not None and time.time() - _cache["at"] < _CACHE_TTL_SECONDS:
return _cache["value"]

docs = _all_docs()
audit_keys = () if include_audit else ("created_at", "updated_at", "updated_by")

global_cfg = {"dry_run": False, "llm_enabled": True}
github_watchers = []
calendar_reminders = []
community = None

for doc in docs:
doc_type = doc.get("type")
cleaned = {k: v for k, v in doc.items() if k not in audit_keys and k != "type"}
if doc.get("id") == GLOBAL_DOC_ID or doc_type == "global":
cleaned.pop("id", None)
global_cfg.update(cleaned)
elif doc_type == "github_watcher":
github_watchers.append(cleaned)
elif doc_type == "calendar_reminder":
calendar_reminders.append(cleaned)
elif doc_type == "community":
community = cleaned
else:
logger.warning("Ignoring praise_bot_config doc %s with unknown type %s",
doc.get("id"), doc_type)

github_watchers.sort(key=lambda d: d.get("name") or "")
calendar_reminders.sort(key=lambda d: d.get("name") or "")

result = {
"configured": len(docs) > 0,
"global": global_cfg,
"github_watchers": github_watchers,
"calendar_reminders": calendar_reminders,
"community": community,
}

if not include_audit:
with _cache_lock:
_cache["value"] = result
_cache["at"] = time.time()
return result


def create_config_doc(json_in, actor):
doc_type = (json_in or {}).get("type")
if doc_type not in DOC_TYPES:
return {"error": f"type must be one of {sorted(DOC_TYPES)}"}, 400
if doc_type == "global":
return {"error": "use PATCH /admin/config/global for global settings"}, 400
if doc_type == "community":
existing = [d for d in _all_docs() if d.get("type") == "community"]
if existing:
return {"error": f"a community config already exists (id {existing[0]['id']}) — PATCH it instead"}, 400

payload = _filter_payload(doc_type, json_in)
errors = _validate_doc(doc_type, payload)
if errors:
return {"error": "; ".join(errors)}, 400

payload["type"] = doc_type
payload.setdefault("enabled", False)
payload["created_at"] = payload["updated_at"] = _now_iso()
payload["updated_by"] = actor

doc_id = str(uuid.uuid4())
get_db().collection(COLLECTION).document(doc_id).set(payload)
_clear_cache()
logger.info("Created praise_bot_config %s doc %s by %s", doc_type, doc_id, actor)
return {"id": doc_id}, 201


def update_config_doc(doc_id, json_in, actor):
db = get_db()

if doc_id == GLOBAL_DOC_ID:
payload = _filter_payload("global", json_in)
errors = _validate_doc("global", payload)
if errors:
return {"error": "; ".join(errors)}, 400
payload["type"] = "global"
payload["updated_at"] = _now_iso()
payload["updated_by"] = actor
db.collection(COLLECTION).document(GLOBAL_DOC_ID).set(payload, merge=True)
_clear_cache()
return {"id": GLOBAL_DOC_ID}, 200

ref = db.collection(COLLECTION).document(doc_id)
snapshot = ref.get()
if not snapshot.exists:
return {"error": "not found"}, 404
doc_type = (snapshot.to_dict() or {}).get("type")
if doc_type not in DOC_TYPES:
return {"error": f"document has unknown type {doc_type}"}, 400

payload = _filter_payload(doc_type, json_in)
if not payload:
return {"error": "no updatable fields in payload"}, 400
errors = _validate_doc(doc_type, payload)
if errors:
return {"error": "; ".join(errors)}, 400

payload["updated_at"] = _now_iso()
payload["updated_by"] = actor
ref.update(payload)
_clear_cache()
logger.info("Updated praise_bot_config doc %s by %s", doc_id, actor)
return {"id": doc_id}, 200


def delete_config_doc(doc_id):
if doc_id == GLOBAL_DOC_ID:
return {"error": "global settings cannot be deleted"}, 400
ref = get_db().collection(COLLECTION).document(doc_id)
if not ref.get().exists:
return {"error": "not found"}, 404
ref.delete()
_clear_cache()
logger.info("Deleted praise_bot_config doc %s", doc_id)
return {"id": doc_id}, 200
Loading
Loading