diff --git a/flexus_client_kit/ckit_automation.py b/flexus_client_kit/ckit_automation.py new file mode 100644 index 00000000..205d2afa --- /dev/null +++ b/flexus_client_kit/ckit_automation.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any, Optional + +import jsonschema +from pymongo.errors import PyMongoError + +logger = logging.getLogger(__name__) + +# Loaded by set_automation_schema_dict() from ckit_automation_v1_schema_build (authoritative) or +# set_automation_schema(path) for tests / offline fixtures. +_AUTOMATION_SCHEMA: dict | None = None + + +def set_automation_schema_dict(schema: dict) -> None: + global _AUTOMATION_SCHEMA + if not isinstance(schema, dict): + raise TypeError("set_automation_schema_dict expects dict") + _AUTOMATION_SCHEMA = schema + + +def set_automation_schema(schema_path: str) -> None: + """ + Load automation JSON Schema from disk. Fail-fast: raises if file is missing or invalid JSON. + """ + global _AUTOMATION_SCHEMA + _AUTOMATION_SCHEMA = json.loads(Path(schema_path).read_text(encoding="utf-8")) + + +def extract_automation_published(persona_setup: dict) -> dict: + """ + Extract published automation config from persona_setup JSONB. + Returns empty dict if no published automations exist. + Published config lives inside persona_setup so changes trigger bot restart + via the existing subscription comparison in ckit_bot_exec.py. + """ + try: + result = persona_setup.get("automation_published", {}) + return result if isinstance(result, dict) else {} + except (AttributeError, TypeError) as e: + logger.error("extract_automation_published failed", exc_info=e) + return {} + + +def extract_automation_draft(persona_automation_draft: Any) -> dict: + """ + Extract draft automation config from the separate persona_automation_draft column. + Draft lives in its own Prisma column to avoid triggering bot restarts on save. + Returns empty dict if column is NULL or invalid. + """ + try: + if persona_automation_draft is None: + return {} + if isinstance(persona_automation_draft, dict): + return persona_automation_draft + return {} + except (AttributeError, TypeError) as e: + logger.error("extract_automation_draft failed", exc_info=e) + return {} + + +def validate_automation_json(data: dict) -> list[str]: + """ + Validate an automation config dict against automation_v1.schema.json. + Returns list of error strings (empty list = valid). + Used by GraphQL mutations automation_draft_save and automation_publish. + """ + if _AUTOMATION_SCHEMA is None: + return ["automation schema not loaded -- call set_automation_schema_dict at backend startup"] + errors = [] + try: + validator = jsonschema.Draft202012Validator(_AUTOMATION_SCHEMA) + for error in validator.iter_errors(data): + path = ".".join(str(p) for p in error.absolute_path) if error.absolute_path else "$" + errors.append("%s: %s" % (path, error.message)) + except jsonschema.SchemaError as e: + errors.append("schema error: %s" % e.message) + return errors + + +class DisabledRulesCache: + def __init__(self, mongo_db: Any, interval: float = 30.0): + self._mongo_db = mongo_db + self._interval = interval + self._disabled: set = set() + self._task: Optional[asyncio.Task] = None + + async def start(self) -> None: + await self._refresh() + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + def get(self) -> set: + return self._disabled + + async def _refresh(self) -> None: + try: + doc = await self._mongo_db["bot_runtime_config"].find_one({"_id": "disabled_rule_ids"}) + if doc and isinstance(doc.get("ids"), list): + self._disabled = {str(x) for x in doc["ids"] if x} + else: + self._disabled = set() + except PyMongoError as e: + logger.warning("DisabledRulesCache refresh failed (mongo), keeping last known state: %s %s", type(e).__name__, e) + except (TypeError, ValueError) as e: + logger.warning("DisabledRulesCache refresh failed (bad doc), keeping last known state: %s %s", type(e).__name__, e) + + async def _loop(self) -> None: + while True: + try: + await asyncio.sleep(self._interval) + await self._refresh() + except asyncio.CancelledError: + break + + +def filter_active_rules(all_rules: list, disabled: set) -> list: + if not disabled: + return all_rules + return [r for r in all_rules if r.get("rule_id", "") not in disabled] diff --git a/flexus_client_kit/ckit_automation_actions.py b/flexus_client_kit/ckit_automation_actions.py new file mode 100644 index 00000000..4ea63ce2 --- /dev/null +++ b/flexus_client_kit/ckit_automation_actions.py @@ -0,0 +1,787 @@ +""" +Execute resolved automation actions (Discord + Mongo) and build job handlers for scheduled rules. + +The automation engine (ckit_automation_engine) produces flat action dicts with pre-resolved +_resolved_body / _resolved_channel_id. This module performs side effects only, returns per-action +results for logging, and emits field_changes for crm_field_changed / status_transition cascades. +Aligned with unified Discord bot plan U2.3. +""" + +from __future__ import annotations + +import logging +import re +import time +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +import aiohttp +import discord +from discord.errors import DiscordException +from pymongo.errors import PyMongoError + +from flexus_client_kit import ckit_crm_members, ckit_job_queue +from flexus_client_kit.ckit_automation import DisabledRulesCache, filter_active_rules +from flexus_client_kit.integrations import fi_discord_community as dc + +logger = logging.getLogger(__name__) + +# Maximum synthetic cascade rounds after CRM mutations (matches bot loop U2.4 guard). +_MAX_CASCADE_DEPTH = 5 + + +# Dispatcher: action type string -> async handler returning either a result dict only or +# (result dict, optional field_change dict) for CRM mutations. +ActionHandler = Callable[ + [dict, dict], + Awaitable[Tuple[dict, Optional[dict]]], +] + + +def _result_dict( + *, + ok: bool, + error: Optional[str] = None, + note: Optional[str] = None, + cancelled_count: Optional[int] = None, +) -> dict: + """ + Normalized per-action outcome merged into execute_actions output rows. + + ok/error are the contract; note carries dedupe hints; cancelled_count is for cancel_pending_jobs. + """ + out: dict[str, Any] = {"ok": ok, "error": error} + if note is not None: + out["note"] = note + if cancelled_count is not None: + out["cancelled_count"] = cancelled_count + return out + + +def _guild_user_from_member_doc(member_doc: dict) -> Tuple[Optional[int], Optional[int]]: + """Read compound natural key from CRM doc; None if missing or non-coercible.""" + try: + gid = member_doc.get("guild_id") + uid = member_doc.get("user_id") + if gid is None or uid is None: + return (None, None) + return (int(gid), int(uid)) + except (TypeError, ValueError): + return (None, None) + + +async def _do_send_dm(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + try: + persona_id = str(ctx.get("persona_id") or "") + body_raw = action.get("_resolved_body") + body = body_raw if isinstance(body_raw, str) else "" + if not (body or "").strip(): + return (_result_dict(ok=False, error="empty_body"), None) + connector = ctx.get("connector") + if connector is not None: + member_doc = ctx.get("member_doc") + if not isinstance(member_doc, dict): + return (_result_dict(ok=False, error="bad_member_doc"), None) + uid_s = str(member_doc.get("user_id", "") or "") + if not uid_s: + return (_result_dict(ok=False, error="missing_user_id"), None) + result = await connector.execute_action("send_dm", {"user_id": uid_s, "text": body}) + return (_result_dict(ok=result.ok, error=result.error), None) + member_discord = ctx.get("member_discord") + if member_discord is None: + dc.log_ctx(persona_id, _guild_user_from_member_doc(ctx.get("member_doc") or {})[0], "send_dm skipped: no member_discord") + return (_result_dict(ok=False, error="no_member_discord"), None) + client = ctx["discord_client"] + ok_dm = await dc.safe_dm(client, member_discord, persona_id, body) + if ok_dm: + return (_result_dict(ok=True, error=None), None) + return (_result_dict(ok=False, error="safe_dm_failed"), None) + except DiscordException as e: + logger.warning("send_dm DiscordException: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except aiohttp.ClientError as e: + logger.warning("send_dm ClientError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (KeyError, TypeError) as e: + logger.error("send_dm context error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +async def _do_post_to_channel(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + try: + persona_id = str(ctx.get("persona_id") or "") + cid = action.get("_resolved_channel_id") + if cid is None: + return (_result_dict(ok=False, error="no_channel_id"), None) + try: + channel_id = int(cid) + except (TypeError, ValueError): + return (_result_dict(ok=False, error="bad_channel_id"), None) + body_raw = action.get("_resolved_body") + body = body_raw if isinstance(body_raw, str) else "" + if not (body or "").strip(): + return (_result_dict(ok=False, error="empty_body"), None) + connector = ctx.get("connector") + if connector is not None: + sid = str(ctx.get("server_id") or "") + payload = {"channel_id": str(channel_id), "text": body} + if sid: + payload["server_id"] = sid + result = await connector.execute_action("post_to_channel", payload) + return (_result_dict(ok=result.ok, error=result.error), None) + guild = ctx["guild"] + ch = guild.get_channel(channel_id) + if ch is None or not isinstance(ch, discord.TextChannel): + dc.log_ctx(persona_id, int(guild.id) if guild else None, "post_to_channel: channel %s missing or not text", channel_id) + return (_result_dict(ok=False, error="channel_not_found"), None) + msg = await dc.safe_send(ch, persona_id, body) + if msg is not None: + return (_result_dict(ok=True, error=None), None) + return (_result_dict(ok=False, error="safe_send_failed"), None) + except DiscordException as e: + logger.warning("post_to_channel DiscordException: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except aiohttp.ClientError as e: + logger.warning("post_to_channel ClientError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (KeyError, TypeError, AttributeError) as e: + logger.error("post_to_channel context error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +async def _do_set_crm_field(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + """ + Persist one CRM field; value is already resolved by the engine (e.g. float for {now}). + + Updates ctx member_doc in memory on success so later actions in the same batch see it. + """ + try: + member_doc = ctx.get("member_doc") + if not isinstance(member_doc, dict): + return (_result_dict(ok=False, error="bad_member_doc"), None) + field = action.get("field") + if not isinstance(field, str) or not field: + return (_result_dict(ok=False, error="bad_field"), None) + value = action.get("value") + guild_id, user_id = _guild_user_from_member_doc(member_doc) + if guild_id is None or user_id is None: + return (_result_dict(ok=False, error="missing_guild_or_user"), None) + old_val = member_doc.get(field) + new_doc = await ckit_crm_members.update_member_field( + ctx["mongo_db"], + guild_id, + user_id, + field, + value, + ) + if new_doc is None: + return (_result_dict(ok=False, error="member_not_found"), None) + ctx["member_doc"] = new_doc + fc = { + "field": field, + "old_value": old_val, + "new_value": value, + "is_status": False, + } + return (_result_dict(ok=True, error=None), fc) + except PyMongoError as e: + logger.warning("set_crm_field PyMongoError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (KeyError, TypeError) as e: + logger.error("set_crm_field context error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +async def _do_set_status(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + """ + Set lifecycle_status via CRM helper; merges returned doc into ctx for downstream actions. + """ + try: + member_doc = ctx.get("member_doc") + if not isinstance(member_doc, dict): + return (_result_dict(ok=False, error="bad_member_doc"), None) + new_status = action.get("status") + if not isinstance(new_status, str) or not new_status: + return (_result_dict(ok=False, error="bad_status"), None) + guild_id, user_id = _guild_user_from_member_doc(member_doc) + if guild_id is None or user_id is None: + return (_result_dict(ok=False, error="missing_guild_or_user"), None) + merged, old_status = await ckit_crm_members.set_member_status( + ctx["mongo_db"], + guild_id, + user_id, + new_status, + ) + if merged is None: + return (_result_dict(ok=False, error="member_not_found"), None) + ctx["member_doc"] = merged + fc = { + "field": "lifecycle_status", + "old_value": old_status, + "new_value": new_status, + "is_status": True, + } + return (_result_dict(ok=True, error=None), fc) + except PyMongoError as e: + logger.warning("set_status PyMongoError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (KeyError, TypeError) as e: + logger.error("set_status context error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +async def _do_enqueue_check(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + """ + Insert dc_community_jobs row for a future scheduled_check, with dedup on (kind, guild, user). + + Anchor-relative scheduling requires anchor_val present on member_doc when anchor_field is set. + """ + try: + member_doc = ctx.get("member_doc") + if not isinstance(member_doc, dict): + return (_result_dict(ok=False, error="bad_member_doc"), None) + check_rule_id = action.get("check_rule_id") + if not isinstance(check_rule_id, str) or not check_rule_id: + return (_result_dict(ok=False, error="bad_check_rule_id"), None) + delay_raw = action.get("delay_seconds") + try: + delay_sec = int(delay_raw) + except (TypeError, ValueError): + return (_result_dict(ok=False, error="bad_delay_seconds"), None) + guild_id, user_id = _guild_user_from_member_doc(member_doc) + if guild_id is None or user_id is None: + return (_result_dict(ok=False, error="missing_guild_or_user"), None) + db = ctx["mongo_db"] + coll = db[ckit_job_queue.COL_JOBS] + dup = await coll.find_one( + { + "kind": check_rule_id, + "payload.guild_id": guild_id, + "payload.user_id": user_id, + "done": False, + }, + ) + if dup is not None: + dc.log_ctx( + str(ctx.get("persona_id") or ""), + guild_id, + "enqueue_check deduped kind=%s user=%s", + check_rule_id, + user_id, + ) + return (_result_dict(ok=True, error=None, note="deduped"), None) + anchor_field = action.get("anchor_field") + if isinstance(anchor_field, str) and anchor_field: + anchor_val = member_doc.get(anchor_field) + if anchor_val is None: + dc.log_ctx( + str(ctx.get("persona_id") or ""), + guild_id, + "enqueue_check skipped: anchor_field %s not set", + anchor_field, + ) + return (_result_dict(ok=False, error="anchor_not_set"), None) + try: + run_at = float(anchor_val) + float(delay_sec) + except (TypeError, ValueError): + return (_result_dict(ok=False, error="bad_anchor_value"), None) + else: + run_at = time.time() + float(delay_sec) + payload = { + "guild_id": guild_id, + "user_id": user_id, + "rule_id": check_rule_id, + "persona_id": str(ctx.get("persona_id") or ""), + } + await ckit_job_queue.enqueue_job(db, check_rule_id, run_at, payload) + return (_result_dict(ok=True, error=None), None) + except PyMongoError as e: + logger.warning("enqueue_check PyMongoError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (KeyError, TypeError) as e: + logger.error("enqueue_check context error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +async def _do_cancel_pending_jobs(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + """ + Mark pending jobs done whose kind starts with job_kind_prefix for this member (regex prefix). + """ + try: + member_doc = ctx.get("member_doc") + if not isinstance(member_doc, dict): + return (_result_dict(ok=False, error="bad_member_doc"), None) + prefix = action.get("job_kind_prefix") + if not isinstance(prefix, str) or not prefix: + return (_result_dict(ok=False, error="bad_prefix"), None) + guild_id, user_id = _guild_user_from_member_doc(member_doc) + if guild_id is None or user_id is None: + return (_result_dict(ok=False, error="missing_guild_or_user"), None) + db = ctx["mongo_db"] + coll = db[ckit_job_queue.COL_JOBS] + pattern = "^%s" % (re.escape(prefix),) + now_ts = time.time() + res = await coll.update_many( + { + "kind": {"$regex": pattern}, + "payload.guild_id": guild_id, + "payload.user_id": user_id, + "done": False, + }, + {"$set": {"done": True, "cancelled": True, "cancelled_ts": now_ts}}, + ) + n = int(getattr(res, "modified_count", 0) or 0) + return (_result_dict(ok=True, error=None, cancelled_count=n), None) + except PyMongoError as e: + logger.warning("cancel_pending_jobs PyMongoError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (KeyError, TypeError) as e: + logger.error("cancel_pending_jobs context error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +async def _do_add_role(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + try: + rid = action.get("_resolved_role_id") + if rid is None: + return (_result_dict(ok=False, error="no_role_id"), None) + member_doc = ctx.get("member_doc") + if not isinstance(member_doc, dict): + return (_result_dict(ok=False, error="bad_member_doc"), None) + uid_s = str(member_doc.get("user_id", "") or "") + if not uid_s: + return (_result_dict(ok=False, error="missing_user_id"), None) + connector = ctx.get("connector") + if connector is None: + return (_result_dict(ok=False, error="no_connector"), None) + sid = str(ctx.get("server_id") or "") + if not sid: + return (_result_dict(ok=False, error="missing_server_id"), None) + result = await connector.execute_action( + "add_role", + {"user_id": uid_s, "role_id": str(int(rid)), "server_id": sid}, + ) + return (_result_dict(ok=result.ok, error=result.error), None) + except DiscordException as e: + logger.warning("add_role DiscordException: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except aiohttp.ClientError as e: + logger.warning("add_role ClientError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (KeyError, TypeError, ValueError) as e: + logger.error("add_role context error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +async def _do_remove_role(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + try: + rid = action.get("_resolved_role_id") + if rid is None: + return (_result_dict(ok=False, error="no_role_id"), None) + member_doc = ctx.get("member_doc") + if not isinstance(member_doc, dict): + return (_result_dict(ok=False, error="bad_member_doc"), None) + uid_s = str(member_doc.get("user_id", "") or "") + if not uid_s: + return (_result_dict(ok=False, error="missing_user_id"), None) + connector = ctx.get("connector") + if connector is None: + return (_result_dict(ok=False, error="no_connector"), None) + sid = str(ctx.get("server_id") or "") + if not sid: + return (_result_dict(ok=False, error="missing_server_id"), None) + result = await connector.execute_action( + "remove_role", + {"user_id": uid_s, "role_id": str(int(rid)), "server_id": sid}, + ) + return (_result_dict(ok=result.ok, error=result.error), None) + except DiscordException as e: + logger.warning("remove_role DiscordException: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except aiohttp.ClientError as e: + logger.warning("remove_role ClientError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (KeyError, TypeError, ValueError) as e: + logger.error("remove_role context error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +async def _do_kick(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + try: + member_doc = ctx.get("member_doc") + if not isinstance(member_doc, dict): + return (_result_dict(ok=False, error="bad_member_doc"), None) + uid_s = str(member_doc.get("user_id", "") or "") + if not uid_s: + return (_result_dict(ok=False, error="missing_user_id"), None) + connector = ctx.get("connector") + if connector is None: + return (_result_dict(ok=False, error="no_connector"), None) + sid = str(ctx.get("server_id") or "") + if not sid: + return (_result_dict(ok=False, error="missing_server_id"), None) + reason_raw = action.get("_resolved_kick_reason") + reason = reason_raw if isinstance(reason_raw, str) else "" + result = await connector.execute_action( + "kick", + {"user_id": uid_s, "reason": reason, "server_id": sid}, + ) + return (_result_dict(ok=result.ok, error=result.error), None) + except DiscordException as e: + logger.warning("kick DiscordException: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except aiohttp.ClientError as e: + logger.warning("kick ClientError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (KeyError, TypeError, ValueError) as e: + logger.error("kick context error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +async def _do_call_gatekeeper_tool(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: + """ + Placeholder for U3 gatekeeper integration; never mutates state in U2. + """ + try: + logger.warning( + "call_gatekeeper_tool not implemented yet (U3 scope) persona_id=%s tool=%s", + ctx.get("persona_id"), + action.get("tool_name"), + ) + return (_result_dict(ok=False, error="not_implemented"), None) + except (TypeError, AttributeError) as e: + logger.error("call_gatekeeper_tool unexpected error", exc_info=e) + return (_result_dict(ok=False, error=type(e).__name__), None) + + +# Maps automation action.type to handler coroutine; extend when schema gains new types. +_ACTION_DISPATCH: Dict[str, ActionHandler] = { + "send_dm": _do_send_dm, + "post_to_channel": _do_post_to_channel, + "set_crm_field": _do_set_crm_field, + "set_status": _do_set_status, + "enqueue_check": _do_enqueue_check, + "cancel_pending_jobs": _do_cancel_pending_jobs, + "add_role": _do_add_role, + "remove_role": _do_remove_role, + "kick": _do_kick, + "call_gatekeeper_tool": _do_call_gatekeeper_tool, +} + + +async def execute_actions(actions: List[dict], ctx: dict) -> Tuple[List[dict], List[dict]]: + """ + Run resolved actions in order; collect logging rows and CRM field deltas for cascades. + + One failing action does not stop the rest. CRM handlers refresh ctx['member_doc'] on success. + """ + try: + results: List[dict] = [] + field_changes: List[dict] = [] + if not isinstance(actions, list): + logger.error("execute_actions: actions must be a list") + return ([], []) + for action in actions: + if not isinstance(action, dict): + logger.warning("execute_actions: skip non-dict action") + continue + action_type = action.get("type") + rule_id = str(action.get("rule_id") or "") + if not isinstance(action_type, str) or not action_type: + results.append( + { + "action_type": "", + "rule_id": rule_id, + "ok": False, + "error": "missing_type", + }, + ) + continue + handler = _ACTION_DISPATCH.get(action_type) + if handler is None: + logger.warning("execute_actions: unknown action type %s", action_type) + results.append( + { + "action_type": action_type, + "rule_id": rule_id, + "ok": False, + "error": "unknown_action_type", + }, + ) + continue + try: + partial, fc = await handler(action, ctx) + except DiscordException as e: + logger.warning("execute_actions handler DiscordException: %s", e) + results.append( + { + "action_type": action_type, + "rule_id": rule_id, + "ok": False, + "error": type(e).__name__, + }, + ) + continue + except aiohttp.ClientError as e: + logger.warning("execute_actions handler ClientError: %s", e) + results.append( + { + "action_type": action_type, + "rule_id": rule_id, + "ok": False, + "error": type(e).__name__, + }, + ) + continue + except PyMongoError as e: + logger.warning("execute_actions handler PyMongoError: %s", e) + results.append( + { + "action_type": action_type, + "rule_id": rule_id, + "ok": False, + "error": type(e).__name__, + }, + ) + continue + except (TypeError, KeyError, ValueError) as e: + logger.error("execute_actions handler data error", exc_info=e) + results.append( + { + "action_type": action_type, + "rule_id": rule_id, + "ok": False, + "error": type(e).__name__, + }, + ) + continue + row = { + "action_type": action_type, + "rule_id": rule_id, + "ok": bool(partial.get("ok")), + "error": partial.get("error"), + } + if "note" in partial: + row["note"] = partial["note"] + if "cancelled_count" in partial: + row["cancelled_count"] = partial["cancelled_count"] + results.append(row) + if isinstance(fc, dict): + field_changes.append(fc) + return (results, field_changes) + except (TypeError, KeyError) as e: + logger.error("execute_actions fatal input error", exc_info=e) + return ([], []) + + +async def _run_cascade( + *, + db: Any, + client: discord.Client | None, + persona_id: str, + setup: dict, + rules: List[dict], + engine_process_fn: Callable[..., List[dict]], + ctx: dict, + initial_field_changes: List[dict], + guild_id: int, + user_id: int, + disabled_rules_cache: Optional[DisabledRulesCache] = None, +) -> None: + """ + Re-run the engine on synthetic CRM events up to _MAX_CASCADE_DEPTH rounds (scheduled job path). + + Mirrors U2.4 bot loop: refresh member from Mongo per change, then process_event + execute_actions. + """ + try: + disabled = disabled_rules_cache.get() if disabled_rules_cache else set() + active_rules = filter_active_rules(rules, disabled) + pending = list(initial_field_changes) + depth = 0 + while pending: + if depth >= _MAX_CASCADE_DEPTH: + logger.warning( + "automation cascade depth limit reached (%s) guild_id=%s user_id=%s", + _MAX_CASCADE_DEPTH, + guild_id, + user_id, + ) + return + depth += 1 + next_pending: List[dict] = [] + for fc in pending: + fresh = await ckit_crm_members.get_member(db, guild_id, user_id) + if fresh is None: + dc.log_ctx(persona_id, guild_id, "cascade skip: member gone user=%s", user_id) + continue + ctx["member_doc"] = fresh + ctx["server_id"] = str(guild_id) + g = client.get_guild(guild_id) if client is not None else None + if ctx.get("connector") is not None: + ctx["platform_user"] = await ctx["connector"].get_user_info( + str(user_id), + server_id=str(guild_id), + ) + else: + ctx["member_discord"] = g.get_member(user_id) if g else None + if fc.get("is_status") is True: + event_type = "status_transition" + event_data = { + "old_status": fc.get("old_value"), + "new_status": fc.get("new_value"), + } + else: + event_type = "crm_field_changed" + event_data = { + "field_name": fc.get("field"), + "new_value": fc.get("new_value"), + } + try: + more_actions = engine_process_fn( + event_type, + event_data, + active_rules, + fresh, + setup, + ) + except (TypeError, KeyError, ValueError) as e: + logger.error( + "cascade engine_process_fn failed event=%s", + event_type, + exc_info=e, + ) + continue + if not isinstance(more_actions, list): + logger.warning("cascade: engine did not return a list") + continue + _, more_fc = await execute_actions(more_actions, ctx) + next_pending.extend(more_fc) + pending = next_pending + except PyMongoError as e: + logger.warning("_run_cascade PyMongoError: %s %s", type(e).__name__, e) + except (TypeError, KeyError) as e: + logger.error("_run_cascade unexpected error", exc_info=e) + + +def make_automation_job_handler( + rules: List[dict], + setup: dict, + engine_process_fn: Callable[..., List[dict]], + db: Any, + client: discord.Client | None, + persona_id: str, + disabled_rules_cache: Optional[DisabledRulesCache] = None, + connector: Any = None, +) -> Dict[str, Callable[[Dict[str, Any]], Awaitable[None]]]: + def _build_one(rule_id: str) -> Callable[[Dict[str, Any]], Awaitable[None]]: + rid = str(rule_id) + + async def _handler(payload: Dict[str, Any]) -> None: + try: + if connector is None and client is None: + dc.log_ctx(persona_id, None, "job %s: no connector or discord client", rid) + return + disabled = disabled_rules_cache.get() if disabled_rules_cache else set() + if rid in disabled: + dc.log_ctx(persona_id, None, "job %s: rule disabled, skipping", rid) + return + raw_g = payload.get("guild_id") + raw_u = payload.get("user_id") + if raw_g is None or raw_u is None: + dc.log_ctx(persona_id, None, "job %s missing guild_id or user_id in payload", rid) + return + try: + g_id = int(raw_g) + u_id = int(raw_u) + except (TypeError, ValueError): + dc.log_ctx(persona_id, None, "job %s bad guild_id/user_id in payload", rid) + return + member = await ckit_crm_members.get_member(db, g_id, u_id) + if member is None: + dc.log_ctx(persona_id, g_id, "scheduled job %s: no CRM row for user=%s", rid, u_id) + return + event_data = { + "check_rule_id": rid, + "guild_id": g_id, + "user_id": u_id, + } + try: + active_rules = filter_active_rules(rules, disabled) + actions = engine_process_fn( + "scheduled_check", + event_data, + active_rules, + member, + setup, + ) + except (TypeError, KeyError, ValueError) as e: + logger.error("job %s engine_process_fn failed", rid, exc_info=e) + return + if not isinstance(actions, list): + logger.warning("job %s: engine returned non-list", rid) + return + gx = client.get_guild(g_id) if client is not None else None + if connector is not None: + ctx = { + "connector": connector, + "mongo_db": db, + "server_id": str(g_id), + "platform_user": await connector.get_user_info(str(u_id), server_id=str(g_id)), + "member_doc": member, + "persona_id": persona_id, + "setup": setup, + } + else: + ctx = { + "discord_client": client, + "mongo_db": db, + "guild": gx, + "member_discord": gx.get_member(u_id) if gx else None, + "member_doc": member, + "persona_id": persona_id, + "setup": setup, + } + _, field_changes = await execute_actions(actions, ctx) + await _run_cascade( + db=db, + client=client, + persona_id=persona_id, + setup=setup, + rules=rules, + engine_process_fn=engine_process_fn, + ctx=ctx, + initial_field_changes=field_changes, + guild_id=g_id, + user_id=u_id, + disabled_rules_cache=disabled_rules_cache, + ) + except PyMongoError as e: + logger.warning("job handler %s PyMongoError: %s %s", rid, type(e).__name__, e) + except DiscordException as e: + logger.warning("job handler %s DiscordException: %s %s", rid, type(e).__name__, e) + except aiohttp.ClientError as e: + logger.warning("job handler %s ClientError: %s %s", rid, type(e).__name__, e) + except (TypeError, KeyError, AttributeError) as e: + logger.error("job handler %s unexpected error", rid, exc_info=e) + + return _handler + + out: Dict[str, Callable[[Dict[str, Any]], Awaitable[None]]] = {} + try: + for rule in rules: + if not isinstance(rule, dict): + continue + trig = rule.get("trigger") + if not isinstance(trig, dict): + continue + if trig.get("type") != "scheduled_relative_to_field": + continue + rid = rule.get("rule_id") + if not isinstance(rid, str) or not rid: + logger.warning("make_automation_job_handler: skip rule without rule_id") + continue + out[rid] = _build_one(rid) + return out + except (TypeError, AttributeError) as e: + logger.error("make_automation_job_handler failed", exc_info=e) + return {} + diff --git a/flexus_client_kit/ckit_automation_engine.py b/flexus_client_kit/ckit_automation_engine.py new file mode 100644 index 00000000..e0eccef8 --- /dev/null +++ b/flexus_client_kit/ckit_automation_engine.py @@ -0,0 +1,432 @@ +""" +Pure automation rule engine: dict in, dict out. No Discord, Mongo, or async. +Used by community bots to match triggers, evaluate CRM conditions, and resolve +action payloads before an executor applies side effects (U2.2 in unified bot plan). +""" + +from __future__ import annotations + +import copy +import logging +import re +import time + +from flexus_client_kit import ckit_automation + +logger = logging.getLogger(__name__) + +# Regex for {placeholder} tokens in templates; word chars only (schema field names). +_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}") + +# Action types that may carry a message body via template or template_field (executor reads _resolved_body). +_BODY_ACTION_TYPES = frozenset({"send_dm", "post_to_channel"}) + +_ROLE_ACTION_TYPES = frozenset({"add_role", "remove_role"}) + + +def _safe_float_pair(field_value, operand) -> tuple[float, float] | None: + """ + Parse both sides to float for numeric comparisons. Returns None on any failure + so callers can treat the condition as failed (fail-safe, no crash on bad data). + """ + try: + return (float(field_value), float(operand)) + except (TypeError, ValueError): + return None + + +def _single_condition_ok(condition: dict, member: dict) -> bool: + """ + Evaluate one condition dict against member. Caller ensures condition is a dict. + Unknown op logs a warning and yields False (blocks the rule). + """ + op = condition.get("op") + field_name = condition.get("field") + if not isinstance(field_name, str): + return False + field_value = member.get(field_name) + + if op == "eq": + return field_value == condition["value"] + if op == "neq": + return field_value != condition["value"] + if op == "gt": + if field_value is None: + return False + pair = _safe_float_pair(field_value, condition["value"]) + return pair is not None and pair[0] > pair[1] + if op == "lt": + if field_value is None: + return False + pair = _safe_float_pair(field_value, condition["value"]) + return pair is not None and pair[0] < pair[1] + if op == "is_set": + return field_value is not None + if op == "is_not_set": + return field_value is None + if op == "elapsed_gt": + if field_value is None: + return False + pair = _safe_float_pair(field_value, condition["value"]) + return pair is not None and (time.time() - pair[0]) > pair[1] + if op == "elapsed_lt": + if field_value is None: + return False + pair = _safe_float_pair(field_value, condition["value"]) + return pair is not None and (time.time() - pair[0]) < pair[1] + + logger.warning("evaluate_conditions: unknown op %r (fail-safe False)", op) + return False + + +def load_rules(persona_setup: dict) -> list[dict]: + """ + Load all automation rules from persona_setup.automation_published. + Returns [] if missing or invalid. Does NOT filter by enabled/disabled; + runtime filtering by disabled_rule_ids is done in the bot layer via MongoDB. + """ + try: + published = ckit_automation.extract_automation_published(persona_setup) + rules_raw = published.get("rules", []) + if not isinstance(rules_raw, list): + return [] + return [r for r in rules_raw if isinstance(r, dict)] + except (KeyError, TypeError, ValueError) as e: + logger.error("load_rules failed", exc_info=e) + return [] + + +def match_trigger(event_type: str, event_data: dict, rule: dict, setup: dict) -> bool: + """ + Return True if this rule's trigger matches the synthetic event_type and payload. + Unknown event_type -> False. Malformed rule/trigger -> False. + """ + try: + trigger = rule.get("trigger") + if not isinstance(trigger, dict): + return False + ttype = trigger.get("type") + + if event_type == "member_joined": + return ttype == "member_joined" + + if event_type == "member_removed": + return ttype == "member_removed" + + if event_type == "message_in_channel": + if ttype != "message_in_channel": + return False + ref = trigger.get("channel_id_field") + if not isinstance(ref, str): + return False + resolved = resolve_channel_id(ref, setup) + return event_data.get("channel_id") == resolved + + if event_type == "scheduled_check": + if ttype != "scheduled_relative_to_field": + return False + return event_data.get("check_rule_id") == rule.get("rule_id") + + if event_type == "crm_field_changed": + if ttype != "crm_field_changed": + return False + if trigger.get("field_name") != event_data.get("field_name"): + return False + if "to_value" not in trigger: + return True + return trigger.get("to_value") == event_data.get("new_value") + + if event_type == "status_transition": + if ttype != "status_transition": + return False + if trigger.get("to_status") != event_data.get("new_status"): + return False + if "from_status" not in trigger: + return True + return trigger.get("from_status") == event_data.get("old_status") + + return False + except (KeyError, TypeError, ValueError) as e: + logger.error("match_trigger failed", exc_info=e) + return False + + +def evaluate_conditions(conditions: list[dict], member: dict) -> bool: + """ + AND all conditions; empty list is True. Uses member.get for CRM fields. + """ + try: + if not conditions: + return True + if not isinstance(conditions, list): + return False + if not isinstance(member, dict): + return False + for cond in conditions: + if not isinstance(cond, dict): + return False + if not _single_condition_ok(cond, member): + return False + return True + except (KeyError, TypeError, ValueError) as e: + logger.error("evaluate_conditions failed", exc_info=e) + return False + + +def resolve_template(template: str, member: dict, setup: dict) -> str: + """ + Replace {name} placeholders: special keys now, mention; else member then setup. + Unknown or unset placeholders stay literal in the output string. + """ + try: + if not isinstance(template, str): + logger.warning( + "resolve_template expected str, got %s", + type(template).__name__, + ) + return "" + + if not isinstance(member, dict): + member = {} + if not isinstance(setup, dict): + setup = {} + + def repl(match) -> str: + name = match.group(1) + if name == "now": + return str(int(time.time())) + if name == "mention": + uid = member.get("user_id") + if uid is None: + return match.group(0) + fmt_fn = setup.get("_format_mention") + if callable(fmt_fn): + return fmt_fn(str(uid)) + return "<@%s>" % uid + if name in member: + v = member[name] + if v is not None: + return str(v) + if name in setup: + v = setup[name] + if v is not None: + return str(v) + return match.group(0) + + return _PLACEHOLDER_RE.sub(repl, template) + except (KeyError, TypeError, ValueError) as e: + logger.error("resolve_template failed", exc_info=e) + return template if isinstance(template, str) else "" + + +def resolve_channel_id(field_ref: str, setup: dict) -> int | None: + """ + Resolve #snowflake literal or setup key to int channel id. None if invalid or missing. + """ + try: + if not isinstance(field_ref, str) or not field_ref: + return None + if not isinstance(setup, dict): + setup = {} + if field_ref.isdigit(): + return int(field_ref) + if field_ref.startswith("#"): + return int(field_ref[1:]) + raw = setup.get(field_ref) + if raw is None: + return None + return int(raw) + except (KeyError, TypeError, ValueError) as e: + logger.error("resolve_channel_id failed for %r", field_ref, exc_info=e) + return None + + +def resolve_role_id(field_ref: str, setup: dict) -> int | None: + """Same resolution rules as resolve_channel_id (setup key, digits, or #snowflake).""" + return resolve_channel_id(field_ref, setup) + + +def _resolve_body_fields(action: dict, member: dict, setup: dict) -> None: + """ + Mutates action copy: sets _resolved_body for send_dm / post_to_channel from + template_field (setup indirection) or inline template. + """ + atype = action.get("type") + if atype not in _BODY_ACTION_TYPES: + return + if "template_field" in action: + key = action["template_field"] + raw = setup.get(key, "") if isinstance(setup, dict) else "" + if raw is None: + raw = "" + if not isinstance(raw, str): + raw = str(raw) + action["_resolved_body"] = resolve_template(raw, member, setup) + elif "template" in action: + tpl = action["template"] + if not isinstance(tpl, str): + tpl = str(tpl) + action["_resolved_body"] = resolve_template(tpl, member, setup) + + +def _resolve_channel_field(action: dict, setup: dict) -> None: + """Mutates action copy: _resolved_channel_id from channel_id_field if present.""" + if "channel_id_field" not in action: + return + ref = action["channel_id_field"] + if isinstance(ref, str): + action["_resolved_channel_id"] = resolve_channel_id(ref, setup) + else: + action["_resolved_channel_id"] = None + + +def _resolve_role_field(action: dict, setup: dict) -> None: + if action.get("type") not in _ROLE_ACTION_TYPES: + return + ref = action.get("role_id_field") + if isinstance(ref, str): + action["_resolved_role_id"] = resolve_role_id(ref, setup) + else: + action["_resolved_role_id"] = None + + +def _resolve_kick_reason(action: dict, member: dict, setup: dict) -> None: + if action.get("type") != "kick": + return + raw = action.get("reason") + if not isinstance(raw, str) or not (raw or "").strip(): + action["_resolved_kick_reason"] = "" + return + action["_resolved_kick_reason"] = resolve_template(raw, member, setup) + + +def _resolve_set_crm_now(action: dict) -> None: + """Mutates action copy: literal value '{now}' -> float unix ts for set_crm_field.""" + if action.get("type") != "set_crm_field": + return + if action.get("value") == "{now}": + action["value"] = time.time() + + +def resolve_actions(actions: list[dict], member: dict, setup: dict) -> list[dict]: + """ + Deep-copy each action and fill executor-facing fields: _resolved_body, + _resolved_channel_id, _resolved_role_id, _resolved_kick_reason, and set_crm_field {now} -> float timestamp. + """ + try: + if not isinstance(actions, list): + return [] + if not isinstance(member, dict): + member = {} + if not isinstance(setup, dict): + setup = {} + out = [] + for act in actions: + if not isinstance(act, dict): + continue + cloned = copy.deepcopy(act) + _resolve_body_fields(cloned, member, setup) + _resolve_channel_field(cloned, setup) + _resolve_role_field(cloned, setup) + _resolve_kick_reason(cloned, member, setup) + _resolve_set_crm_now(cloned) + out.append(cloned) + return out + except (KeyError, TypeError, ValueError) as e: + logger.error("resolve_actions failed", exc_info=e) + return [] + + +def _execute_flat_rule(rule: dict, member: dict, setup: dict, result: list[dict]) -> None: + """Old-style rule: single conditions+actions block.""" + conds = rule.get("conditions", []) + if not evaluate_conditions(conds, member): + return + acts = rule.get("actions", []) + if not isinstance(acts, list): + return + resolved = resolve_actions(acts, member, setup) + rid = rule.get("rule_id", "") + for a in resolved: + a["rule_id"] = rid + result.extend(resolved) + + +def _execute_branched_rule(rule: dict, member: dict, setup: dict, result: list[dict]) -> None: + """Branched rule: first branch whose conditions all pass wins, rest are skipped.""" + branches = rule.get("branches", []) + if not isinstance(branches, list): + return + rid = rule.get("rule_id", "") + for branch in branches: + if not isinstance(branch, dict): + continue + conds = branch.get("conditions", []) + if not evaluate_conditions(conds, member): + continue + acts = branch.get("actions", []) + if not isinstance(acts, list): + continue + resolved = resolve_actions(acts, member, setup) + for a in resolved: + a["rule_id"] = rid + result.extend(resolved) + return + + +def process_event( + event_type: str, + event_data: dict, + rules: list[dict], + member: dict, + setup: dict, +) -> list[dict]: + """ + Run all rules: for each, if trigger matches, evaluate conditions/branches and + append resolved actions. Supports both flat rules (conditions+actions) and + branched rules (branches array, first matching branch wins). + Returns a flat list of action dicts ready for the executor. + """ + try: + if not isinstance(event_data, dict): + event_data = {} + if not isinstance(rules, list): + return [] + if not isinstance(member, dict): + member = {} + if not isinstance(setup, dict): + setup = {} + result = [] + for rule in rules: + if not isinstance(rule, dict): + continue + if not match_trigger(event_type, event_data, rule, setup): + continue + if "branches" in rule: + _execute_branched_rule(rule, member, setup, result) + else: + _execute_flat_rule(rule, member, setup, result) + return result + except (KeyError, TypeError, ValueError) as e: + logger.error("process_event failed", exc_info=e) + return [] + + +def find_scheduled_rules(rules: list[dict]) -> list[dict]: + """ + Rules whose trigger is scheduled_relative_to_field (anchor + delay jobs). + """ + try: + if not isinstance(rules, list): + return [] + out = [] + for rule in rules: + if not isinstance(rule, dict): + continue + trig = rule.get("trigger") + if isinstance(trig, dict) and trig.get("type") == "scheduled_relative_to_field": + out.append(rule) + return out + except (KeyError, TypeError, ValueError) as e: + logger.error("find_scheduled_rules failed", exc_info=e) + return [] diff --git a/flexus_client_kit/ckit_automation_v1_schema_build.py b/flexus_client_kit/ckit_automation_v1_schema_build.py new file mode 100644 index 00000000..8e54986d --- /dev/null +++ b/flexus_client_kit/ckit_automation_v1_schema_build.py @@ -0,0 +1,265 @@ +""" +Build automation_schema_version 1 JSON Schema from the Discord capability catalog +(ckit_connector_discord) plus product-only trigger/action defs. Single compile-time +assembly surface: backend validation and assist contracts derive from the same document. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from flexus_client_kit.ckit_connector_discord import DISCORD_ACTIONS, DISCORD_TRIGGERS +from flexus_client_kit.ckit_discord_automation_schema_defs import ( + SCHEMA_ACTION_CALL_GATEKEEPER_PRODUCT, + SCHEMA_TRIGGER_MANUAL_CAMPAIGN_PRODUCT, +) + + +def _def_name_trigger(type_id: str) -> str: + return "trigger_%s" % type_id + + +def _def_name_action(type_id: str) -> str: + return "action_%s" % type_id + + +def _persisted_keys_from_schema_object(fragment: dict) -> frozenset[str]: + props = fragment.get("properties") + if not isinstance(props, dict): + return frozenset() + return frozenset(props.keys()) + + +def automation_persisted_trigger_property_keys() -> dict[str, frozenset[str]]: + out: dict[str, frozenset[str]] = {} + for t in DISCORD_TRIGGERS: + d = t.automation_schema_def + if d is None: + raise RuntimeError("Discord trigger %r missing automation_schema_def" % t.type) + out[t.type] = _persisted_keys_from_schema_object(d) + out["manual_campaign"] = _persisted_keys_from_schema_object(SCHEMA_TRIGGER_MANUAL_CAMPAIGN_PRODUCT) + return out + + +def automation_persisted_action_property_keys() -> dict[str, frozenset[str]]: + out: dict[str, frozenset[str]] = {} + for a in DISCORD_ACTIONS: + d = a.automation_schema_def + if d is None: + raise RuntimeError("Discord action %r missing automation_schema_def" % a.type) + out[a.type] = _persisted_keys_from_schema_object(d) + out["call_gatekeeper_tool"] = _persisted_keys_from_schema_object(SCHEMA_ACTION_CALL_GATEKEEPER_PRODUCT) + return out + + +def schema_trigger_types_ordered() -> tuple[str, ...]: + keys = sorted(automation_persisted_trigger_property_keys().keys()) + return tuple(keys) + + +def schema_action_types_ordered() -> tuple[str, ...]: + keys = sorted(automation_persisted_action_property_keys().keys()) + return tuple(keys) + + +_STATIC_DEFS: dict[str, Any] = { + "rule": { + "type": "object", + "required": ["rule_id", "enabled", "trigger"], + "additionalProperties": False, + "properties": { + "rule_id": { + "type": "string", + "minLength": 1, + "description": ( + "Stable, human-readable id (e.g. 'intro-reminder-48h'). Unique within a config blob. " + "Used in job dedup keys and logs." + ), + }, + "enabled": { + "type": "boolean", + "description": "Master switch. Disabled rules are stored but never evaluated.", + }, + "description": { + "type": "string", + "description": "Optional human-readable note shown in UI.", + }, + "trigger": {"$ref": "#/$defs/trigger"}, + "conditions": { + "type": "array", + "items": {"$ref": "#/$defs/condition"}, + "default": [], + "description": ( + "All conditions are AND-ed. Empty array = unconditional (trigger alone is sufficient). " + "Used in flat (non-branched) rules." + ), + }, + "actions": { + "type": "array", + "items": {"$ref": "#/$defs/action"}, + "minItems": 1, + "description": ( + "Executed sequentially when trigger fires and all conditions pass. Used in flat (non-branched) rules." + ), + }, + "branches": { + "type": "array", + "items": {"$ref": "#/$defs/branch"}, + "minItems": 1, + "description": ( + "If/elif/else logic: first branch whose conditions all pass is executed, rest are skipped. " + "Mutually exclusive with top-level conditions+actions." + ), + }, + }, + "oneOf": [ + {"required": ["actions"]}, + {"required": ["branches"]}, + ], + }, + "branch": { + "type": "object", + "required": ["actions"], + "additionalProperties": False, + "properties": { + "label": { + "type": "string", + "description": "Optional UI label for this branch (e.g. 'English speakers', 'Default').", + }, + "conditions": { + "type": "array", + "items": {"$ref": "#/$defs/condition"}, + "default": [], + "description": ( + "AND-ed conditions for this branch. Empty array = 'otherwise' (always matches, use as last branch)." + ), + }, + "actions": { + "type": "array", + "items": {"$ref": "#/$defs/action"}, + "minItems": 1, + "description": "Actions to execute when this branch's conditions pass.", + }, + }, + "description": "One branch in an if/elif/else chain. First matching branch wins.", + }, + "condition": { + "type": "object", + "required": ["field", "op"], + "additionalProperties": False, + "properties": { + "field": { + "type": "string", + "minLength": 1, + "description": "CRM member field path to evaluate.", + }, + "op": { + "type": "string", + "enum": ["eq", "neq", "gt", "lt", "is_set", "is_not_set", "elapsed_gt", "elapsed_lt"], + "description": ( + "Comparison operator. elapsed_gt/elapsed_lt: 'now - field_value > value_seconds' / '< value_seconds'. " + "is_set/is_not_set: field is non-null / null (value ignored)." + ), + }, + "value": { + "description": ( + "Comparison operand. Type depends on op: number for gt/lt/elapsed_*, string or number for eq/neq, " + "ignored for is_set/is_not_set." + ), + }, + }, + "description": "Single boolean predicate on a CRM field. All conditions in a rule are AND-ed.", + }, +} + + +def build_automation_v1_schema_document() -> dict[str, Any]: + defs: dict[str, Any] = dict(_STATIC_DEFS) + + trigger_refs: list[dict[str, str]] = [] + for t in sorted(DISCORD_TRIGGERS, key=lambda x: x.type): + frag = t.automation_schema_def + if frag is None: + raise RuntimeError("trigger %s: automation_schema_def required" % t.type) + name = _def_name_trigger(t.type) + defs[name] = copy.deepcopy(frag) + trigger_refs.append({"$ref": "#/$defs/%s" % name}) + + defs["trigger_manual_campaign"] = copy.deepcopy(SCHEMA_TRIGGER_MANUAL_CAMPAIGN_PRODUCT) + trigger_refs.append({"$ref": "#/$defs/trigger_manual_campaign"}) + trigger_refs.sort(key=lambda r: r["$ref"]) + + defs["trigger"] = { + "type": "object", + "required": ["type"], + "description": "Discriminated union on 'type'. Each type carries its own required payload fields.", + "oneOf": trigger_refs, + } + + action_refs: list[dict[str, str]] = [] + for a in sorted(DISCORD_ACTIONS, key=lambda x: x.type): + frag = a.automation_schema_def + if frag is None: + raise RuntimeError("action %s: automation_schema_def required" % a.type) + name = _def_name_action(a.type) + defs[name] = copy.deepcopy(frag) + action_refs.append({"$ref": "#/$defs/%s" % name}) + + defs["action_call_gatekeeper_tool"] = copy.deepcopy(SCHEMA_ACTION_CALL_GATEKEEPER_PRODUCT) + action_refs.append({"$ref": "#/$defs/action_call_gatekeeper_tool"}) + action_refs.sort(key=lambda r: r["$ref"]) + + defs["action"] = { + "type": "object", + "required": ["type"], + "description": "Discriminated union on 'type'.", + "oneOf": action_refs, + } + + return { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "flexus-discord-automation-v1", + "title": "Flexus Discord automation rules v1", + "description": ( + "Machine-readable contract for community bot automation. Built from flexus_client_kit Discord catalog " + "(ckit_connector_discord + product triggers/actions). Validate with jsonschema or check-jsonschema." + ), + "type": "object", + "required": ["automation_schema_version", "rules"], + "additionalProperties": False, + "properties": { + "automation_schema_version": { + "const": 1, + "description": "Schema version for forward-compatible migrations.", + }, + "rules": { + "type": "array", + "items": {"$ref": "#/$defs/rule"}, + "description": ( + "Ordered list of automation rules. Evaluation order matters only for actions that mutate state " + "consumed by later rules in the same event cycle." + ), + }, + }, + "$defs": defs, + } + + +if __name__ == "__main__": + import argparse + import json + from pathlib import Path + + p = argparse.ArgumentParser(description="Write automation v1 JSON Schema built from the Discord catalog.") + p.add_argument( + "--write", + metavar="PATH", + help="If set, write schema JSON to this path (UTF-8, indent 4).", + ) + args = p.parse_args() + doc = build_automation_v1_schema_document() + if args.write: + Path(args.write).write_text(json.dumps(doc, indent=4, ensure_ascii=False) + "\n", encoding="utf-8") + else: + print(json.dumps(doc, indent=2, ensure_ascii=False)) diff --git a/flexus_client_kit/ckit_bot_exec.py b/flexus_client_kit/ckit_bot_exec.py index 6db45fae..fa738e73 100644 --- a/flexus_client_kit/ckit_bot_exec.py +++ b/flexus_client_kit/ckit_bot_exec.py @@ -306,10 +306,12 @@ async def crash_boom_bang(fclient: ckit_client.FlexusClient, rcx: RobotContext, rcx.messengers.clear() # new loop will populate this with new auth continue except RestartBecauseSettingsChanged: + # Subscription handler already created a replacement bot instance with updated setup + # (lines ~496-504), so this old task must exit, not loop again with stale _restart_requested logger.info("%s restart requested (settings changed)", rcx.persona.persona_id) await rcx.wait_for_bg_tasks(timeout=30.0) - rcx.messengers.clear() # new loop will populate this with new settings - continue + rcx.messengers.clear() + break except asyncio.CancelledError: # Only happens on shutdown (hopefully) break diff --git a/flexus_client_kit/ckit_connector.py b/flexus_client_kit/ckit_connector.py new file mode 100644 index 00000000..ff40d768 --- /dev/null +++ b/flexus_client_kit/ckit_connector.py @@ -0,0 +1,122 @@ +# Abstract chat platform connector (unified bot plan U3): triggers, actions, normalized events. + +from __future__ import annotations + +import abc +import dataclasses +import logging +from typing import Any, Awaitable, Callable + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class SemanticContract: + """ + Canonical runtime semantics for one trigger or action: what authors persist, what the + executor fills, and hard guarantees from engine + executor code (single source with descriptors). + """ + + operator_summary: str + rule_author_configures: tuple[str, ...] = () + platform_fills_automatically: tuple[str, ...] = () + runtime_guarantees: tuple[str, ...] = () + operator_must_not_set: tuple[str, ...] = () + + +def semantic_contract_to_dict(contract: SemanticContract | None) -> dict[str, Any] | None: + if contract is None: + return None + return { + "operator_summary": contract.operator_summary, + "rule_author_configures": list(contract.rule_author_configures), + "platform_fills_automatically": list(contract.platform_fills_automatically), + "runtime_guarantees": list(contract.runtime_guarantees), + "operator_must_not_set": list(contract.operator_must_not_set), + } + + +@dataclasses.dataclass +class TriggerDescriptor: + type: str + label: str + description: str + payload_schema: dict + semantic_contract: SemanticContract | None = None + automation_schema_def: dict | None = None + + +@dataclasses.dataclass +class ActionDescriptor: + type: str + label: str + description: str + parameter_schema: dict + semantic_contract: SemanticContract | None = None + automation_schema_def: dict | None = None + + +@dataclasses.dataclass +class NormalizedEvent: + source: str + server_id: str + channel_id: str + user_id: str + event_type: str + payload: dict + timestamp: float + + +@dataclasses.dataclass +class ActionResult: + ok: bool + error: str | None = None + data: dict | None = None + + +class ChatConnector(abc.ABC): + @property + @abc.abstractmethod + def platform(self) -> str: + ... + + @property + @abc.abstractmethod + def raw_client(self) -> Any: + ... + + @abc.abstractmethod + async def connect(self) -> None: + ... + + @abc.abstractmethod + async def disconnect(self) -> None: + ... + + @abc.abstractmethod + def supported_triggers(self) -> list[TriggerDescriptor]: + ... + + @abc.abstractmethod + def supported_actions(self) -> list[ActionDescriptor]: + ... + + @abc.abstractmethod + def on_event(self, callback: Callable[[NormalizedEvent], Awaitable[None]]) -> None: + ... + + @abc.abstractmethod + async def execute_action(self, action_type: str, params: dict) -> ActionResult: + ... + + @abc.abstractmethod + def format_mention(self, user_id: str) -> str: + ... + + @abc.abstractmethod + async def get_user_info(self, user_id: str, server_id: str = "") -> dict | None: + ... + + @abc.abstractmethod + async def get_channel(self, channel_id: str) -> dict | None: + ... diff --git a/flexus_client_kit/ckit_connector_discord.py b/flexus_client_kit/ckit_connector_discord.py new file mode 100644 index 00000000..32ebdd12 --- /dev/null +++ b/flexus_client_kit/ckit_connector_discord.py @@ -0,0 +1,971 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Iterable +from typing import Any, Awaitable, Callable + +import discord +from discord.errors import DiscordException + +import flexus_client_kit.integrations.fi_discord_community as dc +from flexus_client_kit.ckit_discord_actions import discord_run_platform_action +from flexus_client_kit.ckit_discord_automation_schema_defs import ( + SCHEMA_ACTION_ADD_ROLE, + SCHEMA_ACTION_CANCEL_PENDING_JOBS, + SCHEMA_ACTION_ENQUEUE_CHECK, + SCHEMA_ACTION_KICK, + SCHEMA_ACTION_POST_TO_CHANNEL, + SCHEMA_ACTION_REMOVE_ROLE, + SCHEMA_ACTION_SEND_DM, + SCHEMA_ACTION_SET_CRM_FIELD, + SCHEMA_ACTION_SET_STATUS, + SCHEMA_TRIGGER_CRM_FIELD_CHANGED, + SCHEMA_TRIGGER_MEMBER_JOINED, + SCHEMA_TRIGGER_MEMBER_REMOVED, + SCHEMA_TRIGGER_MESSAGE_IN_CHANNEL, + SCHEMA_TRIGGER_SCHEDULED_RELATIVE_TO_FIELD, + SCHEMA_TRIGGER_STATUS_TRANSITION, +) +from flexus_client_kit.ckit_connector import ( + ActionDescriptor, + ActionResult, + ChatConnector, + NormalizedEvent, + SemanticContract, + TriggerDescriptor, + semantic_contract_to_dict, +) + +logger = logging.getLogger(__name__) + +_transports: dict[str, "_SharedDiscordTransport"] = {} +_transports_lock = asyncio.Lock() + + +DISCORD_TRIGGERS: list[TriggerDescriptor] = [ + TriggerDescriptor( + type="member_joined", + label="Member joined", + description="Fires when a new member joins the server", + payload_schema={ + "type": "object", + "properties": { + "guild_id": {"type": "integer"}, + "user_id": {"type": "integer"}, + "username": {"type": "string"}, + }, + }, + semantic_contract=SemanticContract( + operator_summary="Runs when someone joins a Discord server the bot is allowed to see.", + rule_author_configures=("trigger.type member_joined only (no extra trigger fields in saved JSON).",), + platform_fills_automatically=( + "DiscordConnector._handle_member_join sets NormalizedEvent.server_id to the guild id string, " + "user_id to the joined member id string, payload guild_id/user_id integers and username string.", + "Bot loads CRM member_doc for that guild and user before process_event.", + ), + runtime_guarantees=( + "ckit_automation_engine.match_trigger(event_type member_joined) is true iff rule trigger.type is member_joined.", + "Conditions and actions see the current CRM member row for the joined user.", + ), + operator_must_not_set=("Trigger payload keys in persisted rules (automation_v1 has none beyond type).",), + ), + automation_schema_def=SCHEMA_TRIGGER_MEMBER_JOINED, + ), + TriggerDescriptor( + type="message_in_channel", + label="Message in channel", + description="Fires when a message is posted in a watched channel", + payload_schema={ + "type": "object", + "properties": { + "channel_id": {"type": "integer"}, + "guild_id": {"type": "integer"}, + "user_id": {"type": "integer"}, + "content": {"type": "string"}, + "message_id": {"type": "string"}, + }, + }, + semantic_contract=SemanticContract( + operator_summary="Runs when a human posts in one configured channel.", + rule_author_configures=("trigger.channel_id_field referencing a setup key, bare numeric id, or #snowflake literal.",), + platform_fills_automatically=( + "resolve_channel_id(channel_id_field, setup) supplies the int channel id for matching.", + "Event payload channel_id/guild_id/user_id/content/message_id come from DiscordConnector._handle_message.", + ), + runtime_guarantees=( + "match_trigger requires event_data channel_id to equal resolve_channel_id result (both ints); " + "missing or unresolvable channel_id_field yields no match.", + ), + operator_must_not_set=("Hard-coded channel ids inside trigger except via channel_id_field string form.",), + ), + automation_schema_def=SCHEMA_TRIGGER_MESSAGE_IN_CHANNEL, + ), + TriggerDescriptor( + type="member_removed", + label="Member left/kicked", + description="Fires when a member leaves or is removed from the server", + payload_schema={ + "type": "object", + "properties": { + "guild_id": {"type": "integer"}, + "user_id": {"type": "integer"}, + "username": {"type": "string"}, + }, + }, + semantic_contract=SemanticContract( + operator_summary="Runs when someone leaves the server or is kicked (same Discord event).", + rule_author_configures=("trigger.type member_removed only (no extra trigger fields in saved JSON).",), + platform_fills_automatically=( + "DiscordConnector._handle_member_remove emits NormalizedEvent with user_id and payload guild_id/user_id " + "for the leaving member; CRM row is updated before rules run.", + ), + runtime_guarantees=( + "match_trigger(member_removed) is true iff rule trigger.type is member_removed.", + "discord_onboarding_bot runs member_removed rules after handle_member_remove, then status_transition to churned.", + ), + operator_must_not_set=("Extra trigger payload keys beyond type in persisted rules.",), + ), + automation_schema_def=SCHEMA_TRIGGER_MEMBER_REMOVED, + ), + TriggerDescriptor( + type="crm_field_changed", + label="CRM field changed", + description="Fires when a CRM field is modified by another rule", + payload_schema={ + "type": "object", + "properties": { + "field_name": {"type": "string"}, + "new_value": {}, + }, + }, + semantic_contract=SemanticContract( + operator_summary="Runs after another action updates a CRM field on the same member.", + rule_author_configures=( + "trigger.field_name; optional trigger.to_value to restrict to one new value.", + ), + platform_fills_automatically=( + "ckit_automation_actions.execute_actions returns field_change dicts; _run_cascade calls process_event " + "with event_type crm_field_changed and event_data field_name/new_value from the change.", + ), + runtime_guarantees=( + "match_trigger compares trigger.field_name to event_data.field_name; to_value omitted matches any new_value.", + ), + operator_must_not_set=(), + ), + automation_schema_def=SCHEMA_TRIGGER_CRM_FIELD_CHANGED, + ), + TriggerDescriptor( + type="status_transition", + label="Status transition", + description="Fires when lifecycle_status changes", + payload_schema={ + "type": "object", + "properties": { + "old_status": {"type": "string"}, + "new_status": {"type": "string"}, + }, + }, + semantic_contract=SemanticContract( + operator_summary="Runs when lifecycle_status on the member row changes.", + rule_author_configures=("trigger.to_status; optional trigger.from_status.",), + platform_fills_automatically=( + "Synthetic event_data old_status/new_status from set_status field_change or member remove flow.", + ), + runtime_guarantees=( + "match_trigger requires event_data new_status to equal trigger.to_status; from_status optional same way as crm to_value.", + ), + operator_must_not_set=(), + ), + automation_schema_def=SCHEMA_TRIGGER_STATUS_TRANSITION, + ), + TriggerDescriptor( + type="scheduled_relative_to_field", + label="Scheduled check", + description="Fires after a delay relative to a CRM field timestamp", + payload_schema={ + "type": "object", + "properties": { + "anchor_field": {"type": "string"}, + "delay_seconds": {"type": "integer"}, + }, + }, + semantic_contract=SemanticContract( + operator_summary="Runs later when a job fires for this rule after a delay from an anchor timestamp.", + rule_author_configures=( + "trigger.anchor_field (CRM unix float), trigger.delay_seconds, rule rule_id matched by the job.", + ), + platform_fills_automatically=( + "make_automation_job_handler schedules dc_community_jobs; handler builds event_data check_rule_id guild_id user_id.", + ), + runtime_guarantees=( + "match_trigger(scheduled_check) requires trigger.type scheduled_relative_to_field and " + "event_data.check_rule_id == rule.rule_id.", + "discord_onboarding_bot after member_joined may call execute_actions with a synthetic enqueue_check " + "that copies trigger anchor_field and delay_seconds so _do_enqueue_check schedules at anchor+delay.", + ), + operator_must_not_set=(), + ), + automation_schema_def=SCHEMA_TRIGGER_SCHEDULED_RELATIVE_TO_FIELD, + ), +] + + +DISCORD_ACTIONS: list[ActionDescriptor] = [ + ActionDescriptor( + type="send_dm", + label="Send DM", + description="Send a direct message to a user", + parameter_schema={ + "type": "object", + "properties": { + "user_id": {"type": "string"}, + "text": {"type": "string"}, + }, + "required": ["user_id", "text"], + }, + semantic_contract=SemanticContract( + operator_summary="Send a private message to the member in context (automation) or to explicit ids (connector API).", + rule_author_configures=( + "Persisted automation: exactly one of template or template_field for the body (automation_v1).", + "Connector call: user_id and text parameters as in parameter_schema.", + ), + platform_fills_automatically=( + "Engine: _resolve_body_fields sets _resolved_body using resolve_template on template or setup[template_field].", + "Executor _do_send_dm with connector passes user_id from member_doc and text from _resolved_body; " + "it does not read a recipient from the action dict beyond what member_doc supplies.", + ), + runtime_guarantees=( + "Empty _resolved_body after resolution fails with empty_body.", + "discord_run_platform_action send_dm uses params user_id text only.", + ), + operator_must_not_set=( + "Persisted rule: user_id field on the action (not in schema); recipient is always the CRM member in context.", + ), + ), + automation_schema_def=SCHEMA_ACTION_SEND_DM, + ), + ActionDescriptor( + type="post_to_channel", + label="Post to channel", + description="Post a message in a text channel", + parameter_schema={ + "type": "object", + "properties": { + "channel_id": {"type": "string"}, + "text": {"type": "string"}, + "server_id": {"type": "string"}, + }, + "required": ["channel_id", "text"], + }, + semantic_contract=SemanticContract( + operator_summary="Post a message into a chosen channel; guild scope comes from execution context when using the connector.", + rule_author_configures=( + "Persisted automation: channel_id_field and template (engine also resolves template_field like send_dm).", + "Connector call: channel_id text required; optional server_id disambiguates allowed guild in discord_run_platform_action.", + ), + platform_fills_automatically=( + "Engine: _resolve_channel_id(channel_id_field) -> _resolved_channel_id; _resolve_body_fields -> _resolved_body.", + "Executor _do_post_to_channel passes ctx.server_id into payload server_id when non-empty so the connector " + "can resolve guild_not_allowed against allowed guilds.", + ), + runtime_guarantees=( + "discord_run_platform_action post_to_channel loads the channel, requires TextChannel, " + "and resolve_guild(guild.id) must succeed or returns guild_not_allowed.", + ), + operator_must_not_set=( + "Persisted rule: server_id on the action; guild is implied by the event or job ctx.server_id.", + ), + ), + automation_schema_def=SCHEMA_ACTION_POST_TO_CHANNEL, + ), + ActionDescriptor( + type="set_crm_field", + label="Set CRM field", + description="Update a field on the member's CRM record", + parameter_schema={ + "type": "object", + "properties": { + "field": {"type": "string"}, + "value": {}, + }, + "required": ["field", "value"], + }, + semantic_contract=SemanticContract( + operator_summary="Write one field on the member CRM document for the current guild/user.", + rule_author_configures=("field name and value; value may be literal or the string {now} before engine resolution.",), + platform_fills_automatically=( + "Engine _resolve_set_crm_now replaces literal value {now} with float time.time() before execute_actions.", + ), + runtime_guarantees=( + "_do_set_crm_field updates Mongo by guild_id and user_id from member_doc; success refreshes ctx member_doc.", + ), + operator_must_not_set=("guild_id and user_id on the action; target row is ctx member_doc.",), + ), + automation_schema_def=SCHEMA_ACTION_SET_CRM_FIELD, + ), + ActionDescriptor( + type="set_status", + label="Set lifecycle status", + description="Change the member's lifecycle status", + parameter_schema={ + "type": "object", + "properties": {"status": {"type": "string"}}, + "required": ["status"], + }, + semantic_contract=SemanticContract( + operator_summary="Set lifecycle_status on the member; may emit status_transition to other rules.", + rule_author_configures=("status string.",), + platform_fills_automatically=(), + runtime_guarantees=( + "_do_set_status uses member_doc guild_id user_id; returns field_change is_status True for cascades.", + ), + operator_must_not_set=("Explicit member keys on the action.",), + ), + automation_schema_def=SCHEMA_ACTION_SET_STATUS, + ), + ActionDescriptor( + type="enqueue_check", + label="Schedule a check", + description="Enqueue a future scheduled check for this member", + parameter_schema={ + "type": "object", + "properties": { + "check_rule_id": {"type": "string"}, + "delay_seconds": {"type": "integer"}, + "anchor_field": {"type": "string"}, + }, + "required": ["check_rule_id", "delay_seconds"], + }, + semantic_contract=SemanticContract( + operator_summary="Queue a future run of another rule for this member.", + rule_author_configures=( + "check_rule_id of the target rule, delay_seconds; optional anchor_field for delay relative to a CRM timestamp.", + ), + platform_fills_automatically=( + "Job payload guild_id user_id persona_id filled from ctx member_doc and persona_id.", + ), + runtime_guarantees=( + "Dedup: pending job same kind and guild/user skipped with note deduped.", + "If anchor_field set, member_doc must have that field or action fails anchor_not_set.", + ), + operator_must_not_set=("guild_id and user_id in the action; taken from member_doc.",), + ), + automation_schema_def=SCHEMA_ACTION_ENQUEUE_CHECK, + ), + ActionDescriptor( + type="cancel_pending_jobs", + label="Cancel pending jobs", + description="Cancel scheduled jobs matching a prefix", + parameter_schema={ + "type": "object", + "properties": {"job_kind_prefix": {"type": "string"}}, + "required": ["job_kind_prefix"], + }, + semantic_contract=SemanticContract( + operator_summary="Mark pending scheduled jobs done for this member whose kind starts with a prefix.", + rule_author_configures=("job_kind_prefix string matched as regex prefix against dc_community_jobs.kind.",), + platform_fills_automatically=("Scope restricted to payload.guild_id and payload.user_id from member_doc.",), + runtime_guarantees=("_do_cancel_pending_jobs update_many sets done and cancelled flags.",), + operator_must_not_set=(), + ), + automation_schema_def=SCHEMA_ACTION_CANCEL_PENDING_JOBS, + ), + ActionDescriptor( + type="add_role", + label="Add role", + description="Add a Discord role to the member", + parameter_schema={ + "type": "object", + "properties": { + "user_id": {"type": "string"}, + "role_id": {"type": "string"}, + "server_id": {"type": "string"}, + }, + "required": ["user_id", "role_id"], + }, + semantic_contract=SemanticContract( + operator_summary="Add a Discord role to the member the rule is running for.", + rule_author_configures=("role_id_field naming a setup key, literal role id, or #snowflake (same as channel_id_field).",), + platform_fills_automatically=( + "Engine resolve_role_id(role_id_field, setup) -> _resolved_role_id; executor passes user_id from member_doc " + "and server_id from ctx to discord_run_platform_action.", + ), + runtime_guarantees=( + "discord_run_platform_action add_role requires member and role in guild; fails member_or_role_not_found if not.", + ), + operator_must_not_set=("user_id, server_id, guild_id, role_id on persisted action; use role_id_field only.",), + ), + automation_schema_def=SCHEMA_ACTION_ADD_ROLE, + ), + ActionDescriptor( + type="remove_role", + label="Remove role", + description="Remove a Discord role from the member", + parameter_schema={ + "type": "object", + "properties": { + "user_id": {"type": "string"}, + "role_id": {"type": "string"}, + "server_id": {"type": "string"}, + }, + "required": ["user_id", "role_id"], + }, + semantic_contract=SemanticContract( + operator_summary="Remove a Discord role from the member the rule is running for.", + rule_author_configures=("role_id_field like add_role.",), + platform_fills_automatically=("Same resolution and ctx filling as add_role.",), + runtime_guarantees=("Same as add_role for guild and member resolution.",), + operator_must_not_set=("user_id, server_id, guild_id, role_id on persisted action; use role_id_field only.",), + ), + automation_schema_def=SCHEMA_ACTION_REMOVE_ROLE, + ), + ActionDescriptor( + type="kick", + label="Kick member", + description="Kick the member from the server", + parameter_schema={ + "type": "object", + "properties": { + "user_id": {"type": "string"}, + "reason": {"type": "string"}, + "server_id": {"type": "string"}, + }, + "required": ["user_id"], + }, + semantic_contract=SemanticContract( + operator_summary="Kick the member the rule is running for from the current server.", + rule_author_configures=("Optional reason string; supports {field} placeholders like message templates.",), + platform_fills_automatically=( + "Executor supplies user_id from member_doc and server_id from ctx; optional reason resolved via " + "resolve_template when present.", + ), + runtime_guarantees=( + "discord_run_platform_action kick requires member in guild before kick; fails if member already left.", + ), + operator_must_not_set=("user_id, server_id, guild_id on persisted action.",), + ), + automation_schema_def=SCHEMA_ACTION_KICK, + ), +] + + +_DISCORD_AUTOMATION_CROSS_CUTTING: dict[str, dict[str, Any]] = { + "resolve_channel_id": semantic_contract_to_dict( + SemanticContract( + operator_summary="Turns channel_id_field strings into integer Discord channel ids for matching and posting.", + rule_author_configures=( + "channel_id_field on trigger message_in_channel or action post_to_channel.", + ), + platform_fills_automatically=( + "All-decimal string parses as int; #suffix parses suffix as int; else setup[key] coerced with int().", + ), + runtime_guarantees=( + "ckit_automation_engine.resolve_channel_id returns None on invalid input; engine match or action resolution fails closed.", + ), + operator_must_not_set=(), + ), + ), + "resolve_template": semantic_contract_to_dict( + SemanticContract( + operator_summary="Substitutes braced tokens in message templates for DM and channel posts.", + rule_author_configures=( + "template string and/or template_field referencing setup; send_dm and post_to_channel in automation.", + ), + platform_fills_automatically=( + "{now} -> unix seconds; {mention} -> formatted mention from member user_id; " + "other names from member then setup; unknown tokens left unchanged.", + ), + runtime_guarantees=( + "ckit_automation_engine._resolve_body_fields writes _resolved_body for send_dm and post_to_channel only.", + ), + operator_must_not_set=(), + ), + ), +} + + +def discord_automation_semantics_bundle() -> dict[str, Any]: + return { + "semantic_schema_version": 1, + "triggers": {t.type: semantic_contract_to_dict(t.semantic_contract) for t in DISCORD_TRIGGERS}, + "actions": {a.type: semantic_contract_to_dict(a.semantic_contract) for a in DISCORD_ACTIONS}, + "cross_cutting": dict(_DISCORD_AUTOMATION_CROSS_CUTTING), + } + + +class DiscordConnector(ChatConnector): + def __init__( + self, + token: str, + persona_id: str, + *, + initial_guild_ids: set[int] | None = None, + ) -> None: + self._token = token + self._persona_id = persona_id + self._allowed_guild_ids: set[int] = set(initial_guild_ids or []) + self._connected_announced: set[int] = set() + self._missing_access_announced: set[int] = set() + self._client: discord.Client | None = None + self._task: asyncio.Task[None] | None = None + self._shared_transport: _SharedDiscordTransport | None = None + self._event_callback: Callable[[NormalizedEvent], Awaitable[None]] | None = None + + @property + def platform(self) -> str: + return "discord" + + @property + def raw_client(self) -> discord.Client | None: + return self._client + + @property + def allowed_guild_ids(self) -> frozenset[int]: + return frozenset(self._allowed_guild_ids) + + @property + def guild(self) -> discord.Guild | None: + c = self._client + if c is None: + return None + for g in c.guilds: + if int(g.id) in self._allowed_guild_ids: + return g + return c.guilds[0] if c.guilds else None + + def supported_triggers(self) -> list[TriggerDescriptor]: + return DISCORD_TRIGGERS + + def supported_actions(self) -> list[ActionDescriptor]: + return DISCORD_ACTIONS + + def on_event(self, callback: Callable[[NormalizedEvent], Awaitable[None]]) -> None: + self._event_callback = callback + + def format_mention(self, user_id: str) -> str: + return "<@%s>" % (user_id,) + + def _guild_allowed(self, guild: discord.Guild | None) -> bool: + if guild is None: + return False + return int(guild.id) in self._allowed_guild_ids + + def _find_guild(self, guild_id: int) -> discord.Guild | None: + c = self._client + if c is None: + return None + if guild_id not in self._allowed_guild_ids: + return None + g = c.get_guild(guild_id) + return g + + def _resolve_action_guild_id(self, params: dict) -> int | None: + raw = params.get("server_id") or params.get("guild_id") or "" + if raw is None or str(raw).strip() == "": + return None + try: + gid = int(raw) + except (TypeError, ValueError): + return None + return gid + + def _allowed_guild_ids_not_visible( + self, + client: discord.Client, + ) -> set[int]: + visible = {int(g.id) for g in client.guilds} + return {gid for gid in self._allowed_guild_ids if gid not in visible} + + async def _emit_missing_allowed_guild_access_once( + self, + client: discord.Client, + gids: Iterable[int], + ) -> None: + not_visible = self._allowed_guild_ids_not_visible(client) + for gid in gids: + if gid not in not_visible: + continue + if gid in self._missing_access_announced: + continue + dc.log_ctx( + self._persona_id, + gid, + "allowed guild not visible to bot token (not in client.guilds / no access)", + ) + await self._emit( + NormalizedEvent( + source="discord", + server_id=str(gid), + channel_id="", + user_id="", + event_type="server_disconnected", + payload={ + "guild_id": gid, + "missing_bot_access": True, + }, + timestamp=time.time(), + ), + ) + self._missing_access_announced.add(gid) + + async def set_allowed_guild_ids(self, ids: Iterable[int]) -> None: + new_set = {int(x) for x in ids} + old = self._allowed_guild_ids + removed = old - new_set + added = new_set - old + self._allowed_guild_ids = new_set + for gid in removed: + self._missing_access_announced.discard(gid) + if gid in self._connected_announced: + self._connected_announced.discard(gid) + await self._emit( + NormalizedEvent( + source="discord", + server_id=str(gid), + channel_id="", + user_id="", + event_type="server_disconnected", + payload={"guild_id": gid}, + timestamp=time.time(), + ), + ) + c = self._client + if c is not None: + for g in list(c.guilds): + gid = int(g.id) + if gid in new_set and gid not in self._connected_announced: + await self._emit_server_connected(g) + for gid in added: + g = c.get_guild(gid) + if g is not None and gid not in self._connected_announced: + await self._emit_server_connected(g) + elif g is None: + await self._emit_missing_allowed_guild_access_once(c, [gid]) + + async def update_guild_ids(self, ids: Iterable[int]) -> None: + await self.set_allowed_guild_ids(ids) + + async def get_user_info(self, user_id: str, server_id: str = "") -> dict | None: + c = self._client + if c is None: + return None + try: + uid = int(user_id) + except (TypeError, ValueError): + return None + if server_id and str(server_id).strip(): + try: + gid = int(server_id) + except (TypeError, ValueError): + return None + g = self._find_guild(gid) + if g is None: + return None + member = g.get_member(uid) + if member is None: + return None + return {"user_id": str(member.id), "display_name": member.display_name} + for gid in self._allowed_guild_ids: + g = c.get_guild(gid) + if g is None: + continue + member = g.get_member(uid) + if member is not None: + return {"user_id": str(member.id), "display_name": member.display_name} + return None + + async def get_channel(self, channel_id: str) -> dict | None: + c = self._client + if c is None: + return None + try: + cid = int(channel_id) + except (TypeError, ValueError): + return None + ch = c.get_channel(cid) + if ch is None: + return None + g = getattr(ch, "guild", None) + if g is not None and not self._guild_allowed(g): + return None + nm = getattr(ch, "name", None) or "" + out: dict = {"channel_id": str(ch.id), "name": nm, "type": str(ch.type)} + if g is not None: + out["guild_id"] = str(g.id) + me = g.me + if me is not None and hasattr(ch, "permissions_for"): + pr = ch.permissions_for(me) + out["view_channel"] = pr.view_channel + out["send_messages"] = pr.send_messages + out["read_message_history"] = pr.read_message_history + out["manage_messages"] = pr.manage_messages + return out + + async def _emit(self, event: NormalizedEvent) -> None: + cb = self._event_callback + if cb is None: + return + await cb(event) + + async def _emit_server_connected(self, g: discord.Guild) -> None: + gid = int(g.id) + self._missing_access_announced.discard(gid) + self._connected_announced.add(gid) + mc = getattr(g, "member_count", None) + if mc is None: + mc = 0 + await self._emit( + NormalizedEvent( + source="discord", + server_id=str(gid), + channel_id="", + user_id="", + event_type="server_connected", + payload={ + "guild_id": gid, + "guild_name": g.name or "", + "approx_member_count": int(mc), + }, + timestamp=time.time(), + ), + ) + + async def _handle_member_join(self, member: discord.Member) -> None: + if member.bot or not self._guild_allowed(member.guild): + return + event = NormalizedEvent( + source="discord", + server_id=str(member.guild.id), + channel_id="", + user_id=str(member.id), + event_type="member_joined", + payload={ + "username": str(member), + "guild_id": int(member.guild.id), + "user_id": int(member.id), + }, + timestamp=time.time(), + ) + await self._emit(event) + + async def _handle_message(self, message: discord.Message) -> None: + if message.author.bot: + return + if not message.guild: + return + if isinstance(message.channel, discord.DMChannel): + return + if not self._guild_allowed(message.guild): + return + event = NormalizedEvent( + source="discord", + server_id=str(message.guild.id), + channel_id=str(message.channel.id), + user_id=str(message.author.id), + event_type="message_in_channel", + payload={ + "content": message.content or "", + "channel_id": int(message.channel.id), + "guild_id": int(message.guild.id), + "user_id": int(message.author.id), + "message_id": str(message.id), + }, + timestamp=time.time(), + ) + await self._emit(event) + + async def _handle_member_remove(self, member: discord.Member) -> None: + if member.bot or not self._guild_allowed(member.guild): + return + event = NormalizedEvent( + source="discord", + server_id=str(member.guild.id), + channel_id="", + user_id=str(member.id), + event_type="member_removed", + payload={ + "username": str(member), + "guild_id": int(member.guild.id), + "user_id": int(member.id), + }, + timestamp=time.time(), + ) + await self._emit(event) + + async def _handle_guild_remove(self, guild: discord.Guild) -> None: + gid = int(guild.id) + if gid not in self._allowed_guild_ids and gid not in self._connected_announced: + return + if gid in self._connected_announced: + self._connected_announced.discard(gid) + if gid in self._allowed_guild_ids: + self._missing_access_announced.add(gid) + await self._emit( + NormalizedEvent( + source="discord", + server_id=str(gid), + channel_id="", + user_id="", + event_type="server_disconnected", + payload={"guild_id": gid}, + timestamp=time.time(), + ), + ) + + async def _handle_guild_join_available(self, guild: discord.Guild) -> None: + if not self._guild_allowed(guild): + return + gid = int(guild.id) + if gid in self._connected_announced: + return + await self._emit_server_connected(guild) + + async def _sync_shared_ready(self, client: discord.Client) -> None: + for g in list(client.guilds): + if self._guild_allowed(g) and int(g.id) not in self._connected_announced: + await self._emit_server_connected(g) + await self._emit_missing_allowed_guild_access_once( + client, + self._allowed_guild_ids, + ) + + async def connect(self) -> None: + tr = await _get_or_create_transport(self._token) + self._shared_transport = tr + await tr.attach(self) + + async def disconnect(self) -> None: + tr = self._shared_transport + self._shared_transport = None + if tr is not None: + await tr.detach(self) + self._client = None + self._task = None + self._connected_announced.clear() + self._missing_access_announced.clear() + + async def execute_action(self, action_type: str, params: dict) -> ActionResult: + client = self._client + if client is None: + return ActionResult(ok=False, error="not_connected") + + def resolve_guild(gid: int) -> discord.Guild | None: + if gid not in self._allowed_guild_ids: + return None + return client.get_guild(gid) + + return await discord_run_platform_action( + client, + self._persona_id, + action_type, + params, + resolve_guild=resolve_guild, + ) + + +class _SharedDiscordTransport: + def __init__(self, token: str) -> None: + self._token = token + self._client: discord.Client | None = None + self._task: asyncio.Task[None] | None = None + self._connectors: set[DiscordConnector] = set() + self._lock = asyncio.Lock() + + def _bind_client_events(self, client: discord.Client) -> None: + tr = self + + @client.event + async def on_ready() -> None: + logger.info("discord shared transport ready as %s", client.user) + for c in list(tr._connectors): + await c._sync_shared_ready(client) + + @client.event + async def on_member_join(member: discord.Member) -> None: + for c in list(tr._connectors): + await c._handle_member_join(member) + + @client.event + async def on_message(message: discord.Message) -> None: + for c in list(tr._connectors): + await c._handle_message(message) + + @client.event + async def on_member_remove(member: discord.Member) -> None: + for c in list(tr._connectors): + await c._handle_member_remove(member) + + @client.event + async def on_guild_remove(guild: discord.Guild) -> None: + for c in list(tr._connectors): + await c._handle_guild_remove(guild) + + @client.event + async def on_guild_join(guild: discord.Guild) -> None: + for c in list(tr._connectors): + await c._handle_guild_join_available(guild) + + @client.event + async def on_guild_available(guild: discord.Guild) -> None: + for c in list(tr._connectors): + await c._handle_guild_join_available(guild) + + def _start_client(self) -> None: + if self._client is not None: + return + client = discord.Client(intents=dc.build_intents()) + self._bind_client_events(client) + self._client = client + + async def _runner() -> None: + try: + await client.start(self._token) + except asyncio.CancelledError: + raise + except DiscordException as e: + logger.error( + "discord shared transport died: %s %s", + type(e).__name__, + e, + ) + + self._task = asyncio.create_task(_runner()) + + async def _stop_client(self) -> None: + await dc.close_discord_client(self._client, self._task) + self._client = None + self._task = None + async with _transports_lock: + if _transports.get(self._token) is self: + del _transports[self._token] + + async def attach(self, conn: DiscordConnector) -> None: + async with self._lock: + first = len(self._connectors) == 0 + self._connectors.add(conn) + if first: + self._start_client() + assert self._client is not None + conn._client = self._client + cli = self._client + if cli.is_ready(): + await conn._sync_shared_ready(cli) + + async def detach(self, conn: DiscordConnector) -> None: + async with self._lock: + self._connectors.discard(conn) + conn._client = None + if not self._connectors: + await self._stop_client() + + +async def _get_or_create_transport(token: str) -> _SharedDiscordTransport: + async with _transports_lock: + existing = _transports.get(token) + if existing is not None: + return existing + created = _SharedDiscordTransport(token) + _transports[token] = created + return created diff --git a/flexus_client_kit/ckit_connector_discord_gateway.py b/flexus_client_kit/ckit_connector_discord_gateway.py new file mode 100644 index 00000000..5ccca48b --- /dev/null +++ b/flexus_client_kit/ckit_connector_discord_gateway.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Iterable +from typing import Any + +from flexus_client_kit.ckit_connector import ( + ActionDescriptor, + ActionResult, + ChatConnector, + NormalizedEvent, + TriggerDescriptor, +) +from flexus_client_kit.ckit_connector_discord import DISCORD_ACTIONS, DISCORD_TRIGGERS +from flexus_client_kit.gateway.ckit_gateway_redis import DiscordGatewayRedisSidecar + + +class DiscordGatewayConnector(ChatConnector): + def __init__( + self, + token: str, + persona_id: str, + *, + initial_guild_ids: set[int] | None = None, + sidecar: DiscordGatewayRedisSidecar | None = None, + ) -> None: + self._persona_id = persona_id + self._allowed_guild_ids: set[int] = set(initial_guild_ids or []) + self._sidecar = sidecar or DiscordGatewayRedisSidecar(token) + self._event_callback: Callable[[NormalizedEvent], Awaitable[None]] | None = None + self._connected = False + + @property + def platform(self) -> str: + return "discord" + + @property + def raw_client(self) -> Any: + return None + + @property + def allowed_guild_ids(self) -> frozenset[int]: + return frozenset(self._allowed_guild_ids) + + @property + def gateway_instance_key(self) -> str: + return self._sidecar.gateway_instance_key + + def supported_triggers(self) -> list[TriggerDescriptor]: + return DISCORD_TRIGGERS + + def supported_actions(self) -> list[ActionDescriptor]: + return DISCORD_ACTIONS + + def on_event(self, callback: Callable[[NormalizedEvent], Awaitable[None]]) -> None: + self._event_callback = callback + + def format_mention(self, user_id: str) -> str: + return "<@%s>" % (user_id,) + + async def set_allowed_guild_ids(self, ids: Iterable[int]) -> None: + self._allowed_guild_ids = {int(x) for x in ids} + + async def update_guild_ids(self, ids: Iterable[int]) -> None: + await self.set_allowed_guild_ids(ids) + + async def get_user_info(self, user_id: str, server_id: str = "") -> dict | None: + if not self._connected: + return None + r = await self._sidecar.get_user_info(self._persona_id, user_id, server_id) + if not r.ok or not r.data: + return None + return dict(r.data) + + async def get_channel(self, channel_id: str) -> dict | None: + if not self._connected: + return None + r = await self._sidecar.get_channel(self._persona_id, channel_id) + if not r.ok or not r.data: + return None + return dict(r.data) + + def _guild_allowed_id(self, server_id: str) -> bool: + if not server_id.strip(): + return False + try: + gid = int(server_id) + except (TypeError, ValueError): + return False + return gid in self._allowed_guild_ids + + async def _dispatch(self, event: NormalizedEvent) -> None: + if not self._guild_allowed_id(event.server_id): + return + cb = self._event_callback + if cb is not None: + await cb(event) + + async def connect(self) -> None: + await self._sidecar.start_event_consumer(self._dispatch) + self._connected = True + + async def disconnect(self) -> None: + self._connected = False + await self._sidecar.close() + + async def execute_action(self, action_type: str, params: dict) -> ActionResult: + if not self._connected: + return ActionResult(ok=False, error="not_connected") + return await self._sidecar.execute_action( + self._persona_id, + action_type, + params, + ) diff --git a/flexus_client_kit/ckit_crm_members.py b/flexus_client_kit/ckit_crm_members.py new file mode 100644 index 00000000..38ef280a --- /dev/null +++ b/flexus_client_kit/ckit_crm_members.py @@ -0,0 +1,445 @@ +""" +CRM member persistence for the Discord automation engine. + +Owns the dc_members MongoDB collection: indexes, CRUD, handler-facing entry points, +and one-time migration from dc_onboarding_state. Matches async pymongo usage elsewhere +in flexus_client_kit (no ORM). +""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from pymongo import ReturnDocument +from pymongo.errors import PyMongoError + +logger = logging.getLogger(__name__) + +# Primary collection for per-guild Discord member CRM documents (see crm_member.md). +COL_MEMBERS = "dc_members" + +# Legacy onboarding collection name before rename to dc_onboarding_state_legacy. +LEGACY_ONBOARDING = "dc_onboarding_state" + + +def _member_filter(guild_id: int, user_id: int) -> dict[str, int]: + """Equality filter for the compound natural key used across all member operations.""" + return {"guild_id": int(guild_id), "user_id": int(user_id)} + + +async def ensure_member_indexes(db: Any) -> None: + """ + Create dc_members indexes once at bot startup. Idempotent: repeated calls are safe. + + Indexes match the CRM contract: unique member key, sparse scans on status and + last_message_ts, and workspace routing for future multi-tenant gateway (U4). + """ + try: + coll = db[COL_MEMBERS] + await coll.create_index( + [("guild_id", 1), ("user_id", 1)], + unique=True, + ) + await coll.create_index( + [("lifecycle_status", 1)], + sparse=True, + ) + await coll.create_index( + [("last_message_ts", 1)], + sparse=True, + ) + await coll.create_index( + [("workspace_id", 1)], + unique=False, + ) + except PyMongoError as e: + logger.error("ensure_member_indexes: MongoDB index creation failed", exc_info=e) + raise + + +async def upsert_member_on_join( + db: Any, + guild_id: int, + user_id: int, + workspace_id: str, + discord_username: str, +) -> dict: + """ + Insert or update a member row on Discord on_member_join. + + $set refreshes join time, username, lifecycle, and workspace so re-join updates + mutable CRM fields. $setOnInsert applies defaults only on first insert so tags, + intro timestamps, and other accumulated fields survive leave/re-join cycles. + """ + try: + coll = db[COL_MEMBERS] + flt = _member_filter(guild_id, user_id) + now = time.time() + doc = await coll.find_one_and_update( + flt, + { + "$set": { + "member_joined_at": now, + "discord_username": discord_username, + "lifecycle_status": "accepted", + "workspace_id": workspace_id, + "platform": "discord", + }, + "$setOnInsert": { + "dm_opt_out": False, + "tags": [], + "networking_opt_in": False, + }, + }, + upsert=True, + return_document=ReturnDocument.AFTER, + ) + if doc is None: + raise RuntimeError("upsert_member_on_join: find_one_and_update returned None after upsert") + return doc + except PyMongoError as e: + logger.error( + "upsert_member_on_join: guild_id=%s user_id=%s failed", + guild_id, + user_id, + exc_info=e, + ) + raise + + +async def get_member(db: Any, guild_id: int, user_id: int) -> dict | None: + """Load a single member document by guild and user id, or None if absent.""" + try: + coll = db[COL_MEMBERS] + return await coll.find_one(_member_filter(guild_id, user_id)) + except PyMongoError as e: + logger.error("get_member: guild_id=%s user_id=%s failed", guild_id, user_id, exc_info=e) + raise + + +async def update_member_field( + db: Any, + guild_id: int, + user_id: int, + field: str, + value: Any, +) -> dict | None: + """Set one CRM field and return the post-update document (None if member row missing).""" + try: + if not isinstance(field, str) or not field: + raise TypeError("update_member_field: field must be a non-empty str") + coll = db[COL_MEMBERS] + return await coll.find_one_and_update( + _member_filter(guild_id, user_id), + {"$set": {field: value}}, + return_document=ReturnDocument.AFTER, + ) + except TypeError as e: + logger.error( + "update_member_field: guild_id=%s user_id=%s field=%r invalid", + guild_id, + user_id, + field, + exc_info=e, + ) + raise + except PyMongoError as e: + logger.error( + "update_member_field: guild_id=%s user_id=%s field=%s failed", + guild_id, + user_id, + field, + exc_info=e, + ) + raise + + +async def update_member_fields( + db: Any, + guild_id: int, + user_id: int, + fields: dict, +) -> dict | None: + """Atomically $set multiple CRM fields; returns updated doc or None if no row exists.""" + try: + if not isinstance(fields, dict): + raise TypeError("update_member_fields: fields must be a dict") + coll = db[COL_MEMBERS] + if not fields: + # No $set payload: return current row without claiming a multi-field update failed. + return await coll.find_one(_member_filter(guild_id, user_id)) + return await coll.find_one_and_update( + _member_filter(guild_id, user_id), + {"$set": fields}, + return_document=ReturnDocument.AFTER, + ) + except TypeError as e: + logger.error( + "update_member_fields: guild_id=%s user_id=%s invalid fields dict", + guild_id, + user_id, + exc_info=e, + ) + raise + except PyMongoError as e: + logger.error( + "update_member_fields: guild_id=%s user_id=%s failed", + guild_id, + user_id, + exc_info=e, + ) + raise + + +async def set_member_status( + db: Any, + guild_id: int, + user_id: int, + new_status: str, +) -> tuple[dict | None, str | None]: + """ + Atomically set lifecycle_status and expose the previous value for status_transition rules. + + Uses ReturnDocument.BEFORE so old lifecycle_status is read from the same atomic + update. The first tuple element is the effective new member view (BEFORE doc with + lifecycle_status overwritten) so callers avoid a second round-trip. + """ + try: + if not isinstance(new_status, str): + raise TypeError("set_member_status: new_status must be str") + coll = db[COL_MEMBERS] + old_doc = await coll.find_one_and_update( + _member_filter(guild_id, user_id), + {"$set": {"lifecycle_status": new_status}}, + return_document=ReturnDocument.BEFORE, + ) + if old_doc is None: + return (None, None) + old_status = old_doc.get("lifecycle_status") + old_status_str = old_status if isinstance(old_status, str) else None + merged = dict(old_doc) + merged["lifecycle_status"] = new_status + return (merged, old_status_str) + except TypeError as e: + logger.error( + "set_member_status: guild_id=%s user_id=%s new_status=%r invalid", + guild_id, + user_id, + new_status, + exc_info=e, + ) + raise + except PyMongoError as e: + logger.error( + "set_member_status: guild_id=%s user_id=%s new_status=%s failed", + guild_id, + user_id, + new_status, + exc_info=e, + ) + raise + + +async def handle_member_join( + db: Any, + guild_id: int, + user_id: int, + workspace_id: str, + username: str, +) -> dict: + """Engine hook: persist join metadata before automation rules run on member_joined.""" + try: + return await upsert_member_on_join( + db, + guild_id, + user_id, + workspace_id, + username, + ) + except PyMongoError as e: + logger.error( + "handle_member_join: guild_id=%s user_id=%s failed", + guild_id, + user_id, + exc_info=e, + ) + raise + + +async def handle_message(db: Any, guild_id: int, user_id: int) -> None: + """ + Engine hook: bump last_message_ts for inactivity and message-triggered automation. + + Upserts a minimal dc_members row on first observed message so get_member() and + rules that run after handle_message (e.g. message_in_channel) work even when + on_member_join never fired in this deployment. Existing rows only get + last_message_ts updated; we do not set member_joined_at or workspace_id here. + """ + try: + coll = db[COL_MEMBERS] + now = time.time() + await coll.update_one( + _member_filter(guild_id, user_id), + { + "$set": {"last_message_ts": now}, + "$setOnInsert": { + "platform": "discord", + "dm_opt_out": False, + "tags": [], + "networking_opt_in": False, + }, + }, + upsert=True, + ) + except PyMongoError as e: + logger.error( + "handle_message: guild_id=%s user_id=%s failed", + guild_id, + user_id, + exc_info=e, + ) + raise + + +async def handle_member_remove( + db: Any, + guild_id: int, + user_id: int, +) -> tuple[str | None, str | None]: + """ + Engine hook: mark member churned and return (old_status, new_status) for cascades. + + new_status is always the literal "churned". If no CRM row exists, returns (None, None). + """ + try: + coll = db[COL_MEMBERS] + old_doc = await coll.find_one_and_update( + _member_filter(guild_id, user_id), + {"$set": {"lifecycle_status": "churned"}}, + return_document=ReturnDocument.BEFORE, + ) + if old_doc is None: + return (None, None) + old_raw = old_doc.get("lifecycle_status") + old_status = old_raw if isinstance(old_raw, str) else None + return (old_status, "churned") + except PyMongoError as e: + logger.error( + "handle_member_remove: guild_id=%s user_id=%s failed", + guild_id, + user_id, + exc_info=e, + ) + raise + + +def _legacy_float(raw: Any) -> float | None: + """Parse legacy onboarding numeric timestamps; None if missing or not coercible.""" + if raw is None: + return None + try: + return float(raw) + except (TypeError, ValueError): + return None + + +async def migrate_legacy_collections(db: Any) -> None: + """ + One-time import from dc_onboarding_state into dc_members, then rename the source. + + Idempotent: if dc_members already contains any document, skips the whole migration + (including rename) so a second startup does not duplicate or break indexes. + Does not read dc_member_activity (different DB / deferred to U4). + """ + try: + members = db[COL_MEMBERS] + if await members.count_documents({}, limit=1) > 0: + logger.info( + "migrate_legacy_collections: skip (collection %s already has documents)", + COL_MEMBERS, + ) + return + + names = await db.list_collection_names() + if LEGACY_ONBOARDING not in names: + logger.info( + "migrate_legacy_collections: skip (collection %s not found)", + LEGACY_ONBOARDING, + ) + return + + src = db[LEGACY_ONBOARDING] + migrated = 0 + async for leg in src.find({}): + gid = leg.get("guild_id") + uid = leg.get("user_id") + if gid is None or uid is None: + logger.info("migrate_legacy_collections: skip row without guild_id/user_id") + continue + try: + guild_id = int(gid) + user_id = int(uid) + except (TypeError, ValueError): + logger.info("migrate_legacy_collections: skip row with non-int guild_id/user_id") + continue + + joined_ts = _legacy_float(leg.get("joined_ts")) + if joined_ts is None: + logger.info( + "migrate_legacy_collections: skip guild_id=%s user_id=%s (no joined_ts)", + guild_id, + user_id, + ) + continue + + followup_ts = _legacy_float(leg.get("followup_ts")) + engaged = leg.get("engaged") is True + followup_sent = leg.get("followup_sent") is True + last_msg = _legacy_float(leg.get("last_message_ts")) + + intro_done_at = None + if engaged: + intro_done_at = followup_ts if followup_ts is not None else joined_ts + 1.0 + + intro_reminder_sent_at = None + if followup_sent: + intro_reminder_sent_at = ( + followup_ts if followup_ts is not None else joined_ts + 172800.0 + ) + + new_doc: dict[str, Any] = { + "guild_id": guild_id, + "user_id": user_id, + "workspace_id": "", + "discord_username": "", + "member_joined_at": joined_ts, + "lifecycle_status": "accepted", + "dm_opt_out": False, + "tags": [], + "networking_opt_in": False, + } + if intro_done_at is not None: + new_doc["intro_done_at"] = intro_done_at + if intro_reminder_sent_at is not None: + new_doc["intro_reminder_sent_at"] = intro_reminder_sent_at + if last_msg is not None: + new_doc["last_message_ts"] = last_msg + + await members.insert_one(new_doc) + migrated += 1 + + logger.info( + "migrate_legacy_collections: inserted %s documents into %s", + migrated, + COL_MEMBERS, + ) + await src.rename("dc_onboarding_state_legacy") + logger.info( + "migrate_legacy_collections: renamed %s to dc_onboarding_state_legacy", + LEGACY_ONBOARDING, + ) + except PyMongoError as e: + logger.error("migrate_legacy_collections: MongoDB operation failed", exc_info=e) + raise diff --git a/flexus_client_kit/ckit_discord_actions.py b/flexus_client_kit/ckit_discord_actions.py new file mode 100644 index 00000000..2769467f --- /dev/null +++ b/flexus_client_kit/ckit_discord_actions.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from collections.abc import Callable + +import aiohttp +import discord +from discord.errors import DiscordException + +import flexus_client_kit.integrations.fi_discord_community as dc +from flexus_client_kit.ckit_connector import ActionResult + + +async def discord_run_platform_action( + client: discord.Client, + persona_id: str, + action_type: str, + params: dict, + *, + resolve_guild: Callable[[int], discord.Guild | None], +) -> ActionResult: + if action_type == "send_dm": + try: + uid = int(params["user_id"]) + text = str(params["text"]) + except (TypeError, ValueError, KeyError): + return ActionResult(ok=False, error="bad_params") + try: + user = await client.fetch_user(uid) + except DiscordException as e: + dc.log_ctx(persona_id, None, "send_dm fetch_user: %s %s", type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + except aiohttp.ClientError as e: + dc.log_ctx(persona_id, None, "send_dm fetch_user network: %s %s", type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + try: + ok = await dc.safe_dm(client, user, persona_id, text) + return ActionResult(ok=ok) + except DiscordException as e: + dc.log_ctx(persona_id, None, "send_dm: %s %s", type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + except aiohttp.ClientError as e: + dc.log_ctx(persona_id, None, "send_dm network: %s %s", type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + + if action_type == "post_to_channel": + try: + cid = int(params["channel_id"]) + text = str(params["text"]) + except (TypeError, ValueError, KeyError): + return ActionResult(ok=False, error="bad_params") + ch: discord.abc.GuildChannel | discord.Thread | discord.abc.PrivateChannel | None = None + try: + ch = client.get_channel(cid) + if not isinstance(ch, discord.TextChannel): + return ActionResult(ok=False, error="channel_not_found") + gch = ch.guild + if gch is None or resolve_guild(int(gch.id)) is None: + return ActionResult(ok=False, error="guild_not_allowed") + msg = await dc.safe_send(ch, persona_id, text) + return ActionResult(ok=msg is not None) + except DiscordException as e: + lg = None + if isinstance(ch, discord.TextChannel) and ch.guild is not None: + lg = int(ch.guild.id) + dc.log_ctx(persona_id, lg, "post_to_channel: %s %s", type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + except aiohttp.ClientError as e: + dc.log_ctx(persona_id, None, "post_to_channel network: %s %s", type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + + if action_type == "get_user_info": + try: + uid = int(params["user_id"]) + except (TypeError, ValueError, KeyError): + return ActionResult(ok=False, error="bad_params") + raw_sid = params.get("server_id") or params.get("guild_id") or "" + if str(raw_sid).strip(): + try: + gid = int(raw_sid) + except (TypeError, ValueError): + return ActionResult(ok=False, error="bad_server_id") + g = resolve_guild(gid) + if g is None: + return ActionResult(ok=False, error="guild_not_found") + member = g.get_member(uid) + if member is None: + try: + member = await g.fetch_member(uid) + except DiscordException: + member = None + if member is None: + return ActionResult(ok=False, error="member_not_found") + return ActionResult( + ok=True, + data={"user_id": str(member.id), "display_name": member.display_name}, + ) + for guild in client.guilds: + member = guild.get_member(uid) + if member is not None: + return ActionResult( + ok=True, + data={"user_id": str(member.id), "display_name": member.display_name}, + ) + return ActionResult(ok=False, error="member_not_found") + + if action_type == "get_channel": + try: + cid = int(params["channel_id"]) + except (TypeError, ValueError, KeyError): + return ActionResult(ok=False, error="bad_params") + ch = client.get_channel(cid) + if ch is None: + return ActionResult(ok=False, error="channel_not_found") + gch = getattr(ch, "guild", None) + if gch is None: + return ActionResult(ok=False, error="not_guild_channel") + if resolve_guild(int(gch.id)) is None: + return ActionResult(ok=False, error="guild_not_allowed") + nm = getattr(ch, "name", None) or "" + data: dict = { + "channel_id": str(ch.id), + "name": nm, + "type": str(ch.type), + "guild_id": str(gch.id), + } + me = gch.me + if me is not None and hasattr(ch, "permissions_for"): + pr = ch.permissions_for(me) + data["view_channel"] = pr.view_channel + data["send_messages"] = pr.send_messages + data["read_message_history"] = pr.read_message_history + data["manage_messages"] = pr.manage_messages + return ActionResult(ok=True, data=data) + + g: discord.Guild | None = None + if action_type in ("add_role", "remove_role", "kick"): + raw = params.get("server_id") or params.get("guild_id") or "" + if raw is None or str(raw).strip() == "": + return ActionResult(ok=False, error="missing_server_id") + try: + gid = int(raw) + except (TypeError, ValueError): + return ActionResult(ok=False, error="bad_params") + g = resolve_guild(gid) + if g is None: + return ActionResult(ok=False, error="guild_not_found") + + if action_type in ("add_role", "remove_role"): + try: + uid = int(params["user_id"]) + rid = int(params["role_id"]) + except (TypeError, ValueError, KeyError): + return ActionResult(ok=False, error="bad_params") + try: + member = g.get_member(uid) + role = g.get_role(rid) + if member is None or role is None: + return ActionResult(ok=False, error="member_or_role_not_found") + if action_type == "add_role": + await member.add_roles(role) + else: + await member.remove_roles(role) + return ActionResult(ok=True) + except DiscordException as e: + dc.log_ctx(persona_id, g.id, "%s: %s %s", action_type, type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + except aiohttp.ClientError as e: + dc.log_ctx(persona_id, g.id, "%s network: %s %s", action_type, type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + + if action_type == "kick": + try: + uid = int(params["user_id"]) + except (TypeError, ValueError, KeyError): + return ActionResult(ok=False, error="bad_params") + reason = str(params.get("reason") or "") + try: + member = g.get_member(uid) + if member is None: + return ActionResult(ok=False, error="member_not_found") + await member.kick(reason=reason or None) + return ActionResult(ok=True) + except DiscordException as e: + dc.log_ctx(persona_id, g.id, "kick: %s %s", type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + except aiohttp.ClientError as e: + dc.log_ctx(persona_id, g.id, "kick network: %s %s", type(e).__name__, e) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + + return ActionResult(ok=False, error="unknown_action_type") diff --git a/flexus_client_kit/ckit_discord_automation_schema_defs.py b/flexus_client_kit/ckit_discord_automation_schema_defs.py new file mode 100644 index 00000000..a17ac765 --- /dev/null +++ b/flexus_client_kit/ckit_discord_automation_schema_defs.py @@ -0,0 +1,338 @@ +""" +JSON Schema fragments for Discord automation triggers/actions (automation_schema_version 1). +Embedded on TriggerDescriptor / ActionDescriptor in ckit_connector_discord; assembled by +ckit_automation_v1_schema_build into the full document. +""" + +from __future__ import annotations + +SCHEMA_TRIGGER_MEMBER_JOINED = { + "type": "object", + "required": ["type"], + "additionalProperties": False, + "properties": { + "type": {"const": "member_joined"}, + }, + "description": ( + "Fires on Discord on_member_join (or equivalent CRM row creation for backfill). " + "No extra payload -- the member row is the context." + ), +} + +SCHEMA_TRIGGER_MEMBER_REMOVED = { + "type": "object", + "required": ["type"], + "additionalProperties": False, + "properties": { + "type": {"const": "member_removed"}, + }, + "description": ( + "Fires when a member leaves or is removed from the server. No extra saved fields; " + "guild and member context come from the event and CRM row." + ), +} + +SCHEMA_TRIGGER_MESSAGE_IN_CHANNEL = { + "type": "object", + "required": ["type", "channel_id_field"], + "additionalProperties": False, + "properties": { + "type": {"const": "message_in_channel"}, + "channel_id_field": { + "type": "string", + "minLength": 1, + "description": ( + "Reference to a setup field name containing the target channel snowflake " + "(e.g. 'intro_channel_id'), or a literal snowflake string prefixed with '#' " + "(e.g. '#1234567890')." + ), + }, + }, + "description": ( + "Fires when any guild member posts a message in the specified channel. " + "Engine updates last_message_ts as a side effect for every guild message regardless of rules." + ), +} + +SCHEMA_TRIGGER_SCHEDULED_RELATIVE_TO_FIELD = { + "type": "object", + "required": ["type", "anchor_field", "delay_seconds"], + "additionalProperties": False, + "properties": { + "type": {"const": "scheduled_relative_to_field"}, + "anchor_field": { + "type": "string", + "minLength": 1, + "description": ( + "CRM member field name (float unix ts) that serves as T=0 for this rule. " + "Example: 'member_joined_at', 'accepted_at'. The field MUST be populated by some " + "trigger or action (see trigger_field_matrix.md)." + ), + }, + "delay_seconds": { + "type": "integer", + "minimum": 0, + "description": "Seconds after anchor value to schedule the check. 172800 = 48h, 864000 = 10d.", + }, + }, + "description": ( + "Engine enqueues a dc_community_jobs entry at anchor + delay when the anchor field is first set " + "(or when rule is enabled and anchor already populated). The job re-evaluates conditions at fire time." + ), +} + +SCHEMA_TRIGGER_CRM_FIELD_CHANGED = { + "type": "object", + "required": ["type", "field_name"], + "additionalProperties": False, + "properties": { + "type": {"const": "crm_field_changed"}, + "field_name": { + "type": "string", + "minLength": 1, + "description": "CRM member field name to watch.", + }, + "to_value": { + "description": "Optional: fire only when the field transitions to this specific value. Omit to fire on any change.", + }, + }, + "description": ( + "Fires synchronously when engine or tool sets the named CRM field. " + "Useful for chaining (e.g. intro_done_at set -> cancel pending reminder)." + ), +} + +SCHEMA_TRIGGER_STATUS_TRANSITION = { + "type": "object", + "required": ["type", "to_status"], + "additionalProperties": False, + "properties": { + "type": {"const": "status_transition"}, + "from_status": { + "type": "string", + "description": ( + "Optional: only fire when transitioning FROM this lifecycle_status. " + "Omit to fire on any transition into to_status." + ), + }, + "to_status": { + "type": "string", + "description": "Fire when lifecycle_status becomes this value. Must match a value from crm_member lifecycle_status enum.", + }, + }, + "description": "Fires when lifecycle_status changes. Subset of crm_field_changed but semantically clearer for status machines.", +} + +SCHEMA_ACTION_SEND_DM = { + "type": "object", + "required": ["type"], + "additionalProperties": False, + "properties": { + "type": {"const": "send_dm"}, + "template": { + "type": "string", + "description": "Inline message body. Supports {field_name} placeholders resolved from CRM member + setup fields.", + }, + "template_field": { + "type": "string", + "description": ( + "Alternative: name of a setup field containing the message body. " + "Mutually preferred over 'template' when operator should edit copy in Setup UI." + ), + }, + }, + "description": "Send a DM to the member. Exactly one of 'template' or 'template_field' should be provided.", +} + +SCHEMA_ACTION_POST_TO_CHANNEL = { + "type": "object", + "required": ["type", "channel_id_field", "template"], + "additionalProperties": False, + "properties": { + "type": {"const": "post_to_channel"}, + "channel_id_field": { + "type": "string", + "description": "Setup field name or literal '#snowflake' for the target channel.", + }, + "template": { + "type": "string", + "description": "Message body with {field_name} placeholders.", + }, + }, + "description": "Post a message to a guild channel.", +} + +SCHEMA_ACTION_SET_CRM_FIELD = { + "type": "object", + "required": ["type", "field", "value"], + "additionalProperties": False, + "properties": { + "type": {"const": "set_crm_field"}, + "field": { + "type": "string", + "minLength": 1, + "description": "CRM member field to set.", + }, + "value": { + "description": ( + "Value to write. Special string '{now}' = current unix timestamp at execution time. " + "Other strings/numbers written as-is." + ), + }, + }, + "description": ( + "Update a single CRM field on the member document. May trigger crm_field_changed rules in the same cycle." + ), +} + +SCHEMA_ACTION_SET_STATUS = { + "type": "object", + "required": ["type", "status"], + "additionalProperties": False, + "properties": { + "type": {"const": "set_status"}, + "status": { + "type": "string", + "description": "New lifecycle_status value. Must match crm_member lifecycle_status enum.", + }, + }, + "description": "Shorthand for set_crm_field on lifecycle_status. May trigger status_transition rules.", +} + +SCHEMA_ACTION_ENQUEUE_CHECK = { + "type": "object", + "required": ["type", "delay_seconds", "check_rule_id"], + "additionalProperties": False, + "properties": { + "type": {"const": "enqueue_check"}, + "delay_seconds": { + "type": "integer", + "minimum": 0, + "description": "Seconds from now to schedule the follow-up check.", + }, + "anchor_field": { + "type": "string", + "description": "Optional: if provided, delay is relative to this CRM field value instead of 'now'.", + }, + "check_rule_id": { + "type": "string", + "description": ( + "rule_id to re-evaluate when the job fires. Conditions of that rule are checked at fire time " + "(not at enqueue time)." + ), + }, + }, + "description": ( + "Create a dc_community_jobs entry for future re-evaluation of another rule. " + "Job dedup: (guild_id, user_id, check_rule_id) -- see idempotency.md." + ), +} + +SCHEMA_ACTION_CANCEL_PENDING_JOBS = { + "type": "object", + "required": ["type", "job_kind_prefix"], + "additionalProperties": False, + "properties": { + "type": {"const": "cancel_pending_jobs"}, + "job_kind_prefix": { + "type": "string", + "minLength": 1, + "description": ( + "Prefix matched against dc_community_jobs.kind for this (guild_id, user_id). " + "All matching non-done jobs are marked done without execution." + ), + }, + }, + "description": "Cancel scheduled jobs that are no longer needed (e.g. cancel intro reminder when intro detected).", +} + +SCHEMA_ACTION_ADD_ROLE = { + "type": "object", + "required": ["type", "role_id_field"], + "additionalProperties": False, + "properties": { + "type": {"const": "add_role"}, + "role_id_field": { + "type": "string", + "minLength": 1, + "description": ( + "Setup field name holding the role snowflake, or a literal id (digits) or '#snowflake', " + "same resolution rules as channel_id_field. Member and server come from automation context." + ), + }, + }, + "description": "Add a Discord role to the member in context for the current server.", +} + +SCHEMA_ACTION_REMOVE_ROLE = { + "type": "object", + "required": ["type", "role_id_field"], + "additionalProperties": False, + "properties": { + "type": {"const": "remove_role"}, + "role_id_field": { + "type": "string", + "minLength": 1, + "description": ( + "Setup field name holding the role snowflake, or literal id / '#snowflake'. " + "Member and server come from automation context." + ), + }, + }, + "description": "Remove a Discord role from the member in context for the current server.", +} + +SCHEMA_ACTION_KICK = { + "type": "object", + "required": ["type"], + "additionalProperties": False, + "properties": { + "type": {"const": "kick"}, + "reason": { + "type": "string", + "description": ( + "Optional audit reason shown in Discord. Supports {field_name} placeholders like message templates." + ), + }, + }, + "description": "Kick the member in context from the current server. Guild and user ids are filled by the runtime.", +} + +SCHEMA_TRIGGER_MANUAL_CAMPAIGN_PRODUCT = { + "type": "object", + "required": ["type"], + "additionalProperties": False, + "properties": { + "type": {"const": "manual_campaign"}, + "segment_ref": { + "type": "string", + "description": "Optional reference to a saved segment definition or filter id. Omit for 'all members'.", + }, + }, + "description": ( + "Not auto-triggered. Operator initiates from UI ('send now' or 'schedule at'). " + "Segment filtering happens before per-member condition evaluation." + ), +} + +SCHEMA_ACTION_CALL_GATEKEEPER_PRODUCT = { + "type": "object", + "required": ["type", "tool_name"], + "additionalProperties": False, + "properties": { + "type": {"const": "call_gatekeeper_tool"}, + "tool_name": { + "type": "string", + "enum": ["accept", "reject", "request_info"], + "description": "Gatekeeper decision tool to invoke.", + }, + "reason_template": { + "type": "string", + "description": "Optional message/reason with {field} placeholders.", + }, + }, + "description": ( + "Invoke a gatekeeper decision. Typically used in rules triggered by LLM expert output, " + "not by deterministic automation. Included in schema for completeness; most gatekeeper logic lives in expert prompts." + ), +} diff --git a/flexus_client_kit/ckit_discord_gateway_handlers.py b/flexus_client_kit/ckit_discord_gateway_handlers.py new file mode 100644 index 00000000..158b3b02 --- /dev/null +++ b/flexus_client_kit/ckit_discord_gateway_handlers.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import time +from collections.abc import Awaitable, Callable + +import discord + +from flexus_client_kit.ckit_connector import NormalizedEvent + + +def bind_discord_gateway_client( + client: discord.Client, + emit: Callable[[NormalizedEvent], Awaitable[None]], +) -> None: + @client.event + async def on_ready() -> None: + for g in list(client.guilds): + await _emit_server_connected(g, emit) + + @client.event + async def on_member_join(member: discord.Member) -> None: + if member.bot: + return + await emit( + NormalizedEvent( + source="discord", + server_id=str(member.guild.id), + channel_id="", + user_id=str(member.id), + event_type="member_joined", + payload={ + "username": str(member), + "guild_id": int(member.guild.id), + "user_id": int(member.id), + }, + timestamp=time.time(), + ), + ) + + @client.event + async def on_message(message: discord.Message) -> None: + if message.author.bot: + return + if not message.guild: + return + if isinstance(message.channel, discord.DMChannel): + return + await emit( + NormalizedEvent( + source="discord", + server_id=str(message.guild.id), + channel_id=str(message.channel.id), + user_id=str(message.author.id), + event_type="message_in_channel", + payload={ + "content": message.content or "", + "channel_id": int(message.channel.id), + "guild_id": int(message.guild.id), + "user_id": int(message.author.id), + "message_id": str(message.id), + }, + timestamp=time.time(), + ), + ) + + @client.event + async def on_member_remove(member: discord.Member) -> None: + if member.bot: + return + await emit( + NormalizedEvent( + source="discord", + server_id=str(member.guild.id), + channel_id="", + user_id=str(member.id), + event_type="member_removed", + payload={ + "username": str(member), + "guild_id": int(member.guild.id), + "user_id": int(member.id), + }, + timestamp=time.time(), + ), + ) + + @client.event + async def on_guild_remove(guild: discord.Guild) -> None: + await emit( + NormalizedEvent( + source="discord", + server_id=str(guild.id), + channel_id="", + user_id="", + event_type="server_disconnected", + payload={"guild_id": int(guild.id)}, + timestamp=time.time(), + ), + ) + + @client.event + async def on_guild_join(guild: discord.Guild) -> None: + await _emit_server_connected(guild, emit) + + @client.event + async def on_guild_available(guild: discord.Guild) -> None: + await _emit_server_connected(guild, emit) + + +async def _emit_server_connected( + g: discord.Guild, + emit: Callable[[NormalizedEvent], Awaitable[None]], +) -> None: + mc = getattr(g, "member_count", None) + if mc is None: + mc = 0 + await emit( + NormalizedEvent( + source="discord", + server_id=str(g.id), + channel_id="", + user_id="", + event_type="server_connected", + payload={ + "guild_id": int(g.id), + "guild_name": g.name or "", + "approx_member_count": int(mc), + }, + timestamp=time.time(), + ), + ) diff --git a/flexus_client_kit/ckit_guild_map.py b/flexus_client_kit/ckit_guild_map.py new file mode 100644 index 00000000..79487824 --- /dev/null +++ b/flexus_client_kit/ckit_guild_map.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from typing import Optional + +import gql +import gql.transport.exceptions + +from flexus_client_kit import ckit_client + +logger = logging.getLogger(__name__) + +_GQL_LIST = gql.gql( + """query GuildMappingListForPersona($persona_id: String!) { + guild_mapping_list_for_persona(persona_id: $persona_id) { + map_id + guild_id + } + }""", +) + +_GQL_CREATE = gql.gql( + """mutation GuildMappingCreateRuntime( + $guild_id: String! + $ws_id: String! + $persona_id: String! + $platform: String + ) { + guild_mapping_create( + guild_id: $guild_id + ws_id: $ws_id + persona_id: $persona_id + platform: $platform + ) { + map_id + guild_id + } + }""", +) + +_GQL_UPDATE_META = gql.gql( + """mutation GuildMappingUpdateMeta( + $map_id: String! + $guild_name: String! + $approx_member_count: Int! + ) { + guild_mapping_update_meta( + map_id: $map_id + guild_name: $guild_name + approx_member_count: $approx_member_count + ) + }""", +) + +_GQL_UPDATE_STATUS = gql.gql( + """mutation GuildMappingUpdateStatus($map_id: String!, $status: String!) { + guild_mapping_update_status(map_id: $map_id, status: $status) + }""", +) + + +async def guild_mapping_create_runtime( + fclient: ckit_client.FlexusClient, + ws_id: str, + persona_id: str, + guild_id: str, +) -> bool: + try: + async with (await fclient.use_http()) as http: + await http.execute( + _GQL_CREATE, + variable_values={ + "guild_id": guild_id, + "ws_id": ws_id, + "persona_id": persona_id, + "platform": "discord", + }, + ) + return True + except gql.transport.exceptions.TransportQueryError as e: + logger.warning( + "guild_mapping_create_runtime failed: %s %s", + type(e).__name__, + e, + ) + return False + except (TypeError, KeyError, ValueError) as e: + logger.warning( + "guild_mapping_create_runtime bad response: %s %s", + type(e).__name__, + e, + ) + return False + + +async def guild_mapping_update_meta_runtime( + fclient: ckit_client.FlexusClient, + map_id: str, + guild_name: str, + approx_member_count: int, +) -> bool: + try: + async with (await fclient.use_http()) as http: + r = await http.execute( + _GQL_UPDATE_META, + variable_values={ + "map_id": map_id, + "guild_name": guild_name, + "approx_member_count": int(approx_member_count), + }, + ) + v = r.get("guild_mapping_update_meta") if isinstance(r, dict) else None + return v is True + except gql.transport.exceptions.TransportQueryError as e: + logger.warning( + "guild_mapping_update_meta_runtime failed: %s %s", + type(e).__name__, + e, + ) + return False + except (TypeError, KeyError, ValueError) as e: + logger.warning( + "guild_mapping_update_meta_runtime bad response: %s %s", + type(e).__name__, + e, + ) + return False + + +async def guild_mapping_update_status_runtime( + fclient: ckit_client.FlexusClient, + map_id: str, + status: str, +) -> bool: + try: + async with (await fclient.use_http()) as http: + r = await http.execute( + _GQL_UPDATE_STATUS, + variable_values={"map_id": map_id, "status": status}, + ) + v = r.get("guild_mapping_update_status") if isinstance(r, dict) else None + return v is True + except gql.transport.exceptions.TransportQueryError as e: + logger.warning( + "guild_mapping_update_status_runtime failed: %s %s", + type(e).__name__, + e, + ) + return False + except (TypeError, KeyError, ValueError) as e: + logger.warning( + "guild_mapping_update_status_runtime bad response: %s %s", + type(e).__name__, + e, + ) + return False + + +OnGuildIdsChange = Optional[Callable[[set[str]], Awaitable[None]]] + + +class GuildMapCache: + def __init__( + self, + fclient: ckit_client.FlexusClient, + persona_id: str, + *, + interval: float = 30.0, + on_change: OnGuildIdsChange = None, + ) -> None: + self._fclient = fclient + self._persona_id = persona_id + self._interval = interval + self._on_change = on_change + self._guild_ids: set[str] = set() + self._map_by_guild: dict[str, str] = {} + self._task: Optional[asyncio.Task[None]] = None + + def get(self) -> set[str]: + return set(self._guild_ids) + + def map_id_for_guild(self, guild_id_str: str) -> str | None: + return self._map_by_guild.get(str(guild_id_str)) + + async def start(self) -> None: + await self.refresh_now() + self._task = asyncio.create_task(self._loop()) + + async def stop(self) -> None: + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def refresh_now(self) -> None: + await self._refresh() + + async def ensure_legacy_guild_mapped(self, ws_id: str, guild_id_int: int) -> None: + gsid = str(int(guild_id_int)) + if gsid in self._guild_ids: + return + ok = await guild_mapping_create_runtime( + self._fclient, + ws_id, + self._persona_id, + gsid, + ) + if ok: + await self.refresh_now() + + async def _refresh(self) -> None: + prev_ids = set(self._guild_ids) + try: + async with (await self._fclient.use_http()) as http: + r = await http.execute( + _GQL_LIST, + variable_values={"persona_id": self._persona_id}, + ) + rows = r.get("guild_mapping_list_for_persona") if isinstance(r, dict) else None + if not isinstance(rows, list): + logger.warning( + "GuildMapCache: guild_mapping_list_for_persona missing or not a list, keeping last known", + ) + return + new_map: dict[str, str] = {} + new_ids: set[str] = set() + for row in rows: + if not isinstance(row, dict): + continue + gid_raw = row.get("guild_id") + mid = row.get("map_id") + try: + gid = str(int(gid_raw)) + except (TypeError, ValueError): + continue + if not isinstance(mid, str) or not mid: + continue + new_ids.add(gid) + new_map[gid] = mid + self._guild_ids = new_ids + self._map_by_guild = new_map + except ( + gql.transport.exceptions.TransportQueryError, + gql.transport.exceptions.TransportError, + ) as e: + logger.warning( + "GuildMapCache refresh GraphQL error, keeping last known: %s %s", + type(e).__name__, + e, + ) + return + except (TypeError, KeyError, ValueError) as e: + logger.warning( + "GuildMapCache refresh parse error, keeping last known: %s %s", + type(e).__name__, + e, + ) + return + if new_ids != prev_ids and self._on_change is not None: + await self._on_change(set(self._guild_ids)) + + async def _loop(self) -> None: + while True: + try: + await asyncio.sleep(self._interval) + await self._refresh() + except asyncio.CancelledError: + break diff --git a/flexus_client_kit/ckit_job_queue.py b/flexus_client_kit/ckit_job_queue.py new file mode 100644 index 00000000..20d12df5 --- /dev/null +++ b/flexus_client_kit/ckit_job_queue.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import logging +import time +from typing import Any, Awaitable, Callable, Dict + +logger = logging.getLogger(__name__) + + +def _log_ctx(persona_id: str, guild_id: Any, msg: str, *args: Any) -> None: + gid = str(guild_id) if guild_id is not None else "-" + logger.info("[%s guild=%s] " + msg, persona_id, gid, *args) + +COL_JOBS = "dc_community_jobs" + +JobHandler = Callable[[Dict[str, Any]], Awaitable[None]] + + +async def enqueue_job( + db: Any, + kind: str, + run_at_ts: float, + payload: Dict[str, Any], +) -> None: + coll = db[COL_JOBS] + await coll.insert_one( + { + "kind": kind, + "run_at": float(run_at_ts), + "payload": payload, + "done": False, + "created_ts": time.time(), + }, + ) + + +async def drain_due_jobs( + db: Any, + persona_id: str, + handlers: Dict[str, JobHandler], + limit: int = 50, +) -> int: + coll = db[COL_JOBS] + now = time.time() + count = 0 + cursor = coll.find({"done": False, "run_at": {"$lte": now}}).sort("run_at", 1).limit(limit) + async for doc in cursor: + kind = doc.get("kind") or "" + handler = handlers.get(kind) + if not handler: + await coll.update_one({"_id": doc["_id"]}, {"$set": {"done": True, "error": "no_handler"}}) + continue + payload = doc.get("payload") or {} + try: + await handler(payload) + except (TypeError, ValueError, KeyError) as e: + _log_ctx(persona_id, payload.get("guild_id"), "job %s data error: %s %s", kind, type(e).__name__, e) + await coll.update_one({"_id": doc["_id"]}, {"$set": {"done": True, "finished_ts": time.time()}}) + count += 1 + return count diff --git a/flexus_client_kit/ckit_messages.py b/flexus_client_kit/ckit_messages.py new file mode 100644 index 00000000..28c1d0c6 --- /dev/null +++ b/flexus_client_kit/ckit_messages.py @@ -0,0 +1,88 @@ +""" +Ingested chat messages from connected platforms (dc_messages collection). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from pymongo.errors import PyMongoError + +logger = logging.getLogger(__name__) + +COL_MESSAGES = "dc_messages" + + +async def ensure_message_indexes(db: Any) -> None: + try: + coll = db[COL_MESSAGES] + await coll.create_index( + [("server_id", 1), ("channel_id", 1), ("timestamp", 1)], + unique=False, + ) + except PyMongoError as e: + logger.error("ensure_message_indexes: MongoDB index creation failed", exc_info=e) + raise + + +async def store_message( + db: Any, + *, + server_id: str, + channel_id: str, + user_id: str, + platform: str, + content: str, + timestamp: float, + message_id: str, +) -> None: + try: + coll = db[COL_MESSAGES] + doc = { + "server_id": server_id, + "channel_id": channel_id, + "user_id": user_id, + "platform": platform, + "content": content, + "timestamp": timestamp, + "message_id": message_id, + } + await coll.insert_one(doc) + except PyMongoError as e: + logger.error( + "store_message: server_id=%s channel_id=%s message_id=%s failed", + server_id, + channel_id, + message_id, + exc_info=e, + ) + raise + + +async def get_channel_messages( + db: Any, + server_id: str, + channel_id: str, + *, + limit: int = 100, + before_ts: float | None = None, +) -> list[dict]: + try: + coll = db[COL_MESSAGES] + flt: dict[str, Any] = { + "server_id": server_id, + "channel_id": channel_id, + } + if before_ts is not None: + flt["timestamp"] = {"$lt": before_ts} + cursor = coll.find(flt).sort("timestamp", -1).limit(limit) + return await cursor.to_list(length=limit) + except PyMongoError as e: + logger.error( + "get_channel_messages: server_id=%s channel_id=%s failed", + server_id, + channel_id, + exc_info=e, + ) + raise diff --git a/flexus_client_kit/gateway/__init__.py b/flexus_client_kit/gateway/__init__.py new file mode 100644 index 00000000..7412a247 --- /dev/null +++ b/flexus_client_kit/gateway/__init__.py @@ -0,0 +1,45 @@ +from flexus_client_kit.gateway.ckit_gateway_redis import ( + DiscordGatewayRedisSidecar, + redis_client_from_env, + redis_pubsub_client_from_env, +) +from flexus_client_kit.gateway.ckit_gateway_wire import ( + WIRE_V, + GatewayActionCommandEnvelope, + GatewayActionResultEnvelope, + GatewayEventEnvelope, + action_result_from_dict, + action_result_to_dict, + channel_cmd_discord, + channel_events_discord, + channel_reply_discord, + event_envelope_wrap, + gateway_instance_key_from_token, + gateway_result_envelope_from_dict, + normalized_event_from_dict, + normalized_event_to_dict, + parse_action_command_envelope, + parse_event_envelope, +) + +__all__ = [ + "WIRE_V", + "GatewayActionCommandEnvelope", + "GatewayActionResultEnvelope", + "GatewayEventEnvelope", + "DiscordGatewayRedisSidecar", + "action_result_from_dict", + "action_result_to_dict", + "channel_cmd_discord", + "channel_events_discord", + "channel_reply_discord", + "event_envelope_wrap", + "gateway_instance_key_from_token", + "gateway_result_envelope_from_dict", + "normalized_event_from_dict", + "normalized_event_to_dict", + "parse_action_command_envelope", + "parse_event_envelope", + "redis_client_from_env", + "redis_pubsub_client_from_env", +] diff --git a/flexus_client_kit/gateway/ckit_gateway_redis.py b/flexus_client_kit/gateway/ckit_gateway_redis.py new file mode 100644 index 00000000..c82cf525 --- /dev/null +++ b/flexus_client_kit/gateway/ckit_gateway_redis.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import asyncio +import dataclasses +import json +import logging +import os +import time +import uuid +from collections.abc import Awaitable, Callable +from typing import Any + +import redis.asyncio as aioredis +from redis.exceptions import ConnectionError as RedisConnectionError +from redis.exceptions import TimeoutError as RedisTimeoutError + +from flexus_client_kit import ckit_shutdown +from flexus_client_kit.ckit_connector import ActionResult, NormalizedEvent +from flexus_client_kit.gateway.ckit_gateway_wire import ( + WIRE_V, + GatewayActionCommandEnvelope, + GatewayActionResultEnvelope, + GatewayEventEnvelope, + action_result_from_dict, + action_result_to_dict, + channel_cmd_discord, + channel_events_discord, + channel_reply_discord, + gateway_instance_key_from_token, + gateway_result_envelope_from_dict, + normalized_event_from_dict, + parse_event_envelope, +) + +logger = logging.getLogger(__name__) + + +def _redis_common_kwargs() -> dict[str, Any]: + redis_host = os.getenv("REDIS_HOST", "localhost") + redis_port = os.getenv("REDIS_PORT", "6379") + return dict( + host=redis_host, + port=int(redis_port), + username=os.getenv("REDIS_USER"), + password=os.getenv("REDIS_PASSWORD"), + db=int(os.getenv("REDIS_DB", "0")), + decode_responses=True, + socket_timeout=20, + socket_connect_timeout=20, + socket_keepalive=True, + ssl_ca_certs=os.getenv("REDIS_CA_PATH"), + ssl_certfile=os.getenv("REDIS_SSL_CERT"), + ssl_keyfile=os.getenv("REDIS_SSL_KEY"), + ssl=int(redis_port) == 6380, + ssl_check_hostname=False, + ssl_cert_reqs=None, + ) + + +def redis_client_from_env() -> aioredis.StrictRedis: + return aioredis.StrictRedis(**_redis_common_kwargs()) + + +def redis_pubsub_client_from_env() -> aioredis.StrictRedis: + kw = _redis_common_kwargs() + kw["socket_timeout"] = None + kw["health_check_interval"] = 0 + return aioredis.StrictRedis(**kw) + + +class DiscordGatewayRedisSidecar: + def __init__( + self, + token: str, + *, + redis_cmd: aioredis.StrictRedis | None = None, + redis_pubsub: aioredis.StrictRedis | None = None, + ) -> None: + self._token = token + self._key = gateway_instance_key_from_token(token) + self._events_ch = channel_events_discord(self._key) + self._cmd_ch = channel_cmd_discord(self._key) + self._redis_cmd = redis_cmd + self._redis_pubsub = redis_pubsub + self._own_cmd = redis_cmd is None + self._own_pubsub = redis_pubsub is None + self._stop = asyncio.Event() + self._reader_task: asyncio.Task[None] | None = None + self._cb: Callable[[NormalizedEvent], Awaitable[None]] | None = None + + @property + def gateway_instance_key(self) -> str: + return self._key + + @property + def events_channel(self) -> str: + return self._events_ch + + @property + def cmd_channel(self) -> str: + return self._cmd_ch + + async def start_event_consumer(self, on_event: Callable[[NormalizedEvent], Awaitable[None]]) -> None: + self._cb = on_event + self._stop.clear() + if self._redis_pubsub is None: + self._redis_pubsub = redis_pubsub_client_from_env() + self._reader_task = asyncio.create_task(self._event_reader_loop()) + + async def stop_event_consumer(self) -> None: + self._stop.set() + t = self._reader_task + self._reader_task = None + if t is not None: + t.cancel() + try: + await t + except asyncio.CancelledError: + pass + if self._own_pubsub and self._redis_pubsub is not None: + await self._redis_pubsub.close() + self._redis_pubsub = None + + async def close(self) -> None: + await self.stop_event_consumer() + if self._own_cmd and self._redis_cmd is not None: + await self._redis_cmd.close() + self._redis_cmd = None + + async def _event_reader_loop(self) -> None: + r = self._redis_pubsub + if r is None: + return + ps = r.pubsub() + await ps.subscribe(self._events_ch) + try: + while not self._stop.is_set() and not ckit_shutdown.shutdown_event.is_set(): + try: + msg = await ps.get_message(ignore_subscribe_messages=True, timeout=1.0) + except (RedisConnectionError, RedisTimeoutError, OSError, RuntimeError) as e: + logger.warning("gateway event redis read: %s %s", type(e).__name__, e) + await ckit_shutdown.wait(1.0) + continue + if not msg or msg.get("type") != "message": + continue + data = msg.get("data") + if not data: + continue + try: + raw = json.loads(data) + except json.JSONDecodeError: + continue + env = parse_event_envelope(raw) + if env is None: + continue + cb = self._cb + if cb is None: + continue + try: + await cb(normalized_event_from_dict(env.event)) + except asyncio.CancelledError: + raise + finally: + try: + await ps.unsubscribe(self._events_ch) + await ps.close() + except (RedisConnectionError, OSError, RuntimeError): + pass + + async def get_user_info( + self, + persona_id: str, + user_id: str, + server_id: str = "", + *, + timeout_sec: float = 45.0, + ) -> ActionResult: + return await self.execute_action( + persona_id, + "get_user_info", + {"user_id": str(user_id), "server_id": str(server_id or "")}, + timeout_sec=timeout_sec, + ) + + async def get_channel( + self, + persona_id: str, + channel_id: str, + *, + timeout_sec: float = 45.0, + ) -> ActionResult: + return await self.execute_action( + persona_id, + "get_channel", + {"channel_id": str(channel_id)}, + timeout_sec=timeout_sec, + ) + + async def execute_action( + self, + persona_id: str, + action_type: str, + params: dict, + *, + timeout_sec: float = 90.0, + ) -> ActionResult: + if self._redis_cmd is None: + self._redis_cmd = redis_client_from_env() + r = self._redis_cmd + request_id = str(uuid.uuid4()) + reply_ch = channel_reply_discord(self._key, request_id) + cmd = GatewayActionCommandEnvelope( + v=WIRE_V, + request_id=request_id, + platform="discord", + gateway_instance_key=self._key, + persona_id=persona_id, + action_type=action_type, + params=params, + reply_channel=reply_ch, + ) + ps_r = redis_pubsub_client_from_env() + ps = ps_r.pubsub() + await ps.subscribe(reply_ch) + try: + payload = json.dumps(dataclasses.asdict(cmd)) + n = await r.publish(self._cmd_ch, payload) + if n < 1: + logger.warning("gateway cmd publish: no subscribers on %s", self._cmd_ch) + deadline = time.monotonic() + timeout_sec + while time.monotonic() < deadline: + if ckit_shutdown.shutdown_event.is_set(): + return ActionResult(ok=False, error="shutdown") + try: + msg = await ps.get_message( + ignore_subscribe_messages=True, + timeout=min(5.0, max(0.5, deadline - time.monotonic())), + ) + except (RedisConnectionError, RedisTimeoutError, OSError, RuntimeError) as e: + logger.warning("gateway reply redis: %s %s", type(e).__name__, e) + await ckit_shutdown.wait(0.5) + continue + if not msg or msg.get("type") != "message": + continue + raw_data = msg.get("data") + if not raw_data: + continue + try: + raw = json.loads(raw_data) + except json.JSONDecodeError: + continue + renv = gateway_result_envelope_from_dict(raw) + if renv is None or renv.request_id != request_id: + continue + return action_result_from_dict(renv.result) + return ActionResult(ok=False, error="action_timeout") + finally: + try: + await ps.unsubscribe(reply_ch) + await ps.close() + await ps_r.close() + except (RedisConnectionError, OSError, RuntimeError): + pass diff --git a/flexus_client_kit/gateway/ckit_gateway_wire.py b/flexus_client_kit/gateway/ckit_gateway_wire.py new file mode 100644 index 00000000..27833a91 --- /dev/null +++ b/flexus_client_kit/gateway/ckit_gateway_wire.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import dataclasses +import hashlib +import uuid +from typing import Any + +from flexus_client_kit.ckit_connector import ActionResult, NormalizedEvent + +WIRE_V = 1 + + +def gateway_instance_key_from_token(token: str) -> str: + t = (token or "").strip().encode("utf-8") + return hashlib.sha256(t).hexdigest()[:32] + + +def channel_events_discord(gateway_instance_key: str) -> str: + return "gw:discord:%s:events" % (gateway_instance_key,) + + +def channel_cmd_discord(gateway_instance_key: str) -> str: + return "gw:discord:%s:cmd" % (gateway_instance_key,) + + +def channel_reply_discord(gateway_instance_key: str, request_id: str) -> str: + return "gw:discord:%s:reply:%s" % (gateway_instance_key, request_id) + + +@dataclasses.dataclass +class GatewayEventEnvelope: + v: int + envelope_id: str + platform: str + gateway_instance_key: str + event: dict[str, Any] + + +@dataclasses.dataclass +class GatewayActionCommandEnvelope: + v: int + request_id: str + platform: str + gateway_instance_key: str + persona_id: str + action_type: str + params: dict[str, Any] + reply_channel: str + + +@dataclasses.dataclass +class GatewayActionResultEnvelope: + v: int + request_id: str + result: dict[str, Any] + + +def normalized_event_to_dict(ev: NormalizedEvent) -> dict[str, Any]: + return dataclasses.asdict(ev) + + +def normalized_event_from_dict(d: dict[str, Any]) -> NormalizedEvent: + return NormalizedEvent( + source=str(d["source"]), + server_id=str(d["server_id"]), + channel_id=str(d["channel_id"]), + user_id=str(d["user_id"]), + event_type=str(d["event_type"]), + payload=dict(d.get("payload") or {}), + timestamp=float(d["timestamp"]), + ) + + +def action_result_to_dict(r: ActionResult) -> dict[str, Any]: + return dataclasses.asdict(r) + + +def action_result_from_dict(d: dict[str, Any]) -> ActionResult: + return ActionResult( + ok=bool(d["ok"]), + error=d.get("error"), + data=dict(d["data"]) if d.get("data") is not None else None, + ) + + +def event_envelope_wrap(platform: str, gateway_instance_key: str, ev: NormalizedEvent) -> GatewayEventEnvelope: + return GatewayEventEnvelope( + v=WIRE_V, + envelope_id=str(uuid.uuid4()), + platform=platform, + gateway_instance_key=gateway_instance_key, + event=normalized_event_to_dict(ev), + ) + + +def parse_event_envelope(raw: dict[str, Any]) -> GatewayEventEnvelope | None: + try: + if int(raw["v"]) != WIRE_V: + return None + return GatewayEventEnvelope( + v=int(raw["v"]), + envelope_id=str(raw["envelope_id"]), + platform=str(raw["platform"]), + gateway_instance_key=str(raw["gateway_instance_key"]), + event=dict(raw["event"]), + ) + except (KeyError, TypeError, ValueError): + return None + + +def parse_action_command_envelope(raw: dict[str, Any]) -> GatewayActionCommandEnvelope | None: + try: + if int(raw["v"]) != WIRE_V: + return None + return GatewayActionCommandEnvelope( + v=int(raw["v"]), + request_id=str(raw["request_id"]), + platform=str(raw["platform"]), + gateway_instance_key=str(raw["gateway_instance_key"]), + persona_id=str(raw["persona_id"]), + action_type=str(raw["action_type"]), + params=dict(raw.get("params") or {}), + reply_channel=str(raw["reply_channel"]), + ) + except (KeyError, TypeError, ValueError): + return None + + +def gateway_result_envelope_from_dict(raw: dict[str, Any]) -> GatewayActionResultEnvelope | None: + try: + if int(raw["v"]) != WIRE_V: + return None + return GatewayActionResultEnvelope( + v=int(raw["v"]), + request_id=str(raw["request_id"]), + result=dict(raw["result"]), + ) + except (KeyError, TypeError, ValueError): + return None diff --git a/flexus_client_kit/integrations/fi_discord_community.py b/flexus_client_kit/integrations/fi_discord_community.py new file mode 100644 index 00000000..6908a87e --- /dev/null +++ b/flexus_client_kit/integrations/fi_discord_community.py @@ -0,0 +1,294 @@ +""" +Discord community bots: shared gateway helpers (greenfield). + +Does not replace fi_discord2 / Karen. Use for discord_onboarding, discord_moderation, +discord_faq, discord_engagement only. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +import time +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +import aiohttp +import discord +from discord.errors import DiscordException, HTTPException + +logger = logging.getLogger("fi_discord_community") + + +def setup_truthy(raw: Any) -> bool: + if raw is True: + return True + if raw is False or raw is None: + return False + s = str(raw).strip().lower() + return s in ("1", "true", "yes", "on") + +COL_ONBOARDING = "dc_onboarding_state" +COL_MOD_EVENTS = "dc_mod_events" +COL_ACTIVITY = "dc_member_activity" +COL_FAQ_RATE = "dc_faq_rate" +COL_MOD_RATELIMIT = "dc_mod_ratelimit_window" + + +def build_intents() -> discord.Intents: + intents = discord.Intents.default() + intents.message_content = True + intents.members = True + intents.guilds = True + intents.dm_messages = True + intents.guild_messages = True + intents.guild_reactions = True + return intents + + +def parse_snowflake(raw: str) -> Optional[int]: + if not raw or not isinstance(raw, str): + return None + s = raw.strip() + if not s or not s.isdigit(): + return None + return int(s) + + +def discord_bot_api_key_from_external_auth(ext: Dict[str, Any]) -> str: + # UI manual token = discord_manual; OAuth Karen path may use discord. + for provider_key in ("discord_manual", "discord"): + auth = ext.get(provider_key) or {} + if not isinstance(auth, dict): + continue + tok = (auth.get("api_key") or "").strip() + if tok: + return tok + return "" + + +def guild_matches(guild: Optional[discord.Guild], want_id: Optional[int]) -> bool: + if want_id is None: + return True + if guild is None: + return False + return int(guild.id) == int(want_id) + + +def truncate_message(text: str, limit: int = 2000) -> str: + if len(text) <= limit: + return text + return text[: limit - 20] + "\n...(truncated)" + + +def log_ctx(persona_id: str, guild_id: Optional[int], msg: str, *args: Any) -> None: + gid = str(guild_id) if guild_id is not None else "-" + logger.info("[%s guild=%s] " + msg, persona_id, gid, *args) + + +def _channel_guild_id(channel: discord.abc.Messageable) -> Optional[int]: + g = getattr(channel, "guild", None) + return int(g.id) if g is not None else None + + +async def safe_send( + channel: discord.abc.Messageable, + persona_id: str, + content: str, +) -> Optional[discord.Message]: + t = truncate_message(content) + gid = _channel_guild_id(channel) + delay = 1.0 + for attempt in range(5): + try: + return await channel.send(t) + except HTTPException as e: + if e.status == 429 and attempt < 4: + ra = getattr(e, "retry_after", None) + wait = float(ra) if ra is not None else delay + wait = max(0.5, min(wait, 30.0)) + log_ctx(persona_id, gid, "safe_send 429 backoff %.1fs", wait) + await asyncio.sleep(wait) + delay = min(delay * 2.0, 16.0) + continue + log_ctx(persona_id, gid, "safe_send HTTP %s", e.status) + return None + except DiscordException as e: + log_ctx(persona_id, gid, "safe_send failed: %s %s", type(e).__name__, e) + return None + except aiohttp.ClientError as e: + log_ctx(persona_id, gid, "safe_send network: %s %s", type(e).__name__, e) + return None + return None + + +async def safe_dm( + client: discord.Client, + user: discord.abc.User, + persona_id: str, + content: str, +) -> bool: + try: + ch = user.dm_channel or await user.create_dm() + except DiscordException as e: + log_ctx(persona_id, None, "create_dm failed for user=%s: %s %s", getattr(user, "id", "?"), type(e).__name__, e) + return False + except aiohttp.ClientError as e: + log_ctx( + persona_id, + None, + "create_dm network for user=%s: %s %s", + getattr(user, "id", "?"), + type(e).__name__, + e, + ) + return False + m = await safe_send(ch, persona_id, content) + return m is not None + + +def compile_url_patterns(lines: str) -> List[re.Pattern[str]]: + out: List[re.Pattern[str]] = [] + for line in (lines or "").splitlines(): + pat = line.strip() + if not pat: + continue + try: + out.append(re.compile(pat, re.I)) + except re.error: + logger.warning("bad url regex ignored: %r", pat[:80]) + return out + + +DISCORD_INVITE_RE = re.compile( + r"(discord\.gg/|discordapp\.com/invite/|discord\.com/invite/)[a-zA-Z0-9_-]+", + re.I, +) + + +def message_has_invite(content: str) -> bool: + return bool(DISCORD_INVITE_RE.search(content or "")) + + +def match_blocked_url(content: str, patterns: List[re.Pattern[str]]) -> bool: + for p in patterns: + if p.search(content or ""): + return True + return False + + +async def start_discord_client( + token: str, + persona_id: str, + register: Callable[[discord.Client], None], +) -> Tuple[discord.Client, asyncio.Task[None]]: + client = discord.Client(intents=build_intents()) + register(client) + + async def _runner() -> None: + try: + await client.start(token) + except asyncio.CancelledError: + raise + except DiscordException as e: + logger.error("[%s] discord client died: %s %s", persona_id, type(e).__name__, e) + + t = asyncio.create_task(_runner()) + return client, t + + +async def close_discord_client(client: Optional[discord.Client], task: Optional[asyncio.Task]) -> None: + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + if client and not client.is_closed(): + await client.close() + + +def _perm_gaps_basic(perms: discord.Permissions) -> List[str]: + miss: List[str] = [] + if not perms.view_channel: + miss.append("view_channel") + if not perms.send_messages: + miss.append("send_messages") + if not perms.read_message_history: + miss.append("read_message_history") + return miss + + +def _perm_gaps_mod(perms: discord.Permissions) -> List[str]: + miss = _perm_gaps_basic(perms) + if not perms.manage_messages: + miss.append("manage_messages") + return miss + + +def preflight_text_channels( + guild: discord.Guild, + bot_user: discord.ClientUser, + persona_id: str, + bot_label: str, + channels: Dict[str, Tuple[Optional[int], str]], + *, + warn_manage_roles: bool = False, +) -> None: + me = guild.get_member(bot_user.id) + if not me: + log_ctx(persona_id, guild.id, "preflight %s: bot not in guild member cache", bot_label) + return + for label, (cid, level) in channels.items(): + if not cid: + continue + ch = guild.get_channel(int(cid)) + if not isinstance(ch, discord.TextChannel): + log_ctx(persona_id, guild.id, "preflight %s: %s id=%s missing or not text", bot_label, label, cid) + continue + perms = ch.permissions_for(me) + if level == "mod": + miss = _perm_gaps_mod(perms) + else: + miss = _perm_gaps_basic(perms) + if miss: + log_ctx( + persona_id, + guild.id, + "preflight %s: %s ch=%s missing %s", + bot_label, + label, + cid, + ",".join(miss), + ) + if warn_manage_roles and not me.guild_permissions.manage_roles: + log_ctx( + persona_id, + guild.id, + "preflight %s: guild.manage_roles false (assign roles only below bot role)", + bot_label, + ) + + +from flexus_client_kit import ckit_job_queue + +COL_JOBS = ckit_job_queue.COL_JOBS +JobHandler = ckit_job_queue.JobHandler + + +async def enqueue_job( + db: Any, + kind: str, + run_at_ts: float, + payload: Dict[str, Any], +) -> None: + return await ckit_job_queue.enqueue_job(db, kind, run_at_ts, payload) + + +async def drain_due_jobs( + db: Any, + persona_id: str, + handlers: Dict[str, JobHandler], + limit: int = 50, +) -> int: + return await ckit_job_queue.drain_due_jobs(db, persona_id, handlers, limit=limit) diff --git a/flexus_client_kit/integrations/fi_discord_community_test.py b/flexus_client_kit/integrations/fi_discord_community_test.py new file mode 100644 index 00000000..643616d0 --- /dev/null +++ b/flexus_client_kit/integrations/fi_discord_community_test.py @@ -0,0 +1,72 @@ +import re + +import pytest + +from flexus_client_kit.integrations import fi_discord_community as dc + + +@pytest.mark.parametrize( + "raw,expect", + [ + ("true", True), + ("TRUE", True), + ("1", True), + ("yes", True), + ("on", True), + ("false", False), + ("", False), + ("0", False), + ], +) +def test_setup_truthy(raw: str, expect: bool) -> None: + assert dc.setup_truthy(raw) is expect + + +def test_parse_snowflake() -> None: + assert dc.parse_snowflake(" 12345 ") == 12345 + assert dc.parse_snowflake("") is None + assert dc.parse_snowflake("abc") is None + + +def test_discord_bot_api_key_from_external_auth() -> None: + assert dc.discord_bot_api_key_from_external_auth({}) == "" + assert dc.discord_bot_api_key_from_external_auth({"discord_manual": {"api_key": " t "}}) == "t" + assert dc.discord_bot_api_key_from_external_auth({"discord": {"api_key": "x"}}) == "x" + assert ( + dc.discord_bot_api_key_from_external_auth( + {"discord_manual": {"api_key": "manual"}, "discord": {"api_key": "oauth"}}, + ) + == "manual" + ) + + +def test_truncate_message() -> None: + long = "x" * 3000 + out = dc.truncate_message(long, limit=100) + assert len(out) <= 100 + assert "truncated" in out + + +def test_message_has_invite() -> None: + assert dc.message_has_invite("hello discord.gg/abc here") + assert dc.message_has_invite("https://discord.com/invite/xyz") + assert not dc.message_has_invite("no link here") + + +def test_match_blocked_url() -> None: + pats = [re.compile(r"malware\.com", re.I)] + assert dc.match_blocked_url("see malware.com", pats) + assert not dc.match_blocked_url("safe", pats) + + +def test_compile_url_patterns_skips_bad() -> None: + out = dc.compile_url_patterns("foo\\.bar\n(\n") + assert len(out) == 1 + + +def test_guild_matches() -> None: + assert dc.guild_matches(None, 1) is False + class G: + id = 5 + assert dc.guild_matches(G(), 5) is True + assert dc.guild_matches(G(), None) is True diff --git a/flexus_client_kit/setup_schema_schema.json b/flexus_client_kit/setup_schema_schema.json index fbb3cbc4..43a71c4d 100644 --- a/flexus_client_kit/setup_schema_schema.json +++ b/flexus_client_kit/setup_schema_schema.json @@ -16,15 +16,18 @@ "type": "string", "title": "Input Type", "enum": ["string_short", "string_long", "string_multiline", "bool", "int", "float"], - "description": "UI input control type. string_short: single-line text, string_long: wider single-line text, string_multiline: textarea, bool: checkbox, int/float: numeric input." + "description": "UI input control type. string_short: single-line text, string_long: wider single-line text, string_multiline: textarea, bool: toggle, int/float: numeric input." }, "bs_default": { "title": "Default Value", "oneOf": [ { "type": "string" }, + { "type": "boolean" }, { "type": "integer" }, - { "type": "number" }, - { "type": "boolean" } + { + "type": "number", + "not": { "multipleOf": 1 } + } ], "description": "Default value used when the admin has not provided an override in the setup dialog. Must match bs_type." }, diff --git a/flexus_simple_bots/discord_community_assets/source/README.md b/flexus_simple_bots/discord_community_assets/source/README.md new file mode 100644 index 00000000..d2820171 --- /dev/null +++ b/flexus_simple_bots/discord_community_assets/source/README.md @@ -0,0 +1,8 @@ +Place hero images here before running `scripts/discord_community_build_webp.py`: + +- `onboarding.png` (or .jpg) +- `moderation.png` +- `faq.png` +- `engagement.png` + +The build script center-crops to 2:3 for marketplace cards and writes WebP into each `discord_*` bot folder. diff --git a/flexus_simple_bots/discord_community_assets/source/engagement.png b/flexus_simple_bots/discord_community_assets/source/engagement.png new file mode 100644 index 00000000..fb5b844b Binary files /dev/null and b/flexus_simple_bots/discord_community_assets/source/engagement.png differ diff --git a/flexus_simple_bots/discord_community_assets/source/faq.png b/flexus_simple_bots/discord_community_assets/source/faq.png new file mode 100644 index 00000000..b5eccf9f Binary files /dev/null and b/flexus_simple_bots/discord_community_assets/source/faq.png differ diff --git a/flexus_simple_bots/discord_community_assets/source/moderation.png b/flexus_simple_bots/discord_community_assets/source/moderation.png new file mode 100644 index 00000000..fa582505 Binary files /dev/null and b/flexus_simple_bots/discord_community_assets/source/moderation.png differ diff --git a/flexus_simple_bots/discord_community_assets/source/onboarding.png b/flexus_simple_bots/discord_community_assets/source/onboarding.png new file mode 100644 index 00000000..265864d7 Binary files /dev/null and b/flexus_simple_bots/discord_community_assets/source/onboarding.png differ diff --git a/flexus_simple_bots/discord_engagement/README.md b/flexus_simple_bots/discord_engagement/README.md new file mode 100644 index 00000000..280537dd --- /dev/null +++ b/flexus_simple_bots/discord_engagement/README.md @@ -0,0 +1,5 @@ +# Discord Engagement + +Keyword-to-role pings with cooldown, `!interests` / `!match` opt-in networking introductions, and scheduled inactivity check-in DMs for members with configured roles. + +Uses Mongo job queue for scan spacing. Separate Discord token; does not use `fi_discord2`. diff --git a/flexus_simple_bots/discord_engagement/__init__.py b/flexus_simple_bots/discord_engagement/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/flexus_simple_bots/discord_engagement/__init__.py @@ -0,0 +1 @@ + diff --git a/flexus_simple_bots/discord_engagement/default__s1.yaml b/flexus_simple_bots/discord_engagement/default__s1.yaml new file mode 100644 index 00000000..342dfe1b --- /dev/null +++ b/flexus_simple_bots/discord_engagement/default__s1.yaml @@ -0,0 +1,10 @@ +messages: +- role: user + content: Explain keyword pings and !match. +- role: assistant + content: >- + Keyword-to-role pings use whole-word matching with a per-channel cooldown stored in Mongo. + Members run !interests tags to opt in, then !match posts introduction suggestions in the + networking channel. Inactivity check-in DMs run on a scheduled job chain; flags can disable + keyword pings, DMs, or log candidates only. +persona_marketable_name: discord_engagement diff --git a/flexus_simple_bots/discord_engagement/discord_engagement-1024x1536.webp b/flexus_simple_bots/discord_engagement/discord_engagement-1024x1536.webp new file mode 100644 index 00000000..e82880ed Binary files /dev/null and b/flexus_simple_bots/discord_engagement/discord_engagement-1024x1536.webp differ diff --git a/flexus_simple_bots/discord_engagement/discord_engagement-256x256.webp b/flexus_simple_bots/discord_engagement/discord_engagement-256x256.webp new file mode 100644 index 00000000..5c760fa8 Binary files /dev/null and b/flexus_simple_bots/discord_engagement/discord_engagement-256x256.webp differ diff --git a/flexus_simple_bots/discord_engagement/discord_engagement_bot.py b/flexus_simple_bots/discord_engagement/discord_engagement_bot.py new file mode 100644 index 00000000..f06a71b0 --- /dev/null +++ b/flexus_simple_bots/discord_engagement/discord_engagement_bot.py @@ -0,0 +1,341 @@ +import asyncio +import json +import logging +import re +import time +from typing import Any, Dict, List, Optional, Set, Tuple + +import discord +from discord.errors import DiscordException +from pymongo import AsyncMongoClient + +from flexus_client_kit import ckit_bot_exec +from flexus_client_kit import ckit_client +from flexus_client_kit import ckit_mongo +from flexus_client_kit import ckit_shutdown +from flexus_client_kit.integrations import fi_discord_community as dc +from flexus_simple_bots.discord_engagement import discord_engagement_install +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + +logger = logging.getLogger("discord_engagement") + +BOT_NAME = "discord_engagement" +BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION +TOOLS: List[Any] = [] + +KW_CD_COL = "dc_engagement_keyword_cd" + + +def _parse_keyword_map(raw: str) -> Dict[str, int]: + try: + v = json.loads(raw or "{}") + except json.JSONDecodeError: + return {} + if not isinstance(v, dict): + return {} + out: Dict[str, int] = {} + for k, spec in v.items(): + kw = str(k).strip().lower() + if not kw: + continue + if isinstance(spec, dict): + rid = str(spec.get("role_id", "")).strip() + else: + rid = str(spec).strip() + if rid.isdigit(): + out[kw] = int(rid) + return out + + +def _role_ids_csv(s: str) -> List[int]: + out: List[int] = [] + for part in (s or "").split(","): + p = part.strip() + if p.isdigit(): + out.append(int(p)) + return out + + +async def _kw_cooldown_ok(mongo_db: Any, ch_id: int, kw: str, min_sec: float) -> bool: + coll = mongo_db[KW_CD_COL] + now = time.time() + key = {"channel_id": ch_id, "keyword": kw} + doc = await coll.find_one(key) + if doc and now - float(doc.get("last_ts", 0)) < min_sec: + return False + await coll.update_one(key, {"$set": {"last_ts": now}}, upsert=True) + return True + + +async def _activity_touch(mongo_db: Any, guild_id: int, user_id: int) -> None: + coll = mongo_db[dc.COL_ACTIVITY] + now = time.time() + await coll.update_one( + {"guild_id": guild_id, "user_id": user_id}, + {"$set": {"last_message_ts": now}}, + upsert=True, + ) + + +async def _scan_inactivity( + client: discord.Client, + setup: Dict[str, Any], + mongo_db: Any, + persona_id: str, + guild_want: int, + payload: Dict[str, Any], +) -> None: + del payload + interval_h = float(int(setup.get("inactivity_scan_interval_hours") or 24)) + next_run = time.time() + interval_h * 3600.0 + guild = client.get_guild(int(guild_want)) + if not guild: + await dc.enqueue_job(mongo_db, "engagement_inactivity_scan", next_run, {"guild_id": int(guild_want)}) + return + valuable = _role_ids_csv(setup.get("valuable_role_ids", "")) + if not valuable: + await dc.enqueue_job(mongo_db, "engagement_inactivity_scan", next_run, {"guild_id": int(guild_want)}) + return + opt_in = dc.parse_snowflake(setup.get("inactivity_opt_in_role_id", "")) + days = float(int(setup.get("inactivity_days") or 14)) + body = (setup.get("checkin_dm_body") or "").strip() + max_dm = int(setup.get("max_checkin_dms_per_scan") or 10) + cd_days = float(int(setup.get("checkin_cooldown_days") or 7)) + no_dm = dc.setup_truthy(setup.get("disable_inactivity_dm")) + log_only = dc.setup_truthy(setup.get("inactivity_log_only")) + if not body: + await dc.enqueue_job(mongo_db, "engagement_inactivity_scan", next_run, {"guild_id": int(guild_want)}) + return + + coll = mongo_db[dc.COL_ACTIVITY] + now = time.time() + sent = 0 + seen: Set[int] = set() + + for rid in valuable: + role = guild.get_role(rid) + if not role: + continue + for m in role.members: + if m.bot or m.id in seen: + continue + seen.add(m.id) + if opt_in and opt_in not in {x.id for x in m.roles}: + continue + doc = await coll.find_one({"guild_id": int(guild.id), "user_id": int(m.id)}) or {} + last_msg = doc.get("last_message_ts") + joined_ts = m.joined.timestamp() if m.joined else now + last_act = float(last_msg) if last_msg else joined_ts + if now - last_act < days * 86400.0: + continue + last_ck = float(doc.get("last_checkin_ts") or 0) + if last_ck and now - last_ck < cd_days * 86400.0: + continue + if no_dm: + continue + if log_only: + logger.info( + "[%s] inactivity_log_only would_dm user=%s guild=%s", + persona_id, + m.id, + guild.id, + ) + continue + ok = await dc.safe_dm(client, m, persona_id, body) + if ok: + sent += 1 + await coll.update_one( + {"guild_id": int(guild.id), "user_id": int(m.id)}, + {"$set": {"last_checkin_ts": now}}, + upsert=True, + ) + if sent >= max_dm: + break + + await dc.enqueue_job(mongo_db, "engagement_inactivity_scan", next_run, {"guild_id": int(guild_want)}) + + +def _register_discord( + client: discord.Client, + setup: Dict[str, Any], + mongo_db: Any, + persona_id: str, + guild_want: Optional[int], +) -> None: + kw_map = _parse_keyword_map(setup.get("keyword_pings_json", "{}")) + kw_cd = float(int(setup.get("keyword_cooldown_seconds") or 120)) + net_ch = dc.parse_snowflake(setup.get("networking_channel_id", "")) + kw_off = dc.setup_truthy(setup.get("disable_keyword_pings")) + + @client.event + async def on_ready() -> None: + dc.log_ctx(persona_id, None, "discord engagement ready as %s", client.user) + if guild_want and client.user: + g0 = client.get_guild(int(guild_want)) + if g0 and net_ch: + dc.preflight_text_channels( + g0, + client.user, + persona_id, + "discord_engagement", + {"networking": (net_ch, "basic")}, + ) + if guild_want: + pending = await mongo_db[dc.COL_JOBS].count_documents( + {"done": False, "kind": "engagement_inactivity_scan"}, + ) + if pending == 0: + await dc.enqueue_job( + mongo_db, + "engagement_inactivity_scan", + time.time() + 90.0, + {"guild_id": int(guild_want)}, + ) + + @client.event + async def on_message(message: discord.Message) -> None: + if message.author.bot: + return + if not message.guild or not dc.guild_matches(message.guild, guild_want): + return + gid = int(message.guild.id) + uid = int(message.author.id) + await _activity_touch(mongo_db, gid, uid) + + text = (message.content or "").strip() + low = text.lower() + + if low.startswith("!interests "): + raw_tags = text[len("!interests ") :].strip() + tags = [t.strip().lower() for t in raw_tags.replace(";", ",").split(",") if t.strip()] + coll = mongo_db[dc.COL_ACTIVITY] + await coll.update_one( + {"guild_id": gid, "user_id": uid}, + {"$set": {"tags": tags, "networking_opt_in": True, "last_message_ts": time.time()}}, + upsert=True, + ) + await dc.safe_send(message.channel, persona_id, "Saved your interests: %s" % ", ".join(tags) if tags else "Cleared.") + return + + if low == "!match" or low.startswith("!match "): + if not net_ch: + await dc.safe_send(message.channel, persona_id, "Networking channel is not configured.") + return + coll = mongo_db[dc.COL_ACTIVITY] + me = await coll.find_one({"guild_id": gid, "user_id": uid}) or {} + tags = [str(t).lower() for t in (me.get("tags") or [])] + if not me.get("networking_opt_in") or not tags: + await dc.safe_send( + message.channel, + persona_id, + "Use `!interests tag1, tag2` first (opt-in required for introductions).", + ) + return + matches: List[Tuple[int, int]] = [] + others = await coll.find({"guild_id": gid, "networking_opt_in": True}).to_list(length=500) + for doc in others: + ouid = int(doc.get("user_id", 0)) + if ouid == uid: + continue + otags = {str(t).lower() for t in (doc.get("tags") or [])} + overlap = len(set(tags) & otags) + if overlap > 0: + matches.append((overlap, ouid)) + matches.sort(key=lambda x: -x[0]) + ch = message.guild.get_channel(net_ch) if message.guild else None + if not isinstance(ch, discord.TextChannel): + await dc.safe_send(message.channel, persona_id, "Networking channel not found.") + return + lines: List[str] = [] + for _, ouid in matches[:3]: + mem = message.guild.get_member(ouid) + mention = mem.mention if mem else "<@%d>" % ouid + lines.append("Possible match: %s (overlap on shared interest tags)" % mention) + msg_body = "\n".join(lines) if lines else "No opt-in members with overlapping tags yet." + await dc.safe_send(ch, persona_id, "%s asked for intros:\n%s" % (message.author.mention, msg_body)) + return + + if kw_off or not kw_map or not isinstance(message.channel, discord.TextChannel): + return + for kw, role_id in kw_map.items(): + try: + pat = re.compile(r"(? None: + setup = ckit_bot_exec.official_setup_mixing_procedure( + discord_engagement_install.DISCORD_ENGAGEMENT_SETUP_SCHEMA, + rcx.persona.persona_setup, + ) + token = dc.discord_bot_api_key_from_external_auth(rcx.external_auth) + if not token: + logger.error("%s missing discord api_key", rcx.persona.persona_id) + while not ckit_shutdown.shutdown_event.is_set(): + await rcx.unpark_collected_events(sleep_if_no_work=30.0) + return + + mongo_conn_str = await ckit_mongo.mongo_fetch_creds(fclient, rcx.persona.persona_id) + mongo = AsyncMongoClient(mongo_conn_str) + mongo_db = mongo[rcx.persona.persona_id + "_db"] + guild_want = dc.parse_snowflake(setup.get("dc_guild_id", "")) + + holder: Dict[str, Any] = {} + + def register(cl: discord.Client) -> None: + holder["c"] = cl + _register_discord(cl, setup, mongo_db, rcx.persona.persona_id, guild_want) + + cl, task = await dc.start_discord_client(token, rcx.persona.persona_id, register) + holder["t"] = task + + async def on_inactivity(payload: Dict[str, Any]) -> None: + c = holder.get("c") + if not isinstance(c, discord.Client) or not guild_want: + return + await _scan_inactivity(c, setup, mongo_db, rcx.persona.persona_id, int(guild_want), payload) + + jobs = {"engagement_inactivity_scan": on_inactivity} + + try: + while not ckit_shutdown.shutdown_event.is_set(): + await dc.drain_due_jobs(mongo_db, rcx.persona.persona_id, jobs, limit=20) + await rcx.unpark_collected_events(sleep_if_no_work=5.0) + finally: + await dc.close_discord_client(holder.get("c"), holder.get("t")) + await mongo.close() + logger.info("%s exit", rcx.persona.persona_id) + + +def main() -> None: + scenario_fn = ckit_bot_exec.parse_bot_args() + fclient = ckit_client.FlexusClient(ckit_client.bot_service_name(BOT_NAME, BOT_VERSION), endpoint="/v1/jailed-bot") + asyncio.run( + ckit_bot_exec.run_bots_in_this_group( + fclient, + marketable_name=BOT_NAME, + marketable_version_str=BOT_VERSION, + bot_main_loop=discord_engagement_main_loop, + inprocess_tools=TOOLS, + scenario_fn=scenario_fn, + install_func=discord_engagement_install.install, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/flexus_simple_bots/discord_engagement/discord_engagement_install.py b/flexus_simple_bots/discord_engagement/discord_engagement_install.py new file mode 100644 index 00000000..6d245283 --- /dev/null +++ b/flexus_simple_bots/discord_engagement/discord_engagement_install.py @@ -0,0 +1,78 @@ +import asyncio +import base64 +import json +from pathlib import Path + +from flexus_client_kit import ckit_bot_install +from flexus_client_kit import ckit_client +from flexus_client_kit import ckit_cloudtool +from flexus_simple_bots import prompts_common +from flexus_simple_bots.discord_engagement import discord_engagement_prompts +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + + +ROOT = Path(__file__).parent + +DISCORD_ENGAGEMENT_SETUP_SCHEMA = json.loads((ROOT / "setup_schema.json").read_text()) + +EXPERTS = [ + ( + "default", + ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=discord_engagement_prompts.discord_engagement_stub, + fexp_python_kernel="", + fexp_block_tools="", + fexp_allow_tools="", + fexp_inactivity_timeout=3600, + fexp_description="Stub expert; engagement automation runs on Discord gateway.", + fexp_builtin_skills="[]", + ), + ), +] + + +async def install( + client: ckit_client.FlexusClient, + bot_name: str, + bot_version: str, + tools: list[ckit_cloudtool.CloudTool], +): + pic_big_b64 = base64.b64encode((ROOT / "discord_engagement-1024x1536.webp").read_bytes()).decode("ascii") + pic_small_b64 = base64.b64encode((ROOT / "discord_engagement-256x256.webp").read_bytes()).decode("ascii") + desc = (ROOT / "README.md").read_text() + await ckit_bot_install.marketplace_upsert_dev_bot( + client, + ws_id=client.ws_id, + marketable_name=bot_name, + marketable_version=bot_version, + marketable_accent_color="#FEE75C", + marketable_title1="Discord Engagement", + marketable_title2="Interest pings, opt-in intros, inactive-member check-ins.", + marketable_author="Flexus", + marketable_occupation="Community", + marketable_description=desc, + marketable_typical_group="Community / Discord", + marketable_github_repo="https://github.com/smallcloudai/flexus-client-kit.git", + marketable_run_this="python -m flexus_simple_bots.discord_engagement.discord_engagement_bot", + marketable_setup_default=DISCORD_ENGAGEMENT_SETUP_SCHEMA, + marketable_featured_actions=[ + {"feat_question": "How does !match work?", "feat_expert": "default", "feat_depends_on_setup": []}, + ], + marketable_intro_message="I highlight keywords with role pings, help members find each other via opt-in tags, and nudge quiet valued members.", + marketable_preferred_model_default="gpt-5.4-nano", + marketable_experts=[(n, e.filter_tools(tools)) for n, e in EXPERTS], + add_integrations_into_expert_system_prompt=[], + marketable_tags=["Discord", "Engagement", "Community"], + marketable_picture_big_b64=pic_big_b64, + marketable_picture_small_b64=pic_small_b64, + marketable_schedule=[prompts_common.SCHED_PICK_ONE_5M], + marketable_auth_supported=["discord_manual"], + ) + + +if __name__ == "__main__": + async def _main() -> None: + fclient = ckit_client.FlexusClient(ckit_client.bot_service_name("discord_engagement", SIMPLE_BOTS_COMMON_VERSION), endpoint="/v1/jailed-bot") + await install(fclient, "discord_engagement", SIMPLE_BOTS_COMMON_VERSION, []) + + asyncio.run(_main()) diff --git a/flexus_simple_bots/discord_engagement/discord_engagement_prompts.py b/flexus_simple_bots/discord_engagement/discord_engagement_prompts.py new file mode 100644 index 00000000..fa1970cb --- /dev/null +++ b/flexus_simple_bots/discord_engagement/discord_engagement_prompts.py @@ -0,0 +1,4 @@ +discord_engagement_stub = """ +You are the Discord Engagement bot persona. Keyword pings, opt-in interests, and inactivity check-ins run on Discord. +Use Flexus chat for configuration help only. +""" diff --git a/flexus_simple_bots/discord_engagement/setup_schema.json b/flexus_simple_bots/discord_engagement/setup_schema.json new file mode 100644 index 00000000..388849ec --- /dev/null +++ b/flexus_simple_bots/discord_engagement/setup_schema.json @@ -0,0 +1,114 @@ +[ + { + "bs_name": "dc_guild_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Discord", + "bs_importance": 1, + "bs_description": "Guild snowflake ID." + }, + { + "bs_name": "keyword_pings_json", + "bs_type": "string_long", + "bs_default": "{}", + "bs_group": "Engagement", + "bs_importance": 0, + "bs_description": "JSON object: {\"keyword_lower\": {\"role_id\":\"snowflake\"}} — whole-word match pings role." + }, + { + "bs_name": "keyword_cooldown_seconds", + "bs_type": "int", + "bs_default": 120, + "bs_group": "Engagement", + "bs_importance": 0, + "bs_description": "Cooldown per keyword per channel before another ping." + }, + { + "bs_name": "networking_channel_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Engagement", + "bs_importance": 0, + "bs_description": "Channel for !match introductions (empty disables public match posts)." + }, + { + "bs_name": "valuable_role_ids", + "bs_type": "string_long", + "bs_default": "", + "bs_group": "Inactivity", + "bs_importance": 0, + "bs_description": "Comma-separated role IDs: members with any of these roles are eligible for inactivity check-in DMs." + }, + { + "bs_name": "inactivity_opt_in_role_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Inactivity", + "bs_importance": 0, + "bs_description": "If set, only members with this role receive inactivity DMs (in addition to valuable roles)." + }, + { + "bs_name": "inactivity_days", + "bs_type": "int", + "bs_default": 14, + "bs_group": "Inactivity", + "bs_importance": 0, + "bs_description": "No guild messages for this many days triggers check-in (if eligible)." + }, + { + "bs_name": "checkin_dm_body", + "bs_type": "string_long", + "bs_default": "Hey! We missed you — anything we can help with? Recent discussions are in our topic channels.", + "bs_group": "Inactivity", + "bs_importance": 0, + "bs_description": "DM sent to inactive valuable members (rate-limited per scan)." + }, + { + "bs_name": "inactivity_scan_interval_hours", + "bs_type": "int", + "bs_default": 24, + "bs_group": "Inactivity", + "bs_importance": 0, + "bs_description": "How often to run inactivity scan job." + }, + { + "bs_name": "max_checkin_dms_per_scan", + "bs_type": "int", + "bs_default": 10, + "bs_group": "Inactivity", + "bs_importance": 0, + "bs_description": "Cap DMs per scan to avoid bursts." + }, + { + "bs_name": "checkin_cooldown_days", + "bs_type": "int", + "bs_default": 7, + "bs_group": "Inactivity", + "bs_importance": 0, + "bs_description": "Minimum days between repeated inactivity DMs to the same user." + }, + { + "bs_name": "disable_keyword_pings", + "bs_type": "string_short", + "bs_default": "false", + "bs_group": "Flags", + "bs_importance": 0, + "bs_description": "If true, skip keyword-to-role pings." + }, + { + "bs_name": "disable_inactivity_dm", + "bs_type": "string_short", + "bs_default": "false", + "bs_group": "Flags", + "bs_importance": 0, + "bs_description": "If true, inactivity scan runs but never sends check-in DMs." + }, + { + "bs_name": "inactivity_log_only", + "bs_type": "string_short", + "bs_default": "false", + "bs_group": "Flags", + "bs_importance": 0, + "bs_description": "If true, log would-be check-in targets to server logs only (no DM, no last_checkin_ts update)." + } +] diff --git a/flexus_simple_bots/discord_faq/README.md b/flexus_simple_bots/discord_faq/README.md new file mode 100644 index 00000000..b46266c3 --- /dev/null +++ b/flexus_simple_bots/discord_faq/README.md @@ -0,0 +1,5 @@ +# Discord FAQ + +In configured help channels, replies using regex rules from setup. Optional unmatched reply and optional kanban escalation so a `kb_helper` expert can use `flexus_vector_search` / `flexus_read_original` inside Flexus. + +Uses a separate Discord token from other community bots. Does not use `fi_discord2`. diff --git a/flexus_simple_bots/discord_faq/__init__.py b/flexus_simple_bots/discord_faq/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/flexus_simple_bots/discord_faq/__init__.py @@ -0,0 +1 @@ + diff --git a/flexus_simple_bots/discord_faq/default__s1.yaml b/flexus_simple_bots/discord_faq/default__s1.yaml new file mode 100644 index 00000000..569b839f --- /dev/null +++ b/flexus_simple_bots/discord_faq/default__s1.yaml @@ -0,0 +1,9 @@ +messages: +- role: user + content: How do FAQ escalations work? +- role: assistant + content: >- + In configured help channels the bot matches regex rules first. Unmatched questions can post a + kanban inbox task for expert kb_helper, which uses flexus_vector_search and flexus_read_original + inside Flexus. faq_dry_run disables Discord replies and kanban posts for staging. +persona_marketable_name: discord_faq diff --git a/flexus_simple_bots/discord_faq/discord_faq-1024x1536.webp b/flexus_simple_bots/discord_faq/discord_faq-1024x1536.webp new file mode 100644 index 00000000..6211f65b Binary files /dev/null and b/flexus_simple_bots/discord_faq/discord_faq-1024x1536.webp differ diff --git a/flexus_simple_bots/discord_faq/discord_faq-256x256.webp b/flexus_simple_bots/discord_faq/discord_faq-256x256.webp new file mode 100644 index 00000000..a1d1c8bf Binary files /dev/null and b/flexus_simple_bots/discord_faq/discord_faq-256x256.webp differ diff --git a/flexus_simple_bots/discord_faq/discord_faq_bot.py b/flexus_simple_bots/discord_faq/discord_faq_bot.py new file mode 100644 index 00000000..ce1b4190 --- /dev/null +++ b/flexus_simple_bots/discord_faq/discord_faq_bot.py @@ -0,0 +1,212 @@ +import asyncio +import json +import logging +import re +import time +from typing import Any, Dict, List, Optional, Set, Tuple + +import discord +from discord.errors import DiscordException +from pymongo import AsyncMongoClient + +from flexus_client_kit import ckit_bot_exec +from flexus_client_kit import ckit_client +from flexus_client_kit import ckit_integrations_db +from flexus_client_kit import ckit_kanban +from flexus_client_kit import ckit_mongo +from flexus_client_kit import ckit_shutdown +from flexus_client_kit.integrations import fi_discord_community as dc +from flexus_simple_bots.discord_faq import discord_faq_install +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + +logger = logging.getLogger("discord_faq") + +BOT_NAME = "discord_faq" +BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION +TOOLS: List[Any] = [] + + +def _parse_faq_rules(raw: str) -> List[Tuple[re.Pattern[str], str]]: + try: + v = json.loads(raw or "[]") + except json.JSONDecodeError: + return [] + if not isinstance(v, list): + return [] + out: List[Tuple[re.Pattern[str], str]] = [] + for item in v: + if not isinstance(item, dict): + continue + pat = str(item.get("pattern", "")) + resp = str(item.get("response", "")) + if not pat or not resp: + continue + try: + out.append((re.compile(pat), resp)) + except re.error: + logger.warning("faq pattern skipped: %r", pat[:80]) + return out + + +def _channel_set(raw: str) -> Set[int]: + s: Set[int] = set() + for part in (raw or "").split(","): + p = part.strip() + if p.isdigit(): + s.add(int(p)) + return s + + +async def _rate_ok(mongo_db: Any, ch_id: int, uid: int, min_sec: float) -> bool: + coll = mongo_db[dc.COL_FAQ_RATE] + now = time.time() + key = {"channel_id": ch_id, "user_id": uid} + doc = await coll.find_one(key) + if doc and now - float(doc.get("last_ts", 0)) < min_sec: + return False + await coll.update_one(key, {"$set": {"last_ts": now}}, upsert=True) + return True + + +def _register_discord( + client: discord.Client, + setup: Dict[str, Any], + mongo_db: Any, + persona_id: str, + guild_want: Optional[int], + fclient: ckit_client.FlexusClient, +) -> None: + channels = _channel_set(setup.get("faq_channel_ids", "")) + rules = _parse_faq_rules(setup.get("faq_rules_json", "[]")) + prefix = (setup.get("faq_reply_prefix") or "").strip() + unmatched = (setup.get("faq_unmatched_reply") or "").strip() + escalate = str(setup.get("faq_escalate_kanban", "")).lower() in ("1", "true", "yes") + rl = float(int(setup.get("faq_rate_limit_seconds") or 15)) + ctx_n = int(setup.get("faq_context_messages") or 0) + faq_dry = dc.setup_truthy(setup.get("faq_dry_run")) + + @client.event + async def on_ready() -> None: + dc.log_ctx(persona_id, None, "discord faq ready as %s", client.user) + if guild_want and client.user and channels: + g0 = client.get_guild(int(guild_want)) + if g0: + fm: Dict[str, Tuple[Optional[int], str]] = {} + for i, cid in enumerate(sorted(channels)): + fm["faq_channel_%d" % i] = (cid, "basic") + dc.preflight_text_channels(g0, client.user, persona_id, "discord_faq", fm) + + @client.event + async def on_message(message: discord.Message) -> None: + if message.author.bot: + return + if not message.guild or not dc.guild_matches(message.guild, guild_want): + return + if message.channel.id not in channels: + return + text = (message.content or "").strip() + if prefix and not text.startswith(prefix): + return + body = text[len(prefix) :].strip() if prefix else text + if not body: + return + if client.user and message.author.id == client.user.id: + return + if not await _rate_ok(mongo_db, message.channel.id, message.author.id, rl): + return + + if faq_dry: + logger.info("[%s] faq_dry trigger user=%s ch=%s body=%r", persona_id, message.author.id, message.channel.id, body[:300]) + return + + for pat, resp in rules: + if pat.search(body): + await dc.safe_send(message.channel, persona_id, resp) + return + + if unmatched: + await dc.safe_send(message.channel, persona_id, unmatched) + + if escalate: + hist_note = "" + if ctx_n > 0 and isinstance(message.channel, discord.TextChannel): + lines: List[str] = [] + try: + async for m in message.channel.history(limit=ctx_n + 1, before=message): + if m.author.bot: + continue + lines.append("%s: %s" % (m.author.display_name, (m.content or "")[:200])) + except DiscordException as e: + dc.log_ctx(persona_id, message.guild.id, "faq history fetch failed: %s %s", type(e).__name__, e) + lines.reverse() + if lines: + hist_note = "\n\nContext:\n" + "\n".join(lines) + title = "Discord FAQ escalation user=%s channel=%s\n%s" % (message.author.id, message.channel.id, body[:500]) + details = { + "discord_channel_id": str(message.channel.id), + "discord_message_id": str(message.id), + "discord_user_id": str(message.author.id), + "question": body, + } + await ckit_kanban.bot_kanban_post_into_inbox( + fclient, + persona_id, + title=title + hist_note, + details_json=json.dumps(details), + provenance_message="discord_faq_escalation", + fexp_name="kb_helper", + ) + + +async def discord_faq_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: + setup = ckit_bot_exec.official_setup_mixing_procedure(discord_faq_install.DISCORD_FAQ_SETUP_SCHEMA, rcx.persona.persona_setup) + await ckit_integrations_db.main_loop_integrations_init(discord_faq_install.DISCORD_FAQ_INTEGRATIONS, rcx, setup) + + token = dc.discord_bot_api_key_from_external_auth(rcx.external_auth) + if not token: + logger.error("%s missing discord api_key", rcx.persona.persona_id) + while not ckit_shutdown.shutdown_event.is_set(): + await rcx.unpark_collected_events(sleep_if_no_work=30.0) + return + + mongo_conn_str = await ckit_mongo.mongo_fetch_creds(fclient, rcx.persona.persona_id) + mongo = AsyncMongoClient(mongo_conn_str) + mongo_db = mongo[rcx.persona.persona_id + "_db"] + guild_want = dc.parse_snowflake(setup.get("dc_guild_id", "")) + + holder: Dict[str, Any] = {} + + def register(cl: discord.Client) -> None: + holder["c"] = cl + _register_discord(cl, setup, mongo_db, rcx.persona.persona_id, guild_want, fclient) + + cl, task = await dc.start_discord_client(token, rcx.persona.persona_id, register) + holder["t"] = task + + try: + while not ckit_shutdown.shutdown_event.is_set(): + await rcx.unpark_collected_events(sleep_if_no_work=5.0) + finally: + await dc.close_discord_client(holder.get("c"), holder.get("t")) + await mongo.close() + logger.info("%s exit", rcx.persona.persona_id) + + +def main() -> None: + scenario_fn = ckit_bot_exec.parse_bot_args() + fclient = ckit_client.FlexusClient(ckit_client.bot_service_name(BOT_NAME, BOT_VERSION), endpoint="/v1/jailed-bot") + asyncio.run( + ckit_bot_exec.run_bots_in_this_group( + fclient, + marketable_name=BOT_NAME, + marketable_version_str=BOT_VERSION, + bot_main_loop=discord_faq_main_loop, + inprocess_tools=TOOLS, + scenario_fn=scenario_fn, + install_func=discord_faq_install.install, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/flexus_simple_bots/discord_faq/discord_faq_install.py b/flexus_simple_bots/discord_faq/discord_faq_install.py new file mode 100644 index 00000000..b42758bb --- /dev/null +++ b/flexus_simple_bots/discord_faq/discord_faq_install.py @@ -0,0 +1,105 @@ +import asyncio +import base64 +import json +from pathlib import Path + +from flexus_client_kit import ckit_bot_install +from flexus_client_kit import ckit_client +from flexus_client_kit import ckit_cloudtool +from flexus_client_kit import ckit_integrations_db +from flexus_client_kit import ckit_skills +from flexus_simple_bots import prompts_common +from flexus_simple_bots.discord_faq import discord_faq_prompts +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + + +ROOT = Path(__file__).parent + +DISCORD_FAQ_SKILLS = ckit_skills.static_skills_find(ROOT, shared_skills_allowlist="setting-up-external-knowledge-base") + +DISCORD_FAQ_SETUP_SCHEMA = json.loads((ROOT / "setup_schema.json").read_text()) + +DISCORD_FAQ_INTEGRATIONS: list[ckit_integrations_db.IntegrationRecord] = ckit_integrations_db.static_integrations_load( + ROOT, + allowlist=[ + "flexus_policy_document", + "print_widget", + "skills", + "magic_desk", + ], + builtin_skills=DISCORD_FAQ_SKILLS, +) + +EXPERTS = [ + ( + "default", + ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=discord_faq_prompts.discord_faq_stub, + fexp_python_kernel="", + fexp_block_tools="", + fexp_allow_tools="", + fexp_inactivity_timeout=3600, + fexp_description="Operator chat for setup and policy questions.", + fexp_builtin_skills=ckit_skills.read_name_description(ROOT, DISCORD_FAQ_SKILLS), + ), + ), + ( + "kb_helper", + ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=discord_faq_prompts.discord_faq_kb_helper, + fexp_python_kernel="", + fexp_block_tools="", + fexp_allow_tools="flexus_vector_search,flexus_read_original,flexus_bot_kanban", + fexp_inactivity_timeout=3600, + fexp_description="Research escalated Discord questions using the workspace knowledge base.", + fexp_builtin_skills=ckit_skills.read_name_description(ROOT, DISCORD_FAQ_SKILLS), + ), + ), +] + + +async def install( + client: ckit_client.FlexusClient, + bot_name: str, + bot_version: str, + tools: list[ckit_cloudtool.CloudTool], +): + pic_big_b64 = base64.b64encode((ROOT / "discord_faq-1024x1536.webp").read_bytes()).decode("ascii") + pic_small_b64 = base64.b64encode((ROOT / "discord_faq-256x256.webp").read_bytes()).decode("ascii") + desc = (ROOT / "README.md").read_text() + await ckit_bot_install.marketplace_upsert_dev_bot( + client, + ws_id=client.ws_id, + marketable_name=bot_name, + marketable_version=bot_version, + marketable_accent_color="#57F287", + marketable_title1="Discord FAQ", + marketable_title2="Regex auto-replies plus KB-backed escalations in Flexus.", + marketable_author="Flexus", + marketable_occupation="Support", + marketable_description=desc, + marketable_typical_group="Community / Discord", + marketable_github_repo="https://github.com/smallcloudai/flexus-client-kit.git", + marketable_run_this="python -m flexus_simple_bots.discord_faq.discord_faq_bot", + marketable_setup_default=DISCORD_FAQ_SETUP_SCHEMA, + marketable_featured_actions=[ + {"feat_question": "How do FAQ escalations reach the knowledge base?", "feat_expert": "default", "feat_depends_on_setup": []}, + ], + marketable_intro_message="I answer common questions on Discord from your regex rules and can escalate hard questions to Flexus for KB research.", + marketable_preferred_model_default="gpt-5.4-nano", + marketable_experts=[(n, e.filter_tools(tools)) for n, e in EXPERTS], + add_integrations_into_expert_system_prompt=DISCORD_FAQ_INTEGRATIONS, + marketable_tags=["Discord", "FAQ", "Support"], + marketable_picture_big_b64=pic_big_b64, + marketable_picture_small_b64=pic_small_b64, + marketable_schedule=[prompts_common.SCHED_PICK_ONE_5M], + marketable_auth_supported=["discord_manual"], + ) + + +if __name__ == "__main__": + async def _main() -> None: + fclient = ckit_client.FlexusClient(ckit_client.bot_service_name("discord_faq", SIMPLE_BOTS_COMMON_VERSION), endpoint="/v1/jailed-bot") + await install(fclient, "discord_faq", SIMPLE_BOTS_COMMON_VERSION, []) + + asyncio.run(_main()) diff --git a/flexus_simple_bots/discord_faq/discord_faq_prompts.py b/flexus_simple_bots/discord_faq/discord_faq_prompts.py new file mode 100644 index 00000000..56ff1c95 --- /dev/null +++ b/flexus_simple_bots/discord_faq/discord_faq_prompts.py @@ -0,0 +1,12 @@ +discord_faq_stub = """ +You help operators configure the Discord FAQ bot. Runtime answers use regex in setup first; unmatched +questions can create Flexus inbox tasks for deeper research. +""" + +discord_faq_kb_helper = """ +You handle Discord FAQ escalations posted to this bot's kanban inbox. +Read ktask_details for discord_channel_id / discord_user_id / question. +Use flexus_vector_search and flexus_read_original to ground answers. Reply in Flexus with a concise answer +and suggested short text the operator can paste to Discord if they want. +Do not claim you already posted to Discord unless a Discord tool explicitly did so (this expert has no Discord tool). +""" diff --git a/flexus_simple_bots/discord_faq/setup_schema.json b/flexus_simple_bots/discord_faq/setup_schema.json new file mode 100644 index 00000000..27f7bb4b --- /dev/null +++ b/flexus_simple_bots/discord_faq/setup_schema.json @@ -0,0 +1,74 @@ +[ + { + "bs_name": "dc_guild_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Discord", + "bs_importance": 1, + "bs_description": "Guild snowflake ID." + }, + { + "bs_name": "faq_channel_ids", + "bs_type": "string_long", + "bs_default": "", + "bs_group": "FAQ", + "bs_importance": 1, + "bs_description": "Comma-separated channel IDs where the bot answers (regex rules)." + }, + { + "bs_name": "faq_rules_json", + "bs_type": "string_long", + "bs_default": "[]", + "bs_group": "FAQ", + "bs_importance": 1, + "bs_description": "JSON array: [{\"pattern\":\"(?i)pricing\",\"response\":\"See https://example.com/pricing\"}] — first match wins." + }, + { + "bs_name": "faq_reply_prefix", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "FAQ", + "bs_importance": 0, + "bs_description": "If non-empty, only messages starting with this prefix trigger replies (after trimming)." + }, + { + "bs_name": "faq_unmatched_reply", + "bs_type": "string_long", + "bs_default": "I do not have a canned answer. A teammate will follow up, or ask in a thread with details.", + "bs_group": "FAQ", + "bs_importance": 0, + "bs_description": "Sent when no regex matches (only if message was a FAQ trigger)." + }, + { + "bs_name": "faq_escalate_kanban", + "bs_type": "string_short", + "bs_default": "false", + "bs_group": "FAQ", + "bs_importance": 0, + "bs_description": "If true, post an inbox task when there is no regex match (for human + vector follow-up in Flexus)." + }, + { + "bs_name": "faq_rate_limit_seconds", + "bs_type": "int", + "bs_default": 15, + "bs_group": "FAQ", + "bs_importance": 0, + "bs_description": "Minimum seconds between bot replies per user in a channel." + }, + { + "bs_name": "faq_context_messages", + "bs_type": "int", + "bs_default": 3, + "bs_group": "FAQ", + "bs_importance": 0, + "bs_description": "When escalating to kanban, prepend this many prior user messages from the channel as context (0 disables)." + }, + { + "bs_name": "faq_dry_run", + "bs_type": "string_short", + "bs_default": "false", + "bs_group": "Flags", + "bs_importance": 0, + "bs_description": "If true, log FAQ triggers to logger only (no channel reply, no kanban escalation)." + } +] diff --git a/flexus_simple_bots/discord_moderation/README.md b/flexus_simple_bots/discord_moderation/README.md new file mode 100644 index 00000000..bfbfdf39 --- /dev/null +++ b/flexus_simple_bots/discord_moderation/README.md @@ -0,0 +1,5 @@ +# Discord Moderation + +Deletes invite links (optional), URL-regex matches, enforces per-channel regex rules, per-user rate limits, and optional new-account quarantine role or kick. Logs actions to a mod channel and stores events in MongoDB. + +Separate bot token from Onboarding/FAQ/Engagement. Does not modify Karen or `fi_discord2`. diff --git a/flexus_simple_bots/discord_moderation/__init__.py b/flexus_simple_bots/discord_moderation/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/flexus_simple_bots/discord_moderation/__init__.py @@ -0,0 +1 @@ + diff --git a/flexus_simple_bots/discord_moderation/default__s1.yaml b/flexus_simple_bots/discord_moderation/default__s1.yaml new file mode 100644 index 00000000..056f5d26 --- /dev/null +++ b/flexus_simple_bots/discord_moderation/default__s1.yaml @@ -0,0 +1,9 @@ +messages: +- role: user + content: Summarize moderation automation for Discord. +- role: assistant + content: >- + It can delete invite links, match URL regexes, enforce per-channel message format via regex, + apply per-user rate limits, and handle very new accounts with quarantine role or kick. + Violations are logged to a mod channel and stored in Mongo; moderation_dry_run logs only. +persona_marketable_name: discord_moderation diff --git a/flexus_simple_bots/discord_moderation/discord_moderation-1024x1536.webp b/flexus_simple_bots/discord_moderation/discord_moderation-1024x1536.webp new file mode 100644 index 00000000..90499c64 Binary files /dev/null and b/flexus_simple_bots/discord_moderation/discord_moderation-1024x1536.webp differ diff --git a/flexus_simple_bots/discord_moderation/discord_moderation-256x256.webp b/flexus_simple_bots/discord_moderation/discord_moderation-256x256.webp new file mode 100644 index 00000000..8d7f88c5 Binary files /dev/null and b/flexus_simple_bots/discord_moderation/discord_moderation-256x256.webp differ diff --git a/flexus_simple_bots/discord_moderation/discord_moderation_bot.py b/flexus_simple_bots/discord_moderation/discord_moderation_bot.py new file mode 100644 index 00000000..622c4fc6 --- /dev/null +++ b/flexus_simple_bots/discord_moderation/discord_moderation_bot.py @@ -0,0 +1,342 @@ +import asyncio +import json +import logging +import re +import time +from collections import defaultdict, deque +from datetime import datetime, timezone +from typing import Any, Deque, Dict, List, Optional, Set, Tuple + +import discord +from discord.errors import DiscordException +from pymongo import AsyncMongoClient + +from flexus_client_kit import ckit_bot_exec +from flexus_client_kit import ckit_client +from flexus_client_kit import ckit_mongo +from flexus_client_kit import ckit_shutdown +from flexus_client_kit.integrations import fi_discord_community as dc +from flexus_simple_bots.discord_moderation import discord_moderation_install +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + +logger = logging.getLogger("discord_moderation") + +BOT_NAME = "discord_moderation" +BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION +TOOLS: List[Any] = [] + +WINDOW_SEC = 60 + + +def _parse_channel_rules(raw: str) -> List[Tuple[int, re.Pattern[str], str]]: + try: + v = json.loads(raw or "[]") + except json.JSONDecodeError: + return [] + if not isinstance(v, list): + return [] + out: List[Tuple[int, re.Pattern[str], str]] = [] + for item in v: + if not isinstance(item, dict): + continue + cid = str(item.get("channel_id", "")).strip() + pat = str(item.get("must_match_regex", ".*")) + hint = str(item.get("hint", "Message format not allowed here.")) + if not cid.isdigit(): + continue + try: + out.append((int(cid), re.compile(pat), hint)) + except re.error: + logger.warning("bad channel rule regex skipped: %r", pat[:80]) + return out + + +async def _mod_log( + guild: discord.Guild, + setup: Dict[str, Any], + persona_id: str, + text: str, +) -> None: + lid = dc.parse_snowflake(setup.get("mod_log_channel_id", "")) + if not lid: + return + ch = guild.get_channel(lid) + if not isinstance(ch, discord.TextChannel): + return + await dc.safe_send(ch, persona_id, truncate_message_mod(text)) + + +def truncate_message_mod(text: str) -> str: + return dc.truncate_message(text, 1900) + + +async def _record_mod_event(mongo_db: Any, doc: Dict[str, Any]) -> None: + doc["ts"] = time.time() + await mongo_db[dc.COL_MOD_EVENTS].insert_one(doc) + + +def _register_discord( + client: discord.Client, + setup: Dict[str, Any], + mongo_db: Any, + persona_id: str, + guild_want: Optional[int], +) -> None: + url_patterns = dc.compile_url_patterns(setup.get("url_block_regexes", "")) + rules = _parse_channel_rules(setup.get("channel_rules_json", "[]")) + invite_block = str(setup.get("block_invite_links", "true")).lower() in ("1", "true", "yes") + max_per_min = int(setup.get("max_messages_per_minute") or 0) + new_age_days = int(setup.get("new_account_max_age_days") or 0) + quar_rid = dc.parse_snowflake(setup.get("new_account_quarantine_role_id", "")) + dry = dc.setup_truthy(setup.get("moderation_dry_run")) + + buckets: Dict[Tuple[int, int], Deque[float]] = defaultdict(deque) + + def _rate_allow(gid: int, uid: int) -> bool: + if max_per_min <= 0: + return True + now = time.time() + dq = buckets[(gid, uid)] + while dq and now - dq[0] > WINDOW_SEC: + dq.popleft() + if len(dq) >= max_per_min: + return False + dq.append(now) + return True + + @client.event + async def on_ready() -> None: + dc.log_ctx(persona_id, None, "discord moderation ready as %s", client.user) + if guild_want and client.user: + g0 = client.get_guild(int(guild_want)) + if g0: + me = g0.get_member(client.user.id) + if me: + gm: List[str] = [] + if not me.guild_permissions.manage_messages: + gm.append("manage_messages") + if new_age_days > 0 and not quar_rid and not me.guild_permissions.kick_members: + gm.append("kick_members") + if new_age_days > 0 and quar_rid and not me.guild_permissions.manage_roles: + gm.append("manage_roles") + if gm: + dc.log_ctx(persona_id, g0.id, "preflight discord_moderation guild missing %s", ",".join(gm)) + ch_map: Dict[str, Tuple[Optional[int], str]] = { + "mod_log": (dc.parse_snowflake(setup.get("mod_log_channel_id", "")), "basic"), + } + seen: Set[int] = set() + for rid, _pat, _hint in rules: + if rid not in seen: + seen.add(rid) + ch_map["channel_rule_%d" % rid] = (rid, "mod") + dc.preflight_text_channels(g0, client.user, persona_id, "discord_moderation", ch_map) + + @client.event + async def on_member_join(member: discord.Member) -> None: + if not dc.guild_matches(member.guild, guild_want) or member.bot: + return + if new_age_days <= 0: + return + created = member.created_at + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + age_days = (datetime.now(timezone.utc) - created).total_seconds() / 86400.0 + if age_days > float(new_age_days): + return + await _mod_log( + member.guild, + setup, + persona_id, + "New account join: %s (%s) age_days=%.2f" % (member, member.id, age_days), + ) + if dry: + await _mod_log( + member.guild, + setup, + persona_id, + "[DRY] would quarantine or kick new account %s" % member.id, + ) + await _record_mod_event( + mongo_db, + {"action": "dry_new_account", "guild_id": member.guild.id, "user_id": member.id, "age_days": age_days}, + ) + return + if quar_rid: + role = member.guild.get_role(quar_rid) + if role: + try: + await member.add_roles(role, reason="new account quarantine") + except DiscordException as e: + dc.log_ctx(persona_id, member.guild.id, "quarantine role failed: %s %s", type(e).__name__, e) + return + try: + await member.kick(reason="Account too new (configured policy)") + except DiscordException as e: + dc.log_ctx(persona_id, member.guild.id, "kick new account failed: %s %s", type(e).__name__, e) + await _record_mod_event( + mongo_db, + {"action": "kick_new_account", "guild_id": member.guild.id, "user_id": member.id, "age_days": age_days}, + ) + + @client.event + async def on_message(message: discord.Message) -> None: + if message.author.bot: + return + if not message.guild or not dc.guild_matches(message.guild, guild_want): + return + gid = int(message.guild.id) + uid = int(message.author.id) + if not _rate_allow(gid, uid): + if dry: + await _mod_log( + message.guild, + setup, + persona_id, + "[DRY] rate limit would delete user=%s ch=%s" % (uid, message.channel.id), + ) + await _record_mod_event( + mongo_db, + {"action": "rate_limit", "guild_id": gid, "user_id": uid, "channel_id": message.channel.id, "dry_run": True}, + ) + return + try: + await message.delete() + except DiscordException as e: + dc.log_ctx(persona_id, gid, "delete rate limit msg failed: %s %s", type(e).__name__, e) + await _mod_log(message.guild, setup, persona_id, "Rate limit delete user=%s channel=%s" % (uid, message.channel.id)) + await _record_mod_event( + mongo_db, + {"action": "rate_limit", "guild_id": gid, "user_id": uid, "channel_id": message.channel.id}, + ) + return + + content = message.content or "" + if invite_block and dc.message_has_invite(content): + if dry: + await _mod_log(message.guild, setup, persona_id, "[DRY] invite block would delete user=%s" % uid) + await _record_mod_event( + mongo_db, + {"action": "invite_block", "guild_id": gid, "user_id": uid, "channel_id": message.channel.id, "dry_run": True}, + ) + return + try: + await message.delete() + except DiscordException as e: + dc.log_ctx(persona_id, gid, "delete invite failed: %s %s", type(e).__name__, e) + await _mod_log(message.guild, setup, persona_id, "Removed invite link from user=%s" % uid) + await _record_mod_event( + mongo_db, + {"action": "invite_block", "guild_id": gid, "user_id": uid, "channel_id": message.channel.id}, + ) + return + + if dc.match_blocked_url(content, url_patterns): + if dry: + await _mod_log(message.guild, setup, persona_id, "[DRY] URL rule would delete user=%s" % uid) + await _record_mod_event( + mongo_db, + {"action": "url_block", "guild_id": gid, "user_id": uid, "channel_id": message.channel.id, "dry_run": True}, + ) + return + try: + await message.delete() + except DiscordException as e: + dc.log_ctx(persona_id, gid, "delete url pattern failed: %s %s", type(e).__name__, e) + await _mod_log(message.guild, setup, persona_id, "URL pattern delete user=%s" % uid) + await _record_mod_event( + mongo_db, + {"action": "url_block", "guild_id": gid, "user_id": uid, "channel_id": message.channel.id}, + ) + return + + ch_id = message.channel.id + for rid, pat, hint in rules: + if rid != ch_id: + continue + if pat.search(content): + break + if dry: + await _mod_log( + message.guild, + setup, + persona_id, + "[DRY] channel rule would delete user=%s ch=%s" % (uid, ch_id), + ) + await _record_mod_event( + mongo_db, + {"action": "channel_rule", "guild_id": gid, "user_id": uid, "channel_id": ch_id, "dry_run": True}, + ) + return + try: + await message.delete() + except DiscordException as e: + dc.log_ctx(persona_id, gid, "delete channel rule failed: %s %s", type(e).__name__, e) + await _mod_log( + message.guild, + setup, + persona_id, + "Channel rule delete user=%s channel=%s hint=%s" % (uid, ch_id, hint), + ) + await _record_mod_event( + mongo_db, + {"action": "channel_rule", "guild_id": gid, "user_id": uid, "channel_id": ch_id}, + ) + hint_ch = message.channel + if isinstance(hint_ch, discord.TextChannel): + await dc.safe_send(hint_ch, persona_id, hint) + return + + +async def discord_moderation_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: + setup = ckit_bot_exec.official_setup_mixing_procedure( + discord_moderation_install.DISCORD_MODERATION_SETUP_SCHEMA, + rcx.persona.persona_setup, + ) + token = dc.discord_bot_api_key_from_external_auth(rcx.external_auth) + if not token: + logger.error("%s missing discord api_key", rcx.persona.persona_id) + while not ckit_shutdown.shutdown_event.is_set(): + await rcx.unpark_collected_events(sleep_if_no_work=30.0) + return + + mongo_conn_str = await ckit_mongo.mongo_fetch_creds(fclient, rcx.persona.persona_id) + mongo = AsyncMongoClient(mongo_conn_str) + mongo_db = mongo[rcx.persona.persona_id + "_db"] + guild_want = dc.parse_snowflake(setup.get("dc_guild_id", "")) + + holder: Dict[str, Any] = {} + + def register(cl: discord.Client) -> None: + holder["c"] = cl + _register_discord(cl, setup, mongo_db, rcx.persona.persona_id, guild_want) + + cl, task = await dc.start_discord_client(token, rcx.persona.persona_id, register) + holder["t"] = task + + try: + while not ckit_shutdown.shutdown_event.is_set(): + await rcx.unpark_collected_events(sleep_if_no_work=5.0) + finally: + await dc.close_discord_client(holder.get("c"), holder.get("t")) + await mongo.close() + logger.info("%s exit", rcx.persona.persona_id) + + +def main() -> None: + scenario_fn = ckit_bot_exec.parse_bot_args() + fclient = ckit_client.FlexusClient(ckit_client.bot_service_name(BOT_NAME, BOT_VERSION), endpoint="/v1/jailed-bot") + asyncio.run( + ckit_bot_exec.run_bots_in_this_group( + fclient, + marketable_name=BOT_NAME, + marketable_version_str=BOT_VERSION, + bot_main_loop=discord_moderation_main_loop, + inprocess_tools=TOOLS, + scenario_fn=scenario_fn, + install_func=discord_moderation_install.install, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/flexus_simple_bots/discord_moderation/discord_moderation_install.py b/flexus_simple_bots/discord_moderation/discord_moderation_install.py new file mode 100644 index 00000000..51b68e22 --- /dev/null +++ b/flexus_simple_bots/discord_moderation/discord_moderation_install.py @@ -0,0 +1,78 @@ +import asyncio +import base64 +import json +from pathlib import Path + +from flexus_client_kit import ckit_bot_install +from flexus_client_kit import ckit_client +from flexus_client_kit import ckit_cloudtool +from flexus_simple_bots import prompts_common +from flexus_simple_bots.discord_moderation import discord_moderation_prompts +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + + +ROOT = Path(__file__).parent + +DISCORD_MODERATION_SETUP_SCHEMA = json.loads((ROOT / "setup_schema.json").read_text()) + +EXPERTS = [ + ( + "default", + ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=discord_moderation_prompts.discord_moderation_stub, + fexp_python_kernel="", + fexp_block_tools="", + fexp_allow_tools="", + fexp_inactivity_timeout=3600, + fexp_description="Stub expert; moderation runs on Discord gateway.", + fexp_builtin_skills="[]", + ), + ), +] + + +async def install( + client: ckit_client.FlexusClient, + bot_name: str, + bot_version: str, + tools: list[ckit_cloudtool.CloudTool], +): + pic_big_b64 = base64.b64encode((ROOT / "discord_moderation-1024x1536.webp").read_bytes()).decode("ascii") + pic_small_b64 = base64.b64encode((ROOT / "discord_moderation-256x256.webp").read_bytes()).decode("ascii") + desc = (ROOT / "README.md").read_text() + await ckit_bot_install.marketplace_upsert_dev_bot( + client, + ws_id=client.ws_id, + marketable_name=bot_name, + marketable_version=bot_version, + marketable_accent_color="#ED4245", + marketable_title1="Discord Moderation", + marketable_title2="Anti-spam rules, channel format enforcement, mod audit log.", + marketable_author="Flexus", + marketable_occupation="Moderation", + marketable_description=desc, + marketable_typical_group="Community / Discord", + marketable_github_repo="https://github.com/smallcloudai/flexus-client-kit.git", + marketable_run_this="python -m flexus_simple_bots.discord_moderation.discord_moderation_bot", + marketable_setup_default=DISCORD_MODERATION_SETUP_SCHEMA, + marketable_featured_actions=[ + {"feat_question": "What can this bot delete automatically?", "feat_expert": "default", "feat_depends_on_setup": []}, + ], + marketable_intro_message="I enforce server rules: invites, URL patterns, channel-specific formats, rate limits, and optional new-account handling.", + marketable_preferred_model_default="gpt-5.4-nano", + marketable_experts=[(n, e.filter_tools(tools)) for n, e in EXPERTS], + add_integrations_into_expert_system_prompt=[], + marketable_tags=["Discord", "Moderation", "Safety"], + marketable_picture_big_b64=pic_big_b64, + marketable_picture_small_b64=pic_small_b64, + marketable_schedule=[prompts_common.SCHED_PICK_ONE_5M], + marketable_auth_supported=["discord_manual"], + ) + + +if __name__ == "__main__": + async def _main() -> None: + fclient = ckit_client.FlexusClient(ckit_client.bot_service_name("discord_moderation", SIMPLE_BOTS_COMMON_VERSION), endpoint="/v1/jailed-bot") + await install(fclient, "discord_moderation", SIMPLE_BOTS_COMMON_VERSION, []) + + asyncio.run(_main()) diff --git a/flexus_simple_bots/discord_moderation/discord_moderation_prompts.py b/flexus_simple_bots/discord_moderation/discord_moderation_prompts.py new file mode 100644 index 00000000..5c8b441c --- /dev/null +++ b/flexus_simple_bots/discord_moderation/discord_moderation_prompts.py @@ -0,0 +1,4 @@ +discord_moderation_stub = """ +You are the Discord Moderation bot persona. Enforcement runs on Discord events. +Use Flexus chat only for setup help. +""" diff --git a/flexus_simple_bots/discord_moderation/setup_schema.json b/flexus_simple_bots/discord_moderation/setup_schema.json new file mode 100644 index 00000000..44cfb88e --- /dev/null +++ b/flexus_simple_bots/discord_moderation/setup_schema.json @@ -0,0 +1,74 @@ +[ + { + "bs_name": "dc_guild_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Discord", + "bs_importance": 1, + "bs_description": "Guild snowflake ID this bot enforces." + }, + { + "bs_name": "mod_log_channel_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Moderation", + "bs_importance": 1, + "bs_description": "Channel ID for audit messages (deletes, kicks)." + }, + { + "bs_name": "block_invite_links", + "bs_type": "string_short", + "bs_default": "true", + "bs_group": "Moderation", + "bs_importance": 1, + "bs_description": "If true, delete messages containing discord.gg / invite URLs." + }, + { + "bs_name": "url_block_regexes", + "bs_type": "string_long", + "bs_default": "", + "bs_group": "Moderation", + "bs_importance": 0, + "bs_description": "One regex per line; if any matches message content, delete." + }, + { + "bs_name": "channel_rules_json", + "bs_type": "string_long", + "bs_default": "[]", + "bs_group": "Moderation", + "bs_importance": 0, + "bs_description": "JSON array: [{\"channel_id\":\"\",\"must_match_regex\":\".*\",\"hint\":\"\"}] — in that channel, delete if content does not match regex." + }, + { + "bs_name": "max_messages_per_minute", + "bs_type": "int", + "bs_default": 0, + "bs_group": "Moderation", + "bs_importance": 0, + "bs_description": "Per-user rolling limit in the guild (0 = disabled)." + }, + { + "bs_name": "new_account_max_age_days", + "bs_type": "int", + "bs_default": 0, + "bs_group": "Moderation", + "bs_importance": 0, + "bs_description": "If >0, kick accounts newer than this many days (use with care; 0 disables)." + }, + { + "bs_name": "new_account_quarantine_role_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Moderation", + "bs_importance": 0, + "bs_description": "If set with new_account_max_age_days, assign this role instead of kick when account is new." + }, + { + "bs_name": "moderation_dry_run", + "bs_type": "string_short", + "bs_default": "false", + "bs_group": "Flags", + "bs_importance": 0, + "bs_description": "If true, log violations to mod channel and Mongo but do not delete messages, kick, or assign quarantine." + } +] diff --git a/flexus_simple_bots/discord_onboarding/README.md b/flexus_simple_bots/discord_onboarding/README.md new file mode 100644 index 00000000..4c074b41 --- /dev/null +++ b/flexus_simple_bots/discord_onboarding/README.md @@ -0,0 +1,5 @@ +# Discord Onboarding + +Welcome DMs, optional public welcome, delayed follow-up when the member has not posted in the guild, start-here checklist post, reaction roles, and `!announce` for moderators. + +Requires **`discord_manual` bot token** (Flexus: persona context menu **Integrations**, not Setup) and **`dc_guild_id`** in **Setup**. Does not use `fi_discord2` (Karen unchanged). diff --git a/flexus_simple_bots/discord_onboarding/__init__.py b/flexus_simple_bots/discord_onboarding/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/flexus_simple_bots/discord_onboarding/__init__.py @@ -0,0 +1 @@ + diff --git a/flexus_simple_bots/discord_onboarding/default__s1.yaml b/flexus_simple_bots/discord_onboarding/default__s1.yaml new file mode 100644 index 00000000..955d8b4e --- /dev/null +++ b/flexus_simple_bots/discord_onboarding/default__s1.yaml @@ -0,0 +1,9 @@ +messages: +- role: user + content: What does this bot do on Discord? +- role: assistant + content: >- + On the Discord server it sends welcome DMs (unless disabled), optional public welcome posts, + a start-here checklist, delayed follow-up DMs via a job queue, reaction-based role assignment, + and mod-only !announce with optional role pings. All channel and role IDs come from setup. +persona_marketable_name: discord_onboarding diff --git a/flexus_simple_bots/discord_onboarding/discord_onboarding-1024x1536.webp b/flexus_simple_bots/discord_onboarding/discord_onboarding-1024x1536.webp new file mode 100644 index 00000000..7c3aa91e Binary files /dev/null and b/flexus_simple_bots/discord_onboarding/discord_onboarding-1024x1536.webp differ diff --git a/flexus_simple_bots/discord_onboarding/discord_onboarding-256x256.webp b/flexus_simple_bots/discord_onboarding/discord_onboarding-256x256.webp new file mode 100644 index 00000000..90496b6d Binary files /dev/null and b/flexus_simple_bots/discord_onboarding/discord_onboarding-256x256.webp differ diff --git a/flexus_simple_bots/discord_onboarding/discord_onboarding_bot.py b/flexus_simple_bots/discord_onboarding/discord_onboarding_bot.py new file mode 100644 index 00000000..a9f880d8 --- /dev/null +++ b/flexus_simple_bots/discord_onboarding/discord_onboarding_bot.py @@ -0,0 +1,697 @@ +import asyncio +import json +import logging +import os +from typing import Any, Dict, List + +import discord +from discord.errors import DiscordException +from pymongo import AsyncMongoClient +from pymongo.errors import PyMongoError + +from flexus_client_kit import ckit_automation_actions +from flexus_client_kit import ckit_automation_engine +from flexus_client_kit import ckit_bot_exec +from flexus_client_kit import ckit_client +from flexus_client_kit import ckit_crm_members +from flexus_client_kit import ckit_guild_map +from flexus_client_kit import ckit_job_queue +from flexus_client_kit import ckit_messages +from flexus_client_kit import ckit_mongo +from flexus_client_kit import ckit_shutdown +from flexus_client_kit.ckit_automation import DisabledRulesCache, filter_active_rules +from flexus_client_kit.ckit_connector import ChatConnector, NormalizedEvent +from flexus_client_kit.ckit_connector_discord import DiscordConnector +from flexus_client_kit.ckit_connector_discord_gateway import DiscordGatewayConnector +from flexus_client_kit.integrations import fi_discord_community as dc +from flexus_simple_bots.discord_onboarding import discord_onboarding_install +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + +logger = logging.getLogger("discord_onboarding") + + +async def _warn_gateway_channel_acl( + connector: ChatConnector, + persona_id: str, + purpose_label: str, + channel_id: int, +) -> None: + info = await connector.get_channel(str(channel_id)) + if info is None: + logger.warning( + "%s gateway preflight [%s]: channel_id=%s not reachable " + "(missing, not a guild channel, or guild not allowlisted)", + persona_id, + purpose_label, + channel_id, + ) + return + missing = [ + k + for k in ( + "view_channel", + "send_messages", + "read_message_history", + "manage_messages", + ) + if k in info and info[k] is False + ] + if not missing: + return + logger.warning( + "%s gateway preflight [%s]: channel_id=%s guild_id=%s name=%r missing permissions: %s", + persona_id, + purpose_label, + info.get("channel_id", str(channel_id)), + info.get("guild_id"), + info.get("name", ""), + ",".join(missing), + ) + + +async def _gateway_discord_channel_acl_preflight( + connector: ChatConnector, + persona_id: str, + watched_channel_ids: set[int], + setup: Dict[str, Any], +) -> None: + for cid in sorted(watched_channel_ids): + await _warn_gateway_channel_acl(connector, persona_id, "watched message_in_channel", cid) + checklist_cid = dc.parse_snowflake(setup.get("checklist_channel_id", "")) + if checklist_cid and not dc.setup_truthy(setup.get("disable_checklist_auto_post")): + await _warn_gateway_channel_acl(connector, persona_id, "checklist_channel", checklist_cid) + welcome_cid = dc.parse_snowflake(setup.get("welcome_channel_id", "")) + if welcome_cid: + await _warn_gateway_channel_acl(connector, persona_id, "welcome_channel", welcome_cid) + + +BOT_NAME = "discord_onboarding" +BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION + +TOOLS: List[Any] = [] + + +def _discord_onboarding_hosted_bot_token() -> tuple[str, str | None]: + for key in ("FLEXUS_DISCORD_ONBOARDING_BOT_TOKEN", "FLEXUS_DISCORD_BOT_TOKEN"): + v = (os.environ.get(key) or "").strip() + if v: + return v, key + return "", None + + +def _parse_bindings(raw: str) -> List[Dict[str, str]]: + try: + v = json.loads(raw or "[]") + except json.JSONDecodeError: + return [] + if not isinstance(v, list): + return [] + out: List[Dict[str, str]] = [] + for item in v: + if not isinstance(item, dict): + continue + mid = str(item.get("message_id", "")).strip() + emo = str(item.get("emoji", "")).strip() + rid = str(item.get("role_id", "")).strip() + if mid and emo and rid: + out.append({"message_id": mid, "emoji": emo, "role_id": rid}) + return out + + +def _role_ids_csv(s: str) -> List[int]: + out: List[int] = [] + for part in (s or "").split(","): + p = part.strip() + if p.isdigit(): + out.append(int(p)) + return out + + +def _emoji_key(emoji: discord.PartialEmoji | str) -> str: + if isinstance(emoji, str): + return emoji + if emoji.id: + return "%s:%s" % (emoji.name, emoji.id) + return emoji.name or "" + + +def _register_reaction_roles( + raw_client: discord.Client, + setup: Dict[str, Any], + persona_id: str, + connector: ChatConnector, +) -> None: + bindings = _parse_bindings(setup.get("reaction_roles_json", "[]")) + + @raw_client.event + async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None: + if dc.setup_truthy(setup.get("disable_reaction_roles")): + return + if raw_client.user and payload.user_id == raw_client.user.id: + return + allowed = connector.allowed_guild_ids + gid_ev = int(payload.guild_id or 0) + if not allowed or gid_ev not in allowed: + return + guild = raw_client.get_guild(payload.guild_id) + if not guild: + return + mid = str(payload.message_id) + key = _emoji_key(payload.emoji) + for b in bindings: + if b["message_id"] != mid: + continue + if b["emoji"] != key and b["emoji"] != getattr(payload.emoji, "name", None): + continue + role = guild.get_role(int(b["role_id"])) + if not role: + return + member = guild.get_member(payload.user_id) + if not member or member.bot: + return + try: + await member.add_roles(role, reason="reaction role") + except DiscordException as e: + dc.log_ctx(persona_id, guild.id, "add_roles failed: %s %s", type(e).__name__, e) + + @raw_client.event + async def on_raw_reaction_remove(payload: discord.RawReactionActionEvent) -> None: + if dc.setup_truthy(setup.get("disable_reaction_roles")): + return + allowed = connector.allowed_guild_ids + gid_ev = int(payload.guild_id or 0) + if not allowed or gid_ev not in allowed: + return + guild = raw_client.get_guild(payload.guild_id) + if not guild: + return + mid = str(payload.message_id) + key = _emoji_key(payload.emoji) + for b in bindings: + if b["message_id"] != mid: + continue + if b["emoji"] != key and b["emoji"] != getattr(payload.emoji, "name", None): + continue + role = guild.get_role(int(b["role_id"])) + if not role: + return + member = guild.get_member(payload.user_id) + if not member or member.bot: + return + try: + await member.remove_roles(role, reason="reaction role remove") + except DiscordException as e: + dc.log_ctx(persona_id, guild.id, "remove_roles failed: %s %s", type(e).__name__, e) + + +async def _maybe_auto_post_checklist( + connector: ChatConnector, + setup: Dict[str, Any], + mongo_db: Any, + persona_id: str, + guild_ids: set[int], +) -> None: + bindings = _parse_bindings(setup.get("reaction_roles_json", "[]")) + rc = connector.raw_client + if not rc or not guild_ids: + return + if dc.setup_truthy(setup.get("disable_checklist_auto_post")): + return + cid = dc.parse_snowflake(setup.get("checklist_channel_id", "")) + if not cid: + return + checklist_meta_coll = mongo_db["dc_onboarding_meta"] + body = (setup.get("checklist_message_body") or "").strip() + if not body: + return + for gid in guild_ids: + g = rc.get_guild(gid) + if not g: + continue + dc.preflight_text_channels( + g, + rc.user, + persona_id, + "discord_onboarding", + { + "welcome_channel": (dc.parse_snowflake(setup.get("welcome_channel_id", "")), "basic"), + "checklist": (cid, "mod"), + }, + warn_manage_roles=len(bindings) > 0, + ) + meta_id = "checklist_posted:%s" % gid + doc = await checklist_meta_coll.find_one({"_id": meta_id}) + if doc and doc.get("message_id"): + continue + ch = g.get_channel(cid) + if not isinstance(ch, discord.TextChannel): + continue + msg = await dc.safe_send(ch, persona_id, body) + if not msg: + continue + await checklist_meta_coll.update_one( + {"_id": meta_id}, + {"$set": {"message_id": str(msg.id), "channel_id": str(cid), "guild_id": str(gid)}}, + upsert=True, + ) + if dc.setup_truthy(setup.get("pin_checklist")): + try: + await msg.pin(reason="start here checklist") + except DiscordException as e: + dc.log_ctx(persona_id, g.id, "pin checklist failed: %s %s", type(e).__name__, e) + + +async def discord_onboarding_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: + persona_setup_raw = rcx.persona.persona_setup or {} + setup = ckit_bot_exec.official_setup_mixing_procedure( + discord_onboarding_install.DISCORD_ONBOARDING_SETUP_SCHEMA, + persona_setup_raw, + ) + token, hosted_env = _discord_onboarding_hosted_bot_token() + use_gateway = False + if token: + use_gateway = True + logger.info( + "%s Discord runtime: hosted token from %s; guild allowlist from GuildMapCache; " + "gateway-backed transport (worker has no Discord socket)", + rcx.persona.persona_id, + hosted_env, + ) + else: + token = dc.discord_bot_api_key_from_external_auth(rcx.external_auth) + if token: + logger.info( + "%s Discord runtime: legacy external_auth token (DiscordConnector + local socket in worker)", + rcx.persona.persona_id, + ) + if not token: + logger.error( + "%s missing Discord bot token: set FLEXUS_DISCORD_ONBOARDING_BOT_TOKEN (preferred hosted onboarding), " + "or FLEXUS_DISCORD_BOT_TOKEN, or legacy external_auth api_key (discord_manual / discord)", + rcx.persona.persona_id, + ) + while not ckit_shutdown.shutdown_event.is_set(): + await rcx.unpark_collected_events(sleep_if_no_work=30.0) + return + + mongo_conn_str = await ckit_mongo.mongo_fetch_creds(fclient, rcx.persona.persona_id) + mongo = AsyncMongoClient(mongo_conn_str) + mongo_db = mongo[rcx.persona.persona_id + "_db"] + + await ckit_crm_members.migrate_legacy_collections(mongo_db) + await ckit_crm_members.ensure_member_indexes(mongo_db) + + disabled_cache = DisabledRulesCache(mongo_db) + await disabled_cache.start() + + rules = ckit_automation_engine.load_rules(persona_setup_raw) + dc.log_ctx(rcx.persona.persona_id, None, "loaded %d automation rules", len(rules)) + scheduled_rules = ckit_automation_engine.find_scheduled_rules(rules) + + watched_channel_ids: set[int] = set() + for r in rules: + trig = r.get("trigger", {}) + if trig.get("type") == "message_in_channel": + cid = ckit_automation_engine.resolve_channel_id(trig.get("channel_id_field", ""), setup) + if cid is not None: + watched_channel_ids.add(cid) + + workspace_id = rcx.persona.located_fgroup_id or "" + + if len(rules) == 0: + dc.log_ctx(rcx.persona.persona_id, None, "no automation rules published, lifecycle automation inactive") + + mod_roles = set(_role_ids_csv(setup.get("mod_role_ids", ""))) + announce_pings = _role_ids_csv(setup.get("announce_ping_role_ids", "")) + + if use_gateway: + connector: ChatConnector = DiscordGatewayConnector(token, rcx.persona.persona_id, initial_guild_ids=set()) + else: + connector = DiscordConnector(token, rcx.persona.persona_id, initial_guild_ids=set()) + + async def on_guild_map_changed(ids: set[str]) -> None: + parsed = {int(x) for x in ids if str(x).strip().isdigit()} + await connector.set_allowed_guild_ids(parsed) + + guild_map_cache = ckit_guild_map.GuildMapCache( + fclient, + rcx.persona.persona_id, + on_change=on_guild_map_changed, + ) + await guild_map_cache.start() + legacy_gid = dc.parse_snowflake(setup.get("dc_guild_id", "")) + if legacy_gid is not None: + await guild_map_cache.ensure_legacy_guild_mapped(rcx.persona.ws_id, legacy_gid) + await connector.set_allowed_guild_ids( + int(x) for x in guild_map_cache.get() if str(x).strip().isdigit() + ) + + augmented_setup = dict(setup) + augmented_setup["_format_mention"] = connector.format_mention + + await ckit_messages.ensure_message_indexes(mongo_db) + + async def _schedule_scan_after_join(ctx: Dict[str, Any], guild_id: int, user_id: int) -> None: + if not scheduled_rules: + return + fresh_doc = await ckit_crm_members.get_member(mongo_db, guild_id, user_id) + if fresh_doc is None: + return + ctx["member_doc"] = fresh_doc + for sr in scheduled_rules: + trig = sr.get("trigger", {}) + anchor_field = trig.get("anchor_field", "") + delay_seconds = trig.get("delay_seconds", 0) + if anchor_field and fresh_doc.get(anchor_field) is not None: + enqueue_action = { + "type": "enqueue_check", + "check_rule_id": sr["rule_id"], + "anchor_field": anchor_field, + "delay_seconds": delay_seconds, + } + await ckit_automation_actions.execute_actions([enqueue_action], ctx) + + async def handle_normalized_event(event: NormalizedEvent) -> None: + persona_id = rcx.persona.persona_id + try: + if event.event_type == "server_connected": + mid = guild_map_cache.map_id_for_guild(event.server_id) + if mid: + await ckit_guild_map.guild_mapping_update_meta_runtime( + fclient, + mid, + str(event.payload.get("guild_name") or ""), + int(event.payload.get("approx_member_count") or 0), + ) + return + if event.event_type == "server_disconnected": + mid = guild_map_cache.map_id_for_guild(event.server_id) + if mid: + await ckit_guild_map.guild_mapping_update_status_runtime( + fclient, + mid, + "disconnected", + ) + return + if event.event_type == "member_joined": + if len(rules) == 0: + return + pl = event.payload + gid = int(pl["guild_id"]) + uid = int(pl["user_id"]) + uname = pl.get("username", "") + if not isinstance(uname, str): + uname = "" + member_doc = await ckit_crm_members.handle_member_join( + mongo_db, + gid, + uid, + workspace_id, + uname, + ) + ctx: Dict[str, Any] = { + "connector": connector, + "mongo_db": mongo_db, + "server_id": event.server_id, + "platform_user": await connector.get_user_info(event.user_id, server_id=event.server_id), + "member_doc": member_doc, + "persona_id": persona_id, + "setup": augmented_setup, + } + active_rules = filter_active_rules(rules, disabled_cache.get()) + actions = ckit_automation_engine.process_event( + "member_joined", + {"guild_id": gid, "user_id": uid}, + active_rules, + member_doc, + augmented_setup, + ) + _, field_changes = await ckit_automation_actions.execute_actions(actions, ctx) + await ckit_automation_actions._run_cascade( + db=mongo_db, + client=connector.raw_client, + persona_id=persona_id, + setup=augmented_setup, + rules=rules, + engine_process_fn=ckit_automation_engine.process_event, + ctx=ctx, + initial_field_changes=field_changes, + guild_id=gid, + user_id=uid, + disabled_rules_cache=disabled_cache, + ) + await _schedule_scan_after_join(ctx, gid, uid) + return + + if event.event_type == "message_in_channel": + pl = event.payload + content = (pl.get("content") or "").strip() + if content.lower().startswith("!announce ") and mod_roles: + rc_ann = connector.raw_client + if rc_ann is None: + return + try: + gid_ann = int(pl.get("guild_id", 0) or 0) + except (TypeError, ValueError): + gid_ann = 0 + g0 = rc_ann.get_guild(gid_ann) if gid_ann else None + if g0 is not None: + try: + uid_int = int(event.user_id) + except (TypeError, ValueError): + uid_int = 0 + member = g0.get_member(uid_int) if uid_int else None + if member and not member.bot: + author_roles = {r.id for r in member.roles} + if author_roles.intersection(mod_roles): + rest = content[len("!announce ") :].strip() + if rest: + pings = " ".join("<@&%d>" % r for r in announce_pings) + text = "%s\n%s" % (pings, rest) if pings else rest + cid_str = str(pl.get("channel_id", "")) + await connector.execute_action( + "post_to_channel", + { + "channel_id": cid_str, + "text": text, + "server_id": str(gid_ann), + }, + ) + return + + if len(rules) == 0: + return + gid = int(pl["guild_id"]) + uid = int(pl["user_id"]) + ch_id = int(pl["channel_id"]) + await ckit_crm_members.handle_message(mongo_db, gid, uid) + if ch_id in watched_channel_ids: + await ckit_messages.store_message( + mongo_db, + server_id=event.server_id, + channel_id=str(ch_id), + user_id=str(uid), + platform="discord", + content=pl.get("content") or "", + timestamp=event.timestamp, + message_id=str(pl.get("message_id") or ""), + ) + if ch_id not in watched_channel_ids: + return + member_doc = await ckit_crm_members.get_member(mongo_db, gid, uid) + if member_doc is None: + return + ctx_msg: Dict[str, Any] = { + "connector": connector, + "mongo_db": mongo_db, + "server_id": event.server_id, + "platform_user": await connector.get_user_info(event.user_id, server_id=event.server_id), + "member_doc": member_doc, + "persona_id": persona_id, + "setup": augmented_setup, + } + active_rules = filter_active_rules(rules, disabled_cache.get()) + actions = ckit_automation_engine.process_event( + "message_in_channel", + {"guild_id": gid, "user_id": uid, "channel_id": ch_id}, + active_rules, + member_doc, + augmented_setup, + ) + _, field_changes = await ckit_automation_actions.execute_actions(actions, ctx_msg) + await ckit_automation_actions._run_cascade( + db=mongo_db, + client=connector.raw_client, + persona_id=persona_id, + setup=augmented_setup, + rules=rules, + engine_process_fn=ckit_automation_engine.process_event, + ctx=ctx_msg, + initial_field_changes=field_changes, + guild_id=gid, + user_id=uid, + disabled_rules_cache=disabled_cache, + ) + return + + if event.event_type == "member_removed": + pl = event.payload + gid = int(pl["guild_id"]) + uid = int(pl["user_id"]) + old_status, _new_status = await ckit_crm_members.handle_member_remove(mongo_db, gid, uid) + if len(rules) == 0: + return + member_doc = await ckit_crm_members.get_member(mongo_db, gid, uid) + if member_doc is None: + return + ctx_rm: Dict[str, Any] = { + "connector": connector, + "mongo_db": mongo_db, + "server_id": event.server_id, + "platform_user": await connector.get_user_info(event.user_id, server_id=event.server_id), + "member_doc": member_doc, + "persona_id": persona_id, + "setup": augmented_setup, + } + active_rules = filter_active_rules(rules, disabled_cache.get()) + actions_leave = ckit_automation_engine.process_event( + "member_removed", + {"guild_id": gid, "user_id": uid}, + active_rules, + member_doc, + augmented_setup, + ) + _, fc_leave = await ckit_automation_actions.execute_actions(actions_leave, ctx_rm) + await ckit_automation_actions._run_cascade( + db=mongo_db, + client=connector.raw_client, + persona_id=persona_id, + setup=augmented_setup, + rules=rules, + engine_process_fn=ckit_automation_engine.process_event, + ctx=ctx_rm, + initial_field_changes=fc_leave, + guild_id=gid, + user_id=uid, + disabled_rules_cache=disabled_cache, + ) + if old_status is None: + return + member_doc_st = await ckit_crm_members.get_member(mongo_db, gid, uid) + if member_doc_st is None: + return + ctx_rm["member_doc"] = member_doc_st + actions = ckit_automation_engine.process_event( + "status_transition", + {"old_status": old_status, "new_status": "churned"}, + active_rules, + member_doc_st, + augmented_setup, + ) + _, field_changes = await ckit_automation_actions.execute_actions(actions, ctx_rm) + await ckit_automation_actions._run_cascade( + db=mongo_db, + client=connector.raw_client, + persona_id=persona_id, + setup=augmented_setup, + rules=rules, + engine_process_fn=ckit_automation_engine.process_event, + ctx=ctx_rm, + initial_field_changes=field_changes, + guild_id=gid, + user_id=uid, + disabled_rules_cache=disabled_cache, + ) + return + except PyMongoError as e: + gid_log = None + try: + gid_log = int(event.payload.get("guild_id", 0) or 0) or None + except (TypeError, ValueError): + gid_log = None + dc.log_ctx(persona_id, gid_log, "normalized event PyMongoError: %s %s", type(e).__name__, e) + except DiscordException as e: + gid_log = None + try: + gid_log = int(event.payload.get("guild_id", 0) or 0) or None + except (TypeError, ValueError): + gid_log = None + dc.log_ctx(persona_id, gid_log, "normalized event DiscordException: %s %s", type(e).__name__, e) + except (TypeError, KeyError, ValueError) as e: + gid_log = None + try: + gid_log = int(event.payload.get("guild_id", 0) or 0) or None + except (TypeError, ValueError): + gid_log = None + dc.log_ctx(persona_id, gid_log, "normalized event data error: %s %s", type(e).__name__, e) + + connector.on_event(handle_normalized_event) + await connector.connect() + + if use_gateway: + await _gateway_discord_channel_acl_preflight( + connector, + rcx.persona.persona_id, + watched_channel_ids, + setup, + ) + + raw = connector.raw_client + if raw is not None: + _register_reaction_roles(raw, setup, rcx.persona.persona_id, connector) + + checklist_ready_done = False + automation_handlers_built = False + job_handlers: Dict[str, Any] = {} + + try: + while not ckit_shutdown.shutdown_event.is_set(): + if not checklist_ready_done and connector.raw_client is not None: + await _maybe_auto_post_checklist( + connector, + setup, + mongo_db, + rcx.persona.persona_id, + set(connector.allowed_guild_ids), + ) + checklist_ready_done = True + if not automation_handlers_built and len(rules) > 0: + job_handlers = ckit_automation_actions.make_automation_job_handler( + rules, + augmented_setup, + ckit_automation_engine.process_event, + mongo_db, + connector.raw_client, + rcx.persona.persona_id, + disabled_rules_cache=disabled_cache, + connector=connector, + ) + automation_handlers_built = True + await ckit_job_queue.drain_due_jobs(mongo_db, rcx.persona.persona_id, job_handlers, limit=30) + await rcx.unpark_collected_events(sleep_if_no_work=5.0) + finally: + await disabled_cache.stop() + await guild_map_cache.stop() + await connector.disconnect() + await mongo.close() + logger.info("%s exit", rcx.persona.persona_id) + + +def main() -> None: + scenario_fn = ckit_bot_exec.parse_bot_args() + fclient = ckit_client.FlexusClient(ckit_client.bot_service_name(BOT_NAME, BOT_VERSION), endpoint="/v1/jailed-bot") + asyncio.run( + ckit_bot_exec.run_bots_in_this_group( + fclient, + marketable_name=BOT_NAME, + marketable_version_str=BOT_VERSION, + bot_main_loop=discord_onboarding_main_loop, + inprocess_tools=TOOLS, + scenario_fn=scenario_fn, + install_func=discord_onboarding_install.install, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/flexus_simple_bots/discord_onboarding/discord_onboarding_install.py b/flexus_simple_bots/discord_onboarding/discord_onboarding_install.py new file mode 100644 index 00000000..1d94bc1f --- /dev/null +++ b/flexus_simple_bots/discord_onboarding/discord_onboarding_install.py @@ -0,0 +1,77 @@ +import asyncio +import base64 +import json +from pathlib import Path + +from flexus_client_kit import ckit_bot_install +from flexus_client_kit import ckit_client +from flexus_client_kit import ckit_cloudtool +from flexus_simple_bots import prompts_common +from flexus_simple_bots.discord_onboarding import discord_onboarding_prompts +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + + +ROOT = Path(__file__).parent + +DISCORD_ONBOARDING_SETUP_SCHEMA = json.loads((ROOT / "setup_schema.json").read_text()) + +EXPERTS = [ + ( + "default", + ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=discord_onboarding_prompts.discord_onboarding_stub, + fexp_python_kernel="", + fexp_block_tools="", + fexp_allow_tools="", + fexp_inactivity_timeout=3600, + fexp_description="Stub expert; Discord automation runs in the bot process.", + fexp_builtin_skills="[]", + ), + ), +] + + +async def install( + client: ckit_client.FlexusClient, + bot_name: str, + bot_version: str, + tools: list[ckit_cloudtool.CloudTool], +): + pic_big_b64 = base64.b64encode((ROOT / "discord_onboarding-1024x1536.webp").read_bytes()).decode("ascii") + pic_small_b64 = base64.b64encode((ROOT / "discord_onboarding-256x256.webp").read_bytes()).decode("ascii") + desc = (ROOT / "README.md").read_text() + await ckit_bot_install.marketplace_upsert_dev_bot( + client, + ws_id=client.ws_id, + marketable_name=bot_name, + marketable_version=bot_version, + marketable_accent_color="#5865F2", + marketable_title1="Discord Onboarding", + marketable_title2="Welcome flow, follow-up DMs, reaction roles, mod announcements.", + marketable_author="Flexus", + marketable_occupation="Community", + marketable_description=desc, + marketable_typical_group="Community / Discord", + marketable_github_repo="https://github.com/smallcloudai/flexus-client-kit.git", + marketable_run_this="python -m flexus_simple_bots.discord_onboarding.discord_onboarding_bot", + marketable_setup_default=DISCORD_ONBOARDING_SETUP_SCHEMA, + marketable_featured_actions=[ + {"feat_question": "What does this bot do on Discord?", "feat_expert": "default", "feat_depends_on_setup": []}, + ], + marketable_intro_message="I handle member welcome, delayed check-ins, reaction roles, and mod-only !announce on your Discord server.", + marketable_preferred_model_default="gpt-5.4-nano", + marketable_experts=[(n, e.filter_tools(tools)) for n, e in EXPERTS], + add_integrations_into_expert_system_prompt=[], + marketable_tags=["Discord", "Community", "Onboarding"], + marketable_picture_big_b64=pic_big_b64, + marketable_picture_small_b64=pic_small_b64, + marketable_schedule=[prompts_common.SCHED_PICK_ONE_5M], + ) + + +if __name__ == "__main__": + async def _main() -> None: + fclient = ckit_client.FlexusClient(ckit_client.bot_service_name("discord_onboarding", SIMPLE_BOTS_COMMON_VERSION), endpoint="/v1/jailed-bot") + await install(fclient, "discord_onboarding", SIMPLE_BOTS_COMMON_VERSION, []) + + asyncio.run(_main()) diff --git a/flexus_simple_bots/discord_onboarding/discord_onboarding_prompts.py b/flexus_simple_bots/discord_onboarding/discord_onboarding_prompts.py new file mode 100644 index 00000000..cafb32d8 --- /dev/null +++ b/flexus_simple_bots/discord_onboarding/discord_onboarding_prompts.py @@ -0,0 +1,4 @@ +discord_onboarding_stub = """ +You are the Discord Onboarding bot operator persona. Almost all work runs on Discord gateway events. +Use Flexus chat only for workspace setup questions; no Discord tools here. +""" diff --git a/flexus_simple_bots/discord_onboarding/setup_schema.json b/flexus_simple_bots/discord_onboarding/setup_schema.json new file mode 100644 index 00000000..b76e1880 --- /dev/null +++ b/flexus_simple_bots/discord_onboarding/setup_schema.json @@ -0,0 +1,119 @@ +[ + { + "bs_name": "dc_guild_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Discord", + "bs_order": 1, + "bs_importance": 1, + "bs_description": "Target guild snowflake ID (digits only). Bot ignores other guilds." + }, + { + "bs_name": "welcome_dm_body", + "bs_type": "string_multiline", + "bs_default": "Welcome to our server! Read #start-here and say hi in #introductions.", + "bs_group": "Welcome", + "bs_order": 1, + "bs_importance": 1, + "bs_description": "DM sent when a member joins (may fail if user blocks DMs)." + }, + { + "bs_name": "welcome_channel_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Welcome", + "bs_order": 2, + "bs_importance": 0, + "bs_description": "Optional channel ID for a public welcome message (empty to skip)." + }, + { + "bs_name": "intro_channel_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Intro Reminder", + "bs_order": 1, + "bs_importance": 1, + "bs_description": "Channel ID where members post their intro. Used by intro reminder automation to detect whether a member has introduced themselves." + }, + { + "bs_name": "followup_dm_body", + "bs_type": "string_multiline", + "bs_default": "Hi! Just checking in -- need help getting started? Reply in #help any time.", + "bs_group": "Intro Reminder", + "bs_order": 3, + "bs_importance": 1, + "bs_description": "Follow-up DM body when there was no guild text activity since join." + }, + { + "bs_name": "checklist_channel_id", + "bs_type": "string_short", + "bs_default": "", + "bs_group": "Welcome", + "bs_order": 4, + "bs_importance": 0, + "bs_description": "Channel ID where the bot posts the start-here checklist once (empty to skip)." + }, + { + "bs_name": "checklist_message_body", + "bs_type": "string_multiline", + "bs_default": "## Start here\n- [ ] Read rules\n- [ ] Introduce yourself\n- [ ] Pick roles below this message", + "bs_group": "Welcome", + "bs_order": 5, + "bs_importance": 0, + "bs_description": "Checklist content (markdown)." + }, + { + "bs_name": "pin_checklist", + "bs_type": "bool", + "bs_default": false, + "bs_group": "Welcome", + "bs_order": 6, + "bs_importance": 0, + "bs_description": "If enabled, try to pin the checklist message (needs Manage Messages)." + }, + { + "bs_name": "reaction_roles_json", + "bs_type": "string_multiline", + "bs_default": "[]", + "bs_group": "Welcome", + "bs_order": 7, + "bs_importance": 0, + "bs_description": "JSON array: [{\"message_id\":\"\",\"emoji\":\"\",\"role_id\":\"\"}] for reaction roles (message must exist)." + }, + { + "bs_name": "mod_role_ids", + "bs_type": "string_long", + "bs_default": "", + "bs_group": "Announcements", + "bs_order": 1, + "bs_importance": 0, + "bs_description": "Comma-separated role IDs allowed to use !announce in guild." + }, + { + "bs_name": "announce_ping_role_ids", + "bs_type": "string_long", + "bs_default": "", + "bs_group": "Announcements", + "bs_order": 2, + "bs_importance": 0, + "bs_description": "Comma-separated role IDs pinged after each !announce line (optional)." + }, + { + "bs_name": "disable_checklist_auto_post", + "bs_type": "bool", + "bs_default": false, + "bs_group": "Flags", + "bs_order": 3, + "bs_importance": 0, + "bs_description": "If enabled, do not post the start-here checklist on startup." + }, + { + "bs_name": "disable_reaction_roles", + "bs_type": "bool", + "bs_default": false, + "bs_group": "Flags", + "bs_order": 4, + "bs_importance": 0, + "bs_description": "If enabled, ignore reaction add/remove for role assignment." + } +] diff --git a/setup.py b/setup.py index b18ba449..28dc1609 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ def run(self): "openai", "mcp", "python-telegram-bot>=20.0", + "redis>=5", ], extras_require={ "dev": [