From dd5ab66130a0f47803c3fd87610d1d77e378e6c2 Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Wed, 8 Apr 2026 19:18:44 +0200 Subject: [PATCH 01/10] feat: squash discovery Discord kit changes Collapse the current discovery branch into one commit on top of origin/main so the remaining PR diff reflects only the final client-kit state we actually want to review. --- flexus_client_kit/ckit_automation.py | 161 +++ flexus_client_kit/ckit_automation_actions.py | 917 +++++++++++++++ flexus_client_kit/ckit_automation_engine.py | 435 +++++++ .../ckit_automation_schema_defs.py | 208 ++++ .../ckit_automation_v1_schema_build.py | 303 +++++ flexus_client_kit/ckit_bot_exec.py | 2 + flexus_client_kit/ckit_bot_install.py | 7 +- flexus_client_kit/ckit_bot_query.py | 1 + flexus_client_kit/ckit_connector.py | 122 ++ flexus_client_kit/ckit_connector_discord.py | 1014 +++++++++++++++++ .../ckit_connector_discord_gateway.py | 145 +++ flexus_client_kit/ckit_crm_members.py | 445 ++++++++ flexus_client_kit/ckit_discord_actions.py | 190 +++ .../ckit_discord_automation_schema_defs.py | 148 +++ .../ckit_discord_gateway_handlers.py | 130 +++ flexus_client_kit/ckit_job_queue.py | 60 + flexus_client_kit/ckit_messages.py | 90 +- flexus_client_kit/ckit_person_domain.py | 467 ++++++++ flexus_client_kit/erp_schema.py | 123 ++ flexus_client_kit/format_utils.py | 2 - flexus_client_kit/gateway/__init__.py | 45 + .../gateway/ckit_gateway_redis.py | 326 ++++++ .../gateway/ckit_gateway_wire.py | 151 +++ flexus_client_kit/integrations/fi_discord2.py | 274 +++++ flexus_client_kit/integrations/fi_pdoc.py | 44 +- flexus_client_kit/setup_schema_schema.json | 9 +- flexus_simple_bots/boss/boss_bot.py | 4 +- flexus_simple_bots/discord_bot/README.md | 5 + flexus_simple_bots/discord_bot/__init__.py | 1 + .../discord_bot/default__s1.yaml | 9 + .../discord_bot/discord_bot-1024x1536.webp | Bin 0 -> 203850 bytes .../discord_bot/discord_bot-256x256.webp | Bin 0 -> 10656 bytes flexus_simple_bots/discord_bot/discord_bot.py | 884 ++++++++++++++ .../discord_bot/discord_bot_install.py | 85 ++ .../discord_bot/discord_bot_prompts.py | 4 + .../discord_bot/setup_schema.json | 128 +++ flexus_simple_bots/frog/frog_bot.py | 1 - flexus_simple_bots/frog/frog_install.py | 5 +- flexus_simple_bots/karen/karen_prompts.py | 1 - .../karen/very_limited__actual_support.yaml | 164 +-- flexus_simple_bots/version_common.py | 1 + setup.py | 2 + 42 files changed, 6992 insertions(+), 121 deletions(-) create mode 100644 flexus_client_kit/ckit_automation.py create mode 100644 flexus_client_kit/ckit_automation_actions.py create mode 100644 flexus_client_kit/ckit_automation_engine.py create mode 100644 flexus_client_kit/ckit_automation_schema_defs.py create mode 100644 flexus_client_kit/ckit_automation_v1_schema_build.py create mode 100644 flexus_client_kit/ckit_connector.py create mode 100644 flexus_client_kit/ckit_connector_discord.py create mode 100644 flexus_client_kit/ckit_connector_discord_gateway.py create mode 100644 flexus_client_kit/ckit_crm_members.py create mode 100644 flexus_client_kit/ckit_discord_actions.py create mode 100644 flexus_client_kit/ckit_discord_automation_schema_defs.py create mode 100644 flexus_client_kit/ckit_discord_gateway_handlers.py create mode 100644 flexus_client_kit/ckit_job_queue.py create mode 100644 flexus_client_kit/ckit_person_domain.py create mode 100644 flexus_client_kit/gateway/__init__.py create mode 100644 flexus_client_kit/gateway/ckit_gateway_redis.py create mode 100644 flexus_client_kit/gateway/ckit_gateway_wire.py create mode 100644 flexus_simple_bots/discord_bot/README.md create mode 100644 flexus_simple_bots/discord_bot/__init__.py create mode 100644 flexus_simple_bots/discord_bot/default__s1.yaml create mode 100644 flexus_simple_bots/discord_bot/discord_bot-1024x1536.webp create mode 100644 flexus_simple_bots/discord_bot/discord_bot-256x256.webp create mode 100644 flexus_simple_bots/discord_bot/discord_bot.py create mode 100644 flexus_simple_bots/discord_bot/discord_bot_install.py create mode 100644 flexus_simple_bots/discord_bot/discord_bot_prompts.py create mode 100644 flexus_simple_bots/discord_bot/setup_schema.json create mode 100644 flexus_simple_bots/version_common.py diff --git a/flexus_client_kit/ckit_automation.py b/flexus_client_kit/ckit_automation.py new file mode 100644 index 00000000..9e771ca4 --- /dev/null +++ b/flexus_client_kit/ckit_automation.py @@ -0,0 +1,161 @@ +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. + + Prefer resolve_automation_rules() for new code; this function is kept for + legacy call-sites that have not yet been migrated. + """ + 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 resolve_automation_rules(persona_setup: dict) -> dict: + """ + Resolve the published automation rules document from persona_setup JSONB. + + Migration-safe: prefers the new setup field automation_rules (stored as a + JSON string by automation_publish) and falls back to the legacy + automation_published dict for personas that were published before the storage + move. Returns an empty dict when neither field is present or valid. + """ + try: + if not isinstance(persona_setup, dict): + return {} + raw = persona_setup.get("automation_rules") + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + return parsed + except (json.JSONDecodeError, ValueError): + logger.warning("resolve_automation_rules: automation_rules field is not valid JSON, falling back to automation_published") + legacy = persona_setup.get("automation_published", {}) + return legacy if isinstance(legacy, dict) else {} + except (AttributeError, TypeError) as e: + logger.error("resolve_automation_rules 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..c96db9a6 --- /dev/null +++ b/flexus_client_kit/ckit_automation_actions.py @@ -0,0 +1,917 @@ +""" +Execute resolved automation actions 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. + +Generic CRM actions (set_crm_field, set_status, enqueue_check, cancel_pending_jobs) work with +any connector. Discord-specific actions (send_dm, post_to_channel, add_role, remove_role, kick) +delegate to the Discord connector via ctx['connector'].execute_action or the legacy direct-client +path (ctx['discord_client'] / ctx['guild']) for backward compatibility. +""" + +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, ckit_person_domain +from flexus_client_kit.ckit_automation import DisabledRulesCache, filter_active_rules +from flexus_client_kit.integrations import fi_discord2 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) + dm_params: dict = {"user_id": uid_s, "text": body} + sid = str(ctx.get("server_id") or "") + if sid: + # Propagate guild context so the gateway ACL can verify this persona's access. + dm_params["server_id"] = sid + result = await connector.execute_action("send_dm", dm_params) + 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]]: + """ + Apply a gatekeeper decision for the current Discord member. + + Decision mapping: + accept → application DECIDED/APPROVED, CRM lifecycle "accepted" + reject → application DECIDED/REJECTED, CRM lifecycle "rejected" + request_info → application REVIEWING, CRM lifecycle "pending_review" + + Always applies the CRM lifecycle change (Mongo). Person domain sync + (GraphQL) is best-effort: failures are logged but do not abort the action. + Returns a field_change for lifecycle_status so existing status_transition + cascades keep working. + """ + try: + tool_name = action.get("tool_name") + if tool_name not in ("accept", "reject", "request_info"): + return (_result_dict(ok=False, error="bad_tool_name"), None) + + member_doc = ctx.get("member_doc") + if not isinstance(member_doc, dict): + return (_result_dict(ok=False, error="bad_member_doc"), 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) + + # Decision → status mapping + if tool_name == "accept": + app_status = "DECIDED" + app_decision = "APPROVED" + crm_status = "accepted" + elif tool_name == "reject": + app_status = "DECIDED" + app_decision = "REJECTED" + crm_status = "rejected" + else: + app_status = "REVIEWING" + app_decision = "" + crm_status = "pending_review" + + reason_template = action.get("reason_template") + details: Optional[dict] = {"reason_template": reason_template} if isinstance(reason_template, str) and reason_template else None + + # Best-effort person domain sync (requires fclient + ws_id in ctx) + fclient = ctx.get("fclient") + ws_id = ctx.get("ws_id") or "" + if fclient is not None and ws_id: + username = str(member_doc.get("discord_username") or user_id) + discord_user_id = str(user_id) + person_id = await ckit_person_domain.ensure_person_for_discord_user( + fclient, + ws_id, + discord_user_id, + username, + ) + if person_id: + existing_app = await ckit_person_domain.application_find_latest( + fclient, + ws_id, + person_id, + ) + if existing_app: + app_id: Optional[str] = existing_app["application_id"] + else: + app_id = await ckit_person_domain.application_create_pending( + fclient, + ws_id, + person_id, + source="discord_bot", + platform="discord", + payload={"guild_id": str(guild_id), "discord_user_id": discord_user_id}, + ) + if app_id: + await ckit_person_domain.application_apply_decision( + fclient, + app_id, + app_status, + app_decision, + details, + ) + else: + logger.warning( + "call_gatekeeper_tool: fclient or ws_id missing from ctx, skipping person domain sync " + "persona_id=%s tool=%s guild=%s user=%s", + ctx.get("persona_id"), + tool_name, + guild_id, + user_id, + ) + + # Always apply CRM lifecycle change — this is the primary side-effect + old_status = member_doc.get("lifecycle_status") + merged, _prev = await ckit_crm_members.set_member_status( + ctx["mongo_db"], + guild_id, + user_id, + crm_status, + ) + if merged is None: + return (_result_dict(ok=False, error="member_not_found"), None) + + # Stamp accepted_at when the decision is accept, matching crm_member.md contract. + # Best-effort: if the extra write fails the lifecycle status change still stands. + if crm_status == "accepted": + accepted_doc = await ckit_crm_members.update_member_field( + ctx["mongo_db"], + guild_id, + user_id, + "accepted_at", + time.time(), + ) + if accepted_doc is not None: + merged = accepted_doc + + ctx["member_doc"] = merged + fc = { + "field": "lifecycle_status", + "old_value": old_status, + "new_value": crm_status, + "is_status": True, + } + return (_result_dict(ok=True, error=None), fc) + except PyMongoError as e: + logger.warning("call_gatekeeper_tool PyMongoError: %s %s", type(e).__name__, e) + return (_result_dict(ok=False, error=type(e).__name__), None) + except (TypeError, AttributeError, KeyError) 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: Any, + 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: Any, + persona_id: str, + disabled_rules_cache: Optional[DisabledRulesCache] = None, + connector: Any = None, + fclient: Any = None, + ws_id: str = "", +) -> 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, + "fclient": fclient, + "ws_id": ws_id, + } + 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, + "fclient": fclient, + "ws_id": ws_id, + } + _, 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..6fd5af85 --- /dev/null +++ b/flexus_client_kit/ckit_automation_engine.py @@ -0,0 +1,435 @@ +""" +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. Delegates to + resolve_automation_rules which prefers the new automation_rules setup field + and falls back to the legacy automation_published key for backward + compatibility. 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.resolve_automation_rules(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_schema_defs.py b/flexus_client_kit/ckit_automation_schema_defs.py new file mode 100644 index 00000000..48e74b88 --- /dev/null +++ b/flexus_client_kit/ckit_automation_schema_defs.py @@ -0,0 +1,208 @@ +""" +Generic automation trigger/action JSON Schema fragments (automation_schema_version 1). + +These are platform-agnostic schemas shared across all integrations. +Discord-specific schemas (member_joined, send_dm, add_role, etc.) live in +ckit_discord_automation_schema_defs. Assembled into the full document by +ckit_automation_v1_schema_build. +""" + +from __future__ import annotations + +# --- Generic triggers --- + +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 schedules a job 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.", +} + +# Product-level trigger (not tied to any integration platform). +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." + ), +} + +# --- Generic actions --- + +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": ( + "Schedule a future job for 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 automation job 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).", +} + +# Product-level action (not tied to any integration platform). +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_automation_v1_schema_build.py b/flexus_client_kit/ckit_automation_v1_schema_build.py new file mode 100644 index 00000000..7b52b48b --- /dev/null +++ b/flexus_client_kit/ckit_automation_v1_schema_build.py @@ -0,0 +1,303 @@ +""" +Build automation_schema_version 1 JSON Schema from a connector's capability catalog +(triggers + actions) plus product-only trigger/action defs. Single compile-time +assembly surface: backend validation and assist contracts derive from the same document. + +Discord is the default/reference integration; pass triggers/actions explicitly to build +a schema for a different integration. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from flexus_client_kit.ckit_automation_schema_defs import ( + SCHEMA_ACTION_CALL_GATEKEEPER_PRODUCT, + SCHEMA_TRIGGER_MANUAL_CAMPAIGN_PRODUCT, +) +from flexus_client_kit.ckit_connector_discord import DISCORD_ACTIONS, DISCORD_TRIGGERS + + +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( + triggers: list | None = None, +) -> dict[str, frozenset[str]]: + """Return {type -> frozenset of persisted property names} for each trigger. + + Defaults to the Discord integration triggers. Pass ``triggers`` explicitly + to use a different connector's catalog. + """ + if triggers is None: + triggers = DISCORD_TRIGGERS + out: dict[str, frozenset[str]] = {} + for t in triggers: + d = t.automation_schema_def + if d is None: + raise RuntimeError("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( + actions: list | None = None, +) -> dict[str, frozenset[str]]: + """Return {type -> frozenset of persisted property names} for each action. + + Defaults to the Discord integration actions. Pass ``actions`` explicitly + to use a different connector's catalog. + """ + if actions is None: + actions = DISCORD_ACTIONS + out: dict[str, frozenset[str]] = {} + for a in actions: + d = a.automation_schema_def + if d is None: + raise RuntimeError("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(triggers: list | None = None) -> tuple[str, ...]: + keys = sorted(automation_persisted_trigger_property_keys(triggers).keys()) + return tuple(keys) + + +def schema_action_types_ordered(actions: list | None = None) -> tuple[str, ...]: + keys = sorted(automation_persisted_action_property_keys(actions).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( + triggers: list | None = None, + actions: list | None = None, +) -> dict[str, Any]: + """ + Assemble the full automation v1 JSON Schema document. + + ``triggers`` and ``actions`` are lists of TriggerDescriptor / ActionDescriptor from a + connector's capability catalog. Defaults to the Discord integration. Pass different + lists to produce a schema for another integration. + """ + if triggers is None: + triggers = DISCORD_TRIGGERS + if actions is None: + actions = DISCORD_ACTIONS + + defs: dict[str, Any] = dict(_STATIC_DEFS) + + trigger_refs: list[dict[str, str]] = [] + for t in sorted(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(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-automation-v1", + "title": "Flexus automation rules v1", + "description": ( + "Machine-readable contract for community bot automation. Built from a connector's capability " + "catalog (triggers + actions) plus product-level 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() + # Default to Discord integration for the CLI tool. + doc = build_automation_v1_schema_document(triggers=DISCORD_TRIGGERS, actions=DISCORD_ACTIONS) + 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 ef040b75..01c61da2 100644 --- a/flexus_client_kit/ckit_bot_exec.py +++ b/flexus_client_kit/ckit_bot_exec.py @@ -317,6 +317,8 @@ 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) await _close_messengers(rcx) diff --git a/flexus_client_kit/ckit_bot_install.py b/flexus_client_kit/ckit_bot_install.py index 758cd431..2fd1df03 100644 --- a/flexus_client_kit/ckit_bot_install.py +++ b/flexus_client_kit/ckit_bot_install.py @@ -88,6 +88,7 @@ async def marketplace_upsert_dev_bot( marketable_auth_supported: List[str] = [], marketable_auth_scopes: Optional[Dict[str, List[str]]] = None, marketable_features: List[str] = [], + marketable_rules_toolkit: Optional[Any] = None, add_integrations_into_expert_system_prompt: Optional[List[ckit_integrations_db.IntegrationRecord]] = None, ) -> FBotInstallOutput: assert ws_id, "Set FLEXUS_WORKSPACE environment variable to your workspace ID" @@ -178,7 +179,7 @@ async def marketplace_upsert_dev_bot( expert_dict["fexp_name"] = f"{marketable_name}_{expert_name}" experts_input.append(expert_dict) - mutation = gql.gql(f"""mutation InstallBot($ws: String!, $name: String!, $ver: String!, $title1: String!, $title2: String!, $author: String!, $accent_color: String!, $occupation: String!, $desc: String!, $typical_group: String!, $repo: String!, $run: String!, $setup: String!, $featured: [FFeaturedActionInput!]!, $intro: String!, $model_expensive: String!, $model_cheap: String!, $daily: Int!, $inbox: Int!, $experts: [FMarketplaceExpertInput!]!, $schedule: String!, $big: String!, $small: String!, $tags: [String!]!, $forms: String, $required_policydocs: [String!]!, $auth_needed: [String!]!, $auth_supported: [String!]!, $auth_scopes: String, $max_inprogress: Int!, $features: [String!]!) {{ + mutation = gql.gql(f"""mutation InstallBot($ws: String!, $name: String!, $ver: String!, $title1: String!, $title2: String!, $author: String!, $accent_color: String!, $occupation: String!, $desc: String!, $typical_group: String!, $repo: String!, $run: String!, $setup: String!, $featured: [FFeaturedActionInput!]!, $intro: String!, $model_expensive: String!, $model_cheap: String!, $daily: Int!, $inbox: Int!, $experts: [FMarketplaceExpertInput!]!, $schedule: String!, $big: String!, $small: String!, $tags: [String!]!, $forms: String, $required_policydocs: [String!]!, $auth_needed: [String!]!, $auth_supported: [String!]!, $auth_scopes: String, $max_inprogress: Int!, $features: [String!]!, $rules_toolkit: String) {{ marketplace_upsert_dev_bot( ws_id: $ws, marketable_name: $name, @@ -210,7 +211,8 @@ async def marketplace_upsert_dev_bot( marketable_auth_supported: $auth_supported, marketable_auth_scopes: $auth_scopes, marketable_max_inprogress: $max_inprogress, - marketable_features: $features + marketable_features: $features, + marketable_rules_toolkit: $rules_toolkit ) {{ {gql_utils.gql_fields(FBotInstallOutput)} }} @@ -247,6 +249,7 @@ async def marketplace_upsert_dev_bot( "auth_scopes": json.dumps(marketable_auth_scopes) if marketable_auth_scopes else None, "max_inprogress": marketable_max_inprogress, "features": marketable_features, + "rules_toolkit": json.dumps(marketable_rules_toolkit) if marketable_rules_toolkit is not None else None, } http = await client.use_http_on_behalf("", "") async with http as h: diff --git a/flexus_client_kit/ckit_bot_query.py b/flexus_client_kit/ckit_bot_query.py index 5aef2ba6..b3a19b56 100644 --- a/flexus_client_kit/ckit_bot_query.py +++ b/flexus_client_kit/ckit_bot_query.py @@ -28,6 +28,7 @@ class FPersonaOutput: marketable_radix: Optional[int] = None marketable_auth_needed: Optional[List[str]] = None marketable_auth_supported: Optional[List[str]] = None + persona_external_addresses: Optional[Any] = None @dataclass 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..3fb4302b --- /dev/null +++ b/flexus_client_kit/ckit_connector_discord.py @@ -0,0 +1,1014 @@ +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_discord2 as dc +from flexus_client_kit.ckit_discord_actions import discord_run_platform_action +from flexus_client_kit.ckit_automation_schema_defs import ( + SCHEMA_ACTION_CANCEL_PENDING_JOBS, + SCHEMA_ACTION_ENQUEUE_CHECK, + SCHEMA_ACTION_SET_CRM_FIELD, + SCHEMA_ACTION_SET_STATUS, + SCHEMA_TRIGGER_CRM_FIELD_CHANGED, + SCHEMA_TRIGGER_SCHEDULED_RELATIVE_TO_FIELD, + SCHEMA_TRIGGER_STATUS_TRANSITION, +) +from flexus_client_kit.ckit_discord_automation_schema_defs import ( + SCHEMA_ACTION_ADD_ROLE, + SCHEMA_ACTION_KICK, + SCHEMA_ACTION_POST_TO_CHANNEL, + SCHEMA_ACTION_REMOVE_ROLE, + SCHEMA_ACTION_SEND_DM, + SCHEMA_TRIGGER_MEMBER_JOINED, + SCHEMA_TRIGGER_MEMBER_REMOVED, + SCHEMA_TRIGGER_MESSAGE_IN_CHANNEL, +) +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_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_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), + } + + +def discord_capability_export() -> dict[str, Any]: + """ + Single source of truth for backend/UI: all triggers and actions the Discord integration supports. + + Reuses the TriggerDescriptor / ActionDescriptor data from DISCORD_TRIGGERS / DISCORD_ACTIONS so + there is no second parallel catalog model. Backend can call this function to enumerate Discord + capabilities without importing the descriptor lists directly. + + Return shape:: + + { + "integration": "discord", + "version": 1, + "triggers": [{"type": ..., "label": ..., "description": ..., "schema": {...}}, ...], + "actions": [{"type": ..., "label": ..., "description": ..., "schema": {...}}, ...], + } + """ + return { + "integration": "discord", + "version": 1, + "triggers": [ + { + "type": t.type, + "label": t.label, + "description": t.description, + "schema": t.automation_schema_def, + } + for t in DISCORD_TRIGGERS + ], + "actions": [ + { + "type": a.type, + "label": a.label, + "description": a.description, + "schema": a.automation_schema_def, + } + for a in DISCORD_ACTIONS + ], + } + + +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..91621b2e --- /dev/null +++ b/flexus_client_kit/ckit_connector_discord_gateway.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable, Iterable +from typing import Any + +from flexus_client_kit import ckit_shutdown +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 + +logger = logging.getLogger(__name__) + + +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 + self._refresh_task: asyncio.Task[None] | None = None + + @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: + new_ids = {int(x) for x in ids} + self._allowed_guild_ids = new_ids + if self._connected: + await self._sidecar.update_guild_channels(new_ids) + if new_ids: + await self._sidecar.register_persona_guilds(self._persona_id, new_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._allowed_guild_ids) + if self._allowed_guild_ids: + await self._sidecar.register_persona_guilds(self._persona_id, self._allowed_guild_ids) + self._connected = True + self._refresh_task = asyncio.create_task(self._guild_refresh_loop()) + + async def _guild_refresh_loop(self) -> None: + """Re-register persona->guild TTL in Redis every 120s for long-lived processes.""" + while self._connected and not ckit_shutdown.shutdown_event.is_set(): + await ckit_shutdown.wait(120.0) + if not self._connected: + break + if self._allowed_guild_ids: + await self._sidecar.register_persona_guilds(self._persona_id, self._allowed_guild_ids) + + async def disconnect(self) -> None: + self._connected = False + rt = self._refresh_task + self._refresh_task = None + if rt is not None: + rt.cancel() + try: + await rt + except asyncio.CancelledError: + pass + await self._sidecar.unregister_persona_guilds(self._persona_id) + 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..2a1bfdc8 --- /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_discord2 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..57b2553c --- /dev/null +++ b/flexus_client_kit/ckit_discord_automation_schema_defs.py @@ -0,0 +1,148 @@ +""" +Discord-specific automation trigger/action JSON Schema fragments (automation_schema_version 1). + +Only the schemas that reference Discord concepts (guild, channel, role, DM, member join/leave) +live here. Generic schemas (CRM fields, status transitions, scheduled checks, etc.) are in +ckit_automation_schema_defs. Both sets are assembled by ckit_automation_v1_schema_build. +""" + +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_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_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.", +} 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_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 index 6c063cd4..bbf1a5f5 100644 --- a/flexus_client_kit/ckit_messages.py +++ b/flexus_client_kit/ckit_messages.py @@ -1,9 +1,23 @@ +""" +Thread YAML helpers for bot exec / scenarios, plus Discord discovery message persistence (dc_messages collection). +""" + +from __future__ import annotations + import json +import logging +from typing import Any, Optional, TypeVar + import yaml -from typing import Optional, TypeVar +from pymongo.errors import PyMongoError _M = TypeVar("_M") +logger = logging.getLogger(__name__) + +# Mongo collection name for ingested platform messages (Discord discovery). +COL_MESSAGES = "dc_messages" + def linearize_thread_messages(messages: list[_M], target_alt: int, target_num: int) -> list[_M]: by_key = {(m.ftm_alt, m.ftm_num): m for m in messages} @@ -101,3 +115,77 @@ def fmessages_to_yaml(messages: list, *, limits: Optional[dict[str, int]] = None m["call_id"] = msg.ftm_call_id out.append(m) return yaml_dump_with_multiline({"messages": out}) + + +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/ckit_person_domain.py b/flexus_client_kit/ckit_person_domain.py new file mode 100644 index 00000000..a9599c9b --- /dev/null +++ b/flexus_client_kit/ckit_person_domain.py @@ -0,0 +1,467 @@ +""" +Client-kit GraphQL helpers for the person-centric foundation (U4.5). + +Wraps the PersonDomainQuery/PersonDomainMutation GQL API so bot runtime code +can resolve, create, and link persons and applications without direct DB access. + +Public API (all are best-effort: log warnings on error, return None/False): + ensure_person_for_discord_user - resolve or create person + link discord identity + application_find_latest - fetch the most recent application for a person + application_create_pending - create a new PENDING application + application_apply_decision - update status/decision on an existing application + +Style follows existing client-kit GraphQL helper modules: module-level gql constants, module-level async functions, +TransportQueryError + (TypeError, KeyError, ValueError) as the caught exception set. +""" + +from __future__ import annotations + +import json +import logging +from typing import Optional + +import gql +import gql.transport.exceptions + +from flexus_client_kit import ckit_client +from flexus_client_kit import ckit_erp +from flexus_client_kit import erp_schema + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# GQL documents +# --------------------------------------------------------------------------- + +_GQL_PERSON_BY_IDENTITY = gql.gql( + """query PersonByPlatformIdentityRuntime( + $ws_id: String! + $platform: String! + $user_id: String! + ) { + person_by_platform_identity( + ws_id: $ws_id + identity_platform: $platform + identity_external_user_id: $user_id + ) { + person_id + identity_id + } + }""", +) + +_GQL_PERSON_CREATE = gql.gql( + """mutation PersonCreateRuntime($ws_id: String!, $label: String!) { + person_create(input: { ws_id: $ws_id, person_label: $label }) { + person_id + } + }""", +) + +_GQL_IDENTITY_UPSERT = gql.gql( + """mutation PersonUpsertIdentityRuntime( + $person_id: String! + $platform: String! + $user_id: String! + $endpoint: String! + ) { + person_upsert_identity(input: { + person_id: $person_id + identity_platform: $platform + identity_external_user_id: $user_id + identity_external_endpoint: $endpoint + }) { + identity_id + } + }""", +) + +_GQL_APPLICATION_LIST = gql.gql( + """query ApplicationListLatestRuntime($ws_id: String!, $person_id: String!) { + application_list(ws_id: $ws_id, person_id: $person_id, limit: 1) { + application_id + application_status + application_decision + } + }""", +) + +_GQL_APPLICATION_CREATE = gql.gql( + """mutation ApplicationCreateRuntime( + $ws_id: String! + $person_id: String + $status: String! + $source: String! + $platform: String! + $payload: String + $details: String + ) { + application_create(input: { + ws_id: $ws_id + person_id: $person_id + application_status: $status + application_source: $source + application_platform: $platform + application_payload: $payload + application_details: $details + }) { + application_id + application_status + } + }""", +) + +_GQL_APPLICATION_UPDATE = gql.gql( + """mutation ApplicationUpdateRuntime( + $application_id: String! + $status: String + $decision: String + $details: String + ) { + application_update(input: { + application_id: $application_id + application_status: $status + application_decision: $decision + application_details: $details + }) { + application_id + application_status + application_decision + } + }""", +) + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + +async def ensure_person_for_discord_user( + fclient: ckit_client.FlexusClient, + ws_id: str, + discord_user_id: str, + username: str, +) -> Optional[str]: + """ + Resolve or create a canonical person for a Discord user and link the identity. + + Returns person_id on success, None on any error. + + Race note: two concurrent joins for the same Discord user could create two person + records momentarily; the identity unique constraint (ws_id, platform, user_id) means + the last upsert_identity wins, leaving the earlier person_id orphaned. This is + accepted as an extremely rare event in practice. + """ + try: + async with (await fclient.use_http()) as http: + r = await http.execute( + _GQL_PERSON_BY_IDENTITY, + variable_values={ + "ws_id": ws_id, + "platform": "discord", + "user_id": discord_user_id, + }, + ) + existing = r.get("person_by_platform_identity") if isinstance(r, dict) else None + if isinstance(existing, dict) and existing.get("person_id"): + return str(existing["person_id"]) + + # No existing identity: create person + link identity + label = username or ("discord:%s" % discord_user_id) + async with (await fclient.use_http()) as http: + rc = await http.execute( + _GQL_PERSON_CREATE, + variable_values={"ws_id": ws_id, "label": label}, + ) + person_row = rc.get("person_create") if isinstance(rc, dict) else None + if not isinstance(person_row, dict) or not person_row.get("person_id"): + logger.warning( + "ensure_person_for_discord_user: person_create returned no person_id ws=%s uid=%s", + ws_id, + discord_user_id, + ) + return None + person_id = str(person_row["person_id"]) + + async with (await fclient.use_http()) as http: + await http.execute( + _GQL_IDENTITY_UPSERT, + variable_values={ + "person_id": person_id, + "platform": "discord", + "user_id": discord_user_id, + "endpoint": "", + }, + ) + logger.info( + "ensure_person_for_discord_user created person=%s ws=%s discord_uid=%s", + person_id, + ws_id, + discord_user_id, + ) + return person_id + except gql.transport.exceptions.TransportQueryError as e: + logger.warning( + "ensure_person_for_discord_user GQL error ws=%s uid=%s: %s %s", + ws_id, + discord_user_id, + type(e).__name__, + e, + ) + return None + except (TypeError, KeyError, ValueError) as e: + logger.warning( + "ensure_person_for_discord_user parse error ws=%s uid=%s: %s %s", + ws_id, + discord_user_id, + type(e).__name__, + e, + ) + return None + + +async def application_find_latest( + fclient: ckit_client.FlexusClient, + ws_id: str, + person_id: str, +) -> Optional[dict]: + """ + Return the most recent application dict for a person, or None if not found or on error. + + Dict keys: application_id, application_status, application_decision. + """ + try: + async with (await fclient.use_http()) as http: + r = await http.execute( + _GQL_APPLICATION_LIST, + variable_values={"ws_id": ws_id, "person_id": person_id}, + ) + rows = r.get("application_list") if isinstance(r, dict) else None + if not isinstance(rows, list) or not rows: + return None + row = rows[0] + if not isinstance(row, dict) or not row.get("application_id"): + return None + return { + "application_id": str(row["application_id"]), + "application_status": str(row.get("application_status") or ""), + "application_decision": str(row.get("application_decision") or ""), + } + except gql.transport.exceptions.TransportQueryError as e: + logger.warning( + "application_find_latest GQL error ws=%s person=%s: %s %s", + ws_id, + person_id, + type(e).__name__, + e, + ) + return None + except (TypeError, KeyError, ValueError) as e: + logger.warning( + "application_find_latest parse error ws=%s person=%s: %s %s", + ws_id, + person_id, + type(e).__name__, + e, + ) + return None + + +async def application_create_pending( + fclient: ckit_client.FlexusClient, + ws_id: str, + person_id: str, + *, + source: str = "discord_bot", + platform: str = "discord", + payload: Optional[dict] = None, +) -> Optional[str]: + """ + Create a new PENDING application for a person. Returns application_id or None on error. + + Used on member_joined to register a durable application record before any gatekeeper decision. + """ + try: + payload_json = json.dumps(payload) if payload is not None else None + async with (await fclient.use_http()) as http: + r = await http.execute( + _GQL_APPLICATION_CREATE, + variable_values={ + "ws_id": ws_id, + "person_id": person_id, + "status": "PENDING", + "source": source, + "platform": platform, + "payload": payload_json, + "details": None, + }, + ) + row = r.get("application_create") if isinstance(r, dict) else None + if not isinstance(row, dict) or not row.get("application_id"): + logger.warning( + "application_create_pending: no application_id returned ws=%s person=%s", + ws_id, + person_id, + ) + return None + app_id = str(row["application_id"]) + logger.info( + "application_create_pending app=%s ws=%s person=%s", + app_id, + ws_id, + person_id, + ) + return app_id + except gql.transport.exceptions.TransportQueryError as e: + logger.warning( + "application_create_pending GQL error ws=%s person=%s: %s %s", + ws_id, + person_id, + type(e).__name__, + e, + ) + return None + except (TypeError, KeyError, ValueError) as e: + logger.warning( + "application_create_pending parse error ws=%s person=%s: %s %s", + ws_id, + person_id, + type(e).__name__, + e, + ) + return None + + +async def ensure_discord_contact( + fclient: ckit_client.FlexusClient, + ws_id: str, + discord_user_id: str, + display_name: str, +) -> Optional[str]: + """ + Find or create a crm_contact keyed by contact_platform_ids.discord == discord_user_id. + + Returns contact_id on success, None on error. + Idempotent: existing contacts are returned as-is without modification. + Called both on member_joined (future joins) and during bootstrap (existing members). + """ + try: + rows = await ckit_erp.erp_table_data( + await fclient.use_http(), + "crm_contact", + ws_id, + erp_schema.CrmContact, + filters="contact_platform_ids->discord:=:%s" % discord_user_id, + limit=1, + ) + if rows: + return str(rows[0].contact_id) + + name = (display_name or ("discord:%s" % discord_user_id)).strip() + parts = name.split(" ", 1) + first_name = parts[0] + last_name = parts[1] if len(parts) > 1 else "" + + new_id = await ckit_erp.erp_record_create( + await fclient.use_http(), + "crm_contact", + ws_id, + { + "ws_id": ws_id, + "contact_first_name": first_name, + "contact_last_name": last_name, + "contact_platform_ids": {"discord": discord_user_id}, + }, + ) + contact_id = str(new_id) if new_id else "" + if contact_id: + logger.info( + "ensure_discord_contact created contact=%s ws=%s discord_uid=%s", + contact_id, + ws_id, + discord_user_id, + ) + return contact_id + logger.warning( + "ensure_discord_contact: create returned no id ws=%s discord_uid=%s", + ws_id, + discord_user_id, + ) + return None + except gql.transport.exceptions.TransportQueryError as e: + logger.warning( + "ensure_discord_contact GQL error ws=%s uid=%s: %s %s", + ws_id, + discord_user_id, + type(e).__name__, + e, + ) + return None + except (TypeError, KeyError, ValueError) as e: + logger.warning( + "ensure_discord_contact parse error ws=%s uid=%s: %s %s", + ws_id, + discord_user_id, + type(e).__name__, + e, + ) + return None + + +async def application_apply_decision( + fclient: ckit_client.FlexusClient, + application_id: str, + app_status: str, + app_decision: str, + details: Optional[dict] = None, +) -> bool: + """ + Update an application's status, decision, and optional details. Returns True on success. + + app_status values: PENDING / REVIEWING / DECIDED / CLOSED. + app_decision values: APPROVED / REJECTED / WAITLISTED / "" (empty clears decision). + details dict is serialised to JSON and stored in application_details. + """ + try: + details_json = json.dumps(details) if details is not None else None + async with (await fclient.use_http()) as http: + r = await http.execute( + _GQL_APPLICATION_UPDATE, + variable_values={ + "application_id": application_id, + "status": app_status, + "decision": app_decision if app_decision else None, + "details": details_json, + }, + ) + row = r.get("application_update") if isinstance(r, dict) else None + if not isinstance(row, dict) or not row.get("application_id"): + logger.warning( + "application_apply_decision: no application_id in response app=%s status=%s", + application_id, + app_status, + ) + return False + logger.info( + "application_apply_decision app=%s status=%s decision=%s", + application_id, + app_status, + app_decision, + ) + return True + except gql.transport.exceptions.TransportQueryError as e: + logger.warning( + "application_apply_decision GQL error app=%s: %s %s", + application_id, + type(e).__name__, + e, + ) + return False + except (TypeError, KeyError, ValueError) as e: + logger.warning( + "application_apply_decision parse error app=%s: %s %s", + application_id, + type(e).__name__, + e, + ) + return False diff --git a/flexus_client_kit/erp_schema.py b/flexus_client_kit/erp_schema.py index db837e17..7a43ec97 100644 --- a/flexus_client_kit/erp_schema.py +++ b/flexus_client_kit/erp_schema.py @@ -310,12 +310,130 @@ class ComRefund: order: Optional[ComOrder] = field(default=None, metadata={"fk_field": "refund_order_id", "description": "included via include=['order']"}) +@dataclass +class CrmPerson: + ws_id: str + person_id: str = field(default="", metadata={"pkey": True, "display_name": "Person ID"}) + person_label: str = field(default="", metadata={"importance": 1, "display_name": "Label"}) + person_details: dict = field(default_factory=dict, metadata={"display_name": "Details", "description": "Custom JSON attributes for this person"}) + person_created_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Created at"}) + person_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + person_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) + + identities: Optional[List["CrmPersonIdentity"]] = field(default=None, metadata={"inbound_fk_table": "crm_person_identity", "inbound_fk_col": "person_id"}) + applications: Optional[List["CrmApplication"]] = field(default=None, metadata={"inbound_fk_table": "crm_application", "inbound_fk_col": "person_id"}) + + +@dataclass +class CrmPersonIdentity: + ws_id: str + person_id: str = field(metadata={"importance": 1, "display_name": "Person"}) + identity_platform: str = field(metadata={"importance": 1, "display_name": "Platform", "description": "e.g. discord, slack, telegram, email, github"}) + identity_external_user_id: str = field(metadata={"importance": 1, "display_name": "External User ID"}) + identity_id: str = field(default="", metadata={"pkey": True, "display_name": "Identity ID"}) + identity_external_endpoint: str = field(default="", metadata={"importance": 1, "display_name": "Endpoint", "description": "Channel ID, DM endpoint, webhook URL, etc."}) + identity_status: str = field(default="linked", metadata={"importance": 1, "display_name": "Status", "enum": [ + {"value": "linked", "label": "Linked"}, + {"value": "verified", "label": "Verified"}, + {"value": "revoked", "label": "Revoked"}, + ]}) + identity_details: dict = field(default_factory=dict, metadata={"display_name": "Details"}) + identity_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) + identity_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + identity_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) + + person: Optional[CrmPerson] = field(default=None, metadata={"fk_field": "person_id", "description": "included via include=['person']"}) + + +@dataclass +class CrmApplication: + ws_id: str + application_id: str = field(default="", metadata={"pkey": True, "display_name": "Application ID"}) + person_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Person"}) + contact_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Contact"}) + application_source: str = field(default="", metadata={"importance": 1, "display_name": "Source", "description": "Originating channel/system, e.g. discord_onboarding"}) + application_platform: str = field(default="", metadata={"importance": 1, "display_name": "Platform", "enum": [ + {"value": "discord", "label": "Discord"}, + {"value": "slack", "label": "Slack"}, + {"value": "web", "label": "Web"}, + {"value": "email", "label": "Email"}, + {"value": "telegram", "label": "Telegram"}, + ]}) + application_status: str = field(default="PENDING", metadata={"importance": 1, "display_name": "Status", "enum": [ + {"value": "PENDING", "label": "Pending"}, + {"value": "REVIEWING", "label": "Reviewing"}, + {"value": "DECIDED", "label": "Decided"}, + {"value": "CLOSED", "label": "Closed"}, + ]}) + application_decision: str = field(default="", metadata={"importance": 1, "display_name": "Decision", "enum": [ + {"value": "", "label": "—"}, + {"value": "APPROVED", "label": "Approved"}, + {"value": "REJECTED", "label": "Rejected"}, + {"value": "WAITLISTED", "label": "Waitlisted"}, + ]}) + application_payload: dict = field(default_factory=dict, metadata={"display_name": "Payload", "description": "Raw inbound data from the originating event"}) + application_details: dict = field(default_factory=dict, metadata={"display_name": "Details", "description": "Enriched/processed data and bot annotations"}) + application_created_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Created at"}) + application_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + application_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) + application_decision_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Decided at"}) + + person: Optional[CrmPerson] = field(default=None, metadata={"fk_field": "person_id", "description": "included via include=['person']"}) + + +@dataclass +class CrmThreadParticipationPolicy: + ws_id: str + ft_id: str = field(metadata={"importance": 1, "display_name": "Thread ID"}) + policy_id: str = field(default="", metadata={"pkey": True, "display_name": "Policy ID"}) + persona_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Persona"}) + person_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Person"}) + policy_mode: str = field(default="ACTIVE", metadata={"importance": 1, "display_name": "Mode", "enum": [ + {"value": "ACTIVE", "label": "Active"}, + {"value": "PAUSED", "label": "Paused"}, + {"value": "TAKEOVER", "label": "Takeover"}, + {"value": "CLOSED", "label": "Closed"}, + ]}) + policy_active: bool = field(default=True, metadata={"importance": 1, "display_name": "Active"}) + policy_timeout_ts: float = field(default=0.0, metadata={"display_name": "Timeout at", "description": "Epoch timestamp when mode should auto-expire; 0 = no timeout"}) + policy_last_actor: str = field(default="", metadata={"display_name": "Last Actor", "description": "fuser_id, persona_id, or 'system'"}) + policy_details: dict = field(default_factory=dict, metadata={"display_name": "Details"}) + policy_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) + policy_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + + +@dataclass +class CrmExternalConnection: + ws_id: str + connection_provider: str = field(metadata={"importance": 1, "display_name": "Provider", "description": "e.g. discord, slack, github, google"}) + connection_id: str = field(default="", metadata={"pkey": True, "display_name": "Connection ID"}) + persona_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Persona"}) + auth_id: Optional[str] = field(default=None, metadata={"display_name": "Auth ID"}) + connection_platform: str = field(default="", metadata={"importance": 1, "display_name": "Platform", "description": "Sub-platform or environment hint"}) + connection_resource_type: str = field(default="", metadata={"importance": 1, "display_name": "Resource Type", "description": "e.g. guild, channel, workspace, dm, repo"}) + connection_external_id: str = field(default="", metadata={"importance": 1, "display_name": "External ID"}) + connection_status: str = field(default="active", metadata={"importance": 1, "display_name": "Status", "enum": [ + {"value": "active", "label": "Active"}, + {"value": "revoked", "label": "Revoked"}, + {"value": "error", "label": "Error"}, + ]}) + connection_details: dict = field(default_factory=dict, metadata={"display_name": "Details"}) + connection_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) + connection_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) + connection_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) + + ERP_TABLE_TO_SCHEMA: Dict[str, Type] = { "crm_activity": CrmActivity, + "crm_application": CrmApplication, "crm_contact": CrmContact, "crm_deal": CrmDeal, + "crm_external_connection": CrmExternalConnection, + "crm_person": CrmPerson, + "crm_person_identity": CrmPersonIdentity, "crm_pipeline": CrmPipeline, "crm_pipeline_stage": CrmPipelineStage, + "crm_thread_participation_policy": CrmThreadParticipationPolicy, "com_order": ComOrder, "com_order_item": ComOrderItem, "com_payment": ComPayment, @@ -342,10 +460,15 @@ class ComRefund: ERP_DISPLAY_NAME_CONFIGS: Dict[str, str] = { "crm_activity": "{activity_title}", + "crm_application": "{application_platform} {application_status}", "crm_contact": "{contact_first_name} {contact_last_name}", "crm_deal": "{deal_name}", + "crm_external_connection": "{connection_provider} {connection_resource_type}", + "crm_person": "{person_label}", + "crm_person_identity": "{identity_platform} {identity_external_user_id}", "crm_pipeline": "{pipeline_name}", "crm_pipeline_stage": "{stage_name}", + "crm_thread_participation_policy": "{ft_id} {policy_mode}", "com_order": "{order_number}", "com_order_item": "{oitem_name}", "com_payment": "{pay_id}", diff --git a/flexus_client_kit/format_utils.py b/flexus_client_kit/format_utils.py index 1a6a14c6..12b66e1d 100644 --- a/flexus_client_kit/format_utils.py +++ b/flexus_client_kit/format_utils.py @@ -25,7 +25,6 @@ class TextOutputResult: lines: List[str] line1: int # 1-based first line shown line2: int # 1-based last line shown (inclusive) - total_lines: int safety_valve_hit: bool header: str warnings: List[str] = field(default_factory=list) @@ -185,7 +184,6 @@ def format_text_output( lines=result, line1=line_offset + start + 1, line2=line_offset + actual_end + 1, - total_lines=len(lines), safety_valve_hit=hit, header=header, warnings=warnings, 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..424a2bb0 --- /dev/null +++ b/flexus_client_kit/gateway/ckit_gateway_redis.py @@ -0,0 +1,326 @@ +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_events_discord_guild, + channel_reply_discord, + gateway_instance_key_from_token, + gateway_result_envelope_from_dict, + normalized_event_from_dict, + parse_event_envelope, + registry_key_persona_guilds, +) + +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) + # Legacy broadcast channel kept for direct-socket / dev-fallback reference. + 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 + # Live pubsub handle shared between reader loop and update_guild_channels. + self._ps: Any | None = None + # Currently active per-guild subscription channels. + self._subscribed_guild_channels: set[str] = set() + + @property + def gateway_instance_key(self) -> str: + return self._key + + @property + def events_channel(self) -> str: + # Returns legacy broadcast channel for reference; production path uses per-guild channels. + 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]], + guild_ids: set[int] | None = None, + ) -> None: + self._cb = on_event + self._stop.clear() + if self._redis_pubsub is None: + self._redis_pubsub = redis_pubsub_client_from_env() + initial_channels = { + channel_events_discord_guild(self._key, str(g)) + for g in (guild_ids or set()) + } + self._subscribed_guild_channels = initial_channels + self._reader_task = asyncio.create_task(self._event_reader_loop(initial_channels)) + + async def update_guild_channels(self, guild_ids: set[int]) -> None: + """Subscribe to new per-guild channels and drop channels no longer in the allowed set.""" + new_channels = {channel_events_discord_guild(self._key, str(g)) for g in guild_ids} + to_add = new_channels - self._subscribed_guild_channels + to_remove = self._subscribed_guild_channels - new_channels + ps = self._ps + if ps is None: + # Reader loop not yet running; track for when it starts. + self._subscribed_guild_channels = new_channels + return + try: + if to_add: + await ps.subscribe(*to_add) + if to_remove: + await ps.unsubscribe(*to_remove) + except (RedisConnectionError, OSError, RuntimeError) as e: + logger.warning("update_guild_channels: %s %s", type(e).__name__, e) + self._subscribed_guild_channels = new_channels + + async def register_persona_guilds(self, persona_id: str, guild_ids: set[int]) -> None: + """Write persona->guild Set in Redis with a 300s TTL; called on connect and refresh.""" + if self._redis_cmd is None: + self._redis_cmd = redis_client_from_env() + reg_key = registry_key_persona_guilds(self._key, persona_id) + try: + pipe = self._redis_cmd.pipeline() + pipe.delete(reg_key) + if guild_ids: + pipe.sadd(reg_key, *[str(g) for g in guild_ids]) + pipe.expire(reg_key, 300) + await pipe.execute() + except (RedisConnectionError, RedisTimeoutError, OSError, RuntimeError) as e: + logger.warning("register_persona_guilds: %s %s", type(e).__name__, e) + + async def unregister_persona_guilds(self, persona_id: str) -> None: + """Remove persona->guild Set from Redis on clean disconnect.""" + if self._redis_cmd is None: + return + reg_key = registry_key_persona_guilds(self._key, persona_id) + try: + await self._redis_cmd.delete(reg_key) + except (RedisConnectionError, RedisTimeoutError, OSError, RuntimeError) as e: + logger.warning("unregister_persona_guilds: %s %s", type(e).__name__, e) + + 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, initial_channels: set[str]) -> None: + r = self._redis_pubsub + if r is None: + return + ps = r.pubsub() + self._ps = ps + if initial_channels: + await ps.subscribe(*initial_channels) + 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: + self._ps = None + try: + 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..809cac1a --- /dev/null +++ b/flexus_client_kit/gateway/ckit_gateway_wire.py @@ -0,0 +1,151 @@ +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: + # Legacy broadcast channel — kept for direct-socket / dev-fallback mode only. + # Production path uses channel_events_discord_guild for addressed per-guild delivery. + return "gw:discord:%s:events" % (gateway_instance_key,) + + +def channel_events_discord_guild(gateway_instance_key: str, guild_id: str) -> str: + # Per-guild addressed event channel. Workers subscribe only to their allowed guilds. + return "gw:discord:%s:guild:%s:events" % (gateway_instance_key, guild_id) + + +def registry_key_persona_guilds(gateway_instance_key: str, persona_id: str) -> str: + # Redis Set of guild_id strings for a persona; refreshed by the worker with a 300s TTL. + return "gw:discord:%s:persona:%s:guilds" % (gateway_instance_key, persona_id) + + +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_discord2.py b/flexus_client_kit/integrations/fi_discord2.py index 6f607ff5..6564eade 100644 --- a/flexus_client_kit/integrations/fi_discord2.py +++ b/flexus_client_kit/integrations/fi_discord2.py @@ -5,6 +5,7 @@ import logging import os import random +import re import tempfile import time from collections import deque @@ -12,6 +13,8 @@ from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple +import aiohttp + import discord import gql from discord import File @@ -25,6 +28,7 @@ ckit_bot_query, ckit_cloudtool, ckit_client, + ckit_job_queue, ckit_kanban, ckit_scenario, ckit_utils, @@ -755,6 +759,15 @@ async def post_into_captured_thread_as_user(self, activity: ActivityDiscord) -> return False parts = fi_messenger.compact_message_parts(parts) + provenance = { + "source": "discord", + "discord_author_id": str(activity.message_author_id), + "discord_author_name": activity.message_author_name, + "discord_channel_id": str(activity.channel_id), + "discord_message_id": activity.message_id, + "is_dm": activity.is_dm, + } + http = await self.fclient.use_http_on_behalf(self.rcx.persona.persona_id, "") logger.info("captured_thread_post searchable=%s msg=%s", searchable, text[:200]) try: @@ -764,6 +777,7 @@ async def post_into_captured_thread_as_user(self, activity: ActivityDiscord) -> searchable, parts, only_to_expert=self.outside_messages_fexp_name, + ftm_provenance=provenance, thread_too_old_s=30*86400 if activity.thread_id else 300, ) except gql.transport.exceptions.TransportQueryError as e: # type: ignore[attr-defined] @@ -872,3 +886,263 @@ def _format_assistant_message(self, content: Any) -> str: if isinstance(parsed, dict): return parsed.get("m_content", str(parsed)) return str(parsed) + + +# --------------------------------------------------------------------------- +# Community-bot utilities (consolidated from fi_discord_community) +# --------------------------------------------------------------------------- + +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" + +COL_JOBS = ckit_job_queue.COL_JOBS +JobHandler = ckit_job_queue.JobHandler + + +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") + + +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 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) + + +async def safe_send( + channel: discord.abc.Messageable, + persona_id: str, + content: str, +) -> Optional[discord.Message]: + t = truncate_message(content) + g = getattr(channel, "guild", None) + gid = int(g.id) if g is not None else None + delay = 1.0 + for attempt in range(5): + try: + return await channel.send(t) + except discord.errors.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]: + 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, + ) + + +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_pdoc.py b/flexus_client_kit/integrations/fi_pdoc.py index 70c69ce0..4e006a32 100644 --- a/flexus_client_kit/integrations/fi_pdoc.py +++ b/flexus_client_kit/integrations/fi_pdoc.py @@ -36,7 +36,7 @@ parameters={ "type": "object", "properties": { - "op": {"type": "string", "enum": ["help", "list", "cat", "activate", "create", "create_draft_qa", "create_draft_from_template", "overwrite", "update_at_location", "translate_qa", "cp", "mv", "rm"]}, + "op": {"type": "string", "enum": ["help", "list", "cat", "activate", "create", "create_draft_qa", "create_draft_from_template", "overwrite", "update_json_text", "translate_qa", "cp", "mv", "rm"]}, "args": {"type": "object"}, # model guesses p= to write here quite well for some reason, without help, must be something in prompt }, }, @@ -71,15 +71,15 @@ Create a new policy document from a known template. Automatically prepends current date and DRAFT between output_dir and slug, e.g. /plans/20260325-DRAFT-my-thing. Fails if the document already exists. -flexus_policy_document(op="update_at_location", args={"p": "/folder/file", "expected_md5": "abc123", "updates": [["toptag.section1.field1", "value1"], ["toptag.section1.field2", "value2"]]}) - Update fields in a document using dot notation for path. This normally updates strings, but you can try to set - any structure or number, if schema in available in the document. - NEVER call this tool in parallel, backend will serialize calls and only the first will pass md5 gate. - Instead use "updates" array to set multiple fields at once. +flexus_policy_document(op="update_json_text", args={"p": "/folder/file", "json_path": "section1.field", "text": "new value", "expected_md5": "abc123"}) + Update a specific field in a document using json_path with dot notation. + Example: "operations_overview.governance" updates doc["operations_overview"]["governance"] + Pass expected_md5 (from a previous cat/update) to avoid overwriting concurrent changes. + If md5 doesn't match, changes are not saved and latest content is returned so you can retry. flexus_policy_document(op="translate_qa", args={"p": "/folder/file", "expected_md5": "abc123", "translation": [["top-tag.section01-product.question01-description.q", "Translated text"], ...]}) Batch-update question texts in a QA document, typically for translation. - Each entry in translation is [json_path, text] where json_path uses dot notation (same as update_at_location). + Each entry in translation is [json_path, text] where json_path uses dot notation (same as update_json_text). Pass expected_md5 to avoid overwriting concurrent changes. Returns list of still-empty "q" and "a" fields and the new md5. @@ -425,27 +425,19 @@ async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_p raise r += f"✍️ {p}\nmd5={_pdoc_md5(doc)}\n\n✓ Created from template '{template}'" - elif op == "update_at_location": + elif op == "update_json_text": p = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "p", "") p = p or ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "path", "") + json_path = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "json_path", "") + text = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "text", "") expected_md5 = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "expected_md5", "") - # batch: updates=[[json_path, text], ...] or single: json_path= text= - updates_raw = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "updates", None) - if updates_raw and isinstance(updates_raw, list): - updates = [{"json_path": u[0], "text": u[1]} for u in updates_raw] - else: - json_path = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "json_path", "") - text = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "text", "") - if not json_path or not text: - return f"Error: provide json_path+text or updates list\n\n{HELP}" - updates = [{"json_path": json_path, "text": text}] - if not p: - return f"Error: p parameter required\n\n{HELP}" + if not p or not json_path or not text: + return f"Error: p, json_path, and text parameters required\n\n{HELP}" if self.is_fake: return await ckit_scenario.scenario_generate_tool_result_via_model(self.fclient, toolcall, open(__file__).read()) - upd = await self.pdoc_update_at_location(p, updates, persona_id=self.rcx.persona.persona_id, fcall_untrusted_key=toolcall.fcall_untrusted_key, expected_md5=expected_md5) + upd = await self.pdoc_update_json_text(p, json_path, text, persona_id=self.rcx.persona.persona_id, fcall_untrusted_key=toolcall.fcall_untrusted_key, expected_md5=expected_md5) if upd.changes_saved: r += f"✍️ {p}\n" r += f"changes_saved=true\n\n" @@ -621,20 +613,20 @@ async def pdoc_overwrite(self, p: str, text: str, persona_id: str, fcall_untrust ) return gql_utils.dataclass_from_dict(r["policydoc_overwrite"], PdocOverwriteResult) - async def pdoc_update_at_location(self, p: str, updates: list, persona_id: str, fcall_untrusted_key: str, expected_md5: str = "") -> PdocUpdateJsonTextResult: + async def pdoc_update_json_text(self, p: str, json_path: str, text: str, persona_id: str, fcall_untrusted_key: str, expected_md5: str = "") -> PdocUpdateJsonTextResult: http = await self._http(persona_id, fcall_untrusted_key) async with http as h: result = await h.execute( gql.gql(f""" - mutation PdocUpdateAtPath($fgroup_id: String!, $p: String!, $updates: [PolicyDocJsonUpdate!]!, $expected_md5: String) {{ - policydoc_update_at_location(fgroup_id: $fgroup_id, p: $p, updates: $updates, expected_md5: $expected_md5) {{ + mutation PdocUpdateJsonText($fgroup_id: String!, $p: String!, $json_path: String!, $text: String!, $expected_md5: String) {{ + policydoc_update_json_text(fgroup_id: $fgroup_id, p: $p, json_path: $json_path, text: $text, expected_md5: $expected_md5) {{ {gql_utils.gql_fields(PdocUpdateJsonTextResult)} }} }} """), - variable_values={"fgroup_id": self.fgroup_id, "p": p, "updates": updates, "expected_md5": expected_md5}, + variable_values={"fgroup_id": self.fgroup_id, "p": p, "json_path": json_path, "text": text, "expected_md5": expected_md5}, ) - return gql_utils.dataclass_from_dict(result["policydoc_update_at_location"], PdocUpdateJsonTextResult) + return gql_utils.dataclass_from_dict(result["policydoc_update_json_text"], PdocUpdateJsonTextResult) async def pdoc_cp(self, p1: str, p2: str, persona_id: str, fcall_untrusted_key: str) -> None: http = await self._http(persona_id, fcall_untrusted_key) 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/boss/boss_bot.py b/flexus_simple_bots/boss/boss_bot.py index b178b59f..ff8897fe 100644 --- a/flexus_simple_bots/boss/boss_bot.py +++ b/flexus_simple_bots/boss/boss_bot.py @@ -203,7 +203,7 @@ async def handle_plan_update_section( section_data[k] = v.replace("\\n", "\n") if isinstance(v, str) and " " in v and "\n" not in v: section_data[k] = section_data[k].replace(" ", "\n") - upd = await pdoc_integration.pdoc_update_at_location(path, [{"json_path": f"plan.{section}", "text": json.dumps(section_data, ensure_ascii=False)}], persona_id=rcx.persona.persona_id, fcall_untrusted_key=uk, expected_md5=expected_md5) + upd = await pdoc_integration.pdoc_update_json_text(path, f"plan.{section}", json.dumps(section_data, ensure_ascii=False), persona_id=rcx.persona.persona_id, fcall_untrusted_key=uk, expected_md5=expected_md5) if not upd.changes_saved: return f"📄 {path}\nmd5_requested={upd.md5_requested}\nmd5_found={upd.md5_found}\nchanges_saved=false\n\n{upd.problem_message or 'Document changed, please retry'}\n\n{upd.latest_text}" @@ -234,7 +234,7 @@ async def handle_plan_progress_add( return f"Error: plan {path} not found" prev = existing.pdoc_content.get("plan", {}).get("section03-progress", {}).get(field, "").rstrip("\n") new_value = (prev + "\n" + line).lstrip("\n") - upd = await pdoc_integration.pdoc_update_at_location(path, [{"json_path": f"plan.section03-progress.{field}", "text": new_value}], persona_id=rcx.persona.persona_id, fcall_untrusted_key=uk, expected_md5=expected_md5) + upd = await pdoc_integration.pdoc_update_json_text(path, f"plan.section03-progress.{field}", new_value, persona_id=rcx.persona.persona_id, fcall_untrusted_key=uk, expected_md5=expected_md5) if not upd.changes_saved: return f"📄 {path}\nmd5_requested={upd.md5_requested}\nmd5_found={upd.md5_found}\nchanges_saved=false\n\n{upd.problem_message or 'Document changed, please retry.'}\n\n{upd.latest_text}" return f"✍️ {path}\nmd5={upd.md5_found}\n\nAppended to {field}" diff --git a/flexus_simple_bots/discord_bot/README.md b/flexus_simple_bots/discord_bot/README.md new file mode 100644 index 00000000..39065d2c --- /dev/null +++ b/flexus_simple_bots/discord_bot/README.md @@ -0,0 +1,5 @@ +# Discord Bot + +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 **`FLEXUS_DISCORD_BOT_TOKEN`** env var (or legacy `discord_manual` bot token via persona Integrations) and **`dc_guild_id`** in **Setup**. Does not use `fi_discord2` (Karen unchanged). diff --git a/flexus_simple_bots/discord_bot/__init__.py b/flexus_simple_bots/discord_bot/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/flexus_simple_bots/discord_bot/__init__.py @@ -0,0 +1 @@ + diff --git a/flexus_simple_bots/discord_bot/default__s1.yaml b/flexus_simple_bots/discord_bot/default__s1.yaml new file mode 100644 index 00000000..c40b7dc5 --- /dev/null +++ b/flexus_simple_bots/discord_bot/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_bot diff --git a/flexus_simple_bots/discord_bot/discord_bot-1024x1536.webp b/flexus_simple_bots/discord_bot/discord_bot-1024x1536.webp new file mode 100644 index 0000000000000000000000000000000000000000..7c3aa91ebb2dc2285522e10fec5d4699bb1788ca GIT binary patch literal 203850 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 ukTy7JSD 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

L`M5AQ!MuX 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 G0002)+tOnI literal 0 HcmV?d00001 diff --git a/flexus_simple_bots/discord_bot/discord_bot.py b/flexus_simple_bots/discord_bot/discord_bot.py new file mode 100644 index 00000000..d0d0ee05 --- /dev/null +++ b/flexus_simple_bots/discord_bot/discord_bot.py @@ -0,0 +1,884 @@ +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_job_queue +from flexus_client_kit import ckit_messages +from flexus_client_kit import ckit_mongo +from flexus_client_kit import ckit_person_domain +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_discord2 as dc +from flexus_simple_bots.discord_bot import discord_bot_install +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + +logger = logging.getLogger("discord_bot") + + +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_bot" +BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION + + +def _has_gatekeeper_actions(rules: List[dict]) -> bool: + """Return True when any published rule contains at least one call_gatekeeper_tool action.""" + for rule in rules: + actions = rule.get("actions") or [] + if any(isinstance(a, dict) and a.get("type") == "call_gatekeeper_tool" for a in actions): + return True + return False + +TOOLS: List[Any] = [] + + +def _discord_bot_hosted_bot_token() -> tuple[str, str | None]: + v = (os.environ.get("FLEXUS_DISCORD_BOT_TOKEN") or "").strip() + if v: + return v, "FLEXUS_DISCORD_BOT_TOKEN" + return "", None + + +def _decide_use_gateway(base_url_http: str) -> bool: + """Decide whether to use DiscordGatewayConnector (True) or DiscordConnector (False). + + Decision priority: + 1. FLEXUS_DISCORD_USE_GATEWAY env var (explicit override, truthy/falsy string) + 2. Local dev heuristic: if FLEXUS_API_BASEURL points at localhost → direct socket + 3. Hosted default: if base_url is not localhost → gateway + + This prevents local-only workers from routing events through a gateway subscriber + that is not running locally, which would produce "no subscribers" log spam and + silently drop all automation rule executions. + """ + explicit = os.environ.get("FLEXUS_DISCORD_USE_GATEWAY", "").strip().lower() + if explicit in ("1", "true", "yes", "on"): + return True + if explicit in ("0", "false", "no", "off"): + return False + # No explicit override — use the backend URL to detect local dev. + is_local = "localhost" in base_url_http or "127.0.0.1" in base_url_http + return not is_local + + +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 _guild_ids_from_persona(persona: Any, setup: Dict[str, Any]) -> set[int]: + """Parse Discord guild IDs from persona_external_addresses (entries like 'discord:'). + Falls back to the legacy dc_guild_id setup field when the list is absent or yields nothing. + """ + addresses = getattr(persona, "persona_external_addresses", None) + if isinstance(addresses, list): + ids: set[int] = set() + for v in addresses: + if isinstance(v, str) and v.startswith("discord:"): + gid = dc.parse_snowflake(v[len("discord:"):]) + if gid is not None: + ids.add(gid) + if ids: + return ids + legacy_gid = dc.parse_snowflake(setup.get("dc_guild_id", "")) + if legacy_gid is not None: + return {legacy_gid} + return set() + + +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_bot", + { + "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 _bootstrap_existing_members( + raw_client: discord.Client, + allowed_guild_ids: frozenset[int], + fclient: ckit_client.FlexusClient, + workspace_id: str, + persona_id: str, +) -> None: + """ + One-shot idempotent sync for members already in the guild when the bot starts. + + Creates/upserts crm_person + crm_person_identity + crm_contact for every + non-bot member in every allowed guild. Does NOT create crm_application rows + (those are reserved for new joins). Safe to rerun. + + Only called when raw_client is not None (non-gateway / local-socket path). + The gateway path has no access to the member list from the worker process. + """ + total = 0 + for gid in sorted(allowed_guild_ids): + guild = raw_client.get_guild(gid) + if guild is None: + continue + for member in list(guild.members): + if member.bot: + continue + uid = member.id + display = member.display_name or member.name or str(uid) + await ckit_person_domain.ensure_person_for_discord_user( + fclient, + workspace_id, + str(uid), + display, + ) + await ckit_person_domain.ensure_discord_contact( + fclient, + workspace_id, + str(uid), + display, + ) + total += 1 + await asyncio.sleep(0) # yield to event loop between members so heartbeats stay alive + dc.log_ctx(persona_id, None, "member bootstrap complete: %d members synced across %d guilds", total, len(allowed_guild_ids)) + + +async def discord_bot_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_bot_install.DISCORD_BOT_SETUP_SCHEMA, + persona_setup_raw, + ) + token, hosted_env = _discord_bot_hosted_bot_token() + if not token: + token = dc.discord_bot_api_key_from_external_auth(rcx.external_auth) + hosted_env = "external_auth" if token else None + if not token: + logger.error( + "%s missing Discord bot token: set FLEXUS_DISCORD_BOT_TOKEN, " + "or provide 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 + + use_gateway = _decide_use_gateway(fclient.base_url_http) + if use_gateway: + logger.info( + "%s Discord runtime: gateway mode (token source=%s, backend=%s); " + "guild allowlist from persona_external_addresses; " + "worker has no Discord socket — ensure service_discord_gateway subscriber is running. " + "Set FLEXUS_DISCORD_USE_GATEWAY=0 to force direct-socket mode.", + rcx.persona.persona_id, + hosted_env, + fclient.base_url_http, + ) + else: + logger.info( + "%s Discord runtime: direct-socket mode (token source=%s, backend=%s); " + "DiscordConnector holds the gateway connection in this process. " + "Set FLEXUS_DISCORD_USE_GATEWAY=1 to force gateway mode.", + rcx.persona.persona_id, + hosted_env, + fclient.base_url_http, + ) + + 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", "")) + + initial_guild_ids = _guild_ids_from_persona(rcx.persona, setup) + dc.log_ctx(rcx.persona.persona_id, None, "allowed guild ids from persona_external_addresses: %s", sorted(initial_guild_ids)) + if use_gateway: + connector: ChatConnector = DiscordGatewayConnector(token, rcx.persona.persona_id, initial_guild_ids=initial_guild_ids) + else: + connector = DiscordConnector(token, rcx.persona.persona_id, initial_guild_ids=initial_guild_ids) + await connector.set_allowed_guild_ids(initial_guild_ids) + + 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 in ("server_connected", "server_disconnected"): + return + if event.event_type == "member_joined": + 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, + ) + + # Best-effort person domain sync: resolve/create canonical person + + # discord identity, then register a durable application record. + # Runs regardless of whether automation rules exist so that even + # workspaces with zero published rules get a proper foundation record. + if workspace_id: + _has_gk = _has_gatekeeper_actions(rules) + person_id = await ckit_person_domain.ensure_person_for_discord_user( + fclient, + workspace_id, + str(uid), + uname, + ) + if person_id: + existing_app = await ckit_person_domain.application_find_latest( + fclient, + workspace_id, + person_id, + ) + if not existing_app: + _sync_app_id = await ckit_person_domain.application_create_pending( + fclient, + workspace_id, + person_id, + source="discord_bot", + platform="discord", + payload={"guild_id": str(gid), "discord_user_id": str(uid)}, + ) + else: + _sync_app_id = existing_app["application_id"] + # When there is no gatekeeper the member is immediately accepted by + # handle_member_join (lifecycle_status="accepted"). Advance the + # durable application to DECIDED/APPROVED so it stays consistent. + if _sync_app_id and not _has_gk: + _existing_status = ( + existing_app["application_status"] if existing_app else "PENDING" + ) + if _existing_status not in ("DECIDED", "CLOSED"): + await ckit_person_domain.application_apply_decision( + fclient, + _sync_app_id, + "DECIDED", + "APPROVED", + None, + ) + # Ensure a CRM contact row exists for every joining member, + # keyed idempotently by contact_platform_ids.discord. + await ckit_person_domain.ensure_discord_contact( + fclient, + workspace_id, + str(uid), + uname, + ) + + if len(rules) == 0: + return + + # When gatekeeper rules are configured, start member in pending_review + # so the AI review decision (accept/reject/request_info) controls progression. + # Without gatekeeper, preserve the existing "accepted" flow from handle_member_join. + if _has_gatekeeper_actions(rules): + updated_doc, _prev = await ckit_crm_members.set_member_status( + mongo_db, + gid, + uid, + "pending_review", + ) + if updated_doc is not None: + member_doc = updated_doc + + 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, + "fclient": fclient, + "ws_id": workspace_id, + } + 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, + "fclient": fclient, + "ws_id": workspace_id, + } + 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, + "fclient": fclient, + "ws_id": workspace_id, + } + 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 + # Optional single-guild fallback: in direct-socket mode only, if persona_external_addresses + # has no guild IDs configured but the bot token can see exactly one guild, use that guild + # automatically. Strictly bounded to the "exactly one guild visible" case so it cannot + # silently pick the wrong server when multiple guilds are present. + if not use_gateway and raw is not None and not connector.allowed_guild_ids: + visible = list(raw.guilds) + if len(visible) == 1: + fallback_id = int(visible[0].id) + logger.warning( + "%s direct-socket mode: no guild configured in persona_external_addresses; " + "bot token sees exactly one guild (%d / %r) — using it as fallback. " + "Add discord:%d to persona_external_addresses to silence this warning.", + rcx.persona.persona_id, + fallback_id, + visible[0].name, + fallback_id, + ) + await connector.set_allowed_guild_ids({fallback_id}) + elif len(visible) > 1: + logger.warning( + "%s direct-socket mode: no guild configured in persona_external_addresses " + "and bot token sees %d guilds — cannot auto-select. " + "Add discord: to persona_external_addresses.", + rcx.persona.persona_id, + len(visible), + ) + + if raw is not None: + _register_reaction_roles(raw, setup, rcx.persona.persona_id, connector) + + checklist_ready_done = False + bootstrap_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 bootstrap_done and connector.raw_client is not None and workspace_id: + # Fire-and-forget: run bootstrap in the background so the main + # loop keeps processing events while members are being synced. + asyncio.create_task( + _bootstrap_existing_members( + connector.raw_client, + connector.allowed_guild_ids, + fclient, + workspace_id, + rcx.persona.persona_id, + ) + ) + bootstrap_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, + fclient=fclient, + ws_id=workspace_id, + ) + 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 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_bot_main_loop, + inprocess_tools=TOOLS, + scenario_fn=scenario_fn, + install_func=discord_bot_install.install, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/flexus_simple_bots/discord_bot/discord_bot_install.py b/flexus_simple_bots/discord_bot/discord_bot_install.py new file mode 100644 index 00000000..f1bb11cd --- /dev/null +++ b/flexus_simple_bots/discord_bot/discord_bot_install.py @@ -0,0 +1,85 @@ +import asyncio +import base64 +import json +from pathlib import Path + +from flexus_client_kit import ckit_automation_v1_schema_build +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.ckit_connector_discord import DISCORD_ACTIONS, DISCORD_TRIGGERS +from flexus_simple_bots import prompts_common +from flexus_simple_bots.discord_bot import discord_bot_prompts +from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION + + +ROOT = Path(__file__).parent + +DISCORD_BOT_SETUP_SCHEMA = json.loads((ROOT / "setup_schema.json").read_text()) + +EXPERTS = [ + ( + "default", + ckit_bot_install.FMarketplaceExpertInput( + fexp_system_prompt=discord_bot_prompts.discord_bot_stub, + fexp_python_kernel="", + fexp_allow_tools=",".join(sorted(ckit_cloudtool.CLOUDTOOLS_ADVANCED)), + fexp_nature="NATURE_INTERACTIVE", + 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_bot-1024x1536.webp").read_bytes()).decode("ascii") + pic_small_b64 = base64.b64encode((ROOT / "discord_bot-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 Bot", + 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_bot.discord_bot", + marketable_setup_default=DISCORD_BOT_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_expensive="grok-4-1-fast-reasoning", + marketable_preferred_model_cheap="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"], + 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"], + marketable_rules_toolkit=ckit_automation_v1_schema_build.build_automation_v1_schema_document( + triggers=DISCORD_TRIGGERS, + actions=DISCORD_ACTIONS, + ), + ) + + +if __name__ == "__main__": + async def _main() -> None: + fclient = ckit_client.FlexusClient(ckit_client.bot_service_name("discord_bot", SIMPLE_BOTS_COMMON_VERSION), endpoint="/v1/jailed-bot") + await install(fclient, "discord_bot", SIMPLE_BOTS_COMMON_VERSION, []) + + asyncio.run(_main()) diff --git a/flexus_simple_bots/discord_bot/discord_bot_prompts.py b/flexus_simple_bots/discord_bot/discord_bot_prompts.py new file mode 100644 index 00000000..604c572a --- /dev/null +++ b/flexus_simple_bots/discord_bot/discord_bot_prompts.py @@ -0,0 +1,4 @@ +discord_bot_stub = """ +You are the Discord 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_bot/setup_schema.json b/flexus_simple_bots/discord_bot/setup_schema.json new file mode 100644 index 00000000..48d7eaad --- /dev/null +++ b/flexus_simple_bots/discord_bot/setup_schema.json @@ -0,0 +1,128 @@ +[ + { + "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." + }, + { + "bs_name": "automation_rules", + "bs_type": "string_multiline", + "bs_default": "", + "bs_group": "Automation", + "bs_order": 1, + "bs_importance": 0, + "bs_description": "Published automation rules document (JSON). Managed by the automation editor; do not edit manually." + } +] diff --git a/flexus_simple_bots/frog/frog_bot.py b/flexus_simple_bots/frog/frog_bot.py index 3f35b9c3..6af8ba7f 100644 --- a/flexus_simple_bots/frog/frog_bot.py +++ b/flexus_simple_bots/frog/frog_bot.py @@ -41,7 +41,6 @@ "gmail", "google_business", "google_ads", - "google_sheets", "telegram", "slack", "skills" diff --git a/flexus_simple_bots/frog/frog_install.py b/flexus_simple_bots/frog/frog_install.py index 480d03b8..b2277126 100644 --- a/flexus_simple_bots/frog/frog_install.py +++ b/flexus_simple_bots/frog/frog_install.py @@ -79,7 +79,7 @@ async def install(client: ckit_client.FlexusClient): prompts_common.SCHED_TODO_5M | {"sched_when": "EVERY:2m", "sched_first_question": "Work on the assigned task with enthusiasm!"}, ], marketable_forms=ckit_bot_install.load_form_bundles(__file__), - marketable_auth_supported=["gmail", "google_business", "google_ads", "google"], + marketable_auth_supported=["gmail", "google_business", "google_ads"], marketable_auth_scopes={ "gmail": ckit_integrations_db.GOOGLE_OAUTH_BASE_SCOPES + [ "https://www.googleapis.com/auth/gmail.readonly", @@ -94,9 +94,6 @@ async def install(client: ckit_client.FlexusClient): "google_ads": ckit_integrations_db.GOOGLE_OAUTH_BASE_SCOPES + [ "https://www.googleapis.com/auth/adwords", ], - "google": ckit_integrations_db.GOOGLE_OAUTH_BASE_SCOPES + [ - "https://www.googleapis.com/auth/spreadsheets", - ], }, ) return r.marketable_version diff --git a/flexus_simple_bots/karen/karen_prompts.py b/flexus_simple_bots/karen/karen_prompts.py index 845b2cfc..97ac12b5 100644 --- a/flexus_simple_bots/karen/karen_prompts.py +++ b/flexus_simple_bots/karen/karen_prompts.py @@ -149,7 +149,6 @@ ## Sales — C.L.O.S.E.R. Great sales feel like help, not pressure. Listen 70%, talk 30%. When in doubt, be honest and offer a human. -Before quoting pricing, features, or setup details, call flexus_vector_search() to ground your answer in real data. - **Clarify**: ask why they're here — they must verbalize the problem, don't tell them what it is - **Label**: restate their problem in your own words, get agreement diff --git a/flexus_simple_bots/karen/very_limited__actual_support.yaml b/flexus_simple_bots/karen/very_limited__actual_support.yaml index 9c6a9bab..9e75b2d5 100644 --- a/flexus_simple_bots/karen/very_limited__actual_support.yaml +++ b/flexus_simple_bots/karen/very_limited__actual_support.yaml @@ -1,6 +1,5 @@ judge_instructions: | - Telegram capture might go after op=help or immediately, both fine. - But if fails to capture -- that's mission failed. + The assistant should use flexus_vector_search() to find relevant information and compose an answer from the results. Penalize if the assistant fabricates information that does not exist in the search result. messages: @@ -97,8 +96,8 @@ messages: } - role: user content: |- - Telegram private user='Cándido' chat_id=14931503 - hola + Telegram private user='Vasya Pupkin' chat_id=149315030 + hi - role: assistant tool_calls: - id: fakecall_DX8c4k6aOnn44ieE @@ -111,15 +110,15 @@ messages: The current task with details: { "id": "dG8JD1LAaY", - "title": "Telegram private user='Cándido' chat_id=14931503\nhola", + "title": "Telegram private user='Vasya Pupkin' chat_id=149315030\nhi", "details": { - "chat_id": 14931503, + "chat_id": 149315030, "chat_type": "private", "message_id": 410, "attachments": [], - "message_text": "hola", - "message_author_id": 14931503, - "message_author_name": "Cándido" + "message_text": "hi", + "message_author_id": 149315030, + "message_author_name": "Vasya Pupkin" } } @@ -133,7 +132,7 @@ messages: In Progress: [ - {"id": "dG8JD1LAaY", "title": "Telegram private user='Cándido' chat_id=14931503\nhola", "human": "telegram:14931503"}, + {"id": "dG8JD1LAaY", "title": "Telegram private user='Vasya Pupkin' chat_id=149315030\nhi", "human": "telegram:149315030"}, ] Done: @@ -141,57 +140,19 @@ messages: ] call_id: fakecall_DX8c4k6aOnn44ieE - role: title - content: "[telegram] [candido] [greeting] Telegram private: Cándido (chat_id=14931503) — hola" -- role: assistant - tool_calls: - - id: call_dsmSbHjhvgldJcqWQgVW0Rok - type: function - function: - name: telegram - arguments: '{"op": "help"}' -- role: tool - content: | - Help: - - telegram(op="status") - - telegram(op="capture", args={"chat_id": 123456789}) - Capture a Telegram chat. Messages will appear here and your responses will be sent back. - You can only capture chats where the bot is a member. - - telegram(op="uncapture") - Stop capturing this Telegram chat. Do this at the end when you're done talking. - - telegram(op="post", args={"chat_id": 123456789, "text": "Hello!"}) - Post a message to a Telegram chat. Don't use this for captured chats. Remember to use MarkdownV2 markup. - - Telegram uses MarkdownV2 markup: - - *bold* _italic_ __underline__ ~strikethrough~ - `inline code` - ```python - code block - ``` - [link text](https://example.com) - ||spoiler|| - - > blockquote - > each line must start with > - - No bullet lists or tables. - call_id: call_dsmSbHjhvgldJcqWQgVW0Rok + content: "[telegram] [vasya] [greeting] Telegram private: Vasya Pupkin (chat_id=149315030) — hi" - role: assistant tool_calls: - id: call_ch7YyZE8d9jZWDqrVhE4wcqU type: function function: name: telegram - arguments: '{"op": "capture", "args": {"chat_id": 14931503}}' + arguments: '{"op": "capture", "args": {"chat_id": 149315030}}' - role: tool content: | 📌CAPTURED - The next thing you write will be visible. Don't comment on that fact and think about what do you want to say in '14931503'. + The next thing you write will be visible. Don't comment on that fact and think about what do you want to say in '149315030'. Don't use op=post because now anything you say is visible automatically. Reminder: after this point telegram MarkdownV2 markup rules are in effect for your output, there are no tables! Here's markup help for you again. @@ -212,14 +173,14 @@ messages: No bullet lists or tables. call_id: call_ch7YyZE8d9jZWDqrVhE4wcqU - role: assistant - content: "Hola Cándido, soy Karen, asistente de soporte de CloudPipe AI. ¿En qué puedo ayudarte?" + content: Hi Vasya! I'm Karen, support assistant for CloudPipe AI. What can I help you with? - role: user content: - m_type: text m_content: |- - 👤Cándido + 👤Vasya Pupkin - cuánto cuesta Opus? + help me with Opus pricing - role: assistant tool_calls: - id: call_in4SzlVQsFZnqiu5e9f8IP35 @@ -229,12 +190,35 @@ messages: arguments: '{"eds": "", "query": "Opus pricing"}' - role: tool content: |+ - For your query 'Opus pricing' found 5 results in database of 7088 chunks across 182 indexed documents. Each document text is enclosed in ... + For your query 'Opus pricing' found 3 chunks, in database of 7088 chunks across 182 indexed documents. + IMPORTANT: These are only snippets. You must call flexus_read_original(eds="eds_id", op="cat", p="/path") to read the full document before answering. - call flexus_read_original(eds="GewoenvkaD", op="cat", p="/code.claude.com/docs/en/costs.md", line1=1, line2=283) to reproduce, total lines 477 - - # Manage costs effectively + at eds_id=GewoenvkaD found chunk 0 + 📎/code.claude.com/docs/en/fast-mode.md:61-82 + ``` + # Speed up responses with fast mode + Fast mode is a high-speed configuration for Claude Opus 4.6, making the model + 2.5x faster at a higher cost per token. + What to know: + * Fast mode for Opus 4.6 pricing is $30/150 MTok. + * Available to all Claude Code users on subscription plans (Pro/Max/Team/Enterprise) and Claude Console. + ``` + at eds_id=GewoenvkaD found chunk 2 + 📎/code.claude.com/docs/en/model-config.md:235-260 + ``` + The behavior of `default` depends on your account type: + * **Max and Team Premium** : defaults to Opus 4.6 + * **Pro and Team Standard** : defaults to Sonnet 4.6 + * **Enterprise** : Opus 4.6 is available but not the default + Claude Code may automatically fall back to Sonnet if you hit a usage threshold + with Opus. + ``` + + at eds_id=GewoenvkaD found chunk 3 + 📎/code.claude.com/docs/en/costs.md:69-82 + ``` + # Manage costs effectively Claude Code consumes tokens for each interaction. Costs vary based on codebase size, query complexity, and conversation length. The average cost is $6 per developer per day, with daily costs remaining below $12 for 90% of users. For @@ -242,14 +226,34 @@ messages: Code costs ~$100-200/developer per month with Sonnet 4.6 though there is large variance depending on how many instances users are running and whether they're using it in automation. - + ``` + + call_id: call_in4SzlVQsFZnqiu5e9f8IP35 +- role: assistant + tool_calls: + - id: call_EeeZHGhjQD7eUo9U0lqpnAAF + type: function + function: + name: flexus_read_original + arguments: '{"eds": "GewoenvkaD", "op": "cat", "p": "/code.claude.com/docs/en/fast-mode.md", "skip": 61, "take": 120, "safety_valve": "20k"}' + - id: call_R2kqPvNx8mLWf1cDjYbTaS3e + type: function + function: + name: flexus_read_original + arguments: '{"eds": "GewoenvkaD", "op": "cat", "p": "/code.claude.com/docs/en/costs.md", "skip": 69, "take": 50, "safety_valve": "20k"}' +- role: tool + content: |- + 📄 File: code.claude.com/docs/en/fast-mode.md - call flexus_read_original(eds="GewoenvkaD", op="cat", p="/code.claude.com/docs/en/fast-mode.md", line1=1, line2=301) to reproduce, total lines 331 - # Speed up responses with fast mode Fast mode is a high-speed configuration for Claude Opus 4.6, making the model - 2.5x faster at a higher cost per token. + 2.5x faster at a higher cost per token. Toggle it on with `/fast` when you + need speed for interactive work like rapid iteration or live debugging, and + toggle it off when cost matters more than latency. Fast mode is not a + different model. It uses the same Opus 4.6 with a different API configuration + that prioritizes speed over cost efficiency. You get identical quality and + capabilities, just faster responses. What to know: * Fast mode for Opus 4.6 pricing is $30/150 MTok. @@ -260,40 +264,42 @@ messages: Fast mode on Opus 4.6| $30| $150 Fast mode pricing is flat across the full 1M token context window. - + call_id: call_EeeZHGhjQD7eUo9U0lqpnAAF +- role: tool + content: |- + 📄 File: code.claude.com/docs/en/costs.md - call flexus_read_original(eds="GewoenvkaD", op="cat", p="/code.claude.com/docs/en/model-config.md", line1=1, line2=312) to reproduce, total lines 655 - - The behavior of `default` depends on your account type: - * **Max and Team Premium** : defaults to Opus 4.6 - * **Pro and Team Standard** : defaults to Sonnet 4.6 - * **Enterprise** : Opus 4.6 is available but not the default - Claude Code may automatically fall back to Sonnet if you hit a usage threshold - with Opus. - + # Manage costs effectively - call_id: call_in4SzlVQsFZnqiu5e9f8IP35 + Claude Code consumes tokens for each interaction. Costs vary based on codebase + size, query complexity, and conversation length. The average cost is $6 per + developer per day, with daily costs remaining below $12 for 90% of users. For + team usage, Claude Code charges by API token consumption. On average, Claude + Code costs ~$100-200/developer per month with Sonnet 4.6 though there is large + variance depending on how many instances users are running and whether they're + using it in automation. + call_id: call_R2kqPvNx8mLWf1cDjYbTaS3e - role: assistant content: |- - Opus fast mode: $30 por 1M tokens de entrada, $150 por 1M tokens de salida\. Mismo modelo, solo más rápido\. + Opus fast mode: $30 per 1M input tokens, $150 per 1M output tokens. Same model, just faster. - En costos reales: el uso promedio de Claude Code es \~$6/dev/día, menos de $12 para el 90% de usuarios\. Con Sonnet son \~$100\-200/dev/mes — con Opus será más según el volumen\. + For real\-world costs: average Claude Code usage runs ~$6/dev/day, under $12 for 90% of users. With Sonnet that's ~$100\-200/dev/month — Opus will be higher depending on volume. - A través de CloudPipe se suma nuestro 15% de markup\. ¿Quieres que te ayude a estimar para el tamaño de tu equipo? + Through CloudPipe that's plus our 15% markup. Want me to help estimate for your team size? - role: user content: - m_type: text m_content: |- - 👤Cándido + 👤Vasya Pupkin - ok gracias + ok thanks - role: assistant tool_calls: - id: call_GBLCjRLNzwnzmdFOL0f3yvt3 type: function function: name: flexus_kanban_public - arguments: '{"op": "resolve", "search": null, "resolution": {"code": "SUCCESS", "summary": "Answered Cándido on Telegram (in Spanish) about Opus pricing. Looked up Claude Code docs and replied that Opus fast mode costs $30/1M input, $150/1M output tokens, plus CloudPipe 15% markup.", "humanhours": 0.2, "uncapture": true, "pdoc_paths": []}, "details_json": null, "restart_summary": null}' + arguments: '{"op": "resolve", "search": null, "resolution": {"code": "SUCCESS", "summary": "Answered Vasya Pupkin on Telegram about Opus pricing. Looked up Claude Code docs and replied that Opus fast mode costs $30/1M input, $150/1M output tokens — same model, faster latency.", "humanhours": 0.2, "uncapture": true, "pdoc_paths": []}, "details_json": null, "restart_summary": null}' - role: tool content: |- Moved to done dG8JD1LAaY diff --git a/flexus_simple_bots/version_common.py b/flexus_simple_bots/version_common.py new file mode 100644 index 00000000..cdac130b --- /dev/null +++ b/flexus_simple_bots/version_common.py @@ -0,0 +1 @@ +SIMPLE_BOTS_COMMON_VERSION = "1.2.194" diff --git a/setup.py b/setup.py index 6e336666..5b67c458 100644 --- a/setup.py +++ b/setup.py @@ -61,7 +61,9 @@ def run(self): "xai-sdk", "mcp", "python-telegram-bot>=20.0", + "redis>=5", "google-ads", + "redis>=5", ], extras_require={ "dev": [ From 89011779994ffe9e302d4891123e487e641a43bc Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Thu, 9 Apr 2026 11:35:10 +0200 Subject: [PATCH 02/10] chore: trim unrelated discovery kit changes Revert files that leaked into the squashed discovery diff so the PR stays focused on the Discord and automation work reviewers actually need to read. --- flexus_client_kit/erp_schema.py | 123 ------------- flexus_client_kit/format_utils.py | 2 + flexus_client_kit/integrations/fi_pdoc.py | 44 +++-- flexus_simple_bots/boss/boss_bot.py | 4 +- flexus_simple_bots/frog/frog_bot.py | 1 + flexus_simple_bots/frog/frog_install.py | 5 +- flexus_simple_bots/karen/karen_prompts.py | 1 + .../karen/very_limited__actual_support.yaml | 164 +++++++++--------- flexus_simple_bots/version_common.py | 2 +- setup.py | 1 - 10 files changed, 116 insertions(+), 231 deletions(-) diff --git a/flexus_client_kit/erp_schema.py b/flexus_client_kit/erp_schema.py index 7a43ec97..db837e17 100644 --- a/flexus_client_kit/erp_schema.py +++ b/flexus_client_kit/erp_schema.py @@ -310,130 +310,12 @@ class ComRefund: order: Optional[ComOrder] = field(default=None, metadata={"fk_field": "refund_order_id", "description": "included via include=['order']"}) -@dataclass -class CrmPerson: - ws_id: str - person_id: str = field(default="", metadata={"pkey": True, "display_name": "Person ID"}) - person_label: str = field(default="", metadata={"importance": 1, "display_name": "Label"}) - person_details: dict = field(default_factory=dict, metadata={"display_name": "Details", "description": "Custom JSON attributes for this person"}) - person_created_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Created at"}) - person_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) - person_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) - - identities: Optional[List["CrmPersonIdentity"]] = field(default=None, metadata={"inbound_fk_table": "crm_person_identity", "inbound_fk_col": "person_id"}) - applications: Optional[List["CrmApplication"]] = field(default=None, metadata={"inbound_fk_table": "crm_application", "inbound_fk_col": "person_id"}) - - -@dataclass -class CrmPersonIdentity: - ws_id: str - person_id: str = field(metadata={"importance": 1, "display_name": "Person"}) - identity_platform: str = field(metadata={"importance": 1, "display_name": "Platform", "description": "e.g. discord, slack, telegram, email, github"}) - identity_external_user_id: str = field(metadata={"importance": 1, "display_name": "External User ID"}) - identity_id: str = field(default="", metadata={"pkey": True, "display_name": "Identity ID"}) - identity_external_endpoint: str = field(default="", metadata={"importance": 1, "display_name": "Endpoint", "description": "Channel ID, DM endpoint, webhook URL, etc."}) - identity_status: str = field(default="linked", metadata={"importance": 1, "display_name": "Status", "enum": [ - {"value": "linked", "label": "Linked"}, - {"value": "verified", "label": "Verified"}, - {"value": "revoked", "label": "Revoked"}, - ]}) - identity_details: dict = field(default_factory=dict, metadata={"display_name": "Details"}) - identity_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) - identity_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) - identity_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) - - person: Optional[CrmPerson] = field(default=None, metadata={"fk_field": "person_id", "description": "included via include=['person']"}) - - -@dataclass -class CrmApplication: - ws_id: str - application_id: str = field(default="", metadata={"pkey": True, "display_name": "Application ID"}) - person_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Person"}) - contact_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Contact"}) - application_source: str = field(default="", metadata={"importance": 1, "display_name": "Source", "description": "Originating channel/system, e.g. discord_onboarding"}) - application_platform: str = field(default="", metadata={"importance": 1, "display_name": "Platform", "enum": [ - {"value": "discord", "label": "Discord"}, - {"value": "slack", "label": "Slack"}, - {"value": "web", "label": "Web"}, - {"value": "email", "label": "Email"}, - {"value": "telegram", "label": "Telegram"}, - ]}) - application_status: str = field(default="PENDING", metadata={"importance": 1, "display_name": "Status", "enum": [ - {"value": "PENDING", "label": "Pending"}, - {"value": "REVIEWING", "label": "Reviewing"}, - {"value": "DECIDED", "label": "Decided"}, - {"value": "CLOSED", "label": "Closed"}, - ]}) - application_decision: str = field(default="", metadata={"importance": 1, "display_name": "Decision", "enum": [ - {"value": "", "label": "—"}, - {"value": "APPROVED", "label": "Approved"}, - {"value": "REJECTED", "label": "Rejected"}, - {"value": "WAITLISTED", "label": "Waitlisted"}, - ]}) - application_payload: dict = field(default_factory=dict, metadata={"display_name": "Payload", "description": "Raw inbound data from the originating event"}) - application_details: dict = field(default_factory=dict, metadata={"display_name": "Details", "description": "Enriched/processed data and bot annotations"}) - application_created_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Created at"}) - application_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) - application_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) - application_decision_ts: float = field(default=0.0, metadata={"importance": 1, "display_name": "Decided at"}) - - person: Optional[CrmPerson] = field(default=None, metadata={"fk_field": "person_id", "description": "included via include=['person']"}) - - -@dataclass -class CrmThreadParticipationPolicy: - ws_id: str - ft_id: str = field(metadata={"importance": 1, "display_name": "Thread ID"}) - policy_id: str = field(default="", metadata={"pkey": True, "display_name": "Policy ID"}) - persona_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Persona"}) - person_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Person"}) - policy_mode: str = field(default="ACTIVE", metadata={"importance": 1, "display_name": "Mode", "enum": [ - {"value": "ACTIVE", "label": "Active"}, - {"value": "PAUSED", "label": "Paused"}, - {"value": "TAKEOVER", "label": "Takeover"}, - {"value": "CLOSED", "label": "Closed"}, - ]}) - policy_active: bool = field(default=True, metadata={"importance": 1, "display_name": "Active"}) - policy_timeout_ts: float = field(default=0.0, metadata={"display_name": "Timeout at", "description": "Epoch timestamp when mode should auto-expire; 0 = no timeout"}) - policy_last_actor: str = field(default="", metadata={"display_name": "Last Actor", "description": "fuser_id, persona_id, or 'system'"}) - policy_details: dict = field(default_factory=dict, metadata={"display_name": "Details"}) - policy_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) - policy_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) - - -@dataclass -class CrmExternalConnection: - ws_id: str - connection_provider: str = field(metadata={"importance": 1, "display_name": "Provider", "description": "e.g. discord, slack, github, google"}) - connection_id: str = field(default="", metadata={"pkey": True, "display_name": "Connection ID"}) - persona_id: Optional[str] = field(default=None, metadata={"importance": 1, "display_name": "Persona"}) - auth_id: Optional[str] = field(default=None, metadata={"display_name": "Auth ID"}) - connection_platform: str = field(default="", metadata={"importance": 1, "display_name": "Platform", "description": "Sub-platform or environment hint"}) - connection_resource_type: str = field(default="", metadata={"importance": 1, "display_name": "Resource Type", "description": "e.g. guild, channel, workspace, dm, repo"}) - connection_external_id: str = field(default="", metadata={"importance": 1, "display_name": "External ID"}) - connection_status: str = field(default="active", metadata={"importance": 1, "display_name": "Status", "enum": [ - {"value": "active", "label": "Active"}, - {"value": "revoked", "label": "Revoked"}, - {"value": "error", "label": "Error"}, - ]}) - connection_details: dict = field(default_factory=dict, metadata={"display_name": "Details"}) - connection_created_ts: float = field(default=0.0, metadata={"display_name": "Created at"}) - connection_modified_ts: float = field(default=0.0, metadata={"display_name": "Modified at"}) - connection_archived_ts: float = field(default=0.0, metadata={"display_name": "Archived at"}) - - ERP_TABLE_TO_SCHEMA: Dict[str, Type] = { "crm_activity": CrmActivity, - "crm_application": CrmApplication, "crm_contact": CrmContact, "crm_deal": CrmDeal, - "crm_external_connection": CrmExternalConnection, - "crm_person": CrmPerson, - "crm_person_identity": CrmPersonIdentity, "crm_pipeline": CrmPipeline, "crm_pipeline_stage": CrmPipelineStage, - "crm_thread_participation_policy": CrmThreadParticipationPolicy, "com_order": ComOrder, "com_order_item": ComOrderItem, "com_payment": ComPayment, @@ -460,15 +342,10 @@ class CrmExternalConnection: ERP_DISPLAY_NAME_CONFIGS: Dict[str, str] = { "crm_activity": "{activity_title}", - "crm_application": "{application_platform} {application_status}", "crm_contact": "{contact_first_name} {contact_last_name}", "crm_deal": "{deal_name}", - "crm_external_connection": "{connection_provider} {connection_resource_type}", - "crm_person": "{person_label}", - "crm_person_identity": "{identity_platform} {identity_external_user_id}", "crm_pipeline": "{pipeline_name}", "crm_pipeline_stage": "{stage_name}", - "crm_thread_participation_policy": "{ft_id} {policy_mode}", "com_order": "{order_number}", "com_order_item": "{oitem_name}", "com_payment": "{pay_id}", diff --git a/flexus_client_kit/format_utils.py b/flexus_client_kit/format_utils.py index 12b66e1d..1a6a14c6 100644 --- a/flexus_client_kit/format_utils.py +++ b/flexus_client_kit/format_utils.py @@ -25,6 +25,7 @@ class TextOutputResult: lines: List[str] line1: int # 1-based first line shown line2: int # 1-based last line shown (inclusive) + total_lines: int safety_valve_hit: bool header: str warnings: List[str] = field(default_factory=list) @@ -184,6 +185,7 @@ def format_text_output( lines=result, line1=line_offset + start + 1, line2=line_offset + actual_end + 1, + total_lines=len(lines), safety_valve_hit=hit, header=header, warnings=warnings, diff --git a/flexus_client_kit/integrations/fi_pdoc.py b/flexus_client_kit/integrations/fi_pdoc.py index 4e006a32..70c69ce0 100644 --- a/flexus_client_kit/integrations/fi_pdoc.py +++ b/flexus_client_kit/integrations/fi_pdoc.py @@ -36,7 +36,7 @@ parameters={ "type": "object", "properties": { - "op": {"type": "string", "enum": ["help", "list", "cat", "activate", "create", "create_draft_qa", "create_draft_from_template", "overwrite", "update_json_text", "translate_qa", "cp", "mv", "rm"]}, + "op": {"type": "string", "enum": ["help", "list", "cat", "activate", "create", "create_draft_qa", "create_draft_from_template", "overwrite", "update_at_location", "translate_qa", "cp", "mv", "rm"]}, "args": {"type": "object"}, # model guesses p= to write here quite well for some reason, without help, must be something in prompt }, }, @@ -71,15 +71,15 @@ Create a new policy document from a known template. Automatically prepends current date and DRAFT between output_dir and slug, e.g. /plans/20260325-DRAFT-my-thing. Fails if the document already exists. -flexus_policy_document(op="update_json_text", args={"p": "/folder/file", "json_path": "section1.field", "text": "new value", "expected_md5": "abc123"}) - Update a specific field in a document using json_path with dot notation. - Example: "operations_overview.governance" updates doc["operations_overview"]["governance"] - Pass expected_md5 (from a previous cat/update) to avoid overwriting concurrent changes. - If md5 doesn't match, changes are not saved and latest content is returned so you can retry. +flexus_policy_document(op="update_at_location", args={"p": "/folder/file", "expected_md5": "abc123", "updates": [["toptag.section1.field1", "value1"], ["toptag.section1.field2", "value2"]]}) + Update fields in a document using dot notation for path. This normally updates strings, but you can try to set + any structure or number, if schema in available in the document. + NEVER call this tool in parallel, backend will serialize calls and only the first will pass md5 gate. + Instead use "updates" array to set multiple fields at once. flexus_policy_document(op="translate_qa", args={"p": "/folder/file", "expected_md5": "abc123", "translation": [["top-tag.section01-product.question01-description.q", "Translated text"], ...]}) Batch-update question texts in a QA document, typically for translation. - Each entry in translation is [json_path, text] where json_path uses dot notation (same as update_json_text). + Each entry in translation is [json_path, text] where json_path uses dot notation (same as update_at_location). Pass expected_md5 to avoid overwriting concurrent changes. Returns list of still-empty "q" and "a" fields and the new md5. @@ -425,19 +425,27 @@ async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_p raise r += f"✍️ {p}\nmd5={_pdoc_md5(doc)}\n\n✓ Created from template '{template}'" - elif op == "update_json_text": + elif op == "update_at_location": p = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "p", "") p = p or ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "path", "") - json_path = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "json_path", "") - text = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "text", "") expected_md5 = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "expected_md5", "") - if not p or not json_path or not text: - return f"Error: p, json_path, and text parameters required\n\n{HELP}" + # batch: updates=[[json_path, text], ...] or single: json_path= text= + updates_raw = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "updates", None) + if updates_raw and isinstance(updates_raw, list): + updates = [{"json_path": u[0], "text": u[1]} for u in updates_raw] + else: + json_path = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "json_path", "") + text = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "text", "") + if not json_path or not text: + return f"Error: provide json_path+text or updates list\n\n{HELP}" + updates = [{"json_path": json_path, "text": text}] + if not p: + return f"Error: p parameter required\n\n{HELP}" if self.is_fake: return await ckit_scenario.scenario_generate_tool_result_via_model(self.fclient, toolcall, open(__file__).read()) - upd = await self.pdoc_update_json_text(p, json_path, text, persona_id=self.rcx.persona.persona_id, fcall_untrusted_key=toolcall.fcall_untrusted_key, expected_md5=expected_md5) + upd = await self.pdoc_update_at_location(p, updates, persona_id=self.rcx.persona.persona_id, fcall_untrusted_key=toolcall.fcall_untrusted_key, expected_md5=expected_md5) if upd.changes_saved: r += f"✍️ {p}\n" r += f"changes_saved=true\n\n" @@ -613,20 +621,20 @@ async def pdoc_overwrite(self, p: str, text: str, persona_id: str, fcall_untrust ) return gql_utils.dataclass_from_dict(r["policydoc_overwrite"], PdocOverwriteResult) - async def pdoc_update_json_text(self, p: str, json_path: str, text: str, persona_id: str, fcall_untrusted_key: str, expected_md5: str = "") -> PdocUpdateJsonTextResult: + async def pdoc_update_at_location(self, p: str, updates: list, persona_id: str, fcall_untrusted_key: str, expected_md5: str = "") -> PdocUpdateJsonTextResult: http = await self._http(persona_id, fcall_untrusted_key) async with http as h: result = await h.execute( gql.gql(f""" - mutation PdocUpdateJsonText($fgroup_id: String!, $p: String!, $json_path: String!, $text: String!, $expected_md5: String) {{ - policydoc_update_json_text(fgroup_id: $fgroup_id, p: $p, json_path: $json_path, text: $text, expected_md5: $expected_md5) {{ + mutation PdocUpdateAtPath($fgroup_id: String!, $p: String!, $updates: [PolicyDocJsonUpdate!]!, $expected_md5: String) {{ + policydoc_update_at_location(fgroup_id: $fgroup_id, p: $p, updates: $updates, expected_md5: $expected_md5) {{ {gql_utils.gql_fields(PdocUpdateJsonTextResult)} }} }} """), - variable_values={"fgroup_id": self.fgroup_id, "p": p, "json_path": json_path, "text": text, "expected_md5": expected_md5}, + variable_values={"fgroup_id": self.fgroup_id, "p": p, "updates": updates, "expected_md5": expected_md5}, ) - return gql_utils.dataclass_from_dict(result["policydoc_update_json_text"], PdocUpdateJsonTextResult) + return gql_utils.dataclass_from_dict(result["policydoc_update_at_location"], PdocUpdateJsonTextResult) async def pdoc_cp(self, p1: str, p2: str, persona_id: str, fcall_untrusted_key: str) -> None: http = await self._http(persona_id, fcall_untrusted_key) diff --git a/flexus_simple_bots/boss/boss_bot.py b/flexus_simple_bots/boss/boss_bot.py index ff8897fe..b178b59f 100644 --- a/flexus_simple_bots/boss/boss_bot.py +++ b/flexus_simple_bots/boss/boss_bot.py @@ -203,7 +203,7 @@ async def handle_plan_update_section( section_data[k] = v.replace("\\n", "\n") if isinstance(v, str) and " " in v and "\n" not in v: section_data[k] = section_data[k].replace(" ", "\n") - upd = await pdoc_integration.pdoc_update_json_text(path, f"plan.{section}", json.dumps(section_data, ensure_ascii=False), persona_id=rcx.persona.persona_id, fcall_untrusted_key=uk, expected_md5=expected_md5) + upd = await pdoc_integration.pdoc_update_at_location(path, [{"json_path": f"plan.{section}", "text": json.dumps(section_data, ensure_ascii=False)}], persona_id=rcx.persona.persona_id, fcall_untrusted_key=uk, expected_md5=expected_md5) if not upd.changes_saved: return f"📄 {path}\nmd5_requested={upd.md5_requested}\nmd5_found={upd.md5_found}\nchanges_saved=false\n\n{upd.problem_message or 'Document changed, please retry'}\n\n{upd.latest_text}" @@ -234,7 +234,7 @@ async def handle_plan_progress_add( return f"Error: plan {path} not found" prev = existing.pdoc_content.get("plan", {}).get("section03-progress", {}).get(field, "").rstrip("\n") new_value = (prev + "\n" + line).lstrip("\n") - upd = await pdoc_integration.pdoc_update_json_text(path, f"plan.section03-progress.{field}", new_value, persona_id=rcx.persona.persona_id, fcall_untrusted_key=uk, expected_md5=expected_md5) + upd = await pdoc_integration.pdoc_update_at_location(path, [{"json_path": f"plan.section03-progress.{field}", "text": new_value}], persona_id=rcx.persona.persona_id, fcall_untrusted_key=uk, expected_md5=expected_md5) if not upd.changes_saved: return f"📄 {path}\nmd5_requested={upd.md5_requested}\nmd5_found={upd.md5_found}\nchanges_saved=false\n\n{upd.problem_message or 'Document changed, please retry.'}\n\n{upd.latest_text}" return f"✍️ {path}\nmd5={upd.md5_found}\n\nAppended to {field}" diff --git a/flexus_simple_bots/frog/frog_bot.py b/flexus_simple_bots/frog/frog_bot.py index 6af8ba7f..3f35b9c3 100644 --- a/flexus_simple_bots/frog/frog_bot.py +++ b/flexus_simple_bots/frog/frog_bot.py @@ -41,6 +41,7 @@ "gmail", "google_business", "google_ads", + "google_sheets", "telegram", "slack", "skills" diff --git a/flexus_simple_bots/frog/frog_install.py b/flexus_simple_bots/frog/frog_install.py index b2277126..480d03b8 100644 --- a/flexus_simple_bots/frog/frog_install.py +++ b/flexus_simple_bots/frog/frog_install.py @@ -79,7 +79,7 @@ async def install(client: ckit_client.FlexusClient): prompts_common.SCHED_TODO_5M | {"sched_when": "EVERY:2m", "sched_first_question": "Work on the assigned task with enthusiasm!"}, ], marketable_forms=ckit_bot_install.load_form_bundles(__file__), - marketable_auth_supported=["gmail", "google_business", "google_ads"], + marketable_auth_supported=["gmail", "google_business", "google_ads", "google"], marketable_auth_scopes={ "gmail": ckit_integrations_db.GOOGLE_OAUTH_BASE_SCOPES + [ "https://www.googleapis.com/auth/gmail.readonly", @@ -94,6 +94,9 @@ async def install(client: ckit_client.FlexusClient): "google_ads": ckit_integrations_db.GOOGLE_OAUTH_BASE_SCOPES + [ "https://www.googleapis.com/auth/adwords", ], + "google": ckit_integrations_db.GOOGLE_OAUTH_BASE_SCOPES + [ + "https://www.googleapis.com/auth/spreadsheets", + ], }, ) return r.marketable_version diff --git a/flexus_simple_bots/karen/karen_prompts.py b/flexus_simple_bots/karen/karen_prompts.py index 97ac12b5..845b2cfc 100644 --- a/flexus_simple_bots/karen/karen_prompts.py +++ b/flexus_simple_bots/karen/karen_prompts.py @@ -149,6 +149,7 @@ ## Sales — C.L.O.S.E.R. Great sales feel like help, not pressure. Listen 70%, talk 30%. When in doubt, be honest and offer a human. +Before quoting pricing, features, or setup details, call flexus_vector_search() to ground your answer in real data. - **Clarify**: ask why they're here — they must verbalize the problem, don't tell them what it is - **Label**: restate their problem in your own words, get agreement diff --git a/flexus_simple_bots/karen/very_limited__actual_support.yaml b/flexus_simple_bots/karen/very_limited__actual_support.yaml index 9e75b2d5..9c6a9bab 100644 --- a/flexus_simple_bots/karen/very_limited__actual_support.yaml +++ b/flexus_simple_bots/karen/very_limited__actual_support.yaml @@ -1,5 +1,6 @@ judge_instructions: | - The assistant should use flexus_vector_search() to find relevant information and compose an answer from the results. + Telegram capture might go after op=help or immediately, both fine. + But if fails to capture -- that's mission failed. Penalize if the assistant fabricates information that does not exist in the search result. messages: @@ -96,8 +97,8 @@ messages: } - role: user content: |- - Telegram private user='Vasya Pupkin' chat_id=149315030 - hi + Telegram private user='Cándido' chat_id=14931503 + hola - role: assistant tool_calls: - id: fakecall_DX8c4k6aOnn44ieE @@ -110,15 +111,15 @@ messages: The current task with details: { "id": "dG8JD1LAaY", - "title": "Telegram private user='Vasya Pupkin' chat_id=149315030\nhi", + "title": "Telegram private user='Cándido' chat_id=14931503\nhola", "details": { - "chat_id": 149315030, + "chat_id": 14931503, "chat_type": "private", "message_id": 410, "attachments": [], - "message_text": "hi", - "message_author_id": 149315030, - "message_author_name": "Vasya Pupkin" + "message_text": "hola", + "message_author_id": 14931503, + "message_author_name": "Cándido" } } @@ -132,7 +133,7 @@ messages: In Progress: [ - {"id": "dG8JD1LAaY", "title": "Telegram private user='Vasya Pupkin' chat_id=149315030\nhi", "human": "telegram:149315030"}, + {"id": "dG8JD1LAaY", "title": "Telegram private user='Cándido' chat_id=14931503\nhola", "human": "telegram:14931503"}, ] Done: @@ -140,19 +141,57 @@ messages: ] call_id: fakecall_DX8c4k6aOnn44ieE - role: title - content: "[telegram] [vasya] [greeting] Telegram private: Vasya Pupkin (chat_id=149315030) — hi" + content: "[telegram] [candido] [greeting] Telegram private: Cándido (chat_id=14931503) — hola" +- role: assistant + tool_calls: + - id: call_dsmSbHjhvgldJcqWQgVW0Rok + type: function + function: + name: telegram + arguments: '{"op": "help"}' +- role: tool + content: | + Help: + + telegram(op="status") + + telegram(op="capture", args={"chat_id": 123456789}) + Capture a Telegram chat. Messages will appear here and your responses will be sent back. + You can only capture chats where the bot is a member. + + telegram(op="uncapture") + Stop capturing this Telegram chat. Do this at the end when you're done talking. + + telegram(op="post", args={"chat_id": 123456789, "text": "Hello!"}) + Post a message to a Telegram chat. Don't use this for captured chats. Remember to use MarkdownV2 markup. + + Telegram uses MarkdownV2 markup: + + *bold* _italic_ __underline__ ~strikethrough~ + `inline code` + ```python + code block + ``` + [link text](https://example.com) + ||spoiler|| + + > blockquote + > each line must start with > + + No bullet lists or tables. + call_id: call_dsmSbHjhvgldJcqWQgVW0Rok - role: assistant tool_calls: - id: call_ch7YyZE8d9jZWDqrVhE4wcqU type: function function: name: telegram - arguments: '{"op": "capture", "args": {"chat_id": 149315030}}' + arguments: '{"op": "capture", "args": {"chat_id": 14931503}}' - role: tool content: | 📌CAPTURED - The next thing you write will be visible. Don't comment on that fact and think about what do you want to say in '149315030'. + The next thing you write will be visible. Don't comment on that fact and think about what do you want to say in '14931503'. Don't use op=post because now anything you say is visible automatically. Reminder: after this point telegram MarkdownV2 markup rules are in effect for your output, there are no tables! Here's markup help for you again. @@ -173,14 +212,14 @@ messages: No bullet lists or tables. call_id: call_ch7YyZE8d9jZWDqrVhE4wcqU - role: assistant - content: Hi Vasya! I'm Karen, support assistant for CloudPipe AI. What can I help you with? + content: "Hola Cándido, soy Karen, asistente de soporte de CloudPipe AI. ¿En qué puedo ayudarte?" - role: user content: - m_type: text m_content: |- - 👤Vasya Pupkin + 👤Cándido - help me with Opus pricing + cuánto cuesta Opus? - role: assistant tool_calls: - id: call_in4SzlVQsFZnqiu5e9f8IP35 @@ -190,35 +229,12 @@ messages: arguments: '{"eds": "", "query": "Opus pricing"}' - role: tool content: |+ - For your query 'Opus pricing' found 3 chunks, in database of 7088 chunks across 182 indexed documents. - IMPORTANT: These are only snippets. You must call flexus_read_original(eds="eds_id", op="cat", p="/path") to read the full document before answering. - - at eds_id=GewoenvkaD found chunk 0 - 📎/code.claude.com/docs/en/fast-mode.md:61-82 - ``` - # Speed up responses with fast mode - Fast mode is a high-speed configuration for Claude Opus 4.6, making the model - 2.5x faster at a higher cost per token. - What to know: - * Fast mode for Opus 4.6 pricing is $30/150 MTok. - * Available to all Claude Code users on subscription plans (Pro/Max/Team/Enterprise) and Claude Console. - ``` + For your query 'Opus pricing' found 5 results in database of 7088 chunks across 182 indexed documents. Each document text is enclosed in ... - at eds_id=GewoenvkaD found chunk 2 - 📎/code.claude.com/docs/en/model-config.md:235-260 - ``` - The behavior of `default` depends on your account type: - * **Max and Team Premium** : defaults to Opus 4.6 - * **Pro and Team Standard** : defaults to Sonnet 4.6 - * **Enterprise** : Opus 4.6 is available but not the default - Claude Code may automatically fall back to Sonnet if you hit a usage threshold - with Opus. - ``` - - at eds_id=GewoenvkaD found chunk 3 - 📎/code.claude.com/docs/en/costs.md:69-82 - ``` + call flexus_read_original(eds="GewoenvkaD", op="cat", p="/code.claude.com/docs/en/costs.md", line1=1, line2=283) to reproduce, total lines 477 + # Manage costs effectively + Claude Code consumes tokens for each interaction. Costs vary based on codebase size, query complexity, and conversation length. The average cost is $6 per developer per day, with daily costs remaining below $12 for 90% of users. For @@ -226,34 +242,14 @@ messages: Code costs ~$100-200/developer per month with Sonnet 4.6 though there is large variance depending on how many instances users are running and whether they're using it in automation. - ``` - - call_id: call_in4SzlVQsFZnqiu5e9f8IP35 -- role: assistant - tool_calls: - - id: call_EeeZHGhjQD7eUo9U0lqpnAAF - type: function - function: - name: flexus_read_original - arguments: '{"eds": "GewoenvkaD", "op": "cat", "p": "/code.claude.com/docs/en/fast-mode.md", "skip": 61, "take": 120, "safety_valve": "20k"}' - - id: call_R2kqPvNx8mLWf1cDjYbTaS3e - type: function - function: - name: flexus_read_original - arguments: '{"eds": "GewoenvkaD", "op": "cat", "p": "/code.claude.com/docs/en/costs.md", "skip": 69, "take": 50, "safety_valve": "20k"}' -- role: tool - content: |- - 📄 File: code.claude.com/docs/en/fast-mode.md + + call flexus_read_original(eds="GewoenvkaD", op="cat", p="/code.claude.com/docs/en/fast-mode.md", line1=1, line2=301) to reproduce, total lines 331 + # Speed up responses with fast mode Fast mode is a high-speed configuration for Claude Opus 4.6, making the model - 2.5x faster at a higher cost per token. Toggle it on with `/fast` when you - need speed for interactive work like rapid iteration or live debugging, and - toggle it off when cost matters more than latency. Fast mode is not a - different model. It uses the same Opus 4.6 with a different API configuration - that prioritizes speed over cost efficiency. You get identical quality and - capabilities, just faster responses. + 2.5x faster at a higher cost per token. What to know: * Fast mode for Opus 4.6 pricing is $30/150 MTok. @@ -264,42 +260,40 @@ messages: Fast mode on Opus 4.6| $30| $150 Fast mode pricing is flat across the full 1M token context window. - call_id: call_EeeZHGhjQD7eUo9U0lqpnAAF -- role: tool - content: |- - 📄 File: code.claude.com/docs/en/costs.md + - # Manage costs effectively + call flexus_read_original(eds="GewoenvkaD", op="cat", p="/code.claude.com/docs/en/model-config.md", line1=1, line2=312) to reproduce, total lines 655 + + The behavior of `default` depends on your account type: + * **Max and Team Premium** : defaults to Opus 4.6 + * **Pro and Team Standard** : defaults to Sonnet 4.6 + * **Enterprise** : Opus 4.6 is available but not the default + Claude Code may automatically fall back to Sonnet if you hit a usage threshold + with Opus. + - Claude Code consumes tokens for each interaction. Costs vary based on codebase - size, query complexity, and conversation length. The average cost is $6 per - developer per day, with daily costs remaining below $12 for 90% of users. For - team usage, Claude Code charges by API token consumption. On average, Claude - Code costs ~$100-200/developer per month with Sonnet 4.6 though there is large - variance depending on how many instances users are running and whether they're - using it in automation. - call_id: call_R2kqPvNx8mLWf1cDjYbTaS3e + call_id: call_in4SzlVQsFZnqiu5e9f8IP35 - role: assistant content: |- - Opus fast mode: $30 per 1M input tokens, $150 per 1M output tokens. Same model, just faster. + Opus fast mode: $30 por 1M tokens de entrada, $150 por 1M tokens de salida\. Mismo modelo, solo más rápido\. - For real\-world costs: average Claude Code usage runs ~$6/dev/day, under $12 for 90% of users. With Sonnet that's ~$100\-200/dev/month — Opus will be higher depending on volume. + En costos reales: el uso promedio de Claude Code es \~$6/dev/día, menos de $12 para el 90% de usuarios\. Con Sonnet son \~$100\-200/dev/mes — con Opus será más según el volumen\. - Through CloudPipe that's plus our 15% markup. Want me to help estimate for your team size? + A través de CloudPipe se suma nuestro 15% de markup\. ¿Quieres que te ayude a estimar para el tamaño de tu equipo? - role: user content: - m_type: text m_content: |- - 👤Vasya Pupkin + 👤Cándido - ok thanks + ok gracias - role: assistant tool_calls: - id: call_GBLCjRLNzwnzmdFOL0f3yvt3 type: function function: name: flexus_kanban_public - arguments: '{"op": "resolve", "search": null, "resolution": {"code": "SUCCESS", "summary": "Answered Vasya Pupkin on Telegram about Opus pricing. Looked up Claude Code docs and replied that Opus fast mode costs $30/1M input, $150/1M output tokens — same model, faster latency.", "humanhours": 0.2, "uncapture": true, "pdoc_paths": []}, "details_json": null, "restart_summary": null}' + arguments: '{"op": "resolve", "search": null, "resolution": {"code": "SUCCESS", "summary": "Answered Cándido on Telegram (in Spanish) about Opus pricing. Looked up Claude Code docs and replied that Opus fast mode costs $30/1M input, $150/1M output tokens, plus CloudPipe 15% markup.", "humanhours": 0.2, "uncapture": true, "pdoc_paths": []}, "details_json": null, "restart_summary": null}' - role: tool content: |- Moved to done dG8JD1LAaY diff --git a/flexus_simple_bots/version_common.py b/flexus_simple_bots/version_common.py index cdac130b..73850314 100644 --- a/flexus_simple_bots/version_common.py +++ b/flexus_simple_bots/version_common.py @@ -1 +1 @@ -SIMPLE_BOTS_COMMON_VERSION = "1.2.194" +SIMPLE_BOTS_COMMON_VERSION = "1.2.199" diff --git a/setup.py b/setup.py index 5b67c458..11c1d366 100644 --- a/setup.py +++ b/setup.py @@ -63,7 +63,6 @@ def run(self): "python-telegram-bot>=20.0", "redis>=5", "google-ads", - "redis>=5", ], extras_require={ "dev": [ From 6dcf4992d5555e1d4a3d5e672e872100d941c041 Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Thu, 9 Apr 2026 15:49:10 +0200 Subject: [PATCH 03/10] fix: refresh disabled automation rules from backend faster Read disabled rule ids from the backend runtime endpoint and shorten the poll interval so rule toggles apply without a manual bot restart. --- flexus_client_kit/ckit_automation.py | 48 ++++++++++++++----- flexus_simple_bots/discord_bot/discord_bot.py | 2 +- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/flexus_client_kit/ckit_automation.py b/flexus_client_kit/ckit_automation.py index 9e771ca4..2b89d9c9 100644 --- a/flexus_client_kit/ckit_automation.py +++ b/flexus_client_kit/ckit_automation.py @@ -6,11 +6,21 @@ from pathlib import Path from typing import Any, Optional +import aiohttp +import gql +import gql.transport.exceptions import jsonschema -from pymongo.errors import PyMongoError + +from flexus_client_kit import ckit_client logger = logging.getLogger(__name__) +_GQL_DISABLED_RULES = gql.gql( + """query AutomationDisabledRulesRuntime($persona_id: String!) { + automation_disabled_rules(persona_id: $persona_id) + }""" +) + # 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 @@ -113,8 +123,18 @@ def validate_automation_json(data: dict) -> list[str]: class DisabledRulesCache: - def __init__(self, mongo_db: Any, interval: float = 30.0): - self._mongo_db = mongo_db + """ + In-memory cache of disabled automation rule IDs for a persona, refreshed periodically + from the backend GraphQL automation_disabled_rules endpoint. + + Polling at 5 s so that rule enable/disable toggles made in the UI propagate to the + runtime within a few seconds without requiring a bot restart. The GQL query is cheap + (returns only a list of IDs), so the extra request frequency is negligible. + """ + + def __init__(self, fclient: ckit_client.FlexusClient, persona_id: str, interval: float = 5.0): + self._fclient = fclient + self._persona_id = persona_id self._interval = interval self._disabled: set = set() self._task: Optional[asyncio.Task] = None @@ -136,15 +156,19 @@ def get(self) -> set: 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 with (await self._fclient.use_http_on_behalf(self._persona_id, "")) as http: + result = await http.execute( + _GQL_DISABLED_RULES, + variable_values={"persona_id": self._persona_id}, + ) + ids = result.get("automation_disabled_rules") or [] + self._disabled = {str(x) for x in ids if x} + except gql.transport.exceptions.TransportError as e: + logger.warning("DisabledRulesCache refresh failed (backend), keeping last known state: %s %s", type(e).__name__, e) + except aiohttp.ClientError as e: + logger.warning("DisabledRulesCache refresh failed (network), keeping last known state: %s %s", type(e).__name__, e) + except (TypeError, ValueError, KeyError) as e: + logger.warning("DisabledRulesCache refresh failed (bad response), keeping last known state: %s %s", type(e).__name__, e) async def _loop(self) -> None: while True: diff --git a/flexus_simple_bots/discord_bot/discord_bot.py b/flexus_simple_bots/discord_bot/discord_bot.py index d0d0ee05..768e666f 100644 --- a/flexus_simple_bots/discord_bot/discord_bot.py +++ b/flexus_simple_bots/discord_bot/discord_bot.py @@ -403,7 +403,7 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot await ckit_crm_members.migrate_legacy_collections(mongo_db) await ckit_crm_members.ensure_member_indexes(mongo_db) - disabled_cache = DisabledRulesCache(mongo_db) + disabled_cache = DisabledRulesCache(fclient, rcx.persona.persona_id) await disabled_cache.start() rules = ckit_automation_engine.load_rules(persona_setup_raw) From 61644b2fe7e6f4c253c6d8a59b898e652ebcd906 Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 13 Apr 2026 10:43:38 +0200 Subject: [PATCH 04/10] refactor discord automation runtime and gateway transport Load the automation schema eagerly and drop the disabled-rule cache plumbing in favor of published enabled rules. Route hosted Discord actions through trusted backend gateway APIs while consuming inbound events through the existing external-message path. --- flexus_client_kit/ckit_automation.py | 92 +---------- flexus_client_kit/ckit_automation_actions.py | 15 +- flexus_client_kit/ckit_automation_engine.py | 11 +- flexus_client_kit/ckit_connector_discord.py | 41 ----- .../ckit_connector_discord_gateway.py | 147 ++++++++++-------- flexus_simple_bots/discord_bot/discord_bot.py | 63 +++++--- .../discord_bot/discord_bot_install.py | 6 +- 7 files changed, 144 insertions(+), 231 deletions(-) diff --git a/flexus_client_kit/ckit_automation.py b/flexus_client_kit/ckit_automation.py index 2b89d9c9..715acf93 100644 --- a/flexus_client_kit/ckit_automation.py +++ b/flexus_client_kit/ckit_automation.py @@ -1,29 +1,19 @@ from __future__ import annotations -import asyncio import json import logging from pathlib import Path -from typing import Any, Optional +from typing import Any -import aiohttp -import gql -import gql.transport.exceptions import jsonschema -from flexus_client_kit import ckit_client +from flexus_client_kit.ckit_automation_v1_schema_build import build_automation_v1_schema_document logger = logging.getLogger(__name__) -_GQL_DISABLED_RULES = gql.gql( - """query AutomationDisabledRulesRuntime($persona_id: String!) { - automation_disabled_rules(persona_id: $persona_id) - }""" -) - -# 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 +# Eagerly built from the Discord connector catalog at import time. +# Tests and offline fixtures can override via set_automation_schema_dict() / set_automation_schema(). +_AUTOMATION_SCHEMA: dict = build_automation_v1_schema_document() def set_automation_schema_dict(schema: dict) -> None: @@ -34,9 +24,7 @@ def set_automation_schema_dict(schema: dict) -> None: def set_automation_schema(schema_path: str) -> None: - """ - Load automation JSON Schema from disk. Fail-fast: raises if file is missing or invalid JSON. - """ + """Load automation JSON Schema from disk. Raises if file is missing or invalid JSON.""" global _AUTOMATION_SCHEMA _AUTOMATION_SCHEMA = json.loads(Path(schema_path).read_text(encoding="utf-8")) @@ -105,12 +93,9 @@ def extract_automation_draft(persona_automation_draft: Any) -> dict: 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. + Validate an automation config dict against the v1 schema. + Returns list of error strings (empty = valid). """ - 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) @@ -122,64 +107,3 @@ def validate_automation_json(data: dict) -> list[str]: return errors -class DisabledRulesCache: - """ - In-memory cache of disabled automation rule IDs for a persona, refreshed periodically - from the backend GraphQL automation_disabled_rules endpoint. - - Polling at 5 s so that rule enable/disable toggles made in the UI propagate to the - runtime within a few seconds without requiring a bot restart. The GQL query is cheap - (returns only a list of IDs), so the extra request frequency is negligible. - """ - - def __init__(self, fclient: ckit_client.FlexusClient, persona_id: str, interval: float = 5.0): - self._fclient = fclient - self._persona_id = persona_id - 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: - async with (await self._fclient.use_http_on_behalf(self._persona_id, "")) as http: - result = await http.execute( - _GQL_DISABLED_RULES, - variable_values={"persona_id": self._persona_id}, - ) - ids = result.get("automation_disabled_rules") or [] - self._disabled = {str(x) for x in ids if x} - except gql.transport.exceptions.TransportError as e: - logger.warning("DisabledRulesCache refresh failed (backend), keeping last known state: %s %s", type(e).__name__, e) - except aiohttp.ClientError as e: - logger.warning("DisabledRulesCache refresh failed (network), keeping last known state: %s %s", type(e).__name__, e) - except (TypeError, ValueError, KeyError) as e: - logger.warning("DisabledRulesCache refresh failed (bad response), 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 index c96db9a6..10695369 100644 --- a/flexus_client_kit/ckit_automation_actions.py +++ b/flexus_client_kit/ckit_automation_actions.py @@ -24,7 +24,6 @@ from pymongo.errors import PyMongoError from flexus_client_kit import ckit_crm_members, ckit_job_queue, ckit_person_domain -from flexus_client_kit.ckit_automation import DisabledRulesCache, filter_active_rules from flexus_client_kit.integrations import fi_discord2 as dc logger = logging.getLogger(__name__) @@ -709,7 +708,6 @@ async def _run_cascade( 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). @@ -717,8 +715,6 @@ async def _run_cascade( 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: @@ -763,7 +759,7 @@ async def _run_cascade( more_actions = engine_process_fn( event_type, event_data, - active_rules, + rules, fresh, setup, ) @@ -793,7 +789,6 @@ def make_automation_job_handler( db: Any, client: Any, persona_id: str, - disabled_rules_cache: Optional[DisabledRulesCache] = None, connector: Any = None, fclient: Any = None, ws_id: str = "", @@ -806,10 +801,6 @@ async def _handler(payload: Dict[str, Any]) -> None: 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: @@ -831,11 +822,10 @@ async def _handler(payload: Dict[str, Any]) -> None: "user_id": u_id, } try: - active_rules = filter_active_rules(rules, disabled) actions = engine_process_fn( "scheduled_check", event_data, - active_rules, + rules, member, setup, ) @@ -882,7 +872,6 @@ async def _handler(payload: Dict[str, Any]) -> None: 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) diff --git a/flexus_client_kit/ckit_automation_engine.py b/flexus_client_kit/ckit_automation_engine.py index 6fd5af85..03e3f490 100644 --- a/flexus_client_kit/ckit_automation_engine.py +++ b/flexus_client_kit/ckit_automation_engine.py @@ -81,19 +81,20 @@ def _single_condition_ok(condition: dict, member: dict) -> bool: def load_rules(persona_setup: dict) -> list[dict]: """ - Load all automation rules from persona_setup. Delegates to + Load enabled automation rules from persona_setup. Delegates to resolve_automation_rules which prefers the new automation_rules setup field and falls back to the legacy automation_published key for backward - compatibility. 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. + compatibility. Returns [] if missing or invalid. + + Rules with enabled=False are excluded; rules without an enabled field + (legacy documents) are treated as enabled for backward compatibility. """ try: published = ckit_automation.resolve_automation_rules(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)] + return [r for r in rules_raw if isinstance(r, dict) and r.get("enabled", True) is not False] except (KeyError, TypeError, ValueError) as e: logger.error("load_rules failed", exc_info=e) return [] diff --git a/flexus_client_kit/ckit_connector_discord.py b/flexus_client_kit/ckit_connector_discord.py index 3fb4302b..b825a76d 100644 --- a/flexus_client_kit/ckit_connector_discord.py +++ b/flexus_client_kit/ckit_connector_discord.py @@ -497,47 +497,6 @@ def discord_automation_semantics_bundle() -> dict[str, Any]: } -def discord_capability_export() -> dict[str, Any]: - """ - Single source of truth for backend/UI: all triggers and actions the Discord integration supports. - - Reuses the TriggerDescriptor / ActionDescriptor data from DISCORD_TRIGGERS / DISCORD_ACTIONS so - there is no second parallel catalog model. Backend can call this function to enumerate Discord - capabilities without importing the descriptor lists directly. - - Return shape:: - - { - "integration": "discord", - "version": 1, - "triggers": [{"type": ..., "label": ..., "description": ..., "schema": {...}}, ...], - "actions": [{"type": ..., "label": ..., "description": ..., "schema": {...}}, ...], - } - """ - return { - "integration": "discord", - "version": 1, - "triggers": [ - { - "type": t.type, - "label": t.label, - "description": t.description, - "schema": t.automation_schema_def, - } - for t in DISCORD_TRIGGERS - ], - "actions": [ - { - "type": a.type, - "label": a.label, - "description": a.description, - "schema": a.automation_schema_def, - } - for a in DISCORD_ACTIONS - ], - } - - class DiscordConnector(ChatConnector): def __init__( self, diff --git a/flexus_client_kit/ckit_connector_discord_gateway.py b/flexus_client_kit/ckit_connector_discord_gateway.py index 91621b2e..f5094682 100644 --- a/flexus_client_kit/ckit_connector_discord_gateway.py +++ b/flexus_client_kit/ckit_connector_discord_gateway.py @@ -1,11 +1,13 @@ from __future__ import annotations -import asyncio import logging from collections.abc import Awaitable, Callable, Iterable from typing import Any -from flexus_client_kit import ckit_shutdown +import gql +import gql.transport.exceptions + +from flexus_client_kit import ckit_client from flexus_client_kit.ckit_connector import ( ActionDescriptor, ActionResult, @@ -14,26 +16,55 @@ TriggerDescriptor, ) from flexus_client_kit.ckit_connector_discord import DISCORD_ACTIONS, DISCORD_TRIGGERS -from flexus_client_kit.gateway.ckit_gateway_redis import DiscordGatewayRedisSidecar +from flexus_client_kit.gateway.ckit_gateway_wire import gateway_instance_key_from_token logger = logging.getLogger(__name__) +# Module-level GQL document so it is parsed once, not on every call. +_DISCORD_GW_ACTION = gql.gql(""" + mutation BotDiscordGatewayAction( + $personaId: String! + $instanceKey: String! + $actionType: String! + $params: JSON! + ) { + botDiscordGatewayAction( + personaId: $personaId + instanceKey: $instanceKey + actionType: $actionType + params: $params + ) + } +""") + class DiscordGatewayConnector(ChatConnector): + """Backend-backed gateway connector. + + Inbound events arrive via the standard bot_threads_calls_tasks / on_emessage("DISCORD") + path — this connector no longer opens Redis event consumers. + + Outbound actions are forwarded to the backend bot_discord_gateway_action mutation which + internally routes commands through the trusted Redis cmd channel to service_discord_gateway. + """ + def __init__( self, token: str, persona_id: str, + fclient: ckit_client.FlexusClient, *, initial_guild_ids: set[int] | None = None, - sidecar: DiscordGatewayRedisSidecar | None = None, ) -> None: self._persona_id = persona_id + # Instance key derived locally from the token — pure function, no Redis needed. + self._instance_key = gateway_instance_key_from_token(token) + self._fclient = fclient self._allowed_guild_ids: set[int] = set(initial_guild_ids or []) - self._sidecar = sidecar or DiscordGatewayRedisSidecar(token) + # Kept for ChatConnector ABC compliance; not used in gateway mode + # (events arrive via on_emessage instead of this callback). self._event_callback: Callable[[NormalizedEvent], Awaitable[None]] | None = None self._connected = False - self._refresh_task: asyncio.Task[None] | None = None @property def platform(self) -> str: @@ -49,7 +80,7 @@ def allowed_guild_ids(self) -> frozenset[int]: @property def gateway_instance_key(self) -> str: - return self._sidecar.gateway_instance_key + return self._instance_key def supported_triggers(self) -> list[TriggerDescriptor]: return DISCORD_TRIGGERS @@ -58,26 +89,34 @@ def supported_actions(self) -> list[ActionDescriptor]: return DISCORD_ACTIONS def on_event(self, callback: Callable[[NormalizedEvent], Awaitable[None]]) -> None: + # Stored for ABC compliance; in gateway mode the bot uses on_emessage("DISCORD") instead. 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: - new_ids = {int(x) for x in ids} - self._allowed_guild_ids = new_ids - if self._connected: - await self._sidecar.update_guild_channels(new_ids) - if new_ids: - await self._sidecar.register_persona_guilds(self._persona_id, new_ids) + """Update the in-memory guild allowlist. No Redis registration needed.""" + 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 connect(self) -> None: + """Mark connector as active. No Redis subscriptions are opened.""" + self._connected = True + + async def disconnect(self) -> None: + """Mark connector as inactive.""" + self._connected = False + 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) + r = await self.execute_action( + "get_user_info", + {"user_id": str(user_id), "server_id": str(server_id or "")}, + ) if not r.ok or not r.data: return None return dict(r.data) @@ -85,61 +124,39 @@ async def get_user_info(self, user_id: str, server_id: str = "") -> dict | None: 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) + r = await self.execute_action( + "get_channel", + {"channel_id": str(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._allowed_guild_ids) - if self._allowed_guild_ids: - await self._sidecar.register_persona_guilds(self._persona_id, self._allowed_guild_ids) - self._connected = True - self._refresh_task = asyncio.create_task(self._guild_refresh_loop()) - - async def _guild_refresh_loop(self) -> None: - """Re-register persona->guild TTL in Redis every 120s for long-lived processes.""" - while self._connected and not ckit_shutdown.shutdown_event.is_set(): - await ckit_shutdown.wait(120.0) - if not self._connected: - break - if self._allowed_guild_ids: - await self._sidecar.register_persona_guilds(self._persona_id, self._allowed_guild_ids) - - async def disconnect(self) -> None: - self._connected = False - rt = self._refresh_task - self._refresh_task = None - if rt is not None: - rt.cancel() - try: - await rt - except asyncio.CancelledError: - pass - await self._sidecar.unregister_persona_guilds(self._persona_id) - 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, - ) + try: + http_client = await self._fclient.use_http_on_behalf( + self._persona_id, + "discord_gw_action", + execute_timeout=95.0, + ) + async with http_client as http: + result = await http.execute( + _DISCORD_GW_ACTION, + variable_values={ + "personaId": self._persona_id, + "instanceKey": self._instance_key, + "actionType": action_type, + "params": params, + }, + ) + data = result.get("botDiscordGatewayAction") or {} + return ActionResult( + ok=bool(data.get("ok")), + error=data.get("error"), + data=data.get("data"), + ) + except gql.transport.exceptions.TransportError as e: + logger.warning("discord gateway action transport error: %s %s", type(e).__name__, e) + return ActionResult(ok=False, error="transport_error") diff --git a/flexus_simple_bots/discord_bot/discord_bot.py b/flexus_simple_bots/discord_bot/discord_bot.py index 768e666f..484d083e 100644 --- a/flexus_simple_bots/discord_bot/discord_bot.py +++ b/flexus_simple_bots/discord_bot/discord_bot.py @@ -19,10 +19,10 @@ from flexus_client_kit import ckit_mongo from flexus_client_kit import ckit_person_domain 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.gateway.ckit_gateway_wire import normalized_event_from_dict from flexus_client_kit.integrations import fi_discord2 as dc from flexus_simple_bots.discord_bot import discord_bot_install from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION @@ -92,9 +92,15 @@ async def _gateway_discord_channel_acl_preflight( def _has_gatekeeper_actions(rules: List[dict]) -> bool: """Return True when any published rule contains at least one call_gatekeeper_tool action.""" for rule in rules: + # simple rule: top-level actions list actions = rule.get("actions") or [] if any(isinstance(a, dict) and a.get("type") == "call_gatekeeper_tool" for a in actions): return True + # branched rule: each branch carries its own actions list + for branch in (rule.get("branches") or []): + branch_actions = branch.get("actions") or [] + if any(isinstance(a, dict) and a.get("type") == "call_gatekeeper_tool" for a in branch_actions): + return True return False TOOLS: List[Any] = [] @@ -403,9 +409,6 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot await ckit_crm_members.migrate_legacy_collections(mongo_db) await ckit_crm_members.ensure_member_indexes(mongo_db) - disabled_cache = DisabledRulesCache(fclient, rcx.persona.persona_id) - 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) @@ -429,7 +432,12 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot initial_guild_ids = _guild_ids_from_persona(rcx.persona, setup) dc.log_ctx(rcx.persona.persona_id, None, "allowed guild ids from persona_external_addresses: %s", sorted(initial_guild_ids)) if use_gateway: - connector: ChatConnector = DiscordGatewayConnector(token, rcx.persona.persona_id, initial_guild_ids=initial_guild_ids) + connector: ChatConnector = DiscordGatewayConnector( + token, + rcx.persona.persona_id, + fclient, + initial_guild_ids=initial_guild_ids, + ) else: connector = DiscordConnector(token, rcx.persona.persona_id, initial_guild_ids=initial_guild_ids) await connector.set_allowed_guild_ids(initial_guild_ids) @@ -559,11 +567,10 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: "fclient": fclient, "ws_id": workspace_id, } - 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, + rules, member_doc, augmented_setup, ) @@ -579,7 +586,6 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: 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 @@ -653,11 +659,10 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: "fclient": fclient, "ws_id": workspace_id, } - 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, + rules, member_doc, augmented_setup, ) @@ -673,7 +678,6 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: initial_field_changes=field_changes, guild_id=gid, user_id=uid, - disabled_rules_cache=disabled_cache, ) return @@ -698,11 +702,10 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: "fclient": fclient, "ws_id": workspace_id, } - 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, + rules, member_doc, augmented_setup, ) @@ -718,7 +721,6 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: initial_field_changes=fc_leave, guild_id=gid, user_id=uid, - disabled_rules_cache=disabled_cache, ) if old_status is None: return @@ -729,7 +731,7 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: actions = ckit_automation_engine.process_event( "status_transition", {"old_status": old_status, "new_status": "churned"}, - active_rules, + rules, member_doc_st, augmented_setup, ) @@ -745,7 +747,6 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: initial_field_changes=field_changes, guild_id=gid, user_id=uid, - disabled_rules_cache=disabled_cache, ) return except PyMongoError as e: @@ -771,6 +772,34 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: dc.log_ctx(persona_id, gid_log, "normalized event data error: %s %s", type(e).__name__, e) connector.on_event(handle_normalized_event) + + if use_gateway: + # In gateway mode inbound events arrive via flexus_persona_external_message + # (routed by service_discord_gateway through process_external_webhook) rather + # than through a Redis event consumer. Register an on_emessage handler so that + # bot_threads_calls_tasks delivers DISCORD messages here. + @rcx.on_emessage("DISCORD") + async def _on_discord_emessage(emsg) -> None: + try: + ev = normalized_event_from_dict(emsg.emsg_payload) + except (KeyError, TypeError, ValueError) as e: + logger.warning( + "%s discord emessage parse error: %s %s", + rcx.persona.persona_id, + type(e).__name__, + e, + ) + return + # In-memory guild ACL: drop events for guilds this persona does not own. + try: + gid = int(ev.server_id) + except (TypeError, ValueError): + return + allowed = connector.allowed_guild_ids + if allowed and gid not in allowed: + return + await handle_normalized_event(ev) + await connector.connect() if use_gateway: @@ -849,7 +878,6 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: mongo_db, connector.raw_client, rcx.persona.persona_id, - disabled_rules_cache=disabled_cache, connector=connector, fclient=fclient, ws_id=workspace_id, @@ -858,7 +886,6 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: 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 connector.disconnect() await mongo.close() logger.info("%s exit", rcx.persona.persona_id) diff --git a/flexus_simple_bots/discord_bot/discord_bot_install.py b/flexus_simple_bots/discord_bot/discord_bot_install.py index f1bb11cd..88de1c85 100644 --- a/flexus_simple_bots/discord_bot/discord_bot_install.py +++ b/flexus_simple_bots/discord_bot/discord_bot_install.py @@ -7,7 +7,6 @@ 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.ckit_connector_discord import DISCORD_ACTIONS, DISCORD_TRIGGERS from flexus_simple_bots import prompts_common from flexus_simple_bots.discord_bot import discord_bot_prompts from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION @@ -70,10 +69,7 @@ async def install( marketable_picture_small_b64=pic_small_b64, marketable_schedule=[prompts_common.SCHED_PICK_ONE_5M], marketable_auth_supported=["discord"], - marketable_rules_toolkit=ckit_automation_v1_schema_build.build_automation_v1_schema_document( - triggers=DISCORD_TRIGGERS, - actions=DISCORD_ACTIONS, - ), + marketable_rules_toolkit=ckit_automation_v1_schema_build.build_automation_v1_schema_document(), ) From 08e5c8a06cb119c414e28194191b701b05e122a7 Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 13 Apr 2026 10:43:46 +0200 Subject: [PATCH 05/10] chore ignore Cursor metadata Ignore local .cursor files so workspace-specific agent state does not keep polluting the discovery branch. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3ce54dae..e468abbb 100644 --- a/.gitignore +++ b/.gitignore @@ -220,4 +220,5 @@ certs **/tests/ **/test_docs/ -scripts/ \ No newline at end of file +scripts/ +.cursor \ No newline at end of file From 7b2f6e4c2dfd76387f90998909ff7b36c6a081fd Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 13 Apr 2026 16:01:30 +0200 Subject: [PATCH 06/10] refactor discord bot to gateway-only runtime Drop legacy Discord transport and CRM member-state paths so bots use the trusted gateway flow end to end and keep the Discord integration reviewable. --- flexus_client_kit/ckit_automation.py | 2 +- flexus_client_kit/ckit_automation_actions.py | 769 +++-------- flexus_client_kit/ckit_automation_engine.py | 59 +- .../ckit_automation_schema_defs.py | 169 +-- .../ckit_automation_v1_schema_build.py | 24 +- flexus_client_kit/ckit_bot_query.py | 2 +- flexus_client_kit/ckit_connector_discord.py | 776 +---------- .../ckit_connector_discord_gateway.py | 20 +- flexus_client_kit/ckit_crm_members.py | 445 ------- flexus_client_kit/ckit_discord_actions.py | 190 --- .../ckit_discord_automation_schema_defs.py | 15 +- .../ckit_discord_gateway_handlers.py | 130 -- flexus_client_kit/gateway/__init__.py | 16 - .../gateway/ckit_gateway_redis.py | 326 ----- .../gateway/ckit_gateway_wire.py | 52 +- flexus_client_kit/integrations/fi_discord2.py | 408 ++++++ .../admonster/admonster_install.py | 2 +- .../clerkwing/clerkwing_install.py | 2 +- flexus_simple_bots/discord_bot/discord_bot.py | 1140 ++++++----------- .../discord_bot/discord_bot_install.py | 36 +- .../lawyerrat/lawyerrat_install.py | 2 +- 21 files changed, 1092 insertions(+), 3493 deletions(-) delete mode 100644 flexus_client_kit/ckit_crm_members.py delete mode 100644 flexus_client_kit/ckit_discord_actions.py delete mode 100644 flexus_client_kit/ckit_discord_gateway_handlers.py delete mode 100644 flexus_client_kit/gateway/ckit_gateway_redis.py diff --git a/flexus_client_kit/ckit_automation.py b/flexus_client_kit/ckit_automation.py index 715acf93..db38a0ca 100644 --- a/flexus_client_kit/ckit_automation.py +++ b/flexus_client_kit/ckit_automation.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) -# Eagerly built from the Discord connector catalog at import time. +# Eagerly built from the Discord automation catalog (integration triggers/actions) at import time. # Tests and offline fixtures can override via set_automation_schema_dict() / set_automation_schema(). _AUTOMATION_SCHEMA: dict = build_automation_v1_schema_document() diff --git a/flexus_client_kit/ckit_automation_actions.py b/flexus_client_kit/ckit_automation_actions.py index 10695369..a6292c72 100644 --- a/flexus_client_kit/ckit_automation_actions.py +++ b/flexus_client_kit/ckit_automation_actions.py @@ -1,43 +1,26 @@ """ -Execute resolved automation actions and build job handlers for scheduled rules. +Execute resolved automation actions for Discord community bots. 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. - -Generic CRM actions (set_crm_field, set_status, enqueue_check, cancel_pending_jobs) work with -any connector. Discord-specific actions (send_dm, post_to_channel, add_role, remove_role, kick) -delegate to the Discord connector via ctx['connector'].execute_action or the legacy direct-client -path (ctx['discord_client'] / ctx['guild']) for backward compatibility. +_resolved_body / _resolved_channel_id. This module performs side effects only and returns per-action +results for logging. Execution context uses event_member: guild_id, user_id, and optional +discord_username from the inbound gateway event (no persisted CRM rows). """ 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, ckit_person_domain -from flexus_client_kit.integrations import fi_discord2 as dc +from flexus_client_kit import ckit_person_domain 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]]], -] +# Dispatcher: action type string -> async handler returning a normalized result dict. +ActionHandler = Callable[[dict, dict], Awaitable[dict]] def _result_dict( @@ -50,7 +33,7 @@ def _result_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. + ok/error are the contract; note carries dedupe hints where applicable. """ out: dict[str, Any] = {"ok": ok, "error": error} if note is not None: @@ -60,11 +43,11 @@ def _result_dict( 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.""" +def _guild_user_from_event_member(event_member: dict) -> Tuple[Optional[int], Optional[int]]: + """Read guild/user ids from the in-memory event_member dict.""" try: - gid = member_doc.get("guild_id") - uid = member_doc.get("user_id") + gid = event_member.get("guild_id") + uid = event_member.get("user_id") if gid is None or uid is None: return (None, None) return (int(gid), int(uid)) @@ -72,62 +55,59 @@ def _guild_user_from_member_doc(member_doc: dict) -> Tuple[Optional[int], Option return (None, None) -async def _do_send_dm(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: +async def _do_send_dm(action: dict, ctx: dict) -> dict: + """Deliver a DM via the connector gateway action.""" 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) + return _result_dict(ok=False, error="empty_body") 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 "") + em = ctx.get("event_member") + if not isinstance(em, dict): + return _result_dict(ok=False, error="bad_event_member") + uid_s = str(em.get("user_id", "") or "") if not uid_s: - return (_result_dict(ok=False, error="missing_user_id"), None) + return _result_dict(ok=False, error="missing_user_id") dm_params: dict = {"user_id": uid_s, "text": body} sid = str(ctx.get("server_id") or "") if sid: - # Propagate guild context so the gateway ACL can verify this persona's access. dm_params["server_id"] = sid result = await connector.execute_action("send_dm", dm_params) - 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) + return _result_dict(ok=result.ok, error=result.error) + logger.warning( + "send_dm: missing ChatConnector in ctx (gateway-only executor); persona_id=%s", + persona_id, + ) + return _result_dict(ok=False, error="no_connector") 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) + return _result_dict(ok=False, error=type(e).__name__) 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) + return _result_dict(ok=False, error=type(e).__name__) except (KeyError, TypeError) as e: logger.error("send_dm context error", exc_info=e) - return (_result_dict(ok=False, error=type(e).__name__), None) + return _result_dict(ok=False, error=type(e).__name__) -async def _do_post_to_channel(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: +async def _do_post_to_channel(action: dict, ctx: dict) -> dict: + """Post to a guild text channel via connector.execute_action.""" 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) + return _result_dict(ok=False, error="no_channel_id") try: channel_id = int(cid) except (TypeError, ValueError): - return (_result_dict(ok=False, error="bad_channel_id"), None) + return _result_dict(ok=False, error="bad_channel_id") 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) + return _result_dict(ok=False, error="empty_body") connector = ctx.get("connector") if connector is not None: sid = str(ctx.get("server_id") or "") @@ -135,398 +115,159 @@ async def _do_post_to_channel(action: dict, ctx: dict) -> Tuple[dict, Optional[d 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) + return _result_dict(ok=result.ok, error=result.error) + logger.warning( + "post_to_channel: missing ChatConnector in ctx (gateway-only executor); persona_id=%s", + persona_id, + ) + return _result_dict(ok=False, error="no_connector") 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) + return _result_dict(ok=False, error=type(e).__name__) 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) + return _result_dict(ok=False, error=type(e).__name__) 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) + return _result_dict(ok=False, error=type(e).__name__) -async def _do_add_role(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: +async def _do_add_role(action: dict, ctx: dict) -> dict: + """Resolve role id and call gateway add_role.""" 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 "") + return _result_dict(ok=False, error="no_role_id") + em = ctx.get("event_member") + if not isinstance(em, dict): + return _result_dict(ok=False, error="bad_event_member") + uid_s = str(em.get("user_id", "") or "") if not uid_s: - return (_result_dict(ok=False, error="missing_user_id"), None) + return _result_dict(ok=False, error="missing_user_id") connector = ctx.get("connector") if connector is None: - return (_result_dict(ok=False, error="no_connector"), None) + return _result_dict(ok=False, error="no_connector") sid = str(ctx.get("server_id") or "") if not sid: - return (_result_dict(ok=False, error="missing_server_id"), None) + return _result_dict(ok=False, error="missing_server_id") 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) + return _result_dict(ok=result.ok, error=result.error) 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) + return _result_dict(ok=False, error=type(e).__name__) 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) + return _result_dict(ok=False, error=type(e).__name__) 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) + return _result_dict(ok=False, error=type(e).__name__) -async def _do_remove_role(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: +async def _do_remove_role(action: dict, ctx: dict) -> dict: + """Resolve role id and call gateway remove_role.""" 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 "") + return _result_dict(ok=False, error="no_role_id") + em = ctx.get("event_member") + if not isinstance(em, dict): + return _result_dict(ok=False, error="bad_event_member") + uid_s = str(em.get("user_id", "") or "") if not uid_s: - return (_result_dict(ok=False, error="missing_user_id"), None) + return _result_dict(ok=False, error="missing_user_id") connector = ctx.get("connector") if connector is None: - return (_result_dict(ok=False, error="no_connector"), None) + return _result_dict(ok=False, error="no_connector") sid = str(ctx.get("server_id") or "") if not sid: - return (_result_dict(ok=False, error="missing_server_id"), None) + return _result_dict(ok=False, error="missing_server_id") 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) + return _result_dict(ok=result.ok, error=result.error) 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) + return _result_dict(ok=False, error=type(e).__name__) 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) + return _result_dict(ok=False, error=type(e).__name__) 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) + return _result_dict(ok=False, error=type(e).__name__) -async def _do_kick(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: +async def _do_kick(action: dict, ctx: dict) -> dict: + """Kick the member in context via gateway.""" 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 "") + em = ctx.get("event_member") + if not isinstance(em, dict): + return _result_dict(ok=False, error="bad_event_member") + uid_s = str(em.get("user_id", "") or "") if not uid_s: - return (_result_dict(ok=False, error="missing_user_id"), None) + return _result_dict(ok=False, error="missing_user_id") connector = ctx.get("connector") if connector is None: - return (_result_dict(ok=False, error="no_connector"), None) + return _result_dict(ok=False, error="no_connector") sid = str(ctx.get("server_id") or "") if not sid: - return (_result_dict(ok=False, error="missing_server_id"), None) + return _result_dict(ok=False, error="missing_server_id") 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) + return _result_dict(ok=result.ok, error=result.error) except DiscordException as e: logger.warning("kick DiscordException: %s %s", type(e).__name__, e) - return (_result_dict(ok=False, error=type(e).__name__), None) + return _result_dict(ok=False, error=type(e).__name__) 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) + return _result_dict(ok=False, error=type(e).__name__) except (KeyError, TypeError, ValueError) as e: logger.error("kick context error", exc_info=e) - return (_result_dict(ok=False, error=type(e).__name__), None) + return _result_dict(ok=False, error=type(e).__name__) -async def _do_call_gatekeeper_tool(action: dict, ctx: dict) -> Tuple[dict, Optional[dict]]: +async def _do_call_gatekeeper_tool(action: dict, ctx: dict) -> dict: """ - Apply a gatekeeper decision for the current Discord member. + Apply a gatekeeper decision using workspace person-domain APIs only (GraphQL). - Decision mapping: - accept → application DECIDED/APPROVED, CRM lifecycle "accepted" - reject → application DECIDED/REJECTED, CRM lifecycle "rejected" - request_info → application REVIEWING, CRM lifecycle "pending_review" - - Always applies the CRM lifecycle change (Mongo). Person domain sync - (GraphQL) is best-effort: failures are logged but do not abort the action. - Returns a field_change for lifecycle_status so existing status_transition - cascades keep working. + Maps accept / reject / request_info to application status updates. Does not write Mongo. """ try: tool_name = action.get("tool_name") if tool_name not in ("accept", "reject", "request_info"): - return (_result_dict(ok=False, error="bad_tool_name"), None) + return _result_dict(ok=False, error="bad_tool_name") - member_doc = ctx.get("member_doc") - if not isinstance(member_doc, dict): - return (_result_dict(ok=False, error="bad_member_doc"), None) + em = ctx.get("event_member") + if not isinstance(em, dict): + return _result_dict(ok=False, error="bad_event_member") - guild_id, user_id = _guild_user_from_member_doc(member_doc) + guild_id, user_id = _guild_user_from_event_member(em) if guild_id is None or user_id is None: - return (_result_dict(ok=False, error="missing_guild_or_user"), None) + return _result_dict(ok=False, error="missing_guild_or_user") - # Decision → status mapping if tool_name == "accept": app_status = "DECIDED" app_decision = "APPROVED" - crm_status = "accepted" elif tool_name == "reject": app_status = "DECIDED" app_decision = "REJECTED" - crm_status = "rejected" else: app_status = "REVIEWING" app_decision = "" - crm_status = "pending_review" reason_template = action.get("reason_template") details: Optional[dict] = {"reason_template": reason_template} if isinstance(reason_template, str) and reason_template else None - # Best-effort person domain sync (requires fclient + ws_id in ctx) fclient = ctx.get("fclient") ws_id = ctx.get("ws_id") or "" - if fclient is not None and ws_id: - username = str(member_doc.get("discord_username") or user_id) - discord_user_id = str(user_id) - person_id = await ckit_person_domain.ensure_person_for_discord_user( - fclient, - ws_id, - discord_user_id, - username, - ) - if person_id: - existing_app = await ckit_person_domain.application_find_latest( - fclient, - ws_id, - person_id, - ) - if existing_app: - app_id: Optional[str] = existing_app["application_id"] - else: - app_id = await ckit_person_domain.application_create_pending( - fclient, - ws_id, - person_id, - source="discord_bot", - platform="discord", - payload={"guild_id": str(guild_id), "discord_user_id": discord_user_id}, - ) - if app_id: - await ckit_person_domain.application_apply_decision( - fclient, - app_id, - app_status, - app_decision, - details, - ) - else: + if fclient is None or not ws_id: logger.warning( "call_gatekeeper_tool: fclient or ws_id missing from ctx, skipping person domain sync " "persona_id=%s tool=%s guild=%s user=%s", @@ -535,55 +276,61 @@ async def _do_call_gatekeeper_tool(action: dict, ctx: dict) -> Tuple[dict, Optio guild_id, user_id, ) + return _result_dict(ok=False, error="missing_workspace_context") + + username = str(em.get("discord_username") or user_id) + discord_user_id = str(user_id) + person_id = await ckit_person_domain.ensure_person_for_discord_user( + fclient, + ws_id, + discord_user_id, + username, + ) + if not person_id: + return _result_dict(ok=False, error="person_unresolved") - # Always apply CRM lifecycle change — this is the primary side-effect - old_status = member_doc.get("lifecycle_status") - merged, _prev = await ckit_crm_members.set_member_status( - ctx["mongo_db"], - guild_id, - user_id, - crm_status, + existing_app = await ckit_person_domain.application_find_latest( + fclient, + ws_id, + person_id, ) - if merged is None: - return (_result_dict(ok=False, error="member_not_found"), None) - - # Stamp accepted_at when the decision is accept, matching crm_member.md contract. - # Best-effort: if the extra write fails the lifecycle status change still stands. - if crm_status == "accepted": - accepted_doc = await ckit_crm_members.update_member_field( - ctx["mongo_db"], - guild_id, - user_id, - "accepted_at", - time.time(), + if existing_app: + app_id: Optional[str] = existing_app["application_id"] + else: + app_id = await ckit_person_domain.application_create_pending( + fclient, + ws_id, + person_id, + source="discord_bot", + platform="discord", + payload={"guild_id": str(guild_id), "discord_user_id": discord_user_id}, ) - if accepted_doc is not None: - merged = accepted_doc - - ctx["member_doc"] = merged - fc = { - "field": "lifecycle_status", - "old_value": old_status, - "new_value": crm_status, - "is_status": True, - } - return (_result_dict(ok=True, error=None), fc) - except PyMongoError as e: - logger.warning("call_gatekeeper_tool PyMongoError: %s %s", type(e).__name__, e) - return (_result_dict(ok=False, error=type(e).__name__), None) + if not app_id: + return _result_dict(ok=False, error="no_application_id") + + await ckit_person_domain.application_apply_decision( + fclient, + app_id, + app_status, + app_decision, + details, + ) + return _result_dict(ok=True, error=None) + except DiscordException as e: + logger.warning("call_gatekeeper_tool DiscordException: %s %s", type(e).__name__, e) + return _result_dict(ok=False, error=type(e).__name__) + except aiohttp.ClientError as e: + logger.warning("call_gatekeeper_tool ClientError: %s %s", type(e).__name__, e) + return _result_dict(ok=False, error=type(e).__name__) except (TypeError, AttributeError, KeyError) as e: logger.error("call_gatekeeper_tool unexpected error", exc_info=e) - return (_result_dict(ok=False, error=type(e).__name__), None) + return _result_dict(ok=False, error=type(e).__name__) -# Maps automation action.type to handler coroutine; extend when schema gains new types. +# Maps automation action.type to handler coroutine. _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, @@ -591,18 +338,15 @@ async def _do_call_gatekeeper_tool(action: dict, ctx: dict) -> Tuple[dict, Optio } -async def execute_actions(actions: List[dict], ctx: dict) -> Tuple[List[dict], List[dict]]: +async def execute_actions(actions: List[dict], ctx: 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. + Run resolved actions in order; collect logging rows. One failing action does not stop the rest. """ try: results: List[dict] = [] - field_changes: List[dict] = [] if not isinstance(actions, list): logger.error("execute_actions: actions must be a list") - return ([], []) + return [] for action in actions: if not isinstance(action, dict): logger.warning("execute_actions: skip non-dict action") @@ -632,7 +376,7 @@ async def execute_actions(actions: List[dict], ctx: dict) -> Tuple[List[dict], L ) continue try: - partial, fc = await handler(action, ctx) + partial = await handler(action, ctx) except DiscordException as e: logger.warning("execute_actions handler DiscordException: %s", e) results.append( @@ -655,17 +399,6 @@ async def execute_actions(actions: List[dict], ctx: dict) -> Tuple[List[dict], L }, ) 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( @@ -688,219 +421,7 @@ async def execute_actions(actions: List[dict], ctx: dict) -> Tuple[List[dict], L 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) + return results except (TypeError, KeyError) as e: logger.error("execute_actions fatal input error", exc_info=e) - return ([], []) - - -async def _run_cascade( - *, - db: Any, - client: Any, - 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, -) -> 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: - 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, - 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: Any, - persona_id: str, - connector: Any = None, - fclient: Any = None, - ws_id: str = "", -) -> 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 - 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: - actions = engine_process_fn( - "scheduled_check", - event_data, - 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, - "fclient": fclient, - "ws_id": ws_id, - } - 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, - "fclient": fclient, - "ws_id": ws_id, - } - _, 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, - ) - 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 {} - + return [] diff --git a/flexus_client_kit/ckit_automation_engine.py b/flexus_client_kit/ckit_automation_engine.py index 03e3f490..020ed1a8 100644 --- a/flexus_client_kit/ckit_automation_engine.py +++ b/flexus_client_kit/ckit_automation_engine.py @@ -1,7 +1,8 @@ """ 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). + +Community bots pass an in-memory member/event snapshot (fields from the current Discord event) +for condition checks and template resolution before the executor applies side effects. """ from __future__ import annotations @@ -126,29 +127,6 @@ def match_trigger(event_type: str, event_data: dict, rule: dict, setup: dict) -> 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) @@ -157,7 +135,7 @@ def match_trigger(event_type: str, event_data: dict, rule: dict, setup: dict) -> def evaluate_conditions(conditions: list[dict], member: dict) -> bool: """ - AND all conditions; empty list is True. Uses member.get for CRM fields. + AND all conditions; empty list is True. Reads fields from the member/event snapshot dict. """ try: if not conditions: @@ -304,18 +282,10 @@ def _resolve_kick_reason(action: dict, member: dict, setup: dict) -> None: 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. + _resolved_channel_id, _resolved_role_id, _resolved_kick_reason. """ try: if not isinstance(actions, list): @@ -333,7 +303,6 @@ def resolve_actions(actions: list[dict], member: dict, setup: dict) -> list[dict _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: @@ -416,21 +385,3 @@ def process_event( 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_schema_defs.py b/flexus_client_kit/ckit_automation_schema_defs.py index 48e74b88..5a92379c 100644 --- a/flexus_client_kit/ckit_automation_schema_defs.py +++ b/flexus_client_kit/ckit_automation_schema_defs.py @@ -1,85 +1,12 @@ """ -Generic automation trigger/action JSON Schema fragments (automation_schema_version 1). +Product-level and shared JSON Schema fragments for automation_schema_version 1. -These are platform-agnostic schemas shared across all integrations. -Discord-specific schemas (member_joined, send_dm, add_role, etc.) live in -ckit_discord_automation_schema_defs. Assembled into the full document by -ckit_automation_v1_schema_build. +Discord-specific trigger/action shapes live in ckit_discord_automation_schema_defs and are +merged into the full document by ckit_automation_v1_schema_build. """ from __future__ import annotations -# --- Generic triggers --- - -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 schedules a job 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.", -} - # Product-level trigger (not tied to any integration platform). SCHEMA_TRIGGER_MANUAL_CAMPAIGN_PRODUCT = { "type": "object", @@ -98,92 +25,6 @@ ), } -# --- Generic actions --- - -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": ( - "Schedule a future job for 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 automation job 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).", -} - # Product-level action (not tied to any integration platform). SCHEMA_ACTION_CALL_GATEKEEPER_PRODUCT = { "type": "object", @@ -202,7 +43,7 @@ }, }, "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." + "Invoke a gatekeeper decision. Typically used from expert-driven flows; " + "workspace person-domain application state is updated, not local bot CRM." ), } diff --git a/flexus_client_kit/ckit_automation_v1_schema_build.py b/flexus_client_kit/ckit_automation_v1_schema_build.py index 7b52b48b..a5a501b0 100644 --- a/flexus_client_kit/ckit_automation_v1_schema_build.py +++ b/flexus_client_kit/ckit_automation_v1_schema_build.py @@ -1,5 +1,5 @@ """ -Build automation_schema_version 1 JSON Schema from a connector's capability catalog +Build automation_schema_version 1 JSON Schema from a neutral integration catalog (triggers + actions) plus product-only trigger/action defs. Single compile-time assembly surface: backend validation and assist contracts derive from the same document. @@ -12,11 +12,11 @@ import copy from typing import Any +from flexus_client_kit.ckit_connector_discord import DISCORD_ACTIONS, DISCORD_TRIGGERS from flexus_client_kit.ckit_automation_schema_defs import ( SCHEMA_ACTION_CALL_GATEKEEPER_PRODUCT, SCHEMA_TRIGGER_MANUAL_CAMPAIGN_PRODUCT, ) -from flexus_client_kit.ckit_connector_discord import DISCORD_ACTIONS, DISCORD_TRIGGERS def _def_name_trigger(type_id: str) -> str: @@ -39,8 +39,8 @@ def automation_persisted_trigger_property_keys( ) -> dict[str, frozenset[str]]: """Return {type -> frozenset of persisted property names} for each trigger. - Defaults to the Discord integration triggers. Pass ``triggers`` explicitly - to use a different connector's catalog. + Defaults to the Discord integration catalog. Pass ``triggers`` explicitly + to use a different integration's catalog. """ if triggers is None: triggers = DISCORD_TRIGGERS @@ -59,8 +59,8 @@ def automation_persisted_action_property_keys( ) -> dict[str, frozenset[str]]: """Return {type -> frozenset of persisted property names} for each action. - Defaults to the Discord integration actions. Pass ``actions`` explicitly - to use a different connector's catalog. + Defaults to the Discord integration catalog. Pass ``actions`` explicitly + to use a different integration's catalog. """ if actions is None: actions = DISCORD_ACTIONS @@ -173,7 +173,7 @@ def schema_action_types_ordered(actions: list | None = None) -> tuple[str, ...]: "field": { "type": "string", "minLength": 1, - "description": "CRM member field path to evaluate.", + "description": "Member field path to evaluate in automation context (dot path).", }, "op": { "type": "string", @@ -190,7 +190,7 @@ def schema_action_types_ordered(actions: list | None = None) -> tuple[str, ...]: ), }, }, - "description": "Single boolean predicate on a CRM field. All conditions in a rule are AND-ed.", + "description": "Single boolean predicate on a member field. All conditions in a rule are AND-ed.", }, } @@ -202,8 +202,8 @@ def build_automation_v1_schema_document( """ Assemble the full automation v1 JSON Schema document. - ``triggers`` and ``actions`` are lists of TriggerDescriptor / ActionDescriptor from a - connector's capability catalog. Defaults to the Discord integration. Pass different + ``triggers`` and ``actions`` are lists of TriggerDescriptor / ActionDescriptor from an + integration automation catalog. Defaults to the Discord integration. Pass different lists to produce a schema for another integration. """ if triggers is None: @@ -258,7 +258,7 @@ def build_automation_v1_schema_document( "$id": "flexus-automation-v1", "title": "Flexus automation rules v1", "description": ( - "Machine-readable contract for community bot automation. Built from a connector's capability " + "Machine-readable contract for community bot automation. Built from the integration automation " "catalog (triggers + actions) plus product-level triggers/actions. " "Validate with jsonschema or check-jsonschema." ), @@ -288,7 +288,7 @@ def build_automation_v1_schema_document( import json from pathlib import Path - p = argparse.ArgumentParser(description="Write automation v1 JSON Schema built from the Discord catalog.") + p = argparse.ArgumentParser(description="Write automation v1 JSON Schema built from the Discord automation catalog.") p.add_argument( "--write", metavar="PATH", diff --git a/flexus_client_kit/ckit_bot_query.py b/flexus_client_kit/ckit_bot_query.py index b3a19b56..202cb568 100644 --- a/flexus_client_kit/ckit_bot_query.py +++ b/flexus_client_kit/ckit_bot_query.py @@ -28,7 +28,7 @@ class FPersonaOutput: marketable_radix: Optional[int] = None marketable_auth_needed: Optional[List[str]] = None marketable_auth_supported: Optional[List[str]] = None - persona_external_addresses: Optional[Any] = None + persona_external_addresses: Optional[List[str]] = None @dataclass diff --git a/flexus_client_kit/ckit_connector_discord.py b/flexus_client_kit/ckit_connector_discord.py index b825a76d..855f9d62 100644 --- a/flexus_client_kit/ckit_connector_discord.py +++ b/flexus_client_kit/ckit_connector_discord.py @@ -1,25 +1,15 @@ -from __future__ import annotations +""" +Discord automation v1 catalogs and assist semantics (JSON Schema fragments, trigger/action descriptors). -import asyncio -import logging -import time -from collections.abc import Iterable -from typing import Any, Awaitable, Callable +Runtime ingress uses the gateway-backed ``DiscordGatewayConnector`` in ``ckit_connector_discord_gateway``; +there is no in-process discord.py client in this module. ``DISCORD_TRIGGERS``, ``DISCORD_ACTIONS``, and +``discord_automation_semantics_bundle()`` remain the single source for schema assembly and reviewer payloads. +""" -import discord -from discord.errors import DiscordException +from __future__ import annotations + +from typing import Any -import flexus_client_kit.integrations.fi_discord2 as dc -from flexus_client_kit.ckit_discord_actions import discord_run_platform_action -from flexus_client_kit.ckit_automation_schema_defs import ( - SCHEMA_ACTION_CANCEL_PENDING_JOBS, - SCHEMA_ACTION_ENQUEUE_CHECK, - SCHEMA_ACTION_SET_CRM_FIELD, - SCHEMA_ACTION_SET_STATUS, - SCHEMA_TRIGGER_CRM_FIELD_CHANGED, - SCHEMA_TRIGGER_SCHEDULED_RELATIVE_TO_FIELD, - SCHEMA_TRIGGER_STATUS_TRANSITION, -) from flexus_client_kit.ckit_discord_automation_schema_defs import ( SCHEMA_ACTION_ADD_ROLE, SCHEMA_ACTION_KICK, @@ -32,19 +22,12 @@ ) 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() - +# --- Automation v1 Discord catalog (same source as supported_triggers / supported_actions / assist) --- DISCORD_TRIGGERS: list[TriggerDescriptor] = [ TriggerDescriptor( @@ -60,16 +43,16 @@ }, }, semantic_contract=SemanticContract( - operator_summary="Runs when someone joins a Discord server the bot is allowed to see.", + operator_summary="Runs when someone joins a Discord server the bot can access.", 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.", + "When someone joins, the normalized event carries the server id and member id; the payload includes " + "guild id, user id, and display name.", + "The worker builds in-memory event context (ids + username) for templates and actions; no persisted profile.", ), 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.", + "A rule matches when its trigger type is member_joined and the incoming event is a join for that server.", + "Conditions and actions apply to the current member in scope.", ), operator_must_not_set=("Trigger payload keys in persisted rules (automation_v1 has none beyond type).",), ), @@ -93,12 +76,12 @@ 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.", + "The channel reference from the bot setup is resolved to a numeric channel id for matching.", + "The event payload includes channel, server, author, message text, and message id from the live 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.", + "Matching requires the event channel id to equal the resolved channel id from your setup; " + "a missing or unresolvable channel reference yields no match.", ), operator_must_not_set=("Hard-coded channel ids inside trigger except via channel_id_field string form.",), ), @@ -120,97 +103,15 @@ 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.", + "When someone leaves, the normalized event carries the member id, server id, and display name from Discord.", ), runtime_guarantees=( - "match_trigger(member_removed) is true iff rule trigger.type is member_removed.", - "discord_bot runs member_removed rules after handle_member_remove, then status_transition to churned.", + "A rule matches when its trigger type is member_removed and the event is a leave or kick for that member.", ), 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_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, - ), ] @@ -228,22 +129,21 @@ "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).", + operator_summary="Send a private message to the member in context (saved automation) or to explicit ids (programmatic 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.", + "Programmatic 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.", + "The engine resolves the message body from the template or from a setup-backed field, then delivers it.", + "For saved rules, the recipient is the user id from the triggering event; the action does not supply a separate recipient id.", ), runtime_guarantees=( - "Empty _resolved_body after resolution fails with empty_body.", - "discord_run_platform_action send_dm uses params user_id text only.", + "If the body is empty after resolution, delivery fails with empty_body.", + "Direct-message delivery uses the resolved recipient id and body 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.", + "Persisted rule: user_id field on the action (not in schema); recipient is always the member in context.", ), ), automation_schema_def=SCHEMA_ACTION_SEND_DM, @@ -262,118 +162,24 @@ "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.", + operator_summary="Post a message into a chosen channel; guild scope comes from execution context when using the live integration.", 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.", + "Programmatic call: channel_id text required; optional server_id disambiguates allowed guild when posting.", ), 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.", + "The engine resolves the channel id from the field reference and resolves the message body like direct messages.", + "When posting, the current server scope is passed so delivery can reject unauthorized 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.", + "Posting loads the channel, requires a text channel, and the bot must be allowed in that server or delivery 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.", + "Persisted rule: server_id on the action; guild is implied by the event or job execution scope.", ), ), 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", @@ -391,11 +197,10 @@ 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.", + "The engine resolves the role id from the field reference; the executor passes the member id and server scope for delivery.", ), runtime_guarantees=( - "discord_run_platform_action add_role requires member and role in guild; fails member_or_role_not_found if not.", + "The member and role must exist in the server; otherwise delivery fails with member_or_role_not_found.", ), operator_must_not_set=("user_id, server_id, guild_id, role_id on persisted action; use role_id_field only.",), ), @@ -417,7 +222,7 @@ 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.",), + platform_fills_automatically=("Same role resolution and member/server scope 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.",), ), @@ -440,11 +245,10 @@ 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.", + "The platform uses the current member and server in scope and resolves the reason text before delivery.", ), runtime_guarantees=( - "discord_run_platform_action kick requires member in guild before kick; fails if member already left.", + "Kick requires the member to be in the guild before kick; fails if the member already left.", ), operator_must_not_set=("user_id, server_id, guild_id on persisted action.",), ), @@ -464,7 +268,7 @@ "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.", + "Invalid channel references yield no resolved id; trigger matching and posting fail closed.", ), operator_must_not_set=(), ), @@ -480,7 +284,7 @@ "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.", + "Body resolution runs for direct message and channel post actions only; other actions do not use it.", ), operator_must_not_set=(), ), @@ -489,485 +293,17 @@ 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 + """ + Flatten trigger/action semantic contracts plus cross-cutting helpers for assist payloads. + + Returns a JSON-serializable dict (semantic_schema_version, triggers, actions, cross_cutting). + """ + try: + 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), + } + except (TypeError, KeyError, AttributeError) as e: + raise RuntimeError("discord_automation_semantics_bundle: failed to assemble semantics dict") from e diff --git a/flexus_client_kit/ckit_connector_discord_gateway.py b/flexus_client_kit/ckit_connector_discord_gateway.py index f5094682..c2612413 100644 --- a/flexus_client_kit/ckit_connector_discord_gateway.py +++ b/flexus_client_kit/ckit_connector_discord_gateway.py @@ -23,15 +23,15 @@ # Module-level GQL document so it is parsed once, not on every call. _DISCORD_GW_ACTION = gql.gql(""" mutation BotDiscordGatewayAction( - $personaId: String! - $instanceKey: String! - $actionType: String! + $persona_id: String! + $instance_key: String! + $action_type: String! $params: JSON! ) { - botDiscordGatewayAction( - personaId: $personaId - instanceKey: $instanceKey - actionType: $actionType + botDiscordGatewayAction: bot_discord_gateway_action( + persona_id: $persona_id + instance_key: $instance_key + action_type: $action_type params: $params ) } @@ -145,9 +145,9 @@ async def execute_action(self, action_type: str, params: dict) -> ActionResult: result = await http.execute( _DISCORD_GW_ACTION, variable_values={ - "personaId": self._persona_id, - "instanceKey": self._instance_key, - "actionType": action_type, + "persona_id": self._persona_id, + "instance_key": self._instance_key, + "action_type": action_type, "params": params, }, ) diff --git a/flexus_client_kit/ckit_crm_members.py b/flexus_client_kit/ckit_crm_members.py deleted file mode 100644 index 38ef280a..00000000 --- a/flexus_client_kit/ckit_crm_members.py +++ /dev/null @@ -1,445 +0,0 @@ -""" -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 deleted file mode 100644 index 2a1bfdc8..00000000 --- a/flexus_client_kit/ckit_discord_actions.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable - -import aiohttp -import discord -from discord.errors import DiscordException - -import flexus_client_kit.integrations.fi_discord2 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 index 57b2553c..8868fecc 100644 --- a/flexus_client_kit/ckit_discord_automation_schema_defs.py +++ b/flexus_client_kit/ckit_discord_automation_schema_defs.py @@ -1,9 +1,8 @@ """ Discord-specific automation trigger/action JSON Schema fragments (automation_schema_version 1). -Only the schemas that reference Discord concepts (guild, channel, role, DM, member join/leave) -live here. Generic schemas (CRM fields, status transitions, scheduled checks, etc.) are in -ckit_automation_schema_defs. Both sets are assembled by ckit_automation_v1_schema_build. +Schemas for guild-scoped triggers and Discord actions live here; product-level definitions are in +ckit_automation_schema_defs. The full document is assembled by ckit_automation_v1_schema_build. """ from __future__ import annotations @@ -16,8 +15,8 @@ "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." + "Fires on Discord on_member_join (or equivalent member record creation for backfill). " + "No extra trigger fields; context is the joining user's ids and username from the event." ), } @@ -30,7 +29,7 @@ }, "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." + "guild and member context come from the leave/kick event." ), } @@ -52,7 +51,7 @@ }, "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." + "Only messages in the configured channel are delivered as events to the bot." ), } @@ -64,7 +63,7 @@ "type": {"const": "send_dm"}, "template": { "type": "string", - "description": "Inline message body. Supports {field_name} placeholders resolved from CRM member + setup fields.", + "description": "Inline message body. Supports {field_name} placeholders resolved from the event member snapshot and setup fields.", }, "template_field": { "type": "string", diff --git a/flexus_client_kit/ckit_discord_gateway_handlers.py b/flexus_client_kit/ckit_discord_gateway_handlers.py deleted file mode 100644 index 158b3b02..00000000 --- a/flexus_client_kit/ckit_discord_gateway_handlers.py +++ /dev/null @@ -1,130 +0,0 @@ -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/gateway/__init__.py b/flexus_client_kit/gateway/__init__.py index 7412a247..1dd32d5f 100644 --- a/flexus_client_kit/gateway/__init__.py +++ b/flexus_client_kit/gateway/__init__.py @@ -1,45 +1,29 @@ -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 deleted file mode 100644 index 424a2bb0..00000000 --- a/flexus_client_kit/gateway/ckit_gateway_redis.py +++ /dev/null @@ -1,326 +0,0 @@ -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_events_discord_guild, - channel_reply_discord, - gateway_instance_key_from_token, - gateway_result_envelope_from_dict, - normalized_event_from_dict, - parse_event_envelope, - registry_key_persona_guilds, -) - -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) - # Legacy broadcast channel kept for direct-socket / dev-fallback reference. - 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 - # Live pubsub handle shared between reader loop and update_guild_channels. - self._ps: Any | None = None - # Currently active per-guild subscription channels. - self._subscribed_guild_channels: set[str] = set() - - @property - def gateway_instance_key(self) -> str: - return self._key - - @property - def events_channel(self) -> str: - # Returns legacy broadcast channel for reference; production path uses per-guild channels. - 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]], - guild_ids: set[int] | None = None, - ) -> None: - self._cb = on_event - self._stop.clear() - if self._redis_pubsub is None: - self._redis_pubsub = redis_pubsub_client_from_env() - initial_channels = { - channel_events_discord_guild(self._key, str(g)) - for g in (guild_ids or set()) - } - self._subscribed_guild_channels = initial_channels - self._reader_task = asyncio.create_task(self._event_reader_loop(initial_channels)) - - async def update_guild_channels(self, guild_ids: set[int]) -> None: - """Subscribe to new per-guild channels and drop channels no longer in the allowed set.""" - new_channels = {channel_events_discord_guild(self._key, str(g)) for g in guild_ids} - to_add = new_channels - self._subscribed_guild_channels - to_remove = self._subscribed_guild_channels - new_channels - ps = self._ps - if ps is None: - # Reader loop not yet running; track for when it starts. - self._subscribed_guild_channels = new_channels - return - try: - if to_add: - await ps.subscribe(*to_add) - if to_remove: - await ps.unsubscribe(*to_remove) - except (RedisConnectionError, OSError, RuntimeError) as e: - logger.warning("update_guild_channels: %s %s", type(e).__name__, e) - self._subscribed_guild_channels = new_channels - - async def register_persona_guilds(self, persona_id: str, guild_ids: set[int]) -> None: - """Write persona->guild Set in Redis with a 300s TTL; called on connect and refresh.""" - if self._redis_cmd is None: - self._redis_cmd = redis_client_from_env() - reg_key = registry_key_persona_guilds(self._key, persona_id) - try: - pipe = self._redis_cmd.pipeline() - pipe.delete(reg_key) - if guild_ids: - pipe.sadd(reg_key, *[str(g) for g in guild_ids]) - pipe.expire(reg_key, 300) - await pipe.execute() - except (RedisConnectionError, RedisTimeoutError, OSError, RuntimeError) as e: - logger.warning("register_persona_guilds: %s %s", type(e).__name__, e) - - async def unregister_persona_guilds(self, persona_id: str) -> None: - """Remove persona->guild Set from Redis on clean disconnect.""" - if self._redis_cmd is None: - return - reg_key = registry_key_persona_guilds(self._key, persona_id) - try: - await self._redis_cmd.delete(reg_key) - except (RedisConnectionError, RedisTimeoutError, OSError, RuntimeError) as e: - logger.warning("unregister_persona_guilds: %s %s", type(e).__name__, e) - - 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, initial_channels: set[str]) -> None: - r = self._redis_pubsub - if r is None: - return - ps = r.pubsub() - self._ps = ps - if initial_channels: - await ps.subscribe(*initial_channels) - 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: - self._ps = None - try: - 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 index 809cac1a..c3a1f304 100644 --- a/flexus_client_kit/gateway/ckit_gateway_wire.py +++ b/flexus_client_kit/gateway/ckit_gateway_wire.py @@ -2,11 +2,11 @@ import dataclasses import hashlib -import uuid from typing import Any from flexus_client_kit.ckit_connector import ActionResult, NormalizedEvent +# Wire version for cmd/reply JSON envelopes (service_discord_gateway <-> Redis <-> backend callers). WIRE_V = 1 @@ -15,22 +15,6 @@ def gateway_instance_key_from_token(token: str) -> str: return hashlib.sha256(t).hexdigest()[:32] -def channel_events_discord(gateway_instance_key: str) -> str: - # Legacy broadcast channel — kept for direct-socket / dev-fallback mode only. - # Production path uses channel_events_discord_guild for addressed per-guild delivery. - return "gw:discord:%s:events" % (gateway_instance_key,) - - -def channel_events_discord_guild(gateway_instance_key: str, guild_id: str) -> str: - # Per-guild addressed event channel. Workers subscribe only to their allowed guilds. - return "gw:discord:%s:guild:%s:events" % (gateway_instance_key, guild_id) - - -def registry_key_persona_guilds(gateway_instance_key: str, persona_id: str) -> str: - # Redis Set of guild_id strings for a persona; refreshed by the worker with a 300s TTL. - return "gw:discord:%s:persona:%s:guilds" % (gateway_instance_key, persona_id) - - def channel_cmd_discord(gateway_instance_key: str) -> str: return "gw:discord:%s:cmd" % (gateway_instance_key,) @@ -39,15 +23,6 @@ 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 @@ -95,31 +70,6 @@ def action_result_from_dict(d: dict[str, Any]) -> ActionResult: ) -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: diff --git a/flexus_client_kit/integrations/fi_discord2.py b/flexus_client_kit/integrations/fi_discord2.py index 6564eade..44475d89 100644 --- a/flexus_client_kit/integrations/fi_discord2.py +++ b/flexus_client_kit/integrations/fi_discord2.py @@ -33,6 +33,7 @@ ckit_scenario, ckit_utils, ) +from flexus_client_kit.ckit_connector import ActionResult, NormalizedEvent from flexus_client_kit.format_utils import format_cat_output from flexus_client_kit.integrations import fi_messenger from flexus_client_kit.integrations.fi_mongo_store import download_file, validate_path @@ -1068,6 +1069,413 @@ async def close_discord_client(client: Optional[discord.Client], task: Optional[ await client.close() +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: + """ + Execute Discord-side platform actions for connectors and the backend gateway service. + + Maps action_type to fetch_user, safe_dm, safe_send, role/kick operations, and introspection + helpers (get_user_info, get_channel). Lives in fi_discord2 so runtime Discord I/O stays in one + integration module (Architecture: gateway + DiscordConnector delegate here). + """ + 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: + 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: + 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 safe_dm(client, user, persona_id, text) + return ActionResult(ok=ok) + except DiscordException as e: + 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: + 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 safe_send(ch, persona_id, text) + if msg is None: + return ActionResult(ok=False, error="safe_send_failed") + return ActionResult( + ok=True, + data={"message_id": str(msg.id), "channel_id": str(ch.id)}, + ) + except DiscordException as e: + lg = None + if isinstance(ch, discord.TextChannel) and ch.guild is not None: + lg = int(ch.guild.id) + 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: + 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") + role_ids = [str(r.id) for r in member.roles] + return ActionResult( + ok=True, + data={ + "user_id": str(member.id), + "display_name": member.display_name, + "role_ids": role_ids, + }, + ) + for guild in client.guilds: + member = guild.get_member(uid) + if member is not None: + role_ids = [str(r.id) for r in member.roles] + return ActionResult( + ok=True, + data={ + "user_id": str(member.id), + "display_name": member.display_name, + "role_ids": role_ids, + }, + ) + 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[str, Any] = { + "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: + 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: + 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: + 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: + 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") + + +def gateway_reaction_emoji_key(emoji: Any) -> str: + """ + Serialize a discord partial emoji to the same string form as reaction_roles_json bindings + (unicode name, or 'name:id' for custom emoji). Used by gateway-emitted reaction events. + """ + try: + eid = getattr(emoji, "id", None) + if eid: + name = getattr(emoji, "name", None) or "" + return "%s:%s" % (name, eid) + return str(getattr(emoji, "name", None) or "") + except (TypeError, AttributeError): + return "" + + +def bind_discord_gateway_client( + client: discord.Client, + emit: Callable[[NormalizedEvent], Awaitable[None]], +) -> None: + """ + Attach gateway Discord event handlers to ``client`` for the backend sidecar process. + + Emits NormalizedEvent instances into ``emit`` (typically routed to process_external_webhook). + """ + + @client.event + async def on_ready() -> None: + for g in list(client.guilds): + await _emit_gateway_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_gateway_server_connected(guild, emit) + + @client.event + async def on_guild_available(guild: discord.Guild) -> None: + await _emit_gateway_server_connected(guild, emit) + + @client.event + async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None: + """ + Forward reaction adds to worker bots (reaction role bindings) via the same DISCORD + emessage path as messages; no worker-side Discord socket required. + """ + try: + if payload.guild_id is None: + return + bot_user = client.user + if bot_user is not None and int(payload.user_id) == int(bot_user.id): + return + await emit( + NormalizedEvent( + source="discord", + server_id=str(payload.guild_id), + channel_id=str(payload.channel_id), + user_id=str(payload.user_id), + event_type="reaction_added", + payload={ + "guild_id": int(payload.guild_id), + "channel_id": int(payload.channel_id), + "user_id": int(payload.user_id), + "message_id": str(payload.message_id), + "emoji": gateway_reaction_emoji_key(payload.emoji), + }, + timestamp=time.time(), + ), + ) + except (TypeError, ValueError, AttributeError, DiscordException) as e: + log_ctx("gateway", int(payload.guild_id or 0) or None, "on_raw_reaction_add emit: %s %s", type(e).__name__, e) + + @client.event + async def on_raw_reaction_remove(payload: discord.RawReactionActionEvent) -> None: + """Mirror of on_raw_reaction_add for removing roles when a reaction is cleared.""" + try: + if payload.guild_id is None: + return + bot_user = client.user + if bot_user is not None and int(payload.user_id) == int(bot_user.id): + return + await emit( + NormalizedEvent( + source="discord", + server_id=str(payload.guild_id), + channel_id=str(payload.channel_id), + user_id=str(payload.user_id), + event_type="reaction_removed", + payload={ + "guild_id": int(payload.guild_id), + "channel_id": int(payload.channel_id), + "user_id": int(payload.user_id), + "message_id": str(payload.message_id), + "emoji": gateway_reaction_emoji_key(payload.emoji), + }, + timestamp=time.time(), + ), + ) + except (TypeError, ValueError, AttributeError, DiscordException) as e: + log_ctx("gateway", int(payload.guild_id or 0) or None, "on_raw_reaction_remove emit: %s %s", type(e).__name__, e) + + +async def _emit_gateway_server_connected( + g: discord.Guild, + emit: Callable[[NormalizedEvent], Awaitable[None]], +) -> None: + """Emit server_connected with member count for gateway ingress routing.""" + 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(), + ), + ) + + def _perm_gaps_basic(perms: discord.Permissions) -> List[str]: miss: List[str] = [] if not perms.view_channel: diff --git a/flexus_simple_bots/admonster/admonster_install.py b/flexus_simple_bots/admonster/admonster_install.py index b1778d18..0e233c19 100644 --- a/flexus_simple_bots/admonster/admonster_install.py +++ b/flexus_simple_bots/admonster/admonster_install.py @@ -20,7 +20,7 @@ ("setup", ckit_bot_install.FMarketplaceExpertInput( fexp_system_prompt=admonster_prompts.admonster_setup, fexp_python_kernel="", - fexp_allow_tools=",".join(sorted(set(ckit_cloudtool.CLOUDTOOLS_ADVANCED) | TOOL_NAMESET)), + fexp_allow_tools=",".join(sorted(set(ckit_cloudtool.CLOUDTOOLS_QUITE_A_LOT) | TOOL_NAMESET)), fexp_nature="NATURE_INTERACTIVE", fexp_inactivity_timeout=0, fexp_description="Helps users configure Facebook OAuth connections and ad account settings, plus LinkedIn advertising credentials.", diff --git a/flexus_simple_bots/clerkwing/clerkwing_install.py b/flexus_simple_bots/clerkwing/clerkwing_install.py index b45d34a0..f7bfdfc2 100644 --- a/flexus_simple_bots/clerkwing/clerkwing_install.py +++ b/flexus_simple_bots/clerkwing/clerkwing_install.py @@ -36,7 +36,7 @@ ("setup", ckit_bot_install.FMarketplaceExpertInput( fexp_system_prompt=clerkwing_prompts.clerkwing_setup, fexp_python_kernel="", - fexp_allow_tools=",".join(TOOL_NAMESET | ckit_cloudtool.CLOUDTOOLS_ADVANCED), + fexp_allow_tools=",".join(TOOL_NAMESET | ckit_cloudtool.CLOUDTOOLS_QUITE_A_LOT), fexp_nature="NATURE_INTERACTIVE", fexp_description="Configuration assistant for setting up Gmail, Google Calendar, and Jira OAuth connections.", )), diff --git a/flexus_simple_bots/discord_bot/discord_bot.py b/flexus_simple_bots/discord_bot/discord_bot.py index 484d083e..addff205 100644 --- a/flexus_simple_bots/discord_bot/discord_bot.py +++ b/flexus_simple_bots/discord_bot/discord_bot.py @@ -4,7 +4,6 @@ import os from typing import Any, Dict, List -import discord from discord.errors import DiscordException from pymongo import AsyncMongoClient from pymongo.errors import PyMongoError @@ -13,14 +12,10 @@ 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_job_queue from flexus_client_kit import ckit_messages from flexus_client_kit import ckit_mongo -from flexus_client_kit import ckit_person_domain from flexus_client_kit import ckit_shutdown 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.gateway.ckit_gateway_wire import normalized_event_from_dict from flexus_client_kit.integrations import fi_discord2 as dc @@ -36,37 +31,47 @@ async def _warn_gateway_channel_acl( purpose_label: str, channel_id: int, ) -> None: - info = await connector.get_channel(str(channel_id)) - if info is None: + """Log when the gateway cannot see a channel or the bot lacks common text permissions.""" + try: + 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 not reachable " - "(missing, not a guild channel, or guild not allowlisted)", + "%s gateway preflight [%s]: channel_id=%s guild_id=%s name=%r missing permissions: %s", persona_id, purpose_label, - channel_id, + info.get("channel_id", str(channel_id)), + info.get("guild_id"), + info.get("name", ""), + ",".join(missing), ) - return - missing = [ - k - for k in ( - "view_channel", - "send_messages", - "read_message_history", - "manage_messages", + except (TypeError, ValueError, AttributeError) as e: + logger.warning( + "%s gateway preflight [%s] failed: %s %s", + persona_id, + purpose_label, + type(e).__name__, + e, ) - 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( @@ -75,67 +80,40 @@ async def _gateway_discord_channel_acl_preflight( 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) + """Best-effort permission warnings for watched channels and checklist/welcome targets.""" + try: + 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) + except (TypeError, ValueError) as e: + logger.warning("%s acl preflight error: %s %s", persona_id, type(e).__name__, e) BOT_NAME = "discord_bot" BOT_VERSION = SIMPLE_BOTS_COMMON_VERSION -def _has_gatekeeper_actions(rules: List[dict]) -> bool: - """Return True when any published rule contains at least one call_gatekeeper_tool action.""" - for rule in rules: - # simple rule: top-level actions list - actions = rule.get("actions") or [] - if any(isinstance(a, dict) and a.get("type") == "call_gatekeeper_tool" for a in actions): - return True - # branched rule: each branch carries its own actions list - for branch in (rule.get("branches") or []): - branch_actions = branch.get("actions") or [] - if any(isinstance(a, dict) and a.get("type") == "call_gatekeeper_tool" for a in branch_actions): - return True - return False - TOOLS: List[Any] = [] def _discord_bot_hosted_bot_token() -> tuple[str, str | None]: - v = (os.environ.get("FLEXUS_DISCORD_BOT_TOKEN") or "").strip() - if v: - return v, "FLEXUS_DISCORD_BOT_TOKEN" - return "", None - - -def _decide_use_gateway(base_url_http: str) -> bool: - """Decide whether to use DiscordGatewayConnector (True) or DiscordConnector (False). - - Decision priority: - 1. FLEXUS_DISCORD_USE_GATEWAY env var (explicit override, truthy/falsy string) - 2. Local dev heuristic: if FLEXUS_API_BASEURL points at localhost → direct socket - 3. Hosted default: if base_url is not localhost → gateway - - This prevents local-only workers from routing events through a gateway subscriber - that is not running locally, which would produce "no subscribers" log spam and - silently drop all automation rule executions. - """ - explicit = os.environ.get("FLEXUS_DISCORD_USE_GATEWAY", "").strip().lower() - if explicit in ("1", "true", "yes", "on"): - return True - if explicit in ("0", "false", "no", "off"): - return False - # No explicit override — use the backend URL to detect local dev. - is_local = "localhost" in base_url_http or "127.0.0.1" in base_url_http - return not is_local + """Return (token, env var name) when FLEXUS_DISCORD_BOT_TOKEN is set.""" + try: + v = (os.environ.get("FLEXUS_DISCORD_BOT_TOKEN") or "").strip() + if v: + return v, "FLEXUS_DISCORD_BOT_TOKEN" + return "", None + except (TypeError, AttributeError): + return "", None def _parse_bindings(raw: str) -> List[Dict[str, str]]: + """Parse reaction_roles_json: [{message_id, emoji, role_id}, ...].""" try: v = json.loads(raw or "[]") except json.JSONDecodeError: @@ -155,6 +133,7 @@ def _parse_bindings(raw: str) -> List[Dict[str, str]]: def _role_ids_csv(s: str) -> List[int]: + """Parse comma-separated snowflakes from setup (e.g. mod roles).""" out: List[int] = [] for part in (s or "").split(","): p = part.strip() @@ -164,622 +143,415 @@ def _role_ids_csv(s: str) -> List[int]: def _guild_ids_from_persona(persona: Any, setup: Dict[str, Any]) -> set[int]: - """Parse Discord guild IDs from persona_external_addresses (entries like 'discord:'). - Falls back to the legacy dc_guild_id setup field when the list is absent or yields nothing. + """Guild allowlist from persona_external_addresses discord: or legacy dc_guild_id.""" + try: + addresses = getattr(persona, "persona_external_addresses", None) + if isinstance(addresses, list): + ids: set[int] = set() + for v in addresses: + if isinstance(v, str) and v.startswith("discord:"): + gid = dc.parse_snowflake(v[len("discord:") :]) + if gid is not None: + ids.add(gid) + if ids: + return ids + legacy_gid = dc.parse_snowflake(setup.get("dc_guild_id", "")) + if legacy_gid is not None: + return {legacy_gid} + return set() + except (TypeError, ValueError, AttributeError): + return set() + + +def _event_member_dict(guild_id: int, user_id: int, pl: Dict[str, Any]) -> Dict[str, Any]: """ - addresses = getattr(persona, "persona_external_addresses", None) - if isinstance(addresses, list): - ids: set[int] = set() - for v in addresses: - if isinstance(v, str) and v.startswith("discord:"): - gid = dc.parse_snowflake(v[len("discord:"):]) - if gid is not None: - ids.add(gid) - if ids: - return ids - legacy_gid = dc.parse_snowflake(setup.get("dc_guild_id", "")) - if legacy_gid is not None: - return {legacy_gid} - return set() - - -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, + Minimal member snapshot for the automation engine and actions (templates, role tools). + + Loaded from the normalized Discord payload only — nothing persisted on the worker. + """ + uname = pl.get("username", "") + if not isinstance(uname, str): + uname = "" + return { + "guild_id": guild_id, + "user_id": user_id, + "discord_username": uname, + } + + +async def _maybe_gateway_auto_post_checklist( + connector: ChatConnector, setup: Dict[str, Any], + mongo_db: Any, persona_id: str, - connector: ChatConnector, + pl: Dict[str, Any], + allowed_guild_ids: frozenset[int], ) -> None: - bindings = _parse_bindings(setup.get("reaction_roles_json", "[]")) + """ + One-time checklist message per guild via gateway post_to_channel + dc_onboarding_meta marker. - @raw_client.event - async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None: - if dc.setup_truthy(setup.get("disable_reaction_roles")): + Replaces the old raw-client path that called channel.send locally. + """ + try: + if dc.setup_truthy(setup.get("disable_checklist_auto_post")): return - if raw_client.user and payload.user_id == raw_client.user.id: + gid = int(pl.get("guild_id", 0) or 0) + if not gid or (allowed_guild_ids and gid not in allowed_guild_ids): return - allowed = connector.allowed_guild_ids - gid_ev = int(payload.guild_id or 0) - if not allowed or gid_ev not in allowed: + cid = dc.parse_snowflake(setup.get("checklist_channel_id", "")) + if not cid: return - guild = raw_client.get_guild(payload.guild_id) - if not guild: + body = (setup.get("checklist_message_body") or "").strip() + if not body: 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: + checklist_meta_coll = mongo_db["dc_onboarding_meta"] + meta_id = "checklist_posted:%s" % gid + doc = await checklist_meta_coll.find_one({"_id": meta_id}) + if doc and doc.get("posted"): 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_bot", + result = await connector.execute_action( + "post_to_channel", { - "welcome_channel": (dc.parse_snowflake(setup.get("welcome_channel_id", "")), "basic"), - "checklist": (cid, "mod"), + "channel_id": str(cid), + "text": body, + "server_id": str(gid), }, - 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 not result.ok: + dc.log_ctx(persona_id, gid, "checklist auto-post failed: %s", result.error or "unknown") + return + extra: Dict[str, Any] = {"posted": True, "channel_id": str(cid), "guild_id": str(gid)} + data = getattr(result, "data", None) + if isinstance(data, dict) and data.get("message_id"): + extra["message_id"] = data["message_id"] + await checklist_meta_coll.update_one({"_id": meta_id}, {"$set": extra}, 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) + dc.log_ctx( + persona_id, + gid, + "pin_checklist requested but gateway checklist path does not pin messages; message was posted only.", + ) + except PyMongoError as e: + dc.log_ctx(persona_id, None, "checklist meta PyMongoError: %s %s", type(e).__name__, e) + except (TypeError, ValueError, KeyError) as e: + dc.log_ctx(persona_id, None, "checklist auto-post error: %s %s", type(e).__name__, e) -async def _bootstrap_existing_members( - raw_client: discord.Client, - allowed_guild_ids: frozenset[int], - fclient: ckit_client.FlexusClient, - workspace_id: str, +async def _handle_reaction_binding_event( + connector: ChatConnector, + setup: Dict[str, Any], persona_id: str, + event_type: str, + pl: Dict[str, Any], ) -> None: """ - One-shot idempotent sync for members already in the guild when the bot starts. - - Creates/upserts crm_person + crm_person_identity + crm_contact for every - non-bot member in every allowed guild. Does NOT create crm_application rows - (those are reserved for new joins). Safe to rerun. - - Only called when raw_client is not None (non-gateway / local-socket path). - The gateway path has no access to the member list from the worker process. + Apply reaction_roles_json using gateway add_role/remove_role (reaction_* events from service_discord_gateway). """ - total = 0 - for gid in sorted(allowed_guild_ids): - guild = raw_client.get_guild(gid) - if guild is None: - continue - for member in list(guild.members): - if member.bot: + try: + if dc.setup_truthy(setup.get("disable_reaction_roles")): + return + try: + gid = int(pl.get("guild_id", 0) or 0) + uid = int(pl.get("user_id", 0) or 0) + except (TypeError, ValueError): + return + if not gid or not uid: + return + mid = str(pl.get("message_id", "") or "") + key = str(pl.get("emoji", "") or "") + if not mid or not key: + return + bindings = _parse_bindings(setup.get("reaction_roles_json", "[]")) + for b in bindings: + if b["message_id"] != mid: continue - uid = member.id - display = member.display_name or member.name or str(uid) - await ckit_person_domain.ensure_person_for_discord_user( - fclient, - workspace_id, - str(uid), - display, - ) - await ckit_person_domain.ensure_discord_contact( - fclient, - workspace_id, - str(uid), - display, + if b["emoji"] != key: + continue + act = "add_role" if event_type == "reaction_added" else "remove_role" + result = await connector.execute_action( + act, + { + "user_id": str(uid), + "role_id": b["role_id"], + "server_id": str(gid), + }, ) - total += 1 - await asyncio.sleep(0) # yield to event loop between members so heartbeats stay alive - dc.log_ctx(persona_id, None, "member bootstrap complete: %d members synced across %d guilds", total, len(allowed_guild_ids)) + if not result.ok: + dc.log_ctx(persona_id, gid, "reaction role %s failed: %s", act, result.error or "unknown") + return + except (TypeError, ValueError, KeyError) as e: + dc.log_ctx(persona_id, None, "reaction binding error: %s %s", type(e).__name__, e) async def discord_bot_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_bot_install.DISCORD_BOT_SETUP_SCHEMA, - persona_setup_raw, - ) - token, hosted_env = _discord_bot_hosted_bot_token() - if not token: - token = dc.discord_bot_api_key_from_external_auth(rcx.external_auth) - hosted_env = "external_auth" if token else None - if not token: - logger.error( - "%s missing Discord bot token: set FLEXUS_DISCORD_BOT_TOKEN, " - "or provide legacy external_auth api_key (discord_manual / discord)", - rcx.persona.persona_id, + """Discord community bot: gateway-only ingress (on_emessage DISCORD); no in-process Discord socket.""" + connector: ChatConnector | None = None + mongo: Any = None + persona_id_loop = rcx.persona.persona_id + try: + persona_setup_raw = rcx.persona.persona_setup or {} + setup = ckit_bot_exec.official_setup_mixing_procedure( + discord_bot_install.DISCORD_BOT_SETUP_SCHEMA, + persona_setup_raw, ) - while not ckit_shutdown.shutdown_event.is_set(): - await rcx.unpark_collected_events(sleep_if_no_work=30.0) - return + token, hosted_env = _discord_bot_hosted_bot_token() + if not token: + token = dc.discord_bot_api_key_from_external_auth(rcx.external_auth) + hosted_env = "external_auth" if token else None + if not token: + logger.error( + "%s missing Discord bot token: set FLEXUS_DISCORD_BOT_TOKEN, " + "or provide 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 - use_gateway = _decide_use_gateway(fclient.base_url_http) - if use_gateway: - logger.info( - "%s Discord runtime: gateway mode (token source=%s, backend=%s); " - "guild allowlist from persona_external_addresses; " - "worker has no Discord socket — ensure service_discord_gateway subscriber is running. " - "Set FLEXUS_DISCORD_USE_GATEWAY=0 to force direct-socket mode.", - rcx.persona.persona_id, - hosted_env, - fclient.base_url_http, - ) - else: logger.info( - "%s Discord runtime: direct-socket mode (token source=%s, backend=%s); " - "DiscordConnector holds the gateway connection in this process. " - "Set FLEXUS_DISCORD_USE_GATEWAY=1 to force gateway mode.", + "%s Discord runtime: gateway-only (token source=%s, backend=%s); " + "ingress via on_emessage(DISCORD); service_discord_gateway must be running.", rcx.persona.persona_id, hosted_env, fclient.base_url_http, ) - 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"] + mongo_conn_str = await ckit_mongo.mongo_fetch_creds(fclient, rcx.persona.persona_id) + mongo = AsyncMongoClient(mongo_conn_str) + mongo_db = mongo[persona_id_loop + "_db"] - await ckit_crm_members.migrate_legacy_collections(mongo_db) - await ckit_crm_members.ensure_member_indexes(mongo_db) + rules = ckit_automation_engine.load_rules(persona_setup_raw) + dc.log_ctx(rcx.persona.persona_id, None, "loaded %d automation rules", len(rules)) - 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) - 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 "" - 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") - 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", "")) - mod_roles = set(_role_ids_csv(setup.get("mod_role_ids", ""))) - announce_pings = _role_ids_csv(setup.get("announce_ping_role_ids", "")) + initial_guild_ids = _guild_ids_from_persona(rcx.persona, setup) + dc.log_ctx(rcx.persona.persona_id, None, "allowed guild ids from persona_external_addresses: %s", sorted(initial_guild_ids)) - initial_guild_ids = _guild_ids_from_persona(rcx.persona, setup) - dc.log_ctx(rcx.persona.persona_id, None, "allowed guild ids from persona_external_addresses: %s", sorted(initial_guild_ids)) - if use_gateway: - connector: ChatConnector = DiscordGatewayConnector( + gw = DiscordGatewayConnector( token, rcx.persona.persona_id, fclient, initial_guild_ids=initial_guild_ids, ) - else: - connector = DiscordConnector(token, rcx.persona.persona_id, initial_guild_ids=initial_guild_ids) - await connector.set_allowed_guild_ids(initial_guild_ids) + connector = gw + await connector.set_allowed_guild_ids(initial_guild_ids) - augmented_setup = dict(setup) - augmented_setup["_format_mention"] = connector.format_mention + augmented_setup = dict(setup) + augmented_setup["_format_mention"] = connector.format_mention - await ckit_messages.ensure_message_indexes(mongo_db) + 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 in ("server_connected", "server_disconnected"): - return - if event.event_type == "member_joined": - 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, - ) + async def handle_normalized_event(event: NormalizedEvent) -> None: + """Dispatch one gateway-normalized Discord event (member, message, checklist, reactions).""" + try: + persona_id = rcx.persona.persona_id + pl = event.payload if isinstance(event.payload, dict) else {} - # Best-effort person domain sync: resolve/create canonical person + - # discord identity, then register a durable application record. - # Runs regardless of whether automation rules exist so that even - # workspaces with zero published rules get a proper foundation record. - if workspace_id: - _has_gk = _has_gatekeeper_actions(rules) - person_id = await ckit_person_domain.ensure_person_for_discord_user( - fclient, - workspace_id, - str(uid), - uname, - ) - if person_id: - existing_app = await ckit_person_domain.application_find_latest( - fclient, - workspace_id, - person_id, - ) - if not existing_app: - _sync_app_id = await ckit_person_domain.application_create_pending( - fclient, - workspace_id, - person_id, - source="discord_bot", - platform="discord", - payload={"guild_id": str(gid), "discord_user_id": str(uid)}, - ) - else: - _sync_app_id = existing_app["application_id"] - # When there is no gatekeeper the member is immediately accepted by - # handle_member_join (lifecycle_status="accepted"). Advance the - # durable application to DECIDED/APPROVED so it stays consistent. - if _sync_app_id and not _has_gk: - _existing_status = ( - existing_app["application_status"] if existing_app else "PENDING" - ) - if _existing_status not in ("DECIDED", "CLOSED"): - await ckit_person_domain.application_apply_decision( - fclient, - _sync_app_id, - "DECIDED", - "APPROVED", - None, - ) - # Ensure a CRM contact row exists for every joining member, - # keyed idempotently by contact_platform_ids.discord. - await ckit_person_domain.ensure_discord_contact( - fclient, - workspace_id, - str(uid), - uname, + if event.event_type == "server_connected": + await _maybe_gateway_auto_post_checklist( + connector, + setup, + mongo_db, + persona_id, + pl, + connector.allowed_guild_ids, ) + return - if len(rules) == 0: + if event.event_type in ("server_disconnected",): return - # When gatekeeper rules are configured, start member in pending_review - # so the AI review decision (accept/reject/request_info) controls progression. - # Without gatekeeper, preserve the existing "accepted" flow from handle_member_join. - if _has_gatekeeper_actions(rules): - updated_doc, _prev = await ckit_crm_members.set_member_status( - mongo_db, - gid, - uid, - "pending_review", - ) - if updated_doc is not None: - member_doc = updated_doc - - 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, - "fclient": fclient, - "ws_id": workspace_id, - } - actions = ckit_automation_engine.process_event( - "member_joined", - {"guild_id": gid, "user_id": uid}, - 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, - ) - await _schedule_scan_after_join(ctx, gid, uid) - return + if event.event_type in ("reaction_added", "reaction_removed"): + await _handle_reaction_binding_event(connector, setup, persona_id, event.event_type, pl) + 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 + if event.event_type == "member_joined": 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: + gid = int(pl["guild_id"]) + uid = int(pl["user_id"]) + except (KeyError, TypeError, ValueError): + return + event_member = _event_member_dict(gid, uid, pl) + + if len(rules) == 0: + return + + 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), + "event_member": event_member, + "persona_id": persona_id, + "setup": augmented_setup, + "fclient": fclient, + "ws_id": workspace_id, + } + actions = ckit_automation_engine.process_event( + "member_joined", + {"guild_id": gid, "user_id": uid}, + rules, + event_member, + augmented_setup, + ) + await ckit_automation_actions.execute_actions(actions, ctx) + return + + if event.event_type == "message_in_channel": + pl_msg = event.payload if isinstance(event.payload, dict) else {} + content = (pl_msg.get("content") or "").strip() + if content.lower().startswith("!announce ") and mod_roles: + try: + gid_ann = int(pl_msg.get("guild_id", 0) or 0) + except (TypeError, ValueError): + gid_ann = 0 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 gid_ann and uid_int: + info = await connector.get_user_info(str(uid_int), server_id=str(gid_ann)) + if isinstance(info, dict): + raw_roles = info.get("role_ids") or [] + author_roles = set() + for x in raw_roles: + try: + author_roles.add(int(x)) + except (TypeError, ValueError): + continue + 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_msg.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 len(rules) == 0: + return + try: + gid = int(pl_msg["guild_id"]) + uid = int(pl_msg["user_id"]) + ch_id = int(pl_msg["channel_id"]) + except (KeyError, TypeError, ValueError): + return + + 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_msg.get("content") or "", + timestamp=event.timestamp, + message_id=str(pl_msg.get("message_id") or ""), + ) + if ch_id not in watched_channel_ids: + return + + event_member = _event_member_dict(gid, uid, pl_msg) + 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), + "event_member": event_member, + "persona_id": persona_id, + "setup": augmented_setup, + "fclient": fclient, + "ws_id": workspace_id, + } + actions = ckit_automation_engine.process_event( + "message_in_channel", + {"guild_id": gid, "user_id": uid, "channel_id": ch_id}, + rules, + event_member, + augmented_setup, ) - 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: + await ckit_automation_actions.execute_actions(actions, ctx_msg) 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, - "fclient": fclient, - "ws_id": workspace_id, - } - actions = ckit_automation_engine.process_event( - "message_in_channel", - {"guild_id": gid, "user_id": uid, "channel_id": ch_id}, - 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, - ) - 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, - "fclient": fclient, - "ws_id": workspace_id, - } - actions_leave = ckit_automation_engine.process_event( - "member_removed", - {"guild_id": gid, "user_id": uid}, - 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, - ) - 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: + if event.event_type == "member_removed": + if len(rules) == 0: + return + try: + gid = int(pl["guild_id"]) + uid = int(pl["user_id"]) + except (KeyError, TypeError, ValueError): + return + event_member = _event_member_dict(gid, uid, pl) + 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), + "event_member": event_member, + "persona_id": persona_id, + "setup": augmented_setup, + "fclient": fclient, + "ws_id": workspace_id, + } + actions_leave = ckit_automation_engine.process_event( + "member_removed", + {"guild_id": gid, "user_id": uid}, + rules, + event_member, + augmented_setup, + ) + await ckit_automation_actions.execute_actions(actions_leave, ctx_rm) return - ctx_rm["member_doc"] = member_doc_st - actions = ckit_automation_engine.process_event( - "status_transition", - {"old_status": old_status, "new_status": "churned"}, - 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, - ) - 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): + except PyMongoError as e: 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): + try: + payload = event.payload if isinstance(event.payload, dict) else {} + gid_log = int(payload.get("guild_id", 0) or 0) or None + except (TypeError, ValueError): + gid_log = None + dc.log_ctx(rcx.persona.persona_id, gid_log, "normalized event PyMongoError: %s %s", type(e).__name__, e) + except DiscordException as e: 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): + try: + payload = event.payload if isinstance(event.payload, dict) else {} + gid_log = int(payload.get("guild_id", 0) or 0) or None + except (TypeError, ValueError): + gid_log = None + dc.log_ctx(rcx.persona.persona_id, gid_log, "normalized event DiscordException: %s %s", type(e).__name__, e) + except (TypeError, KeyError, ValueError) as e: 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) + try: + payload = event.payload if isinstance(event.payload, dict) else {} + gid_log = int(payload.get("guild_id", 0) or 0) or None + except (TypeError, ValueError): + gid_log = None + dc.log_ctx(rcx.persona.persona_id, gid_log, "normalized event data error: %s %s", type(e).__name__, e) - if use_gateway: - # In gateway mode inbound events arrive via flexus_persona_external_message - # (routed by service_discord_gateway through process_external_webhook) rather - # than through a Redis event consumer. Register an on_emessage handler so that - # bot_threads_calls_tasks delivers DISCORD messages here. @rcx.on_emessage("DISCORD") async def _on_discord_emessage(emsg) -> None: + """Inbound path: backend delivers gateway-normalized Discord payloads here.""" try: ev = normalized_event_from_dict(emsg.emsg_payload) except (KeyError, TypeError, ValueError) as e: @@ -790,7 +562,6 @@ async def _on_discord_emessage(emsg) -> None: e, ) return - # In-memory guild ACL: drop events for guilds this persona does not own. try: gid = int(ev.server_id) except (TypeError, ValueError): @@ -800,9 +571,8 @@ async def _on_discord_emessage(emsg) -> None: return await handle_normalized_event(ev) - await connector.connect() + await connector.connect() - if use_gateway: await _gateway_discord_channel_acl_preflight( connector, rcx.persona.persona_id, @@ -810,101 +580,39 @@ async def _on_discord_emessage(emsg) -> None: setup, ) - raw = connector.raw_client - # Optional single-guild fallback: in direct-socket mode only, if persona_external_addresses - # has no guild IDs configured but the bot token can see exactly one guild, use that guild - # automatically. Strictly bounded to the "exactly one guild visible" case so it cannot - # silently pick the wrong server when multiple guilds are present. - if not use_gateway and raw is not None and not connector.allowed_guild_ids: - visible = list(raw.guilds) - if len(visible) == 1: - fallback_id = int(visible[0].id) - logger.warning( - "%s direct-socket mode: no guild configured in persona_external_addresses; " - "bot token sees exactly one guild (%d / %r) — using it as fallback. " - "Add discord:%d to persona_external_addresses to silence this warning.", - rcx.persona.persona_id, - fallback_id, - visible[0].name, - fallback_id, - ) - await connector.set_allowed_guild_ids({fallback_id}) - elif len(visible) > 1: - logger.warning( - "%s direct-socket mode: no guild configured in persona_external_addresses " - "and bot token sees %d guilds — cannot auto-select. " - "Add discord: to persona_external_addresses.", - rcx.persona.persona_id, - len(visible), - ) - - if raw is not None: - _register_reaction_roles(raw, setup, rcx.persona.persona_id, connector) - - checklist_ready_done = False - bootstrap_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 bootstrap_done and connector.raw_client is not None and workspace_id: - # Fire-and-forget: run bootstrap in the background so the main - # loop keeps processing events while members are being synced. - asyncio.create_task( - _bootstrap_existing_members( - connector.raw_client, - connector.allowed_guild_ids, - fclient, - workspace_id, - rcx.persona.persona_id, - ) - ) - bootstrap_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, - connector=connector, - fclient=fclient, - ws_id=workspace_id, - ) - 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 connector.disconnect() - await mongo.close() - logger.info("%s exit", rcx.persona.persona_id) + if connector is not None: + try: + await connector.disconnect() + except (RuntimeError, AttributeError) as e: + logger.warning("connector disconnect: %s %s", type(e).__name__, e) + if mongo is not None: + try: + await mongo.close() + except (RuntimeError, AttributeError, TypeError) as e: + logger.warning("mongo close: %s %s", type(e).__name__, e) + logger.info("%s exit", persona_id_loop) 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_bot_main_loop, - inprocess_tools=TOOLS, - scenario_fn=scenario_fn, - install_func=discord_bot_install.install, + """CLI entry: run bots for this process group with the discord_bot loop.""" + try: + 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, + bot_main_loop=discord_bot_main_loop, + inprocess_tools=TOOLS, + scenario_fn=scenario_fn, + install_func=discord_bot_install.install, + ) ) - ) + except (RuntimeError, OSError) as e: + logger.error("main failed: %s %s", type(e).__name__, e) + raise if __name__ == "__main__": diff --git a/flexus_simple_bots/discord_bot/discord_bot_install.py b/flexus_simple_bots/discord_bot/discord_bot_install.py index 88de1c85..9be2075a 100644 --- a/flexus_simple_bots/discord_bot/discord_bot_install.py +++ b/flexus_simple_bots/discord_bot/discord_bot_install.py @@ -1,5 +1,4 @@ import asyncio -import base64 import json from pathlib import Path @@ -12,6 +11,8 @@ from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION +# Bot package root: same folder as discord_bot.py, setup_schema.json, webp assets, README. +# Passed to marketplace_upsert_dev_bot as bot_dir so name/version/repo/run/pictures match other simple bots. ROOT = Path(__file__).parent DISCORD_BOT_SETUP_SCHEMA = json.loads((ROOT / "setup_schema.json").read_text()) @@ -22,7 +23,7 @@ ckit_bot_install.FMarketplaceExpertInput( fexp_system_prompt=discord_bot_prompts.discord_bot_stub, fexp_python_kernel="", - fexp_allow_tools=",".join(sorted(ckit_cloudtool.CLOUDTOOLS_ADVANCED)), + fexp_allow_tools=",".join(sorted(ckit_cloudtool.CLOUDTOOLS_QUITE_A_LOT)), fexp_nature="NATURE_INTERACTIVE", fexp_inactivity_timeout=3600, fexp_description="Stub expert; Discord automation runs in the bot process.", @@ -32,29 +33,22 @@ ] -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_bot-1024x1536.webp").read_bytes()).decode("ascii") - pic_small_b64 = base64.b64encode((ROOT / "discord_bot-256x256.webp").read_bytes()).decode("ascii") - desc = (ROOT / "README.md").read_text() - await ckit_bot_install.marketplace_upsert_dev_bot( +async def install(client: ckit_client.FlexusClient): + """Upsert this dev bot in the marketplace; name/version/run/repo/images are derived inside marketplace_upsert_dev_bot from bot_dir.""" + # Import inside install so this module can load before discord_bot (discord_bot imports discord_bot_install at package load). + from flexus_simple_bots.discord_bot import discord_bot as _discord_bot + + r = await ckit_bot_install.marketplace_upsert_dev_bot( client, ws_id=client.ws_id, - marketable_name=bot_name, - marketable_version=bot_version, + bot_dir=ROOT, marketable_accent_color="#5865F2", marketable_title1="Discord Bot", marketable_title2="Welcome flow, follow-up DMs, reaction roles, mod announcements.", marketable_author="Flexus", marketable_occupation="Community", - marketable_description=desc, + marketable_description=(ROOT / "README.md").read_text(), 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_bot.discord_bot", marketable_setup_default=DISCORD_BOT_SETUP_SCHEMA, marketable_featured_actions=[ {"feat_question": "What does this bot do on Discord?", "feat_expert": "default", "feat_depends_on_setup": []}, @@ -62,20 +56,18 @@ async def install( marketable_intro_message="I handle member welcome, delayed check-ins, reaction roles, and mod-only !announce on your Discord server.", marketable_preferred_model_expensive="grok-4-1-fast-reasoning", marketable_preferred_model_cheap="gpt-5.4-nano", - marketable_experts=[(n, e.filter_tools(tools)) for n, e in EXPERTS], - add_integrations_into_expert_system_prompt=[], + marketable_experts=[(n, e.filter_tools(_discord_bot.TOOLS)) for n, e in EXPERTS], marketable_tags=["Discord", "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"], marketable_rules_toolkit=ckit_automation_v1_schema_build.build_automation_v1_schema_document(), ) + return r.marketable_version if __name__ == "__main__": async def _main() -> None: fclient = ckit_client.FlexusClient(ckit_client.bot_service_name("discord_bot", SIMPLE_BOTS_COMMON_VERSION), endpoint="/v1/jailed-bot") - await install(fclient, "discord_bot", SIMPLE_BOTS_COMMON_VERSION, []) + await install(fclient) asyncio.run(_main()) diff --git a/flexus_simple_bots/lawyerrat/lawyerrat_install.py b/flexus_simple_bots/lawyerrat/lawyerrat_install.py index 1c1867eb..49b6011e 100644 --- a/flexus_simple_bots/lawyerrat/lawyerrat_install.py +++ b/flexus_simple_bots/lawyerrat/lawyerrat_install.py @@ -90,7 +90,7 @@ ("setup", ckit_bot_install.FMarketplaceExpertInput( fexp_system_prompt=lawyerrat_prompts.lawyerrat_setup, fexp_python_kernel=LAWYERRAT_DEFAULT_LARK, - fexp_allow_tools=",".join(TOOL_NAMESET | ckit_cloudtool.CLOUDTOOLS_ADVANCED), + fexp_allow_tools=",".join(TOOL_NAMESET | ckit_cloudtool.CLOUDTOOLS_QUITE_A_LOT), fexp_nature="NATURE_INTERACTIVE", fexp_description="Setup assistant for configuring legal specialty, formality, and jurisdiction.", )), From 0a9a661ea0e2bf39bdee4d59590c7fb36ca28d3d Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 13 Apr 2026 16:34:49 +0200 Subject: [PATCH 07/10] chore drop dead discord checklist flag Remove the unused pin_checklist setup flag and the redundant gateway allowlist re-application so the Discord bot keeps less stale legacy surface. --- flexus_simple_bots/discord_bot/discord_bot.py | 7 ------- flexus_simple_bots/discord_bot/setup_schema.json | 9 --------- 2 files changed, 16 deletions(-) diff --git a/flexus_simple_bots/discord_bot/discord_bot.py b/flexus_simple_bots/discord_bot/discord_bot.py index addff205..9b58ea1a 100644 --- a/flexus_simple_bots/discord_bot/discord_bot.py +++ b/flexus_simple_bots/discord_bot/discord_bot.py @@ -225,12 +225,6 @@ async def _maybe_gateway_auto_post_checklist( if isinstance(data, dict) and data.get("message_id"): extra["message_id"] = data["message_id"] await checklist_meta_coll.update_one({"_id": meta_id}, {"$set": extra}, upsert=True) - if dc.setup_truthy(setup.get("pin_checklist")): - dc.log_ctx( - persona_id, - gid, - "pin_checklist requested but gateway checklist path does not pin messages; message was posted only.", - ) except PyMongoError as e: dc.log_ctx(persona_id, None, "checklist meta PyMongoError: %s %s", type(e).__name__, e) except (TypeError, ValueError, KeyError) as e: @@ -349,7 +343,6 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot initial_guild_ids=initial_guild_ids, ) connector = gw - await connector.set_allowed_guild_ids(initial_guild_ids) augmented_setup = dict(setup) augmented_setup["_format_mention"] = connector.format_mention diff --git a/flexus_simple_bots/discord_bot/setup_schema.json b/flexus_simple_bots/discord_bot/setup_schema.json index 48d7eaad..c94bca25 100644 --- a/flexus_simple_bots/discord_bot/setup_schema.json +++ b/flexus_simple_bots/discord_bot/setup_schema.json @@ -62,15 +62,6 @@ "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", From 129f864fa1013d784f908862a9847c5a61b94712 Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 13 Apr 2026 17:20:51 +0200 Subject: [PATCH 08/10] chore save gateway cleanup checkpoint Capture the current gateway-only cleanup before the next simplification pass so we can refactor the Discord stack with an easy rollback point. --- flexus_client_kit/ckit_automation_actions.py | 30 -------------- .../ckit_connector_discord_gateway.py | 4 -- flexus_client_kit/gateway/__init__.py | 2 - .../gateway/ckit_gateway_wire.py | 4 -- flexus_simple_bots/discord_bot/discord_bot.py | 39 ++++++++++++------- 5 files changed, 25 insertions(+), 54 deletions(-) diff --git a/flexus_client_kit/ckit_automation_actions.py b/flexus_client_kit/ckit_automation_actions.py index a6292c72..7143a972 100644 --- a/flexus_client_kit/ckit_automation_actions.py +++ b/flexus_client_kit/ckit_automation_actions.py @@ -13,7 +13,6 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple import aiohttp -from discord.errors import DiscordException from flexus_client_kit import ckit_person_domain @@ -82,9 +81,6 @@ async def _do_send_dm(action: dict, ctx: dict) -> dict: persona_id, ) return _result_dict(ok=False, error="no_connector") - except DiscordException as e: - logger.warning("send_dm DiscordException: %s %s", type(e).__name__, e) - return _result_dict(ok=False, error=type(e).__name__) 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__) @@ -121,9 +117,6 @@ async def _do_post_to_channel(action: dict, ctx: dict) -> dict: persona_id, ) return _result_dict(ok=False, error="no_connector") - 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__) 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__) @@ -155,9 +148,6 @@ async def _do_add_role(action: dict, ctx: dict) -> dict: {"user_id": uid_s, "role_id": str(int(rid)), "server_id": sid}, ) return _result_dict(ok=result.ok, error=result.error) - except DiscordException as e: - logger.warning("add_role DiscordException: %s %s", type(e).__name__, e) - return _result_dict(ok=False, error=type(e).__name__) 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__) @@ -189,9 +179,6 @@ async def _do_remove_role(action: dict, ctx: dict) -> dict: {"user_id": uid_s, "role_id": str(int(rid)), "server_id": sid}, ) return _result_dict(ok=result.ok, error=result.error) - except DiscordException as e: - logger.warning("remove_role DiscordException: %s %s", type(e).__name__, e) - return _result_dict(ok=False, error=type(e).__name__) 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__) @@ -222,9 +209,6 @@ async def _do_kick(action: dict, ctx: dict) -> dict: {"user_id": uid_s, "reason": reason, "server_id": sid}, ) return _result_dict(ok=result.ok, error=result.error) - except DiscordException as e: - logger.warning("kick DiscordException: %s %s", type(e).__name__, e) - return _result_dict(ok=False, error=type(e).__name__) except aiohttp.ClientError as e: logger.warning("kick ClientError: %s %s", type(e).__name__, e) return _result_dict(ok=False, error=type(e).__name__) @@ -316,9 +300,6 @@ async def _do_call_gatekeeper_tool(action: dict, ctx: dict) -> dict: details, ) return _result_dict(ok=True, error=None) - except DiscordException as e: - logger.warning("call_gatekeeper_tool DiscordException: %s %s", type(e).__name__, e) - return _result_dict(ok=False, error=type(e).__name__) except aiohttp.ClientError as e: logger.warning("call_gatekeeper_tool ClientError: %s %s", type(e).__name__, e) return _result_dict(ok=False, error=type(e).__name__) @@ -377,17 +358,6 @@ async def execute_actions(actions: List[dict], ctx: dict) -> List[dict]: continue try: partial = 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( diff --git a/flexus_client_kit/ckit_connector_discord_gateway.py b/flexus_client_kit/ckit_connector_discord_gateway.py index c2612413..e90f3fff 100644 --- a/flexus_client_kit/ckit_connector_discord_gateway.py +++ b/flexus_client_kit/ckit_connector_discord_gateway.py @@ -78,10 +78,6 @@ def raw_client(self) -> Any: def allowed_guild_ids(self) -> frozenset[int]: return frozenset(self._allowed_guild_ids) - @property - def gateway_instance_key(self) -> str: - return self._instance_key - def supported_triggers(self) -> list[TriggerDescriptor]: return DISCORD_TRIGGERS diff --git a/flexus_client_kit/gateway/__init__.py b/flexus_client_kit/gateway/__init__.py index 1dd32d5f..fe8e5be3 100644 --- a/flexus_client_kit/gateway/__init__.py +++ b/flexus_client_kit/gateway/__init__.py @@ -9,7 +9,6 @@ gateway_instance_key_from_token, gateway_result_envelope_from_dict, normalized_event_from_dict, - normalized_event_to_dict, parse_action_command_envelope, ) @@ -24,6 +23,5 @@ "gateway_instance_key_from_token", "gateway_result_envelope_from_dict", "normalized_event_from_dict", - "normalized_event_to_dict", "parse_action_command_envelope", ] diff --git a/flexus_client_kit/gateway/ckit_gateway_wire.py b/flexus_client_kit/gateway/ckit_gateway_wire.py index c3a1f304..2b2eb44f 100644 --- a/flexus_client_kit/gateway/ckit_gateway_wire.py +++ b/flexus_client_kit/gateway/ckit_gateway_wire.py @@ -42,10 +42,6 @@ class GatewayActionResultEnvelope: 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"]), diff --git a/flexus_simple_bots/discord_bot/discord_bot.py b/flexus_simple_bots/discord_bot/discord_bot.py index 9b58ea1a..cf0e2fa5 100644 --- a/flexus_simple_bots/discord_bot/discord_bot.py +++ b/flexus_simple_bots/discord_bot/discord_bot.py @@ -3,8 +3,6 @@ import logging import os from typing import Any, Dict, List - -from discord.errors import DiscordException from pymongo import AsyncMongoClient from pymongo.errors import PyMongoError @@ -196,7 +194,8 @@ async def _maybe_gateway_auto_post_checklist( if dc.setup_truthy(setup.get("disable_checklist_auto_post")): return gid = int(pl.get("guild_id", 0) or 0) - if not gid or (allowed_guild_ids and gid not in allowed_guild_ids): + # Empty allowlist must not mean "all guilds" — deny checklist until persona lists guilds. + if not gid or not allowed_guild_ids or gid not in allowed_guild_ids: return cid = dc.parse_snowflake(setup.get("checklist_channel_id", "")) if not cid: @@ -334,7 +333,19 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot announce_pings = _role_ids_csv(setup.get("announce_ping_role_ids", "")) initial_guild_ids = _guild_ids_from_persona(rcx.persona, setup) - dc.log_ctx(rcx.persona.persona_id, None, "allowed guild ids from persona_external_addresses: %s", sorted(initial_guild_ids)) + if not initial_guild_ids: + logger.warning( + "%s Discord guild allowlist is empty (no discord: addresses in persona_external_addresses " + "and no legacy dc_guild_id). Inbound events and checklist autopost are denied until configured.", + rcx.persona.persona_id, + ) + else: + dc.log_ctx( + rcx.persona.persona_id, + None, + "allowed guild ids from persona_external_addresses: %s", + sorted(initial_guild_ids), + ) gw = DiscordGatewayConnector( token, @@ -433,7 +444,7 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: pings = " ".join("<@&%d>" % r for r in announce_pings) text = "%s\n%s" % (pings, rest) if pings else rest cid_str = str(pl_msg.get("channel_id", "")) - await connector.execute_action( + result = await connector.execute_action( "post_to_channel", { "channel_id": cid_str, @@ -441,6 +452,13 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: "server_id": str(gid_ann), }, ) + if not result.ok: + dc.log_ctx( + rcx.persona.persona_id, + gid_ann, + "!announce failed: %s", + result.error or "unknown", + ) return if len(rules) == 0: @@ -525,14 +543,6 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: except (TypeError, ValueError): gid_log = None dc.log_ctx(rcx.persona.persona_id, gid_log, "normalized event PyMongoError: %s %s", type(e).__name__, e) - except DiscordException as e: - gid_log = None - try: - payload = event.payload if isinstance(event.payload, dict) else {} - gid_log = int(payload.get("guild_id", 0) or 0) or None - except (TypeError, ValueError): - gid_log = None - dc.log_ctx(rcx.persona.persona_id, gid_log, "normalized event DiscordException: %s %s", type(e).__name__, e) except (TypeError, KeyError, ValueError) as e: gid_log = None try: @@ -560,7 +570,8 @@ async def _on_discord_emessage(emsg) -> None: except (TypeError, ValueError): return allowed = connector.allowed_guild_ids - if allowed and gid not in allowed: + # Deny-all when allowlist is empty; do not treat empty as unrestricted. + if not allowed or gid not in allowed: return await handle_normalized_event(ev) From a425aa67fb8403dca147a727cf138cfa10c1836c Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Mon, 13 Apr 2026 17:40:31 +0200 Subject: [PATCH 09/10] refactor share discord gateway helper primitives Move the gateway-only bot onto shared lightweight Discord helpers so it stops importing the full integration module for snowflake parsing, setup flags, auth token lookup, and contextual logging. Reuse the same helper definitions in fi_discord2 to reduce duplication without changing gateway behavior. --- flexus_client_kit/ckit_connector_discord.py | 74 ++++++++++++++++++- flexus_client_kit/integrations/fi_discord2.py | 43 ++--------- flexus_simple_bots/discord_bot/discord_bot.py | 47 ++++++------ 3 files changed, 105 insertions(+), 59 deletions(-) diff --git a/flexus_client_kit/ckit_connector_discord.py b/flexus_client_kit/ckit_connector_discord.py index 855f9d62..17164e3a 100644 --- a/flexus_client_kit/ckit_connector_discord.py +++ b/flexus_client_kit/ckit_connector_discord.py @@ -8,7 +8,8 @@ from __future__ import annotations -from typing import Any +import logging +from typing import Any, Dict, Optional from flexus_client_kit.ckit_discord_automation_schema_defs import ( SCHEMA_ACTION_ADD_ROLE, @@ -307,3 +308,74 @@ def discord_automation_semantics_bundle() -> dict[str, Any]: } except (TypeError, KeyError, AttributeError) as e: raise RuntimeError("discord_automation_semantics_bundle: failed to assemble semantics dict") from e + + +# --------------------------------------------------------------------------- +# Shared Discord helpers (no discord.py): used by gateway-only discord_bot and +# fi_discord2 so snowflake/setup/auth/logging stay one place (architecture: thin +# connector surface, heavy integration defers to these). +# --------------------------------------------------------------------------- + +# Same logger name as fi_discord2.IntegrationDiscord / community utilities so log +# lines keep the same logger and formatting when code moves between modules. +_discord_shared_logger = logging.getLogger("discord") + + +def parse_snowflake(raw: str) -> Optional[int]: + """ + Parse a bare decimal Discord snowflake string to int, or None if invalid. + + Accepts only stripped all-digit strings; used for setup ids and persona_external_addresses. + """ + 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 setup_truthy(raw: Any) -> bool: + """ + Coerce setup checkbox / string flags to bool (1, true, yes, on). + + Matches legacy community-bot semantics for disable_* and similar keys. + """ + 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") + + +def discord_bot_api_key_from_external_auth(ext: Dict[str, Any]) -> str: + """ + Resolve Discord bot token from workspace external_auth (legacy OAuth payloads). + + Precedence: discord_manual, then discord; skips non-dict provider values with a warning. + """ + for provider_key in ("discord_manual", "discord"): + raw = ext.get(provider_key) + if raw is None: + continue + if not isinstance(raw, dict): + _discord_shared_logger.warning( + "discord_bot_api_key_from_external_auth: provider %r value is not a dict, skipping", + provider_key, + ) + continue + tok = (raw.get("api_key") or "").strip() + if tok: + return tok + return "" + + +def log_ctx(persona_id: str, guild_id: Optional[int], msg: str, *args: Any) -> None: + """ + Prefix structured Discord integration logs with persona and optional guild id. + + Format matches historical fi_discord2 community-bot lines: [%s guild=%s] + message. + """ + gid = str(guild_id) if guild_id is not None else "-" + _discord_shared_logger.info("[%s guild=%s] " + msg, persona_id, gid, *args) diff --git a/flexus_client_kit/integrations/fi_discord2.py b/flexus_client_kit/integrations/fi_discord2.py index 44475d89..ea1dfcc8 100644 --- a/flexus_client_kit/integrations/fi_discord2.py +++ b/flexus_client_kit/integrations/fi_discord2.py @@ -34,6 +34,12 @@ ckit_utils, ) from flexus_client_kit.ckit_connector import ActionResult, NormalizedEvent +from flexus_client_kit.ckit_connector_discord import ( + discord_bot_api_key_from_external_auth, + log_ctx, + parse_snowflake, + setup_truthy, +) from flexus_client_kit.format_utils import format_cat_output from flexus_client_kit.integrations import fi_messenger from flexus_client_kit.integrations.fi_mongo_store import download_file, validate_path @@ -128,20 +134,6 @@ def _parse_channel_reference(ref: str) -> Tuple[Optional[str], Optional[str]]: return channel or None, thread or None -def discord_bot_api_key_from_external_auth(ext: Dict[str, Any]) -> str: - for provider_key in ("discord_manual", "discord"): - raw = ext.get(provider_key) - if raw is None: - continue - if not isinstance(raw, dict): - logger.warning("discord_bot_api_key_from_external_auth: provider %r value is not a dict, skipping", provider_key) - continue - tok = (raw.get("api_key") or "").strip() - if tok: - return tok - return "" - - class IntegrationDiscord(fi_messenger.FlexusMessenger): platform_name = "discord" emessage_type = "DISCORD" @@ -903,15 +895,6 @@ def _format_assistant_message(self, content: Any) -> str: JobHandler = ckit_job_queue.JobHandler -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") - - def build_intents() -> discord.Intents: intents = discord.Intents.default() intents.message_content = True @@ -923,15 +906,6 @@ def build_intents() -> discord.Intents: 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 guild_matches(guild: Optional[discord.Guild], want_id: Optional[int]) -> bool: if want_id is None: return True @@ -946,11 +920,6 @@ def truncate_message(text: str, limit: int = 2000) -> str: 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) - - async def safe_send( channel: discord.abc.Messageable, persona_id: str, diff --git a/flexus_simple_bots/discord_bot/discord_bot.py b/flexus_simple_bots/discord_bot/discord_bot.py index cf0e2fa5..3a6d22d0 100644 --- a/flexus_simple_bots/discord_bot/discord_bot.py +++ b/flexus_simple_bots/discord_bot/discord_bot.py @@ -14,9 +14,14 @@ from flexus_client_kit import ckit_mongo from flexus_client_kit import ckit_shutdown from flexus_client_kit.ckit_connector import ChatConnector, NormalizedEvent +from flexus_client_kit.ckit_connector_discord import ( + discord_bot_api_key_from_external_auth, + log_ctx, + parse_snowflake, + setup_truthy, +) from flexus_client_kit.ckit_connector_discord_gateway import DiscordGatewayConnector from flexus_client_kit.gateway.ckit_gateway_wire import normalized_event_from_dict -from flexus_client_kit.integrations import fi_discord2 as dc from flexus_simple_bots.discord_bot import discord_bot_install from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION @@ -82,10 +87,10 @@ async def _gateway_discord_channel_acl_preflight( try: 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")): + checklist_cid = parse_snowflake(setup.get("checklist_channel_id", "")) + if checklist_cid and not 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", "")) + welcome_cid = parse_snowflake(setup.get("welcome_channel_id", "")) if welcome_cid: await _warn_gateway_channel_acl(connector, persona_id, "welcome_channel", welcome_cid) except (TypeError, ValueError) as e: @@ -148,12 +153,12 @@ def _guild_ids_from_persona(persona: Any, setup: Dict[str, Any]) -> set[int]: ids: set[int] = set() for v in addresses: if isinstance(v, str) and v.startswith("discord:"): - gid = dc.parse_snowflake(v[len("discord:") :]) + gid = parse_snowflake(v[len("discord:") :]) if gid is not None: ids.add(gid) if ids: return ids - legacy_gid = dc.parse_snowflake(setup.get("dc_guild_id", "")) + legacy_gid = parse_snowflake(setup.get("dc_guild_id", "")) if legacy_gid is not None: return {legacy_gid} return set() @@ -191,13 +196,13 @@ async def _maybe_gateway_auto_post_checklist( Replaces the old raw-client path that called channel.send locally. """ try: - if dc.setup_truthy(setup.get("disable_checklist_auto_post")): + if setup_truthy(setup.get("disable_checklist_auto_post")): return gid = int(pl.get("guild_id", 0) or 0) # Empty allowlist must not mean "all guilds" — deny checklist until persona lists guilds. if not gid or not allowed_guild_ids or gid not in allowed_guild_ids: return - cid = dc.parse_snowflake(setup.get("checklist_channel_id", "")) + cid = parse_snowflake(setup.get("checklist_channel_id", "")) if not cid: return body = (setup.get("checklist_message_body") or "").strip() @@ -217,7 +222,7 @@ async def _maybe_gateway_auto_post_checklist( }, ) if not result.ok: - dc.log_ctx(persona_id, gid, "checklist auto-post failed: %s", result.error or "unknown") + log_ctx(persona_id, gid, "checklist auto-post failed: %s", result.error or "unknown") return extra: Dict[str, Any] = {"posted": True, "channel_id": str(cid), "guild_id": str(gid)} data = getattr(result, "data", None) @@ -225,9 +230,9 @@ async def _maybe_gateway_auto_post_checklist( extra["message_id"] = data["message_id"] await checklist_meta_coll.update_one({"_id": meta_id}, {"$set": extra}, upsert=True) except PyMongoError as e: - dc.log_ctx(persona_id, None, "checklist meta PyMongoError: %s %s", type(e).__name__, e) + log_ctx(persona_id, None, "checklist meta PyMongoError: %s %s", type(e).__name__, e) except (TypeError, ValueError, KeyError) as e: - dc.log_ctx(persona_id, None, "checklist auto-post error: %s %s", type(e).__name__, e) + log_ctx(persona_id, None, "checklist auto-post error: %s %s", type(e).__name__, e) async def _handle_reaction_binding_event( @@ -241,7 +246,7 @@ async def _handle_reaction_binding_event( Apply reaction_roles_json using gateway add_role/remove_role (reaction_* events from service_discord_gateway). """ try: - if dc.setup_truthy(setup.get("disable_reaction_roles")): + if setup_truthy(setup.get("disable_reaction_roles")): return try: gid = int(pl.get("guild_id", 0) or 0) @@ -270,10 +275,10 @@ async def _handle_reaction_binding_event( }, ) if not result.ok: - dc.log_ctx(persona_id, gid, "reaction role %s failed: %s", act, result.error or "unknown") + log_ctx(persona_id, gid, "reaction role %s failed: %s", act, result.error or "unknown") return except (TypeError, ValueError, KeyError) as e: - dc.log_ctx(persona_id, None, "reaction binding error: %s %s", type(e).__name__, e) + log_ctx(persona_id, None, "reaction binding error: %s %s", type(e).__name__, e) async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: @@ -289,7 +294,7 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot ) token, hosted_env = _discord_bot_hosted_bot_token() if not token: - token = dc.discord_bot_api_key_from_external_auth(rcx.external_auth) + token = discord_bot_api_key_from_external_auth(rcx.external_auth) hosted_env = "external_auth" if token else None if not token: logger.error( @@ -314,7 +319,7 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot mongo_db = mongo[persona_id_loop + "_db"] rules = ckit_automation_engine.load_rules(persona_setup_raw) - dc.log_ctx(rcx.persona.persona_id, None, "loaded %d automation rules", len(rules)) + log_ctx(rcx.persona.persona_id, None, "loaded %d automation rules", len(rules)) watched_channel_ids: set[int] = set() for r in rules: @@ -327,7 +332,7 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot 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") + 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", "")) @@ -340,7 +345,7 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot rcx.persona.persona_id, ) else: - dc.log_ctx( + log_ctx( rcx.persona.persona_id, None, "allowed guild ids from persona_external_addresses: %s", @@ -453,7 +458,7 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: }, ) if not result.ok: - dc.log_ctx( + log_ctx( rcx.persona.persona_id, gid_ann, "!announce failed: %s", @@ -542,7 +547,7 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: gid_log = int(payload.get("guild_id", 0) or 0) or None except (TypeError, ValueError): gid_log = None - dc.log_ctx(rcx.persona.persona_id, gid_log, "normalized event PyMongoError: %s %s", type(e).__name__, e) + log_ctx(rcx.persona.persona_id, gid_log, "normalized event PyMongoError: %s %s", type(e).__name__, e) except (TypeError, KeyError, ValueError) as e: gid_log = None try: @@ -550,7 +555,7 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: gid_log = int(payload.get("guild_id", 0) or 0) or None except (TypeError, ValueError): gid_log = None - dc.log_ctx(rcx.persona.persona_id, gid_log, "normalized event data error: %s %s", type(e).__name__, e) + log_ctx(rcx.persona.persona_id, gid_log, "normalized event data error: %s %s", type(e).__name__, e) @rcx.on_emessage("DISCORD") async def _on_discord_emessage(emsg) -> None: From f14320c397fc8006cc82e68a22a0409a3ceeb752 Mon Sep 17 00:00:00 2001 From: lev-goryachev Date: Wed, 15 Apr 2026 10:33:27 +0200 Subject: [PATCH 10/10] refactor separate automation catalog from connector runtime Split automation descriptors and Discord rule catalogs out of connector runtime code, keep the local Discord connector path, and remove the dead gateway wire layer so the bot runtime reflects the current architecture. --- flexus_client_kit/ckit_automation_actions.py | 14 +- flexus_client_kit/ckit_automation_catalog.py | 53 +++ .../ckit_automation_v1_schema_build.py | 2 +- flexus_client_kit/ckit_connector.py | 51 +-- flexus_client_kit/ckit_connector_discord.py | 312 +----------------- .../ckit_connector_discord_catalog.py | 306 +++++++++++++++++ .../ckit_connector_discord_gateway.py | 158 --------- .../ckit_connector_discord_local.py | 184 +++++++++++ flexus_client_kit/gateway/__init__.py | 27 -- .../gateway/ckit_gateway_wire.py | 97 ------ flexus_client_kit/integrations/fi_discord2.py | 4 +- flexus_simple_bots/discord_bot/discord_bot.py | 67 ++-- 12 files changed, 580 insertions(+), 695 deletions(-) create mode 100644 flexus_client_kit/ckit_automation_catalog.py create mode 100644 flexus_client_kit/ckit_connector_discord_catalog.py delete mode 100644 flexus_client_kit/ckit_connector_discord_gateway.py create mode 100644 flexus_client_kit/ckit_connector_discord_local.py delete mode 100644 flexus_client_kit/gateway/__init__.py delete mode 100644 flexus_client_kit/gateway/ckit_gateway_wire.py diff --git a/flexus_client_kit/ckit_automation_actions.py b/flexus_client_kit/ckit_automation_actions.py index 7143a972..0540452d 100644 --- a/flexus_client_kit/ckit_automation_actions.py +++ b/flexus_client_kit/ckit_automation_actions.py @@ -4,7 +4,7 @@ The automation engine (ckit_automation_engine) produces flat action dicts with pre-resolved _resolved_body / _resolved_channel_id. This module performs side effects only and returns per-action results for logging. Execution context uses event_member: guild_id, user_id, and optional -discord_username from the inbound gateway event (no persisted CRM rows). +discord_username from the inbound normalized Discord event (no persisted CRM rows). """ from __future__ import annotations @@ -55,7 +55,7 @@ def _guild_user_from_event_member(event_member: dict) -> Tuple[Optional[int], Op async def _do_send_dm(action: dict, ctx: dict) -> dict: - """Deliver a DM via the connector gateway action.""" + """Deliver a DM via connector.execute_action(send_dm).""" try: persona_id = str(ctx.get("persona_id") or "") body_raw = action.get("_resolved_body") @@ -77,7 +77,7 @@ async def _do_send_dm(action: dict, ctx: dict) -> dict: result = await connector.execute_action("send_dm", dm_params) return _result_dict(ok=result.ok, error=result.error) logger.warning( - "send_dm: missing ChatConnector in ctx (gateway-only executor); persona_id=%s", + "send_dm: missing ChatConnector in ctx; persona_id=%s", persona_id, ) return _result_dict(ok=False, error="no_connector") @@ -113,7 +113,7 @@ async def _do_post_to_channel(action: dict, ctx: dict) -> dict: result = await connector.execute_action("post_to_channel", payload) return _result_dict(ok=result.ok, error=result.error) logger.warning( - "post_to_channel: missing ChatConnector in ctx (gateway-only executor); persona_id=%s", + "post_to_channel: missing ChatConnector in ctx; persona_id=%s", persona_id, ) return _result_dict(ok=False, error="no_connector") @@ -126,7 +126,7 @@ async def _do_post_to_channel(action: dict, ctx: dict) -> dict: async def _do_add_role(action: dict, ctx: dict) -> dict: - """Resolve role id and call gateway add_role.""" + """Resolve role id and call connector add_role.""" try: rid = action.get("_resolved_role_id") if rid is None: @@ -157,7 +157,7 @@ async def _do_add_role(action: dict, ctx: dict) -> dict: async def _do_remove_role(action: dict, ctx: dict) -> dict: - """Resolve role id and call gateway remove_role.""" + """Resolve role id and call connector remove_role.""" try: rid = action.get("_resolved_role_id") if rid is None: @@ -188,7 +188,7 @@ async def _do_remove_role(action: dict, ctx: dict) -> dict: async def _do_kick(action: dict, ctx: dict) -> dict: - """Kick the member in context via gateway.""" + """Kick the member in context via connector.execute_action(kick).""" try: em = ctx.get("event_member") if not isinstance(em, dict): diff --git a/flexus_client_kit/ckit_automation_catalog.py b/flexus_client_kit/ckit_automation_catalog.py new file mode 100644 index 00000000..1f13b06b --- /dev/null +++ b/flexus_client_kit/ckit_automation_catalog.py @@ -0,0 +1,53 @@ +# Automation catalog descriptors: persisted-rule semantics and trigger/action metadata for schema assembly. +# Runtime connector contracts (NormalizedEvent, ChatConnector) live in ckit_connector. + +from __future__ import annotations + +import dataclasses +from typing import Any + + +@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 diff --git a/flexus_client_kit/ckit_automation_v1_schema_build.py b/flexus_client_kit/ckit_automation_v1_schema_build.py index a5a501b0..bc81f8af 100644 --- a/flexus_client_kit/ckit_automation_v1_schema_build.py +++ b/flexus_client_kit/ckit_automation_v1_schema_build.py @@ -12,7 +12,7 @@ import copy from typing import Any -from flexus_client_kit.ckit_connector_discord import DISCORD_ACTIONS, DISCORD_TRIGGERS +from flexus_client_kit.ckit_connector_discord_catalog import DISCORD_ACTIONS, DISCORD_TRIGGERS from flexus_client_kit.ckit_automation_schema_defs import ( SCHEMA_ACTION_CALL_GATEKEEPER_PRODUCT, SCHEMA_TRIGGER_MANUAL_CAMPAIGN_PRODUCT, diff --git a/flexus_client_kit/ckit_connector.py b/flexus_client_kit/ckit_connector.py index ff40d768..33ec846e 100644 --- a/flexus_client_kit/ckit_connector.py +++ b/flexus_client_kit/ckit_connector.py @@ -1,4 +1,5 @@ -# Abstract chat platform connector (unified bot plan U3): triggers, actions, normalized events. +# Abstract chat platform connector (unified bot plan U3): normalized events, actions, runtime ChatConnector API. +# Automation catalog descriptors (TriggerDescriptor, ActionDescriptor, SemanticContract) live in ckit_automation_catalog. from __future__ import annotations @@ -7,53 +8,9 @@ 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), - } +from flexus_client_kit.ckit_automation_catalog import ActionDescriptor, TriggerDescriptor - -@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 +logger = logging.getLogger(__name__) @dataclasses.dataclass diff --git a/flexus_client_kit/ckit_connector_discord.py b/flexus_client_kit/ckit_connector_discord.py index 17164e3a..0f7e65f7 100644 --- a/flexus_client_kit/ckit_connector_discord.py +++ b/flexus_client_kit/ckit_connector_discord.py @@ -1,9 +1,8 @@ """ -Discord automation v1 catalogs and assist semantics (JSON Schema fragments, trigger/action descriptors). +Shared Discord helpers (no discord.py socket here): snowflake/setup/auth/logging for bots, connectors, fi_discord2. -Runtime ingress uses the gateway-backed ``DiscordGatewayConnector`` in ``ckit_connector_discord_gateway``; -there is no in-process discord.py client in this module. ``DISCORD_TRIGGERS``, ``DISCORD_ACTIONS``, and -``discord_automation_semantics_bundle()`` remain the single source for schema assembly and reviewer payloads. +Automation v1 trigger/action catalogs and ``discord_automation_semantics_bundle()`` live in +``ckit_connector_discord_catalog``; runtime ``DiscordLocalConnector`` is in ``ckit_connector_discord_local``. """ from __future__ import annotations @@ -11,311 +10,6 @@ import logging from typing import Any, Dict, Optional -from flexus_client_kit.ckit_discord_automation_schema_defs import ( - SCHEMA_ACTION_ADD_ROLE, - SCHEMA_ACTION_KICK, - SCHEMA_ACTION_POST_TO_CHANNEL, - SCHEMA_ACTION_REMOVE_ROLE, - SCHEMA_ACTION_SEND_DM, - SCHEMA_TRIGGER_MEMBER_JOINED, - SCHEMA_TRIGGER_MEMBER_REMOVED, - SCHEMA_TRIGGER_MESSAGE_IN_CHANNEL, -) -from flexus_client_kit.ckit_connector import ( - ActionDescriptor, - SemanticContract, - TriggerDescriptor, - semantic_contract_to_dict, -) - -# --- Automation v1 Discord catalog (same source as supported_triggers / supported_actions / assist) --- - -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 can access.", - rule_author_configures=("trigger.type member_joined only (no extra trigger fields in saved JSON).",), - platform_fills_automatically=( - "When someone joins, the normalized event carries the server id and member id; the payload includes " - "guild id, user id, and display name.", - "The worker builds in-memory event context (ids + username) for templates and actions; no persisted profile.", - ), - runtime_guarantees=( - "A rule matches when its trigger type is member_joined and the incoming event is a join for that server.", - "Conditions and actions apply to the current member in scope.", - ), - 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=( - "The channel reference from the bot setup is resolved to a numeric channel id for matching.", - "The event payload includes channel, server, author, message text, and message id from the live message.", - ), - runtime_guarantees=( - "Matching requires the event channel id to equal the resolved channel id from your setup; " - "a missing or unresolvable channel reference 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=( - "When someone leaves, the normalized event carries the member id, server id, and display name from Discord.", - ), - runtime_guarantees=( - "A rule matches when its trigger type is member_removed and the event is a leave or kick for that member.", - ), - operator_must_not_set=("Extra trigger payload keys beyond type in persisted rules.",), - ), - automation_schema_def=SCHEMA_TRIGGER_MEMBER_REMOVED, - ), -] - - -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 (saved automation) or to explicit ids (programmatic API).", - rule_author_configures=( - "Persisted automation: exactly one of template or template_field for the body (automation_v1).", - "Programmatic call: user_id and text parameters as in parameter_schema.", - ), - platform_fills_automatically=( - "The engine resolves the message body from the template or from a setup-backed field, then delivers it.", - "For saved rules, the recipient is the user id from the triggering event; the action does not supply a separate recipient id.", - ), - runtime_guarantees=( - "If the body is empty after resolution, delivery fails with empty_body.", - "Direct-message delivery uses the resolved recipient id and body text only.", - ), - operator_must_not_set=( - "Persisted rule: user_id field on the action (not in schema); recipient is always the 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 live integration.", - rule_author_configures=( - "Persisted automation: channel_id_field and template (engine also resolves template_field like send_dm).", - "Programmatic call: channel_id text required; optional server_id disambiguates allowed guild when posting.", - ), - platform_fills_automatically=( - "The engine resolves the channel id from the field reference and resolves the message body like direct messages.", - "When posting, the current server scope is passed so delivery can reject unauthorized guilds.", - ), - runtime_guarantees=( - "Posting loads the channel, requires a text channel, and the bot must be allowed in that server or delivery returns guild_not_allowed.", - ), - operator_must_not_set=( - "Persisted rule: server_id on the action; guild is implied by the event or job execution scope.", - ), - ), - automation_schema_def=SCHEMA_ACTION_POST_TO_CHANNEL, - ), - 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=( - "The engine resolves the role id from the field reference; the executor passes the member id and server scope for delivery.", - ), - runtime_guarantees=( - "The member and role must exist in the server; otherwise delivery fails with member_or_role_not_found.", - ), - 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 role resolution and member/server scope 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=( - "The platform uses the current member and server in scope and resolves the reason text before delivery.", - ), - runtime_guarantees=( - "Kick requires the member to be in the guild before kick; fails if the 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=( - "Invalid channel references yield no resolved id; trigger matching and posting fail 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=( - "Body resolution runs for direct message and channel post actions only; other actions do not use it.", - ), - operator_must_not_set=(), - ), - ), -} - - -def discord_automation_semantics_bundle() -> dict[str, Any]: - """ - Flatten trigger/action semantic contracts plus cross-cutting helpers for assist payloads. - - Returns a JSON-serializable dict (semantic_schema_version, triggers, actions, cross_cutting). - """ - try: - 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), - } - except (TypeError, KeyError, AttributeError) as e: - raise RuntimeError("discord_automation_semantics_bundle: failed to assemble semantics dict") from e - - -# --------------------------------------------------------------------------- -# Shared Discord helpers (no discord.py): used by gateway-only discord_bot and -# fi_discord2 so snowflake/setup/auth/logging stay one place (architecture: thin -# connector surface, heavy integration defers to these). -# --------------------------------------------------------------------------- - # Same logger name as fi_discord2.IntegrationDiscord / community utilities so log # lines keep the same logger and formatting when code moves between modules. _discord_shared_logger = logging.getLogger("discord") diff --git a/flexus_client_kit/ckit_connector_discord_catalog.py b/flexus_client_kit/ckit_connector_discord_catalog.py new file mode 100644 index 00000000..7ba44893 --- /dev/null +++ b/flexus_client_kit/ckit_connector_discord_catalog.py @@ -0,0 +1,306 @@ +""" +Discord automation v1 catalogs and assist semantics (JSON Schema fragments, trigger/action descriptors). + +No discord.py: ``DISCORD_TRIGGERS``, ``DISCORD_ACTIONS``, and ``discord_automation_semantics_bundle()`` +feed schema assembly and reviewer payloads. Shared Discord runtime helpers stay in ``ckit_connector_discord``. +""" + +from __future__ import annotations + +from typing import Any + +from flexus_client_kit.ckit_automation_catalog import ( + ActionDescriptor, + SemanticContract, + TriggerDescriptor, + semantic_contract_to_dict, +) +from flexus_client_kit.ckit_discord_automation_schema_defs import ( + SCHEMA_ACTION_ADD_ROLE, + SCHEMA_ACTION_KICK, + SCHEMA_ACTION_POST_TO_CHANNEL, + SCHEMA_ACTION_REMOVE_ROLE, + SCHEMA_ACTION_SEND_DM, + SCHEMA_TRIGGER_MEMBER_JOINED, + SCHEMA_TRIGGER_MEMBER_REMOVED, + SCHEMA_TRIGGER_MESSAGE_IN_CHANNEL, +) + +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 can access.", + rule_author_configures=("trigger.type member_joined only (no extra trigger fields in saved JSON).",), + platform_fills_automatically=( + "When someone joins, the normalized event carries the server id and member id; the payload includes " + "guild id, user id, and display name.", + "The worker builds in-memory event context (ids + username) for templates and actions; no persisted profile.", + ), + runtime_guarantees=( + "A rule matches when its trigger type is member_joined and the incoming event is a join for that server.", + "Conditions and actions apply to the current member in scope.", + ), + 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=( + "The channel reference from the bot setup is resolved to a numeric channel id for matching.", + "The event payload includes channel, server, author, message text, and message id from the live message.", + ), + runtime_guarantees=( + "Matching requires the event channel id to equal the resolved channel id from your setup; " + "a missing or unresolvable channel reference 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=( + "When someone leaves, the normalized event carries the member id, server id, and display name from Discord.", + ), + runtime_guarantees=( + "A rule matches when its trigger type is member_removed and the event is a leave or kick for that member.", + ), + operator_must_not_set=("Extra trigger payload keys beyond type in persisted rules.",), + ), + automation_schema_def=SCHEMA_TRIGGER_MEMBER_REMOVED, + ), +] + + +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 (saved automation) or to explicit ids (programmatic API).", + rule_author_configures=( + "Persisted automation: exactly one of template or template_field for the body (automation_v1).", + "Programmatic call: user_id and text parameters as in parameter_schema.", + ), + platform_fills_automatically=( + "The engine resolves the message body from the template or from a setup-backed field, then delivers it.", + "For saved rules, the recipient is the user id from the triggering event; the action does not supply a separate recipient id.", + ), + runtime_guarantees=( + "If the body is empty after resolution, delivery fails with empty_body.", + "Direct-message delivery uses the resolved recipient id and body text only.", + ), + operator_must_not_set=( + "Persisted rule: user_id field on the action (not in schema); recipient is always the 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 live integration.", + rule_author_configures=( + "Persisted automation: channel_id_field and template (engine also resolves template_field like send_dm).", + "Programmatic call: channel_id text required; optional server_id disambiguates allowed guild when posting.", + ), + platform_fills_automatically=( + "The engine resolves the channel id from the field reference and resolves the message body like direct messages.", + "When posting, the current server scope is passed so delivery can reject unauthorized guilds.", + ), + runtime_guarantees=( + "Posting loads the channel, requires a text channel, and the bot must be allowed in that server or delivery returns guild_not_allowed.", + ), + operator_must_not_set=( + "Persisted rule: server_id on the action; guild is implied by the event or job execution scope.", + ), + ), + automation_schema_def=SCHEMA_ACTION_POST_TO_CHANNEL, + ), + 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=( + "The engine resolves the role id from the field reference; the executor passes the member id and server scope for delivery.", + ), + runtime_guarantees=( + "The member and role must exist in the server; otherwise delivery fails with member_or_role_not_found.", + ), + 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 role resolution and member/server scope 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=( + "The platform uses the current member and server in scope and resolves the reason text before delivery.", + ), + runtime_guarantees=( + "Kick requires the member to be in the guild before kick; fails if the 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=( + "Invalid channel references yield no resolved id; trigger matching and posting fail 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=( + "Body resolution runs for direct message and channel post actions only; other actions do not use it.", + ), + operator_must_not_set=(), + ), + ), +} + + +def discord_automation_semantics_bundle() -> dict[str, Any]: + """ + Flatten trigger/action semantic contracts plus cross-cutting helpers for assist payloads. + + Returns a JSON-serializable dict (semantic_schema_version, triggers, actions, cross_cutting). + """ + try: + 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), + } + except (TypeError, KeyError, AttributeError) as e: + raise RuntimeError("discord_automation_semantics_bundle: failed to assemble semantics dict") from e diff --git a/flexus_client_kit/ckit_connector_discord_gateway.py b/flexus_client_kit/ckit_connector_discord_gateway.py deleted file mode 100644 index e90f3fff..00000000 --- a/flexus_client_kit/ckit_connector_discord_gateway.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Callable, Iterable -from typing import Any - -import gql -import gql.transport.exceptions - -from flexus_client_kit import ckit_client -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_wire import gateway_instance_key_from_token - -logger = logging.getLogger(__name__) - -# Module-level GQL document so it is parsed once, not on every call. -_DISCORD_GW_ACTION = gql.gql(""" - mutation BotDiscordGatewayAction( - $persona_id: String! - $instance_key: String! - $action_type: String! - $params: JSON! - ) { - botDiscordGatewayAction: bot_discord_gateway_action( - persona_id: $persona_id - instance_key: $instance_key - action_type: $action_type - params: $params - ) - } -""") - - -class DiscordGatewayConnector(ChatConnector): - """Backend-backed gateway connector. - - Inbound events arrive via the standard bot_threads_calls_tasks / on_emessage("DISCORD") - path — this connector no longer opens Redis event consumers. - - Outbound actions are forwarded to the backend bot_discord_gateway_action mutation which - internally routes commands through the trusted Redis cmd channel to service_discord_gateway. - """ - - def __init__( - self, - token: str, - persona_id: str, - fclient: ckit_client.FlexusClient, - *, - initial_guild_ids: set[int] | None = None, - ) -> None: - self._persona_id = persona_id - # Instance key derived locally from the token — pure function, no Redis needed. - self._instance_key = gateway_instance_key_from_token(token) - self._fclient = fclient - self._allowed_guild_ids: set[int] = set(initial_guild_ids or []) - # Kept for ChatConnector ABC compliance; not used in gateway mode - # (events arrive via on_emessage instead of this callback). - 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) - - 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: - # Stored for ABC compliance; in gateway mode the bot uses on_emessage("DISCORD") instead. - 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: - """Update the in-memory guild allowlist. No Redis registration needed.""" - 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 connect(self) -> None: - """Mark connector as active. No Redis subscriptions are opened.""" - self._connected = True - - async def disconnect(self) -> None: - """Mark connector as inactive.""" - self._connected = False - - async def get_user_info(self, user_id: str, server_id: str = "") -> dict | None: - if not self._connected: - return None - r = await self.execute_action( - "get_user_info", - {"user_id": str(user_id), "server_id": str(server_id or "")}, - ) - 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.execute_action( - "get_channel", - {"channel_id": str(channel_id)}, - ) - if not r.ok or not r.data: - return None - return dict(r.data) - - async def execute_action(self, action_type: str, params: dict) -> ActionResult: - if not self._connected: - return ActionResult(ok=False, error="not_connected") - try: - http_client = await self._fclient.use_http_on_behalf( - self._persona_id, - "discord_gw_action", - execute_timeout=95.0, - ) - async with http_client as http: - result = await http.execute( - _DISCORD_GW_ACTION, - variable_values={ - "persona_id": self._persona_id, - "instance_key": self._instance_key, - "action_type": action_type, - "params": params, - }, - ) - data = result.get("botDiscordGatewayAction") or {} - return ActionResult( - ok=bool(data.get("ok")), - error=data.get("error"), - data=data.get("data"), - ) - except gql.transport.exceptions.TransportError as e: - logger.warning("discord gateway action transport error: %s %s", type(e).__name__, e) - return ActionResult(ok=False, error="transport_error") diff --git a/flexus_client_kit/ckit_connector_discord_local.py b/flexus_client_kit/ckit_connector_discord_local.py new file mode 100644 index 00000000..990140f5 --- /dev/null +++ b/flexus_client_kit/ckit_connector_discord_local.py @@ -0,0 +1,184 @@ +""" +In-process Discord ``ChatConnector`` for the discord_bot worker: one discord.py client per +process, events as ``NormalizedEvent`` via ``bind_discord_gateway_client``, actions via +``discord_run_platform_action``. Guild allowlist is enforced on both ingress and +``resolve_guild`` so behavior matches the former gateway emessage path. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable, Iterable +from typing import Any + +import discord +from discord.errors import DiscordException + +from flexus_client_kit.ckit_automation_catalog import ActionDescriptor, TriggerDescriptor +from flexus_client_kit.ckit_connector import ActionResult, ChatConnector, NormalizedEvent +from flexus_client_kit.ckit_connector_discord_catalog import DISCORD_ACTIONS, DISCORD_TRIGGERS +from flexus_client_kit.integrations.fi_discord2 import ( + bind_discord_gateway_client, + close_discord_client, + discord_run_platform_action, + start_discord_client, +) + +logger = logging.getLogger(__name__) + + +class DiscordLocalConnector(ChatConnector): + """ + Live discord.py client in the bot process: same normalized events and actions as the + gateway service, without Redis or ``on_emessage("DISCORD")``. + """ + + def __init__( + self, + token: str, + persona_id: str, + *, + initial_guild_ids: set[int] | None = None, + ) -> None: + self._token = (token or "").strip() + self._persona_id = persona_id + self._allowed_guild_ids: set[int] = {int(x) for x in (initial_guild_ids or set())} + self._client: discord.Client | None = None + self._runner_task: asyncio.Task[None] | None = None + 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 self._client + + @property + def allowed_guild_ids(self) -> frozenset[int]: + return frozenset(self._allowed_guild_ids) + + 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) + + 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 _resolve_guild(self, gid: int) -> discord.Guild | None: + try: + if not self._allowed_guild_ids or gid not in self._allowed_guild_ids: + return None + if self._client is None: + return None + g = self._client.get_guild(gid) + return g + except (TypeError, ValueError, AttributeError) as e: + logger.warning("resolve_guild: %s %s", type(e).__name__, e) + return None + + async def connect(self) -> None: + try: + if not self._token: + raise ValueError("DiscordLocalConnector: empty token") + + def register(client: discord.Client) -> None: + async def emit(ev: NormalizedEvent) -> None: + try: + gid = int(ev.server_id) + except (TypeError, ValueError) as e: + logger.warning( + "%s discord emit skip (bad server_id): %s %s", + self._persona_id, + type(e).__name__, + e, + ) + return + allowed = self._allowed_guild_ids + if not allowed or gid not in allowed: + return + cb = self._event_callback + if cb is None: + return + await cb(ev) + + bind_discord_gateway_client(client, emit) + + self._client, self._runner_task = await start_discord_client( + self._token, + self._persona_id, + register, + ) + self._connected = True + except (ValueError, DiscordException, RuntimeError, OSError) as e: + logger.error("DiscordLocalConnector connect failed: %s %s", type(e).__name__, e) + raise + + async def disconnect(self) -> None: + try: + await close_discord_client(self._client, self._runner_task) + except asyncio.CancelledError: + raise + except (DiscordException, RuntimeError) as e: + logger.warning("DiscordLocalConnector disconnect: %s %s", type(e).__name__, e) + finally: + self._client = None + self._runner_task = None + self._connected = False + + async def execute_action(self, action_type: str, params: dict) -> ActionResult: + try: + if not self._connected or self._client is None: + return ActionResult(ok=False, error="not_connected") + return await discord_run_platform_action( + self._client, + self._persona_id, + action_type, + params, + resolve_guild=self._resolve_guild, + ) + except DiscordException as e: + logger.warning( + "DiscordLocalConnector execute_action %s: %s %s", + action_type, + type(e).__name__, + e, + ) + return ActionResult(ok=False, error="%s: %s" % (type(e).__name__, e)) + + async def get_user_info(self, user_id: str, server_id: str = "") -> dict | None: + try: + r = await self.execute_action( + "get_user_info", + {"user_id": str(user_id), "server_id": str(server_id or "")}, + ) + if not r.ok or not r.data: + return None + return dict(r.data) + except (TypeError, ValueError, KeyError) as e: + logger.warning("get_user_info: %s %s", type(e).__name__, e) + return None + + async def get_channel(self, channel_id: str) -> dict | None: + try: + r = await self.execute_action("get_channel", {"channel_id": str(channel_id)}) + if not r.ok or not r.data: + return None + return dict(r.data) + except (TypeError, ValueError, KeyError) as e: + logger.warning("get_channel: %s %s", type(e).__name__, e) + return None diff --git a/flexus_client_kit/gateway/__init__.py b/flexus_client_kit/gateway/__init__.py deleted file mode 100644 index fe8e5be3..00000000 --- a/flexus_client_kit/gateway/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -from flexus_client_kit.gateway.ckit_gateway_wire import ( - WIRE_V, - GatewayActionCommandEnvelope, - GatewayActionResultEnvelope, - action_result_from_dict, - action_result_to_dict, - channel_cmd_discord, - channel_reply_discord, - gateway_instance_key_from_token, - gateway_result_envelope_from_dict, - normalized_event_from_dict, - parse_action_command_envelope, -) - -__all__ = [ - "WIRE_V", - "GatewayActionCommandEnvelope", - "GatewayActionResultEnvelope", - "action_result_from_dict", - "action_result_to_dict", - "channel_cmd_discord", - "channel_reply_discord", - "gateway_instance_key_from_token", - "gateway_result_envelope_from_dict", - "normalized_event_from_dict", - "parse_action_command_envelope", -] diff --git a/flexus_client_kit/gateway/ckit_gateway_wire.py b/flexus_client_kit/gateway/ckit_gateway_wire.py deleted file mode 100644 index 2b2eb44f..00000000 --- a/flexus_client_kit/gateway/ckit_gateway_wire.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import annotations - -import dataclasses -import hashlib -from typing import Any - -from flexus_client_kit.ckit_connector import ActionResult, NormalizedEvent - -# Wire version for cmd/reply JSON envelopes (service_discord_gateway <-> Redis <-> backend callers). -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_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 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_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 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_discord2.py b/flexus_client_kit/integrations/fi_discord2.py index ea1dfcc8..1904852d 100644 --- a/flexus_client_kit/integrations/fi_discord2.py +++ b/flexus_client_kit/integrations/fi_discord2.py @@ -1260,9 +1260,9 @@ def bind_discord_gateway_client( emit: Callable[[NormalizedEvent], Awaitable[None]], ) -> None: """ - Attach gateway Discord event handlers to ``client`` for the backend sidecar process. + Attach Discord event handlers to ``client`` that emit ``NormalizedEvent`` (same shape as gateway wire). - Emits NormalizedEvent instances into ``emit`` (typically routed to process_external_webhook). + Used by ``DiscordLocalConnector`` in the discord_bot worker to keep runtime event shape stable. """ @client.event diff --git a/flexus_simple_bots/discord_bot/discord_bot.py b/flexus_simple_bots/discord_bot/discord_bot.py index 3a6d22d0..519842a2 100644 --- a/flexus_simple_bots/discord_bot/discord_bot.py +++ b/flexus_simple_bots/discord_bot/discord_bot.py @@ -20,26 +20,25 @@ parse_snowflake, setup_truthy, ) -from flexus_client_kit.ckit_connector_discord_gateway import DiscordGatewayConnector -from flexus_client_kit.gateway.ckit_gateway_wire import normalized_event_from_dict +from flexus_client_kit.ckit_connector_discord_local import DiscordLocalConnector from flexus_simple_bots.discord_bot import discord_bot_install from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION logger = logging.getLogger("discord_bot") -async def _warn_gateway_channel_acl( +async def _warn_discord_channel_acl( connector: ChatConnector, persona_id: str, purpose_label: str, channel_id: int, ) -> None: - """Log when the gateway cannot see a channel or the bot lacks common text permissions.""" + """Log when the connector cannot see a channel or the bot lacks common text permissions.""" try: info = await connector.get_channel(str(channel_id)) if info is None: logger.warning( - "%s gateway preflight [%s]: channel_id=%s not reachable " + "%s channel preflight [%s]: channel_id=%s not reachable " "(missing, not a guild channel, or guild not allowlisted)", persona_id, purpose_label, @@ -59,7 +58,7 @@ async def _warn_gateway_channel_acl( if not missing: return logger.warning( - "%s gateway preflight [%s]: channel_id=%s guild_id=%s name=%r missing permissions: %s", + "%s channel preflight [%s]: channel_id=%s guild_id=%s name=%r missing permissions: %s", persona_id, purpose_label, info.get("channel_id", str(channel_id)), @@ -69,7 +68,7 @@ async def _warn_gateway_channel_acl( ) except (TypeError, ValueError, AttributeError) as e: logger.warning( - "%s gateway preflight [%s] failed: %s %s", + "%s channel preflight [%s] failed: %s %s", persona_id, purpose_label, type(e).__name__, @@ -77,7 +76,7 @@ async def _warn_gateway_channel_acl( ) -async def _gateway_discord_channel_acl_preflight( +async def _discord_channel_acl_preflight( connector: ChatConnector, persona_id: str, watched_channel_ids: set[int], @@ -86,13 +85,13 @@ async def _gateway_discord_channel_acl_preflight( """Best-effort permission warnings for watched channels and checklist/welcome targets.""" try: for cid in sorted(watched_channel_ids): - await _warn_gateway_channel_acl(connector, persona_id, "watched message_in_channel", cid) + await _warn_discord_channel_acl(connector, persona_id, "watched message_in_channel", cid) checklist_cid = parse_snowflake(setup.get("checklist_channel_id", "")) if checklist_cid and not setup_truthy(setup.get("disable_checklist_auto_post")): - await _warn_gateway_channel_acl(connector, persona_id, "checklist_channel", checklist_cid) + await _warn_discord_channel_acl(connector, persona_id, "checklist_channel", checklist_cid) welcome_cid = parse_snowflake(setup.get("welcome_channel_id", "")) if welcome_cid: - await _warn_gateway_channel_acl(connector, persona_id, "welcome_channel", welcome_cid) + await _warn_discord_channel_acl(connector, persona_id, "welcome_channel", welcome_cid) except (TypeError, ValueError) as e: logger.warning("%s acl preflight error: %s %s", persona_id, type(e).__name__, e) @@ -182,7 +181,7 @@ def _event_member_dict(guild_id: int, user_id: int, pl: Dict[str, Any]) -> Dict[ } -async def _maybe_gateway_auto_post_checklist( +async def _maybe_auto_post_checklist( connector: ChatConnector, setup: Dict[str, Any], mongo_db: Any, @@ -191,9 +190,7 @@ async def _maybe_gateway_auto_post_checklist( allowed_guild_ids: frozenset[int], ) -> None: """ - One-time checklist message per guild via gateway post_to_channel + dc_onboarding_meta marker. - - Replaces the old raw-client path that called channel.send locally. + One-time checklist message per guild via connector post_to_channel + dc_onboarding_meta marker. """ try: if setup_truthy(setup.get("disable_checklist_auto_post")): @@ -243,7 +240,7 @@ async def _handle_reaction_binding_event( pl: Dict[str, Any], ) -> None: """ - Apply reaction_roles_json using gateway add_role/remove_role (reaction_* events from service_discord_gateway). + Apply reaction_roles_json using connector add_role/remove_role (reaction_* events from the live client). """ try: if setup_truthy(setup.get("disable_reaction_roles")): @@ -282,7 +279,7 @@ async def _handle_reaction_binding_event( async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext) -> None: - """Discord community bot: gateway-only ingress (on_emessage DISCORD); no in-process Discord socket.""" + """Discord community bot: in-process discord.py client; events via ChatConnector.on_event.""" connector: ChatConnector | None = None mongo: Any = None persona_id_loop = rcx.persona.persona_id @@ -307,8 +304,7 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot return logger.info( - "%s Discord runtime: gateway-only (token source=%s, backend=%s); " - "ingress via on_emessage(DISCORD); service_discord_gateway must be running.", + "%s Discord runtime: in-process client (token source=%s, backend=%s).", rcx.persona.persona_id, hosted_env, fclient.base_url_http, @@ -352,13 +348,11 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot sorted(initial_guild_ids), ) - gw = DiscordGatewayConnector( + connector = DiscordLocalConnector( token, rcx.persona.persona_id, - fclient, initial_guild_ids=initial_guild_ids, ) - connector = gw augmented_setup = dict(setup) augmented_setup["_format_mention"] = connector.format_mention @@ -366,13 +360,13 @@ async def discord_bot_main_loop(fclient: ckit_client.FlexusClient, rcx: ckit_bot await ckit_messages.ensure_message_indexes(mongo_db) async def handle_normalized_event(event: NormalizedEvent) -> None: - """Dispatch one gateway-normalized Discord event (member, message, checklist, reactions).""" + """Dispatch one normalized Discord event (member, message, checklist, reactions).""" try: persona_id = rcx.persona.persona_id pl = event.payload if isinstance(event.payload, dict) else {} if event.event_type == "server_connected": - await _maybe_gateway_auto_post_checklist( + await _maybe_auto_post_checklist( connector, setup, mongo_db, @@ -557,32 +551,11 @@ async def handle_normalized_event(event: NormalizedEvent) -> None: gid_log = None log_ctx(rcx.persona.persona_id, gid_log, "normalized event data error: %s %s", type(e).__name__, e) - @rcx.on_emessage("DISCORD") - async def _on_discord_emessage(emsg) -> None: - """Inbound path: backend delivers gateway-normalized Discord payloads here.""" - try: - ev = normalized_event_from_dict(emsg.emsg_payload) - except (KeyError, TypeError, ValueError) as e: - logger.warning( - "%s discord emessage parse error: %s %s", - rcx.persona.persona_id, - type(e).__name__, - e, - ) - return - try: - gid = int(ev.server_id) - except (TypeError, ValueError): - return - allowed = connector.allowed_guild_ids - # Deny-all when allowlist is empty; do not treat empty as unrestricted. - if not allowed or gid not in allowed: - return - await handle_normalized_event(ev) + connector.on_event(handle_normalized_event) await connector.connect() - await _gateway_discord_channel_acl_preflight( + await _discord_channel_acl_preflight( connector, rcx.persona.persona_id, watched_channel_ids,