{"ok":true,"files":{"runtime/package-lock.json":"{\n  \"name\": \"bottly-bot-runtime\",\n  \"version\": \"1.0.0\",\n  \"lockfileVersion\": 3,\n  \"requires\": true,\n  \"packages\": {\n    \"\": {\n      \"name\": \"bottly-bot-runtime\",\n      \"version\": \"1.0.0\",\n      \"dependencies\": {\n        \"discord.js\": \"14.16.3\"\n      },\n      \"devDependencies\": {\n        \"@types/node\": \"22.16.5\",\n        \"typescript\": \"5.8.3\"\n      },\n      \"engines\": {\n        \"node\": \">=20.11.0\"\n      }\n    },\n    \"node_modules/@discordjs/builders\": {\n      \"version\": \"1.14.1\",\n      \"resolved\": \"https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz\",\n      \"integrity\": \"sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==\",\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@discordjs/formatters\": \"^0.6.2\",\n        \"@discordjs/util\": \"^1.2.0\",\n        \"@sapphire/shapeshift\": \"^4.0.0\",\n        \"discord-api-types\": \"^0.38.40\",\n        \"fast-deep-equal\": \"^3.1.3\",\n        \"ts-mixer\": \"^6.0.4\",\n        \"tslib\": \"^2.6.3\"\n      },\n      \"engines\": {\n        \"node\": \">=16.11.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/discordjs/discord.js?sponsor\"\n      }\n    },\n    \"node_modules/@discordjs/builders/node_modules/@discordjs/formatters\": {\n      \"version\": \"0.6.2\",\n      \"resolved\": \"https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz\",\n      \"integrity\": \"sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==\",\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"discord-api-types\": \"^0.38.33\"\n      },\n      \"engines\": {\n        \"node\": \">=16.11.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/discordjs/discord.js?sponsor\"\n      }\n    },\n    \"node_modules/@discordjs/builders/node_modules/discord-api-types\": {\n      \"version\": \"0.38.54\",\n      \"resolved\": \"https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.54.tgz\",\n      \"integrity\": \"sha512-3704EKdPtVl0Mozoe6uBJQ8GNmUkH8c81nfXkdSBx+Um8GN3FI/003uTY0Pg0A4KjL8g2Q+4v5cHR247kv6vwA==\",\n      \"license\": \"MIT\",\n      \"workspaces\": [\n        \"scripts/actions/documentation\"\n      ]\n    },\n    \"node_modules/@discordjs/collection\": {\n      \"version\": \"1.5.3\",\n      \"resolved\": \"https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz\",\n      \"integrity\": \"sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==\",\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \">=16.11.0\"\n      }\n    },\n    \"node_modules/@discordjs/formatters\": {\n      \"version\": \"0.5.0\",\n      \"resolved\": \"https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.5.0.tgz\",\n      \"integrity\": \"sha512-98b3i+Y19RFq1Xke4NkVY46x8KjJQjldHUuEbCqMvp1F5Iq9HgnGpu91jOi/Ufazhty32eRsKnnzS8n4c+L93g==\",\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"discord-api-types\": \"0.37.97\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/discordjs/discord.js?sponsor\"\n      }\n    },\n    \"node_modules/@discordjs/formatters/node_modules/discord-api-types\": {\n      \"version\": \"0.37.97\",\n      \"resolved\": \"https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.97.tgz\",\n      \"integrity\": \"sha512-No1BXPcVkyVD4ZVmbNgDKaBoqgeQ+FJpzZ8wqHkfmBnTZig1FcH3iPPersiK1TUIAzgClh2IvOuVUYfcWLQAOA==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@discordjs/rest\": {\n      \"version\": \"2.6.3\",\n      \"resolved\": \"https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz\",\n      \"integrity\": \"sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==\",\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@discordjs/collection\": \"^2.1.1\",\n        \"@discordjs/util\": \"^1.2.0\",\n        \"@sapphire/async-queue\": \"^1.5.3\",\n        \"@sapphire/snowflake\": \"^3.5.5\",\n        \"@vladfrangu/async_event_emitter\": \"^2.4.6\",\n        \"discord-api-types\": \"^0.38.50\",\n        \"magic-bytes.js\": \"^1.13.0\",\n        \"tslib\": \"^2.6.3\",\n        \"undici\": \"^6.27.0\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/discordjs/discord.js?sponsor\"\n      }\n    },\n    \"node_modules/@discordjs/rest/node_modules/@discordjs/collection\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz\",\n      \"integrity\": \"sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==\",\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/discordjs/discord.js?sponsor\"\n      }\n    },\n    \"node_modules/@discordjs/rest/node_modules/@sapphire/snowflake\": {\n      \"version\": \"3.5.5\",\n      \"resolved\": \"https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz\",\n      \"integrity\": \"sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=v14.0.0\",\n        \"npm\": \">=7.0.0\"\n      }\n    },\n    \"node_modules/@discordjs/rest/node_modules/discord-api-types\": {\n      \"version\": \"0.38.54\",\n      \"resolved\": \"https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.54.tgz\",\n      \"integrity\": \"sha512-3704EKdPtVl0Mozoe6uBJQ8GNmUkH8c81nfXkdSBx+Um8GN3FI/003uTY0Pg0A4KjL8g2Q+4v5cHR247kv6vwA==\",\n      \"license\": \"MIT\",\n      \"workspaces\": [\n        \"scripts/actions/documentation\"\n      ]\n    },\n    \"node_modules/@discordjs/rest/node_modules/undici\": {\n      \"version\": \"6.28.0\",\n      \"resolved\": \"https://registry.npmjs.org/undici/-/undici-6.28.0.tgz\",\n      \"integrity\": \"sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=18.17\"\n      }\n    },\n    \"node_modules/@discordjs/util\": {\n      \"version\": \"1.2.0\",\n      \"resolved\": \"https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz\",\n      \"integrity\": \"sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==\",\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"discord-api-types\": \"^0.38.33\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/discordjs/discord.js?sponsor\"\n      }\n    },\n    \"node_modules/@discordjs/util/node_modules/discord-api-types\": {\n      \"version\": \"0.38.54\",\n      \"resolved\": \"https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.54.tgz\",\n      \"integrity\": \"sha512-3704EKdPtVl0Mozoe6uBJQ8GNmUkH8c81nfXkdSBx+Um8GN3FI/003uTY0Pg0A4KjL8g2Q+4v5cHR247kv6vwA==\",\n      \"license\": \"MIT\",\n      \"workspaces\": [\n        \"scripts/actions/documentation\"\n      ]\n    },\n    \"node_modules/@discordjs/ws\": {\n      \"version\": \"1.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@discordjs/ws/-/ws-1.1.1.tgz\",\n      \"integrity\": \"sha512-PZ+vLpxGCRtmr2RMkqh8Zp+BenUaJqlS6xhgWKEZcgC/vfHLEzpHtKkB0sl3nZWpwtcKk6YWy+pU3okL2I97FA==\",\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@discordjs/collection\": \"^2.1.0\",\n        \"@discordjs/rest\": \"^2.3.0\",\n        \"@discordjs/util\": \"^1.1.0\",\n        \"@sapphire/async-queue\": \"^1.5.2\",\n        \"@types/ws\": \"^8.5.10\",\n        \"@vladfrangu/async_event_emitter\": \"^2.2.4\",\n        \"discord-api-types\": \"0.37.83\",\n        \"tslib\": \"^2.6.2\",\n        \"ws\": \"^8.16.0\"\n      },\n      \"engines\": {\n        \"node\": \">=16.11.0\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/discordjs/discord.js?sponsor\"\n      }\n    },\n    \"node_modules/@discordjs/ws/node_modules/@discordjs/collection\": {\n      \"version\": \"2.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz\",\n      \"integrity\": \"sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==\",\n      \"license\": \"Apache-2.0\",\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/discordjs/discord.js?sponsor\"\n      }\n    },\n    \"node_modules/@discordjs/ws/node_modules/discord-api-types\": {\n      \"version\": \"0.37.83\",\n      \"resolved\": \"https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.83.tgz\",\n      \"integrity\": \"sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/@sapphire/async-queue\": {\n      \"version\": \"1.5.5\",\n      \"resolved\": \"https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz\",\n      \"integrity\": \"sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=v14.0.0\",\n        \"npm\": \">=7.0.0\"\n      }\n    },\n    \"node_modules/@sapphire/shapeshift\": {\n      \"version\": \"4.0.0\",\n      \"resolved\": \"https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz\",\n      \"integrity\": \"sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"fast-deep-equal\": \"^3.1.3\",\n        \"lodash\": \"^4.17.21\"\n      },\n      \"engines\": {\n        \"node\": \">=v16\"\n      }\n    },\n    \"node_modules/@sapphire/snowflake\": {\n      \"version\": \"3.5.3\",\n      \"resolved\": \"https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz\",\n      \"integrity\": \"sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=v14.0.0\",\n        \"npm\": \">=7.0.0\"\n      }\n    },\n    \"node_modules/@types/node\": {\n      \"version\": \"22.16.5\",\n      \"resolved\": \"https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz\",\n      \"integrity\": \"sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"undici-types\": \"~6.21.0\"\n      }\n    },\n    \"node_modules/@types/ws\": {\n      \"version\": \"8.18.1\",\n      \"resolved\": \"https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz\",\n      \"integrity\": \"sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==\",\n      \"license\": \"MIT\",\n      \"dependencies\": {\n        \"@types/node\": \"*\"\n      }\n    },\n    \"node_modules/@vladfrangu/async_event_emitter\": {\n      \"version\": \"2.4.7\",\n      \"resolved\": \"https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz\",\n      \"integrity\": \"sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=v14.0.0\",\n        \"npm\": \">=7.0.0\"\n      }\n    },\n    \"node_modules/discord-api-types\": {\n      \"version\": \"0.37.100\",\n      \"resolved\": \"https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.100.tgz\",\n      \"integrity\": \"sha512-a8zvUI0GYYwDtScfRd/TtaNBDTXwP5DiDVX7K5OmE+DRT57gBqKnwtOC5Ol8z0mRW8KQfETIgiB8U0YZ9NXiCA==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/discord.js\": {\n      \"version\": \"14.16.3\",\n      \"resolved\": \"https://registry.npmjs.org/discord.js/-/discord.js-14.16.3.tgz\",\n      \"integrity\": \"sha512-EPCWE9OkA9DnFFNrO7Kl1WHHDYFXu3CNVFJg63bfU7hVtjZGyhShwZtSBImINQRWxWP2tgo2XI+QhdXx28r0aA==\",\n      \"deprecated\": \"This version is deprecated. Please use a newer version\",\n      \"license\": \"Apache-2.0\",\n      \"dependencies\": {\n        \"@discordjs/builders\": \"^1.9.0\",\n        \"@discordjs/collection\": \"1.5.3\",\n        \"@discordjs/formatters\": \"^0.5.0\",\n        \"@discordjs/rest\": \"^2.4.0\",\n        \"@discordjs/util\": \"^1.1.1\",\n        \"@discordjs/ws\": \"1.1.1\",\n        \"@sapphire/snowflake\": \"3.5.3\",\n        \"discord-api-types\": \"0.37.100\",\n        \"fast-deep-equal\": \"3.1.3\",\n        \"lodash.snakecase\": \"4.1.1\",\n        \"tslib\": \"^2.6.3\",\n        \"undici\": \"6.19.8\"\n      },\n      \"engines\": {\n        \"node\": \">=18\"\n      },\n      \"funding\": {\n        \"url\": \"https://github.com/discordjs/discord.js?sponsor\"\n      }\n    },\n    \"node_modules/fast-deep-equal\": {\n      \"version\": \"3.1.3\",\n      \"resolved\": \"https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz\",\n      \"integrity\": \"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/lodash\": {\n      \"version\": \"4.18.1\",\n      \"resolved\": \"https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz\",\n      \"integrity\": \"sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/lodash.snakecase\": {\n      \"version\": \"4.1.1\",\n      \"resolved\": \"https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz\",\n      \"integrity\": \"sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/magic-bytes.js\": {\n      \"version\": \"1.13.1\",\n      \"resolved\": \"https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz\",\n      \"integrity\": \"sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/ts-mixer\": {\n      \"version\": \"6.0.4\",\n      \"resolved\": \"https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz\",\n      \"integrity\": \"sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/tslib\": {\n      \"version\": \"2.8.1\",\n      \"resolved\": \"https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz\",\n      \"integrity\": \"sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==\",\n      \"license\": \"0BSD\"\n    },\n    \"node_modules/typescript\": {\n      \"version\": \"5.8.3\",\n      \"resolved\": \"https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz\",\n      \"integrity\": \"sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==\",\n      \"dev\": true,\n      \"license\": \"Apache-2.0\",\n      \"bin\": {\n        \"tsc\": \"bin/tsc\",\n        \"tsserver\": \"bin/tsserver\"\n      },\n      \"engines\": {\n        \"node\": \">=14.17\"\n      }\n    },\n    \"node_modules/undici\": {\n      \"version\": \"6.19.8\",\n      \"resolved\": \"https://registry.npmjs.org/undici/-/undici-6.19.8.tgz\",\n      \"integrity\": \"sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=18.17\"\n      }\n    },\n    \"node_modules/undici-types\": {\n      \"version\": \"6.21.0\",\n      \"resolved\": \"https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz\",\n      \"integrity\": \"sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==\",\n      \"license\": \"MIT\"\n    },\n    \"node_modules/ws\": {\n      \"version\": \"8.21.3\",\n      \"resolved\": \"https://registry.npmjs.org/ws/-/ws-8.21.3.tgz\",\n      \"integrity\": \"sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==\",\n      \"license\": \"MIT\",\n      \"engines\": {\n        \"node\": \">=10.0.0\"\n      },\n      \"peerDependencies\": {\n        \"bufferutil\": \"^4.0.1\",\n        \"utf-8-validate\": \">=5.0.2\"\n      },\n      \"peerDependenciesMeta\": {\n        \"bufferutil\": {\n          \"optional\": true\n        },\n        \"utf-8-validate\": {\n          \"optional\": true\n        }\n      }\n    }\n  }\n}\n","runtime/package.json":"{\n  \"name\": \"bottly-bot-runtime\",\n  \"version\": \"1.0.0\",\n  \"private\": true,\n  \"description\": \"Long-lived Discord gateway runtime for Bottly. Owns the bot connections; the web app is only its control plane.\",\n  \"type\": \"module\",\n  \"engines\": {\n    \"node\": \">=20.11.0\"\n  },\n  \"main\": \"dist/index.js\",\n  \"scripts\": {\n    \"sync:contract\": \"node scripts/sync-contract.mjs\",\n    \"build\": \"node scripts/sync-contract.mjs && tsc -p tsconfig.json\",\n    \"typecheck\": \"node scripts/sync-contract.mjs && tsc -p tsconfig.json --noEmit\",\n    \"start\": \"node dist/index.js\",\n    \"watch\": \"tsc -p tsconfig.json --watch\",\n    \"dev\": \"node --watch dist/index.js\"\n  },\n  \"dependencies\": {\n    \"discord.js\": \"14.16.3\"\n  },\n  \"devDependencies\": {\n    \"@types/node\": \"22.16.5\",\n    \"typescript\": \"5.8.3\"\n  }\n}","runtime/scripts/sync-contract.mjs":"/**\n * Copies the shared flow contract from the web app into the runtime.\n *\n * The contract is the single source of truth for flow semantics; it must be\n * byte-identical on both sides, so it is generated instead of hand-maintained.\n * Run automatically by `npm run build` / `npm run typecheck` in this package.\n */\nimport { readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nconst here = dirname(fileURLToPath(import.meta.url));\nconst source = resolve(here, \"../../src/lib/flow/contract.ts\");\nconst target = resolve(here, \"../src/flow-contract.ts\");\n\nconst banner =\n  \"/* AUTO-GENERATED FILE — do not edit.\\n\" +\n  \" * Source: src/lib/flow/contract.ts (web app)\\n\" +\n  \" * Regenerate: npm run sync:contract (inside runtime/)\\n\" +\n  \" */\\n\";\n\nconst body = readFileSync(source, \"utf8\");\nwriteFileSync(target, banner + body, \"utf8\");\nconsole.log(`Synced flow contract -> ${target}`);\n","runtime/src/commands.ts":"/**\n * Slash-command registration.\n *\n * Commands are registered per guild (Guild#commands.set) rather than globally:\n * guild commands appear immediately, while global ones can take up to an hour\n * to propagate — a terrible experience right after clicking \"Publish\".\n *\n * set() is a full overwrite, so removing a Slash Command node and publishing\n * again removes it from Discord too.\n */\nimport {\n  ApplicationCommandOptionType,\n  Client,\n  Events,\n  type ApplicationCommandDataResolvable,\n  type ApplicationCommandOptionData,\n  type Guild,\n} from \"discord.js\";\nimport { logger } from \"./logger.js\";\nimport { describeError } from \"./redact.js\";\nimport type { CompiledCommand, CompiledFlow } from \"./flow-contract.js\";\n\nconst OPTION_TYPES: Record<string, ApplicationCommandOptionType> = {\n  string: ApplicationCommandOptionType.String,\n  integer: ApplicationCommandOptionType.Integer,\n  number: ApplicationCommandOptionType.Number,\n  boolean: ApplicationCommandOptionType.Boolean,\n  user: ApplicationCommandOptionType.User,\n  role: ApplicationCommandOptionType.Role,\n  channel: ApplicationCommandOptionType.Channel,\n  mentionable: ApplicationCommandOptionType.Mentionable,\n  attachment: ApplicationCommandOptionType.Attachment,\n};\n\nfunction toOption(option: CompiledCommand[\"options\"][number]): ApplicationCommandOptionData {\n  const type = OPTION_TYPES[option.type] ?? ApplicationCommandOptionType.String;\n  const base = {\n    name: option.name,\n    description: option.description.slice(0, 100) || option.name,\n    required: option.required,\n  };\n\n  // Autocomplete only exists on string/integer/number options.\n  if (\n    option.autocomplete &&\n    (type === ApplicationCommandOptionType.String ||\n      type === ApplicationCommandOptionType.Integer ||\n      type === ApplicationCommandOptionType.Number)\n  ) {\n    return { ...base, type, autocomplete: true } as ApplicationCommandOptionData;\n  }\n  return { ...base, type } as ApplicationCommandOptionData;\n}\n\nexport function commandPayload(flows: CompiledFlow[]): ApplicationCommandDataResolvable[] {\n  const seen = new Set<string>();\n  const payload: ApplicationCommandDataResolvable[] = [];\n\n  for (const flow of flows) {\n    for (const command of flow.commands) {\n      if (command.name === \"\" || seen.has(command.name)) continue;\n      seen.add(command.name);\n      payload.push({\n        name: command.name,\n        description: command.description.slice(0, 100) || \"A Bottly command\",\n        options: command.options.map(toOption),\n      });\n    }\n  }\n  return payload;\n}\n\n/**\n * Publishes the command set GLOBALLY.\n *\n * The control plane registers the same set over REST when a flow is published,\n * so registering globally here keeps exactly one copy of every command instead\n * of a global copy plus a per-guild duplicate.\n */\nexport async function syncCommands(\n  client: Client<true>,\n  flows: CompiledFlow[],\n): Promise<{ commands: number; guilds: number; failures: number }> {\n  const payload = commandPayload(flows);\n\n  try {\n    await client.application.commands.set(payload);\n    return { commands: payload.length, guilds: client.guilds.cache.size, failures: 0 };\n  } catch (error) {\n    logger.warn(`Could not register global commands: ${describeError(error)}`);\n  }\n\n  // Fallback: per-guild registration still works when the application-level\n  // write is refused (for example while Discord rate limits it).\n  let guilds = 0;\n  let failures = 0;\n  for (const guild of client.guilds.cache.values()) {\n    try {\n      await guild.commands.set(payload);\n      guilds += 1;\n    } catch (error) {\n      failures += 1;\n      logger.warn(`Could not register commands in guild ${guild.id}: ${describeError(error)}`);\n    }\n  }\n\n  client.on(Events.GuildCreate, (guild: Guild) => {\n    void guild.commands.set(payload).catch((error: unknown) => {\n      logger.warn(`Could not register commands in new guild ${guild.id}: ${describeError(error)}`);\n    });\n  });\n\n  return { commands: payload.length, guilds, failures };\n}\n","runtime/src/components.ts":"/**\n * Converts Bottly's stored component payload (src/types/bot.ts -> BotComponent)\n * into real discord.js builders.\n *\n * Supported (classic message components, one component per action row):\n *   button, link-button, string-select, user-select, role-select,\n *   channel-select, mentionable-select\n *\n * NOT supported here (documented in docs/flow-node-support.md): Components V2\n * containers, sections, text displays, media galleries, files and separators —\n * they need the IsComponentsV2 message flag and a different send shape.\n */\nimport {\n  ActionRowBuilder,\n  ButtonBuilder,\n  ButtonStyle,\n  ChannelSelectMenuBuilder,\n  MentionableSelectMenuBuilder,\n  RoleSelectMenuBuilder,\n  StringSelectMenuBuilder,\n  StringSelectMenuOptionBuilder,\n  UserSelectMenuBuilder,\n} from \"discord.js\";\n\nconst MAX_ROWS = 5;\n\nexport type MessageRow = ActionRowBuilder<\n  | ButtonBuilder\n  | StringSelectMenuBuilder\n  | UserSelectMenuBuilder\n  | RoleSelectMenuBuilder\n  | ChannelSelectMenuBuilder\n  | MentionableSelectMenuBuilder\n>;\n\ninterface RawOption {\n  label?: string;\n  value?: string;\n  description?: string;\n}\n\ninterface RawComponent {\n  type?: string;\n  label?: string;\n  style?: string;\n  emoji?: string;\n  action?: string;\n  url?: string;\n  disabled?: boolean;\n  placeholder?: string;\n  options?: RawOption[];\n}\n\nconst BUTTON_STYLES: Record<string, ButtonStyle> = {\n  primary: ButtonStyle.Primary,\n  secondary: ButtonStyle.Secondary,\n  success: ButtonStyle.Success,\n  danger: ButtonStyle.Danger,\n};\n\nfunction customIdFor(component: RawComponent, index: number): string {\n  const raw = (component.action ?? \"\").trim();\n  const fallback = `bottly_${index}`;\n  return (raw === \"\" ? fallback : raw).slice(0, 100);\n}\n\nfunction buildButton(\n  component: RawComponent,\n  index: number,\n  resolve: (value: string) => string,\n): ButtonBuilder | null {\n  const label = resolve(component.label ?? \"\").slice(0, 80);\n  const button = new ButtonBuilder();\n\n  if (component.type === \"link-button\" || component.style === \"link\") {\n    const url = resolve(component.url ?? \"\");\n    if (!/^https?:\\/\\//.test(url)) return null; // Discord rejects non-http links\n    button.setStyle(ButtonStyle.Link).setURL(url);\n  } else {\n    button\n      .setStyle(BUTTON_STYLES[component.style ?? \"primary\"] ?? ButtonStyle.Primary)\n      .setCustomId(customIdFor(component, index));\n  }\n\n  if (label !== \"\") button.setLabel(label);\n  else if (!component.emoji) button.setLabel(\"Button\");\n\n  if (component.emoji) {\n    try {\n      button.setEmoji(component.emoji);\n    } catch {\n      /* an invalid emoji must not sink the whole message */\n    }\n  }\n  if (component.disabled === true) button.setDisabled(true);\n\n  return button;\n}\n\nfunction buildSelect(\n  component: RawComponent,\n  index: number,\n  resolve: (value: string) => string,\n):\n  | StringSelectMenuBuilder\n  | UserSelectMenuBuilder\n  | RoleSelectMenuBuilder\n  | ChannelSelectMenuBuilder\n  | MentionableSelectMenuBuilder\n  | null {\n  const customId = customIdFor(component, index);\n  const placeholder = resolve(component.placeholder ?? \"\").slice(0, 150);\n\n  switch (component.type) {\n    case \"string-select\": {\n      const options = (component.options ?? [])\n        .map((option) =>\n          new StringSelectMenuOptionBuilder()\n            .setLabel((resolve(option.label ?? \"\") || \"Option\").slice(0, 100))\n            .setValue((option.value ?? option.label ?? \"option\").slice(0, 100)),\n        )\n        .slice(0, 25);\n      if (options.length === 0) return null; // Discord requires >= 1 option\n      const menu = new StringSelectMenuBuilder().setCustomId(customId).addOptions(options);\n      if (placeholder !== \"\") menu.setPlaceholder(placeholder);\n      if (component.disabled === true) menu.setDisabled(true);\n      return menu;\n    }\n    case \"user-select\": {\n      const menu = new UserSelectMenuBuilder().setCustomId(customId);\n      if (placeholder !== \"\") menu.setPlaceholder(placeholder);\n      if (component.disabled === true) menu.setDisabled(true);\n      return menu;\n    }\n    case \"role-select\": {\n      const menu = new RoleSelectMenuBuilder().setCustomId(customId);\n      if (placeholder !== \"\") menu.setPlaceholder(placeholder);\n      if (component.disabled === true) menu.setDisabled(true);\n      return menu;\n    }\n    case \"channel-select\": {\n      const menu = new ChannelSelectMenuBuilder().setCustomId(customId);\n      if (placeholder !== \"\") menu.setPlaceholder(placeholder);\n      if (component.disabled === true) menu.setDisabled(true);\n      return menu;\n    }\n    case \"mentionable-select\": {\n      const menu = new MentionableSelectMenuBuilder().setCustomId(customId);\n      if (placeholder !== \"\") menu.setPlaceholder(placeholder);\n      if (component.disabled === true) menu.setDisabled(true);\n      return menu;\n    }\n    default:\n      return null;\n  }\n}\n\n/** Flattens action rows/containers so nested buttons are still emitted. */\nfunction flatten(raw: unknown, depth = 0): RawComponent[] {\n  if (!Array.isArray(raw) || depth > 3) return [];\n  const out: RawComponent[] = [];\n  for (const entry of raw) {\n    if (typeof entry !== \"object\" || entry === null) continue;\n    const component = entry as RawComponent & { children?: unknown };\n    if (component.type === \"action-row\" || component.type === \"container\") {\n      out.push(...flatten(component.children, depth + 1));\n      continue;\n    }\n    out.push(component);\n  }\n  return out;\n}\n\nexport function buildMessageComponents(\n  raw: unknown,\n  resolve: (value: string) => string,\n): MessageRow[] {\n  const components = flatten(raw);\n  if (components.length === 0) return [];\n\n  const rows: MessageRow[] = [];\n  let buttonRow: MessageRow | null = null;\n  let buttonsInRow = 0;\n\n  for (const [index, component] of components.entries()) {\n    if (rows.length >= MAX_ROWS) break;\n\n    if (component.type === \"button\" || component.type === \"link-button\") {\n      const button = buildButton(component, index, resolve);\n      if (button === null) continue;\n      if (buttonRow === null || buttonsInRow >= 5) {\n        buttonRow = new ActionRowBuilder();\n        rows.push(buttonRow);\n        buttonsInRow = 0;\n      }\n      buttonRow.addComponents(button);\n      buttonsInRow += 1;\n      continue;\n    }\n\n    const select = buildSelect(component, index, resolve);\n    if (select === null) continue;\n    // A select menu must be alone in its row.\n    const row: MessageRow = new ActionRowBuilder();\n    row.addComponents(select);\n    rows.push(row);\n    buttonRow = null;\n    buttonsInRow = 0;\n  }\n\n  return rows.slice(0, MAX_ROWS);\n}\n","runtime/src/config.ts":"/**\n * Environment configuration. Anything invalid throws at boot: a runtime that\n * cannot authenticate or decrypt tokens should fail loudly, not half-work.\n */\nimport { GatewayIntentBits } from \"discord.js\";\nimport { logger } from \"./logger.js\";\n\n/** Both shared secrets are the entire security perimeter of this service. */\nconst MIN_SECRET_LENGTH = 32;\n\n/**\n * Non-privileged intents that are always requested. Guilds is required for the\n * guild cache (guild_count); GuildMessages is what message-triggered flows need.\n * Privileged intents (GuildMembers, GuildPresences, MessageContent) must be\n * enabled in the Discord developer portal AND listed in DISCORD_EXTRA_INTENTS.\n */\nconst DEFAULT_INTENT_NAMES = [\"Guilds\", \"GuildMessages\"] as const;\n\nexport interface RuntimeConfig {\n  host: string;\n  port: number;\n  /** Inbound: control plane → runtime (BOT_RUNTIME_SHARED_SECRET). */\n  inboundSecret: string;\n  /** Outbound: runtime → control plane (BOT_RUNTIME_CALLBACK_SECRET). */\n  callbackSecret: string;\n  /** Origin of the web app, no trailing slash. */\n  controlPlaneUrl: string;\n  intents: number[];\n  maxConcurrentBots: number;\n}\n\nfunction requireEnv(name: string): string {\n  const value = process.env[name]?.trim();\n  if (!value) throw new Error(`Missing required environment variable ${name}.`);\n  return value;\n}\n\nfunction requireSecret(name: string): string {\n  const value = requireEnv(name);\n  if (value.length < MIN_SECRET_LENGTH) {\n    throw new Error(\n      `${name} must be at least ${MIN_SECRET_LENGTH} characters. ` +\n        `Generate one with: node -e \"console.log(require('node:crypto').randomBytes(32).toString('base64url'))\"`,\n    );\n  }\n  return value;\n}\n\nfunction parsePort(raw: string | undefined, fallback: number): number {\n  if (!raw) return fallback;\n  const port = Number.parseInt(raw, 10);\n  if (!Number.isInteger(port) || port < 1 || port > 65535) {\n    throw new Error(`BOT_RUNTIME_PORT must be a port number between 1 and 65535, got \"${raw}\".`);\n  }\n  return port;\n}\n\nfunction parsePositiveInt(name: string, raw: string | undefined, fallback: number): number {\n  if (!raw) return fallback;\n  const value = Number.parseInt(raw, 10);\n  if (!Number.isInteger(value) || value < 1) {\n    throw new Error(`${name} must be a positive integer, got \"${raw}\".`);\n  }\n  return value;\n}\n\nfunction parseControlPlaneUrl(raw: string): string {\n  let parsed: URL;\n  try {\n    parsed = new URL(raw);\n  } catch {\n    throw new Error(`CONTROL_PLANE_URL must be an absolute URL, got \"${raw}\".`);\n  }\n  if (parsed.protocol !== \"https:\" && parsed.protocol !== \"http:\") {\n    throw new Error(`CONTROL_PLANE_URL must use http or https, got \"${parsed.protocol}\".`);\n  }\n  const isLocal = parsed.hostname === \"localhost\" || parsed.hostname === \"127.0.0.1\";\n  if (parsed.protocol === \"http:\" && !isLocal) {\n    // The callback secret and encrypted tokens travel over this connection.\n    logger.warn(\n      `CONTROL_PLANE_URL uses plain http (${parsed.origin}). Use https outside local development.`,\n    );\n  }\n  return parsed.origin + parsed.pathname.replace(/\\/+$/, \"\");\n}\n\n/**\n * Resolves intent names against discord.js's own enum, so no intent name is\n * hardcoded (and typos fail at boot instead of at identify time).\n */\nfunction parseIntents(raw: string | undefined): number[] {\n  const extra = (raw ?? \"\")\n    .split(\",\")\n    .map((part) => part.trim())\n    .filter((part) => part.length > 0);\n\n  const table = GatewayIntentBits as unknown as Record<string, number | string | undefined>;\n  const bits: number[] = [];\n\n  for (const name of [...DEFAULT_INTENT_NAMES, ...extra]) {\n    const bit = table[name];\n    if (typeof bit !== \"number\") {\n      throw new Error(\n        `Unknown gateway intent \"${name}\" in DISCORD_EXTRA_INTENTS. ` +\n          \"Use the discord.js GatewayIntentBits names, e.g. GuildMembers,MessageContent.\",\n      );\n    }\n    if (!bits.includes(bit)) bits.push(bit);\n  }\n\n  return bits;\n}\n\nfunction load(): RuntimeConfig {\n  return {\n    host: process.env[\"BOT_RUNTIME_HOST\"]?.trim() || \"0.0.0.0\",\n    port: parsePort(process.env[\"BOT_RUNTIME_PORT\"], 8787),\n    inboundSecret: requireSecret(\"BOT_RUNTIME_SHARED_SECRET\"),\n    callbackSecret: requireSecret(\"BOT_RUNTIME_CALLBACK_SECRET\"),\n    controlPlaneUrl: parseControlPlaneUrl(requireEnv(\"CONTROL_PLANE_URL\")),\n    intents: parseIntents(process.env[\"DISCORD_EXTRA_INTENTS\"]),\n    maxConcurrentBots: parsePositiveInt(\n      \"MAX_CONCURRENT_BOTS\",\n      process.env[\"MAX_CONCURRENT_BOTS\"],\n      50,\n    ),\n  };\n}\n\nexport const config: RuntimeConfig = load();\n","runtime/src/control-plane.ts":"/**\n * Outbound calls to the web app's internal API.\n *\n * Auth: Authorization: Bearer ${BOT_RUNTIME_CALLBACK_SECRET} — see\n * src/lib/internal-api-auth.server.ts in the web app. The runtime never touches\n * Supabase directly; the control plane owns all database access.\n */\nimport { config } from \"./config.js\";\nimport { logger } from \"./logger.js\";\nimport { describeError } from \"./redact.js\";\nimport type { EncryptedBotToken, RuntimeReport } from \"./types.js\";\n\nconst TOKEN_TIMEOUT_MS = 8_000;\nconst REPORT_TIMEOUT_MS = 8_000;\nconst REPORT_RETRY_DELAY_MS = 750;\n\n/** Caps mirroring the control plane's zod schema, so a report is never rejected. */\nconst MAX_EVENTS = 50;\nconst MAX_EVENT_NAME = 120;\nconst MAX_DESCRIPTION = 2_000;\nconst MAX_USERNAME = 120;\n\nexport type ControlPlaneErrorKind = \"missing\" | \"unauthorized\" | \"unavailable\" | \"invalid\";\n\nexport class ControlPlaneError extends Error {\n  readonly kind: ControlPlaneErrorKind;\n  readonly status: number | undefined;\n\n  constructor(kind: ControlPlaneErrorKind, message: string, status?: number) {\n    super(message);\n    this.name = \"ControlPlaneError\";\n    this.kind = kind;\n    this.status = status;\n  }\n}\n\nfunction authHeaders(extra?: Record<string, string>): Record<string, string> {\n  return {\n    Authorization: `Bearer ${config.callbackSecret}`,\n    accept: \"application/json\",\n    ...extra,\n  };\n}\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => {\n    setTimeout(resolve, ms);\n  });\n}\n\n/** Fetches the ENCRYPTED token record for one bot. Plaintext never crosses the wire. */\nexport async function fetchEncryptedToken(\n  botId: string,\n  userId: string,\n): Promise<EncryptedBotToken> {\n  const url =\n    `${config.controlPlaneUrl}/api/internal/bots/${encodeURIComponent(botId)}/token` +\n    `?userId=${encodeURIComponent(userId)}`;\n\n  let response: Response;\n  try {\n    response = await fetch(url, {\n      method: \"GET\",\n      headers: authHeaders(),\n      signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),\n    });\n  } catch (error) {\n    throw new ControlPlaneError(\n      \"unavailable\",\n      `Could not reach the control plane for a token: ${describeError(error)}`,\n    );\n  }\n\n  if (response.status === 404) {\n    throw new ControlPlaneError(\"missing\", \"No token is stored for this bot.\", 404);\n  }\n  if (response.status === 401 || response.status === 403) {\n    throw new ControlPlaneError(\n      \"unauthorized\",\n      \"The control plane rejected the runtime callback secret.\",\n      response.status,\n    );\n  }\n  if (!response.ok) {\n    throw new ControlPlaneError(\n      \"unavailable\",\n      `The control plane returned ${response.status} for a token lookup.`,\n      response.status,\n    );\n  }\n\n  const body: unknown = await response.json().catch(() => null);\n  if (typeof body !== \"object\" || body === null) {\n    throw new ControlPlaneError(\"invalid\", \"Token lookup returned a non-JSON body.\");\n  }\n\n  const record = body as {\n    ok?: unknown;\n    ciphertext?: unknown;\n    iv?: unknown;\n    keyVersion?: unknown;\n    applicationId?: unknown;\n  };\n\n  if (\n    record.ok !== true ||\n    typeof record.ciphertext !== \"string\" ||\n    typeof record.iv !== \"string\" ||\n    typeof record.keyVersion !== \"number\"\n  ) {\n    throw new ControlPlaneError(\"invalid\", \"Token lookup returned an unexpected payload.\");\n  }\n\n  return {\n    ciphertext: record.ciphertext,\n    iv: record.iv,\n    keyVersion: record.keyVersion,\n    applicationId: typeof record.applicationId === \"string\" ? record.applicationId : null,\n  };\n}\n\nfunction clampReport(report: RuntimeReport): RuntimeReport {\n  return {\n    botId: report.botId,\n    userId: report.userId,\n    state: report.state,\n    startedAt: report.startedAt,\n    lastError: report.lastError === null ? null : report.lastError.slice(0, MAX_DESCRIPTION),\n    guildCount: report.guildCount === null ? null : Math.max(0, Math.trunc(report.guildCount)),\n    username: report.username === null ? null : report.username.slice(0, MAX_USERNAME),\n    events: report.events.slice(0, MAX_EVENTS).map((entry) => ({\n      event: entry.event.slice(0, MAX_EVENT_NAME),\n      level: entry.level,\n      description: entry.description.slice(0, MAX_DESCRIPTION),\n      at: entry.at,\n    })),\n  };\n}\n\nasync function postReport(body: RuntimeReport): Promise<{ ok: boolean; retryable: boolean }> {\n  try {\n    const response = await fetch(`${config.controlPlaneUrl}/api/internal/runtime/events`, {\n      method: \"POST\",\n      headers: authHeaders({ \"content-type\": \"application/json\" }),\n      body: JSON.stringify(body),\n      signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),\n    });\n\n    if (response.ok) return { ok: true, retryable: false };\n\n    // 400 means our payload is wrong — retrying cannot help.\n    const retryable = response.status >= 500 || response.status === 429;\n    logger.warn(\n      `Control plane rejected a runtime report for bot ${body.botId} (${response.status}).`,\n    );\n    return { ok: false, retryable };\n  } catch (error) {\n    logger.warn(`Runtime report for bot ${body.botId} failed: ${describeError(error)}`);\n    return { ok: false, retryable: true };\n  }\n}\n\n/**\n * Pushes state + activity to the control plane. Never throws: a reporting\n * failure must not tear down a working gateway connection. The dashboard's own\n * status probe re-reads live state, so a dropped report is self-healing.\n */\nexport async function reportRuntimeState(report: RuntimeReport): Promise<boolean> {\n  const body = clampReport(report);\n\n  const first = await postReport(body);\n  if (first.ok) return true;\n  if (!first.retryable) return false;\n\n  await sleep(REPORT_RETRY_DELAY_MS);\n  const second = await postReport(body);\n  if (!second.ok) {\n    logger.error(`Giving up on runtime report for bot ${body.botId} (state: ${body.state}).`);\n  }\n  return second.ok;\n}\n","runtime/src/crypto.ts":"/**\n * AES-256-GCM decryption for stored bot tokens.\n *\n * Mirror of src/lib/bot-token-crypto.server.ts in the web app: the control\n * plane encrypts, the runtime decrypts, and BOT_TOKEN_ENCRYPTION_KEY must be\n * byte-identical in both environments. Plaintext tokens exist only inside this\n * process, only long enough to call client.login().\n */\nimport { webcrypto } from \"node:crypto\";\n\nconst KEY_BYTES = 32;\nconst IV_BYTES = 12;\n\n/** Key version this build understands. Bump in lockstep with the web app. */\nexport const BOT_TOKEN_KEY_VERSION = 1;\n\ntype AesKey = Awaited<ReturnType<typeof webcrypto.subtle.importKey>>;\n\nlet cachedKey: Promise<AesKey> | undefined;\n\n/** Returns an ArrayBuffer-backed view, required by Web Crypto's BufferSource. */\nfunction base64ToBytes(value: string): Uint8Array<ArrayBuffer> {\n  const decoded = Buffer.from(value.trim(), \"base64\");\n  const bytes = new Uint8Array(decoded.byteLength);\n  bytes.set(decoded);\n  return bytes;\n}\n\nfunction importEncryptionKey(): Promise<AesKey> {\n  const raw = process.env[\"BOT_TOKEN_ENCRYPTION_KEY\"];\n  if (!raw) {\n    throw new Error(\n      \"Missing BOT_TOKEN_ENCRYPTION_KEY. It must match the value used by the web app.\",\n    );\n  }\n\n  const keyBytes = base64ToBytes(raw);\n  if (keyBytes.length !== KEY_BYTES) {\n    throw new Error(\n      `BOT_TOKEN_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes, got ${keyBytes.length}.`,\n    );\n  }\n\n  return webcrypto.subtle.importKey(\"raw\", keyBytes, { name: \"AES-GCM\" }, false, [\"decrypt\"]);\n}\n\nfunction getEncryptionKey(): Promise<AesKey> {\n  if (!cachedKey) {\n    cachedKey = importEncryptionKey().catch((error: unknown) => {\n      // Never cache a rejection: fixing the env var should recover on next use.\n      cachedKey = undefined;\n      throw error;\n    });\n  }\n  return cachedKey;\n}\n\nexport async function decryptBotToken(record: {\n  ciphertext: string;\n  iv: string;\n  keyVersion: number;\n}): Promise<string> {\n  if (record.keyVersion !== BOT_TOKEN_KEY_VERSION) {\n    throw new Error(\n      `Token was encrypted with key version ${record.keyVersion}, this runtime only handles ${BOT_TOKEN_KEY_VERSION}.`,\n    );\n  }\n\n  const iv = base64ToBytes(record.iv);\n  if (iv.length !== IV_BYTES) {\n    throw new Error(`Token nonce must decode to ${IV_BYTES} bytes, got ${iv.length}.`);\n  }\n\n  const key = await getEncryptionKey();\n  const opened = await webcrypto.subtle.decrypt(\n    { name: \"AES-GCM\", iv },\n    key,\n    base64ToBytes(record.ciphertext),\n  );\n\n  const token = new TextDecoder().decode(opened);\n  if (!token) throw new Error(\"Decrypted token is empty.\");\n  return token;\n}\n\n/** True when the failure is a bad key / tampered ciphertext rather than config. */\nexport function isDecryptionFailure(error: unknown): boolean {\n  return error instanceof Error && /operation|decrypt|tag/i.test(error.message);\n}\n","runtime/src/deployment.ts":"/**\n * Fetches the PUBLISHED, server-compiled flow for a bot.\n *\n * The runtime never reads Supabase and never compiles a builder graph itself:\n * the control plane validates and compiles, and hands over the executable form\n * (see src/lib/flow/contract.ts -> CompiledFlow).\n */\nimport { config } from \"./config.js\";\nimport { ControlPlaneError } from \"./control-plane.js\";\nimport { describeError } from \"./redact.js\";\nimport { FLOW_CONTRACT_VERSION, type CompiledFlow } from \"./flow-contract.js\";\n\nconst TIMEOUT_MS = 8_000;\n\nexport interface BotPresenceConfig {\n  status?: string;\n  activityType?: string;\n  activityName?: string;\n  activityState?: string;\n  streamUrl?: string;\n}\n\nexport interface BotDeployment {\n  version: number;\n  flows: CompiledFlow[];\n  /** Union of every flow's intents, computed by the control plane. */\n  intents: string[];\n  /** Owner-configured Discord presence, applied once the bot is ready. */\n  presence: BotPresenceConfig | null;\n}\n\nfunction isCompiledFlow(value: unknown): value is CompiledFlow {\n  if (typeof value !== \"object\" || value === null) return false;\n  const flow = value as Partial<CompiledFlow>;\n  return (\n    flow.contractVersion === FLOW_CONTRACT_VERSION &&\n    typeof flow.flowId === \"string\" &&\n    Array.isArray(flow.triggers) &&\n    Array.isArray(flow.commands) &&\n    typeof flow.nodes === \"object\" &&\n    flow.nodes !== null\n  );\n}\n\n/**\n * Returns `null` when the bot has no published flow — that is a valid state\n * (the bot connects and simply does nothing), not an error.\n */\nexport async function fetchDeployment(\n  botId: string,\n  userId: string,\n): Promise<BotDeployment | null> {\n  const url =\n    `${config.controlPlaneUrl}/api/internal/bots/${encodeURIComponent(botId)}/deployment` +\n    `?userId=${encodeURIComponent(userId)}`;\n\n  let response: Response;\n  try {\n    response = await fetch(url, {\n      method: \"GET\",\n      headers: {\n        Authorization: `Bearer ${config.callbackSecret}`,\n        accept: \"application/json\",\n      },\n      signal: AbortSignal.timeout(TIMEOUT_MS),\n    });\n  } catch (error) {\n    throw new ControlPlaneError(\n      \"unavailable\",\n      `Could not reach the control plane for the published flow: ${describeError(error)}`,\n    );\n  }\n\n  if (response.status === 404) return null;\n  if (response.status === 401 || response.status === 403) {\n    throw new ControlPlaneError(\n      \"unauthorized\",\n      \"The control plane rejected the runtime callback secret.\",\n      response.status,\n    );\n  }\n  if (!response.ok) {\n    throw new ControlPlaneError(\n      \"unavailable\",\n      `The control plane returned ${response.status} for a deployment lookup.`,\n      response.status,\n    );\n  }\n\n  const body: unknown = await response.json().catch(() => null);\n  if (typeof body !== \"object\" || body === null) {\n    throw new ControlPlaneError(\"invalid\", \"Deployment lookup returned a non-JSON body.\");\n  }\n\n  const record = body as { ok?: unknown; version?: unknown; flows?: unknown; intents?: unknown };\n  if (record.ok !== true || !Array.isArray(record.flows)) {\n    throw new ControlPlaneError(\"invalid\", \"Deployment lookup returned an unexpected payload.\");\n  }\n\n  const flows = record.flows.filter(isCompiledFlow);\n  if (flows.length !== record.flows.length) {\n    throw new ControlPlaneError(\n      \"invalid\",\n      \"The published deployment was built by an incompatible Bottly version. Publish the flow again.\",\n    );\n  }\n\n  const intents = Array.isArray(record.intents)\n    ? record.intents.filter((value): value is string => typeof value === \"string\")\n    : [];\n\n  const rawPresence = (record as { presence?: unknown }).presence;\n  const presence =\n    typeof rawPresence === \"object\" && rawPresence !== null\n      ? (rawPresence as BotPresenceConfig)\n      : null;\n\n  return {\n    version: typeof record.version === \"number\" ? record.version : 0,\n    flows,\n    intents,\n    presence,\n  };\n}\n","runtime/src/executor.ts":"/**\n * FLOW EXECUTOR — the layer that turns a CompiledFlow into real discord.js calls.\n *\n * Nothing here invents Discord behaviour: every node maps onto the discord.js\n * API documented in flow-contract.ts (NODE_SPECS[...].discord). Nodes whose\n * spec says \"unsupported\" are stripped by the compiler and never reach here.\n *\n * One FlowRunner is attached to one Client (one bot). It owns:\n *   - the gateway event listeners that start flow runs,\n *   - the per-run variable scope,\n *   - the in-memory cooldown map,\n *   - the minute ticker for scheduled triggers.\n */\nimport {\n  ActivityType,\n  ChannelType,\n  Client,\n  EmbedBuilder,\n  Events,\n  PermissionFlagsBits,\n  type AnySelectMenuInteraction,\n  type ButtonInteraction,\n  type Guild,\n  type GuildBasedChannel,\n  type GuildMember,\n  type PresenceStatusData,\n  type Interaction,\n  type Message,\n  type MessageCreateOptions,\n  type MessageEditOptions,\n  type InteractionReplyOptions,\n  type ModalSubmitInteraction,\n  type RepliableInteraction,\n  type Role,\n  type TextBasedChannel,\n  type User,\n} from \"discord.js\";\nimport { logger } from \"./logger.js\";\nimport { describeError } from \"./redact.js\";\nimport { buildMessageComponents, type MessageRow } from \"./components.js\";\n\n/** What a message-producing node builds, before it is cast to a discord.js payload. */\ninterface FlowPayload {\n  content?: string;\n  embeds?: EmbedBuilder[];\n  components?: MessageRow[];\n}\nimport type { CompiledFlow, CompiledNode, CompiledTrigger, ConfigValue } from \"./flow-contract.js\";\nimport { nowIso, type RuntimeEvent } from \"./types.js\";\n\n/** Hard stop so a cycle in the graph can never spin the process. */\nconst MAX_STEPS = 200;\n/** Discord invalidates an interaction token after 3s; defer well before that. */\nconst AUTO_DEFER_AFTER_MS = 2_000;\nconst MAX_DELAY_SECONDS = 900;\n\n/**\n * Values shared between runs of the same bot process (\"Save Shared Value\").\n * In-memory only: they reset when the bot restarts.\n */\nconst GLOBALS = new Map<string, string>();\n\nexport type EventSink = (event: RuntimeEvent) => void;\n\ninterface RunContext {\n  client: Client;\n  flow: CompiledFlow;\n  vars: Map<string, string>;\n  interaction: RepliableInteraction | null;\n  message: Message | null;\n  member: GuildMember | null;\n  user: User | null;\n  guild: Guild | null;\n  channel: TextBasedChannel | null;\n  /** Set once we have replied/deferred, so follow-ups use followUp(). */\n  acknowledged: boolean;\n  steps: number;\n}\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => {\n    setTimeout(resolve, ms);\n  });\n}\n\nfunction text(value: ConfigValue | undefined): string {\n  return value === undefined || value === null ? \"\" : String(value);\n}\n\nfunction num(value: ConfigValue | undefined, fallback: number): number {\n  const parsed = Number(value);\n  return Number.isFinite(parsed) ? parsed : fallback;\n}\n\nfunction truthy(value: ConfigValue | undefined): boolean {\n  return value === true || value === \"true\";\n}\n\n/* ------------------------------------------------------------------ *\n * Variables\n * ------------------------------------------------------------------ */\n\nfunction builtinVariable(name: string, ctx: RunContext): string | null {\n  const user = ctx.user;\n  const guild = ctx.guild;\n\n  switch (name) {\n    case \"user\":\n      return user ? `<@${user.id}>` : \"\";\n    case \"user.id\":\n      return user?.id ?? \"\";\n    case \"username\":\n      return user?.username ?? \"\";\n    case \"user.displayName\":\n      return ctx.member?.displayName ?? user?.displayName ?? user?.username ?? \"\";\n    case \"user.avatar\":\n      return user?.displayAvatarURL() ?? \"\";\n    case \"user.tag\":\n      return user?.tag ?? \"\";\n    case \"server\":\n    case \"server.name\":\n      return guild?.name ?? \"\";\n    case \"server.id\":\n      return guild?.id ?? \"\";\n    case \"server.memberCount\":\n      return guild ? String(guild.memberCount) : \"\";\n    case \"channel\":\n      return ctx.channel ? `<#${ctx.channel.id}>` : \"\";\n    case \"channel.id\":\n      return ctx.channel?.id ?? \"\";\n    case \"channel.name\":\n      return ctx.channel && \"name\" in ctx.channel ? String(ctx.channel.name ?? \"\") : \"\";\n    case \"message.id\":\n      return ctx.message?.id ?? \"\";\n    case \"message.content\":\n      return ctx.message?.content ?? \"\";\n    case \"command\":\n      return ctx.interaction?.isChatInputCommand() ? ctx.interaction.commandName : \"\";\n    case \"timestamp\":\n      return new Date().toISOString();\n    default:\n      return null;\n  }\n}\n\n/** Replaces every {token} with its resolved value. Unknown tokens become \"\". */\nfunction interpolate(input: string, ctx: RunContext): string {\n  if (input === \"\") return \"\";\n  return input.replace(/\\{([a-zA-Z0-9_.-]+)\\}/g, (_match, token: string) => {\n    const stored = ctx.vars.get(token);\n    if (stored !== undefined) return stored;\n    const builtin = builtinVariable(token, ctx);\n    return builtin ?? \"\";\n  });\n}\n\nfunction cfg(node: CompiledNode, key: string, ctx: RunContext): string {\n  return interpolate(text(node.config[key]), ctx).trim();\n}\n\n/* ------------------------------------------------------------------ *\n * Discord resolvers\n * ------------------------------------------------------------------ */\n\nconst SNOWFLAKE = /(\\d{15,25})/;\n\nfunction extractId(raw: string): string | null {\n  const match = SNOWFLAKE.exec(raw);\n  return match ? (match[1] as string) : null;\n}\n\nasync function resolveUser(raw: string, ctx: RunContext): Promise<User | null> {\n  if (raw === \"\") return ctx.user;\n  const id = extractId(raw);\n  if (id === null) return ctx.user;\n  try {\n    return await ctx.client.users.fetch(id);\n  } catch {\n    return null;\n  }\n}\n\nasync function resolveMember(raw: string, ctx: RunContext): Promise<GuildMember | null> {\n  if (ctx.guild === null) return null;\n  const id = raw === \"\" ? (ctx.member?.id ?? null) : extractId(raw);\n  if (id === null) return ctx.member;\n  try {\n    return await ctx.guild.members.fetch(id);\n  } catch {\n    return null;\n  }\n}\n\nasync function resolveChannel(raw: string, ctx: RunContext): Promise<TextBasedChannel | null> {\n  if (raw === \"\") return ctx.channel;\n\n  const id = extractId(raw);\n  if (id !== null) {\n    try {\n      const channel = await ctx.client.channels.fetch(id);\n      return channel !== null && channel.isTextBased() ? channel : null;\n    } catch {\n      return null;\n    }\n  }\n\n  // \"#general\" / \"general\" — resolve by name inside the current guild.\n  const name = raw.replace(/^#/, \"\").toLowerCase();\n  const found = ctx.guild?.channels.cache.find(\n    (channel) => channel.name.toLowerCase() === name && channel.isTextBased(),\n  );\n  return found && found.isTextBased() ? found : null;\n}\n\nasync function resolveVoiceChannelId(raw: string, ctx: RunContext): Promise<string | null> {\n  const id = extractId(raw);\n  if (id !== null) return id;\n  const name = raw.replace(/^#/, \"\").toLowerCase();\n  const found = ctx.guild?.channels.cache.find(\n    (channel) => channel.name.toLowerCase() === name && channel.type === ChannelType.GuildVoice,\n  );\n  return found?.id ?? null;\n}\n\nasync function resolveRole(raw: string, ctx: RunContext): Promise<Role | null> {\n  if (ctx.guild === null || raw === \"\") return null;\n  const id = extractId(raw);\n  if (id !== null) {\n    try {\n      return await ctx.guild.roles.fetch(id);\n    } catch {\n      return null;\n    }\n  }\n  const name = raw.replace(/^@/, \"\").toLowerCase();\n  return ctx.guild.roles.cache.find((role) => role.name.toLowerCase() === name) ?? null;\n}\n\n/* ------------------------------------------------------------------ *\n * Payload builders\n * ------------------------------------------------------------------ */\n\ninterface RawEmbed {\n  author?: { name?: string; icon?: string; url?: string };\n  title?: string;\n  description?: string;\n  url?: string;\n  color?: string;\n  thumbnail?: string;\n  image?: string;\n  fields?: { name?: string; value?: string; inline?: boolean }[];\n  footer?: { text?: string; icon?: string };\n  timestamp?: boolean;\n}\n\nfunction buildEmbed(raw: unknown, ctx: RunContext): EmbedBuilder | null {\n  if (typeof raw !== \"object\" || raw === null) return null;\n  const source = raw as RawEmbed;\n  const embed = new EmbedBuilder();\n  let filled = false;\n\n  const title = interpolate(source.title ?? \"\", ctx);\n  if (title !== \"\") {\n    embed.setTitle(title.slice(0, 256));\n    filled = true;\n  }\n\n  const description = interpolate(source.description ?? \"\", ctx);\n  if (description !== \"\") {\n    embed.setDescription(description.slice(0, 4096));\n    filled = true;\n  }\n\n  if (source.url && /^https?:\\/\\//.test(source.url)) embed.setURL(source.url);\n\n  if (source.color && /^#?[0-9a-fA-F]{6}$/.test(source.color)) {\n    embed.setColor(Number.parseInt(source.color.replace(\"#\", \"\"), 16));\n    filled = true;\n  }\n\n  const authorName = interpolate(source.author?.name ?? \"\", ctx);\n  if (authorName !== \"\") {\n    embed.setAuthor({\n      name: authorName.slice(0, 256),\n      ...(source.author?.icon && /^https?:\\/\\//.test(source.author.icon)\n        ? { iconURL: source.author.icon }\n        : {}),\n      ...(source.author?.url && /^https?:\\/\\//.test(source.author.url)\n        ? { url: source.author.url }\n        : {}),\n    });\n    filled = true;\n  }\n\n  const thumbnail = interpolate(source.thumbnail ?? \"\", ctx);\n  if (/^https?:\\/\\//.test(thumbnail)) {\n    embed.setThumbnail(thumbnail);\n    filled = true;\n  }\n\n  const image = interpolate(source.image ?? \"\", ctx);\n  if (/^https?:\\/\\//.test(image)) {\n    embed.setImage(image);\n    filled = true;\n  }\n\n  const fields = (source.fields ?? [])\n    .map((field) => ({\n      name: interpolate(field.name ?? \"\", ctx).slice(0, 256),\n      value: interpolate(field.value ?? \"\", ctx).slice(0, 1024),\n      inline: field.inline === true,\n    }))\n    .filter((field) => field.name !== \"\" && field.value !== \"\")\n    .slice(0, 25);\n  if (fields.length > 0) {\n    embed.addFields(fields);\n    filled = true;\n  }\n\n  const footer = interpolate(source.footer?.text ?? \"\", ctx);\n  if (footer !== \"\") {\n    embed.setFooter({\n      text: footer.slice(0, 2048),\n      ...(source.footer?.icon && /^https?:\\/\\//.test(source.footer.icon)\n        ? { iconURL: source.footer.icon }\n        : {}),\n    });\n    filled = true;\n  }\n\n  if (source.timestamp === true) {\n    embed.setTimestamp(new Date());\n    filled = true;\n  }\n\n  return filled ? embed : null;\n}\n\nfunction buildPayload(node: CompiledNode, ctx: RunContext): FlowPayload {\n  const content = cfg(node, \"content\", ctx);\n  const embed = buildEmbed(node.embed, ctx);\n  const components = buildMessageComponents(node.components, (value) => interpolate(value, ctx));\n\n  const payload: FlowPayload = {};\n  if (content !== \"\") payload.content = content.slice(0, 2000);\n  if (embed !== null) payload.embeds = [embed];\n  if (components.length > 0) payload.components = components;\n  if (payload.content === undefined && payload.embeds === undefined && components.length === 0) {\n    payload.content = \"\\u200b\";\n  }\n  return payload;\n}\n\n/* ------------------------------------------------------------------ *\n * FlowRunner\n * ------------------------------------------------------------------ */\n\nexport class FlowRunner {\n  private readonly client: Client;\n  private readonly flows: CompiledFlow[];\n  private readonly emit: EventSink;\n  private readonly cooldowns = new Map<string, number>();\n  private cronTimer: NodeJS.Timeout | null = null;\n  private lastCronMinute = \"\";\n\n  constructor(client: Client, flows: CompiledFlow[], emit: EventSink) {\n    this.client = client;\n    this.flows = flows;\n    this.emit = emit;\n  }\n\n  /** Registers gateway listeners for every trigger present in the deployment. */\n  attach(): void {\n    const kinds = new Set<string>();\n    for (const flow of this.flows) for (const trigger of flow.triggers) kinds.add(trigger.type);\n\n    if (\n      kinds.has(\"slash-command\") ||\n      kinds.has(\"button-click\") ||\n      kinds.has(\"dropdown-select\") ||\n      kinds.has(\"modal-submit\")\n    ) {\n      this.client.on(Events.InteractionCreate, (interaction: Interaction) => {\n        void this.onInteraction(interaction).catch((error: unknown) => {\n          logger.error(`Interaction handling failed: ${describeError(error)}`);\n        });\n      });\n    }\n\n    if (kinds.has(\"message-received\")) {\n      this.client.on(Events.MessageCreate, (message: Message) => {\n        if (message.author.bot) return;\n        void this.onMessage(message).catch((error: unknown) => {\n          logger.error(`Message handling failed: ${describeError(error)}`);\n        });\n      });\n    }\n\n    if (kinds.has(\"member-joined\")) {\n      this.client.on(Events.GuildMemberAdd, (member: GuildMember) => {\n        void this.runTriggers(\"member-joined\", () => true, {\n          member,\n          guild: member.guild,\n          user: member.user,\n        });\n      });\n    }\n\n    if (kinds.has(\"member-left\")) {\n      this.client.on(Events.GuildMemberRemove, (member) => {\n        void this.runTriggers(\"member-left\", () => true, {\n          member: member.partial ? null : (member as GuildMember),\n          guild: member.guild,\n          user: member.user,\n        });\n      });\n    }\n\n    if (kinds.has(\"reaction-added\")) {\n      this.client.on(Events.MessageReactionAdd, (reaction, user) => {\n        const emoji = reaction.emoji.name ?? \"\";\n        void this.runTriggers(\n          \"reaction-added\",\n          (trigger) => matchesEmoji(text(trigger.config[\"emoji\"]), emoji),\n          {\n            message: reaction.message.partial ? null : (reaction.message as Message),\n            guild: reaction.message.guild,\n            user: user.partial ? null : (user as User),\n          },\n        );\n      });\n    }\n\n    if (kinds.has(\"reaction-removed\")) {\n      this.client.on(Events.MessageReactionRemove, (reaction, user) => {\n        const emoji = reaction.emoji.name ?? \"\";\n        void this.runTriggers(\n          \"reaction-removed\",\n          (trigger) => matchesEmoji(text(trigger.config[\"emoji\"]), emoji),\n          {\n            message: reaction.message.partial ? null : (reaction.message as Message),\n            guild: reaction.message.guild,\n            user: user.partial ? null : (user as User),\n          },\n        );\n      });\n    }\n\n    if (kinds.has(\"dm-received\")) {\n      this.client.on(Events.MessageCreate, (message: Message) => {\n        if (message.author.bot || message.guild !== null) return;\n        void this.runTriggers(\"dm-received\", () => true, {\n          message,\n          user: message.author,\n          channel: message.channel,\n        });\n      });\n    }\n\n    if (kinds.has(\"message-edited\")) {\n      this.client.on(Events.MessageUpdate, (_old, updated) => {\n        if (updated.partial || updated.author.bot) return;\n        const message = updated as Message;\n        void this.runTriggers(\n          \"message-edited\",\n          (trigger) => messageChannelMatches(text(trigger.config[\"channel\"]), message),\n          {\n            message,\n            guild: message.guild,\n            member: message.member,\n            user: message.author,\n            channel: message.channel,\n          },\n        );\n      });\n    }\n\n    if (kinds.has(\"message-deleted\")) {\n      this.client.on(Events.MessageDelete, (deleted) => {\n        if (deleted.partial) return;\n        const message = deleted as Message;\n        void this.runTriggers(\n          \"message-deleted\",\n          (trigger) => messageChannelMatches(text(trigger.config[\"channel\"]), message),\n          {\n            message,\n            guild: message.guild,\n            member: message.member,\n            user: message.author,\n            channel: message.channel,\n          },\n        );\n      });\n    }\n\n    if (kinds.has(\"voice-join\") || kinds.has(\"voice-leave\") || kinds.has(\"voice-move\")) {\n      this.client.on(Events.VoiceStateUpdate, (before, after) => {\n        const member = after.member ?? before.member;\n        const guild = after.guild;\n        const seed = {\n          member,\n          guild,\n          ...(member === null ? {} : { user: member.user }),\n        };\n\n        if (before.channelId === null && after.channelId !== null) {\n          void this.runTriggers(\n            \"voice-join\",\n            (trigger) => voiceChannelMatches(text(trigger.config[\"channel\"]), after.channel),\n            seed,\n          );\n        } else if (before.channelId !== null && after.channelId === null) {\n          void this.runTriggers(\n            \"voice-leave\",\n            (trigger) => voiceChannelMatches(text(trigger.config[\"channel\"]), before.channel),\n            seed,\n          );\n        } else if (before.channelId !== after.channelId) {\n          void this.runTriggers(\"voice-move\", () => true, seed);\n        }\n      });\n    }\n\n    if (kinds.has(\"channel-created\")) {\n      this.client.on(Events.ChannelCreate, (channel) => {\n        void this.runTriggers(\"channel-created\", () => true, {\n          guild: channel.guild,\n          ...(channel.isTextBased() ? { channel } : {}),\n        });\n      });\n    }\n\n    if (kinds.has(\"channel-deleted\")) {\n      this.client.on(Events.ChannelDelete, (channel) => {\n        if (channel.isDMBased()) return;\n        void this.runTriggers(\"channel-deleted\", () => true, { guild: channel.guild });\n      });\n    }\n\n    if (kinds.has(\"thread-created\")) {\n      this.client.on(Events.ThreadCreate, (thread) => {\n        void this.runTriggers(\"thread-created\", () => true, {\n          guild: thread.guild,\n          channel: thread,\n        });\n      });\n    }\n\n    if (kinds.has(\"role-created\")) {\n      this.client.on(Events.GuildRoleCreate, (role) => {\n        void this.runTriggers(\"role-created\", () => true, { guild: role.guild });\n      });\n    }\n\n    if (kinds.has(\"role-deleted\")) {\n      this.client.on(Events.GuildRoleDelete, (role) => {\n        void this.runTriggers(\"role-deleted\", () => true, { guild: role.guild });\n      });\n    }\n\n    if (kinds.has(\"member-banned\")) {\n      this.client.on(Events.GuildBanAdd, (ban) => {\n        void this.runTriggers(\"member-banned\", () => true, {\n          guild: ban.guild,\n          user: ban.user,\n        });\n      });\n    }\n\n    if (kinds.has(\"member-unbanned\")) {\n      this.client.on(Events.GuildBanRemove, (ban) => {\n        void this.runTriggers(\"member-unbanned\", () => true, {\n          guild: ban.guild,\n          user: ban.user,\n        });\n      });\n    }\n\n    if (kinds.has(\"member-updated\")) {\n      this.client.on(Events.GuildMemberUpdate, (_before, after) => {\n        void this.runTriggers(\"member-updated\", () => true, {\n          member: after,\n          guild: after.guild,\n          user: after.user,\n        });\n      });\n    }\n\n    if (kinds.has(\"invite-created\")) {\n      this.client.on(Events.InviteCreate, (invite) => {\n        void this.runTriggers(\"invite-created\", () => true, {\n          ...(invite.guild !== null && \"id\" in invite.guild\n            ? { guild: invite.guild as Guild }\n            : {}),\n          ...(invite.inviter === null ? {} : { user: invite.inviter }),\n        });\n      });\n    }\n\n    if (kinds.has(\"typing-start\")) {\n      this.client.on(Events.TypingStart, (typing) => {\n        void this.runTriggers(\"typing-start\", () => true, {\n          ...(typing.guild === null ? {} : { guild: typing.guild }),\n          ...(typing.user.partial ? {} : { user: typing.user as User }),\n          ...(typing.channel.isTextBased() ? { channel: typing.channel } : {}),\n        });\n      });\n    }\n\n    if (kinds.has(\"guild-joined\")) {\n      this.client.on(Events.GuildCreate, (guild) => {\n        void this.runTriggers(\"guild-joined\", () => true, { guild });\n      });\n    }\n\n    if (kinds.has(\"bot-ready\")) {\n      void this.runTriggers(\"bot-ready\", () => true, {});\n    }\n\n    if (kinds.has(\"scheduled-event\")) this.startCron();\n  }\n\n  detach(): void {\n    if (this.cronTimer !== null) {\n      clearInterval(this.cronTimer);\n      this.cronTimer = null;\n    }\n  }\n\n  /* ---------------------------- dispatch ---------------------------- */\n\n  private async onInteraction(interaction: Interaction): Promise<void> {\n    if (interaction.isChatInputCommand()) {\n      const name = interaction.commandName;\n      const matched = await this.dispatch(\n        (trigger) =>\n          trigger.type === \"slash-command\" &&\n          text(trigger.config[\"name\"]).toLowerCase() === name.toLowerCase(),\n        (ctx) => {\n          for (const option of interaction.options.data) {\n            const value = option.value === undefined ? \"\" : String(option.value);\n            ctx.vars.set(`option.${option.name}`, value);\n            ctx.vars.set(option.name, value);\n          }\n          const reason = interaction.options.data.find((option) => option.name === \"reason\");\n          if (reason) ctx.vars.set(\"reason\", String(reason.value ?? \"\"));\n          const target = interaction.options.data.find((option) => option.user !== undefined);\n          if (target?.user) {\n            ctx.vars.set(\"user\", `<@${target.user.id}>`);\n            ctx.vars.set(\"user.id\", target.user.id);\n            ctx.vars.set(\"username\", target.user.username);\n          }\n        },\n        interactionContext(interaction),\n      );\n\n      if (matched === 0 && !interaction.replied && !interaction.deferred) {\n        await interaction\n          .reply({\n            content: `\\`/${name}\\` has no published flow on this bot yet. Publish it in the Bottly flow builder.`,\n            ephemeral: true,\n          })\n          .catch(() => undefined);\n      }\n      return;\n    }\n\n    if (interaction.isButton()) {\n      const button = interaction as ButtonInteraction;\n      await this.dispatch(\n        (trigger) =>\n          trigger.type === \"button-click\" && text(trigger.config[\"customId\"]) === button.customId,\n        () => undefined,\n        interactionContext(button),\n      );\n      return;\n    }\n\n    if (interaction.isAnySelectMenu()) {\n      const select = interaction as AnySelectMenuInteraction;\n      await this.dispatch(\n        (trigger) =>\n          trigger.type === \"dropdown-select\" &&\n          text(trigger.config[\"customId\"]) === select.customId,\n        (ctx) => {\n          const values = \"values\" in select ? select.values : [];\n          ctx.vars.set(\"selected\", values.join(\", \"));\n          ctx.vars.set(\"selected.first\", values[0] ?? \"\");\n        },\n        interactionContext(select),\n      );\n      return;\n    }\n\n    if (interaction.isModalSubmit()) {\n      const modal = interaction as ModalSubmitInteraction;\n      await this.dispatch(\n        (trigger) =>\n          trigger.type === \"modal-submit\" && text(trigger.config[\"customId\"]) === modal.customId,\n        (ctx) => {\n          for (const row of modal.fields.fields.values()) {\n            ctx.vars.set(`field.${row.customId}`, row.value);\n            ctx.vars.set(row.customId, row.value);\n          }\n        },\n        interactionContext(modal),\n      );\n    }\n  }\n\n  private async onMessage(message: Message): Promise<void> {\n    await this.dispatch(\n      (trigger) => {\n        if (trigger.type !== \"message-received\") return false;\n        const filter = text(trigger.config[\"channel\"]).trim();\n        if (filter === \"\") return true;\n        const id = extractId(filter);\n        if (id !== null) return message.channelId === id;\n        const name = filter.replace(/^#/, \"\").toLowerCase();\n        return (\n          \"name\" in message.channel && String(message.channel.name ?? \"\").toLowerCase() === name\n        );\n      },\n      () => undefined,\n      {\n        message,\n        guild: message.guild,\n        member: message.member,\n        user: message.author,\n        channel: message.channel,\n      },\n    );\n  }\n\n  private async runTriggers(\n    type: string,\n    matches: (trigger: CompiledTrigger) => boolean,\n    seed: Partial<Omit<RunContext, \"client\" | \"flow\" | \"vars\" | \"steps\" | \"acknowledged\">>,\n  ): Promise<void> {\n    await this.dispatch(\n      (trigger) => trigger.type === type && matches(trigger),\n      () => undefined,\n      seed,\n    );\n  }\n\n  private async dispatch(\n    matches: (trigger: CompiledTrigger) => boolean,\n    seedVars: (ctx: RunContext) => void,\n    seed: Partial<Omit<RunContext, \"client\" | \"flow\" | \"vars\" | \"steps\" | \"acknowledged\">>,\n  ): Promise<number> {\n    let matched = 0;\n    for (const flow of this.flows) {\n      for (const trigger of flow.triggers) {\n        if (!matches(trigger)) continue;\n        matched += 1;\n\n        const ctx: RunContext = {\n          client: this.client,\n          flow,\n          vars: new Map<string, string>(),\n          interaction: seed.interaction ?? null,\n          message: seed.message ?? null,\n          member: seed.member ?? null,\n          user: seed.user ?? null,\n          guild: seed.guild ?? null,\n          channel: seed.channel ?? null,\n          acknowledged: false,\n          steps: 0,\n        };\n        seedVars(ctx);\n\n        const deferTimer =\n          ctx.interaction !== null\n            ? setTimeout(() => {\n                void this.autoDefer(ctx);\n              }, AUTO_DEFER_AFTER_MS)\n            : null;\n\n        try {\n          await this.walk(flow, trigger.nodeId, \"out\", ctx);\n        } catch (error) {\n          const description = describeError(error, 300);\n          logger.warn(`Flow ${flow.flowId} failed: ${description}`);\n          this.emit({\n            event: \"flow.error\",\n            level: \"error\",\n            description: `Flow \"${flow.flowName}\" stopped: ${description}`,\n            at: nowIso(),\n          });\n        } finally {\n          if (deferTimer !== null) clearTimeout(deferTimer);\n          await this.settleInteraction(ctx);\n        }\n      }\n    }\n    return matched;\n  }\n\n  /**\n   * Discord shows \"the application did not respond\" whenever an interaction is\n   * never acknowledged. A flow that produced no reply still has to close it.\n   */\n  private async settleInteraction(ctx: RunContext): Promise<void> {\n    const interaction = ctx.interaction;\n    if (interaction === null) return;\n    try {\n      if (interaction.deferred && !interaction.replied) {\n        await interaction.editReply({ content: \"Done.\" });\n      } else if (!interaction.deferred && !interaction.replied) {\n        await interaction.reply({\n          content: \"This flow finished without a reply step.\",\n          ephemeral: true,\n        });\n      }\n    } catch (error) {\n      logger.debug(`Could not settle interaction: ${describeError(error)}`);\n    }\n  }\n\n  /** Keeps the interaction alive when the flow is slower than Discord's 3s window. */\n  private async autoDefer(ctx: RunContext): Promise<void> {\n    const interaction = ctx.interaction;\n    if (interaction === null || ctx.acknowledged) return;\n    if (interaction.replied || interaction.deferred) {\n      ctx.acknowledged = true;\n      return;\n    }\n    try {\n      await interaction.deferReply();\n      ctx.acknowledged = true;\n    } catch (error) {\n      logger.debug(`Auto-defer failed: ${describeError(error)}`);\n    }\n  }\n\n  /* ------------------------------ walk ------------------------------ */\n\n  private async walk(\n    flow: CompiledFlow,\n    fromNodeId: string,\n    handle: string,\n    ctx: RunContext,\n  ): Promise<void> {\n    const source = flow.nodes[fromNodeId];\n    const targets = source?.next[handle] ?? [];\n\n    for (const targetId of targets) {\n      const node = flow.nodes[targetId];\n      if (node === undefined) continue;\n\n      ctx.steps += 1;\n      if (ctx.steps > MAX_STEPS) {\n        throw new Error(`Flow exceeded ${MAX_STEPS} steps — check for a loop.`);\n      }\n\n      const nextHandle = await this.execute(node, ctx);\n      if (nextHandle === null) continue; // node stops this branch\n      await this.walk(flow, node.id, nextHandle, ctx);\n    }\n  }\n\n  /**\n   * Runs one node. Returns the handle to continue on, or null to stop the branch.\n   */\n  private async execute(node: CompiledNode, ctx: RunContext): Promise<string | null> {\n    switch (node.type) {\n      /* ---------------------------- messages --------------------------- */\n      case \"send-message\":\n      case \"send-embed\": {\n        const payload = buildPayload(node, ctx);\n        const ephemeral = truthy(node.config[\"ephemeral\"]);\n        const targetRaw = cfg(node, \"channel\", ctx);\n\n        if (ephemeral && ctx.interaction !== null) {\n          await this.respond(ctx, payload, true);\n          return \"out\";\n        }\n\n        const channel = await resolveChannel(targetRaw, ctx);\n        if (channel === null || !(\"send\" in channel)) {\n          throw new Error(`Channel \"${targetRaw || \"current\"}\" could not be resolved.`);\n        }\n        const sent = await channel.send(payload as MessageCreateOptions);\n        ctx.vars.set(\"lastMessage.id\", sent.id);\n        return \"out\";\n      }\n\n      case \"reply\": {\n        const payload = buildPayload(node, ctx);\n        await this.respond(ctx, payload, truthy(node.config[\"ephemeral\"]));\n        return \"out\";\n      }\n\n      case \"edit-message\": {\n        const messageId = cfg(node, \"messageId\", ctx);\n        const channel = ctx.channel;\n        if (channel === null || !(\"messages\" in channel)) {\n          throw new Error(\"Edit Message needs a text channel context.\");\n        }\n        const target = await channel.messages.fetch(messageId);\n        const payload = buildPayload(node, ctx);\n        await target.edit({\n          content: payload.content ?? null,\n          embeds: payload.embeds ?? [],\n          components: payload.components ?? [],\n        } as MessageEditOptions);\n        return \"out\";\n      }\n\n      case \"delete-message\": {\n        const messageId = cfg(node, \"messageId\", ctx);\n        const delay = Math.min(Math.max(num(node.config[\"delay\"], 0), 0), MAX_DELAY_SECONDS);\n        const channel = ctx.channel;\n        if (channel === null || !(\"messages\" in channel)) {\n          throw new Error(\"Delete Message needs a text channel context.\");\n        }\n        if (delay > 0) await sleep(delay * 1000);\n        const target = await channel.messages.fetch(messageId);\n        await target.delete();\n        return \"out\";\n      }\n\n      case \"send-dm\": {\n        const user = await resolveUser(cfg(node, \"user\", ctx), ctx);\n        if (user === null) throw new Error(\"Send DM could not resolve the target user.\");\n        try {\n          await user.send(buildPayload(node, ctx) as MessageCreateOptions);\n        } catch {\n          // Closed DMs are a normal Discord outcome, not a flow failure.\n          this.emit({\n            event: \"flow.dm-blocked\",\n            level: \"warning\",\n            description: `${user.tag} does not accept direct messages from this bot.`,\n            at: nowIso(),\n          });\n        }\n        return \"out\";\n      }\n\n      /* --------------------------- moderation -------------------------- */\n      case \"ban-user\": {\n        const guild = this.requireGuild(ctx, \"Ban User\");\n        const user = await resolveUser(cfg(node, \"user\", ctx), ctx);\n        if (user === null) throw new Error(\"Ban User could not resolve the target user.\");\n        const seconds = deleteHistorySeconds(text(node.config[\"deleteHistory\"]));\n        const banReason = cfg(node, \"reason\", ctx);\n        await guild.members.ban(user.id, {\n          ...(banReason === \"\" ? {} : { reason: banReason }),\n          deleteMessageSeconds: seconds,\n        });\n        return \"out\";\n      }\n\n      case \"kick-user\": {\n        const member = await this.requireMember(node, ctx, \"Kick User\");\n        await member.kick(cfg(node, \"reason\", ctx) || undefined);\n        return \"out\";\n      }\n\n      case \"timeout-user\": {\n        const member = await this.requireMember(node, ctx, \"Timeout User\");\n        const ms = durationToMs(text(node.config[\"duration\"]));\n        if (ms === null) throw new Error(\"Timeout User has an unsupported duration.\");\n        await member.timeout(ms, cfg(node, \"reason\", ctx) || undefined);\n        return \"out\";\n      }\n\n      case \"warn-user\": {\n        const user = await resolveUser(cfg(node, \"user\", ctx), ctx);\n        const reason = cfg(node, \"reason\", ctx);\n        if (user !== null && truthy(node.config[\"notify\"])) {\n          try {\n            await user.send(\n              `You have been warned in **${ctx.guild?.name ?? \"this server\"}**${reason ? `: ${reason}` : \".\"}`,\n            );\n          } catch {\n            /* closed DMs */\n          }\n        }\n        this.emit({\n          event: \"flow.warn\",\n          level: \"warning\",\n          description: `Warned ${user?.tag ?? \"unknown user\"}${reason ? `: ${reason}` : \"\"}`,\n          at: nowIso(),\n        });\n        return \"out\";\n      }\n\n      case \"delete-messages\": {\n        const channel = await resolveChannel(cfg(node, \"channel\", ctx), ctx);\n        if (channel === null || !(\"bulkDelete\" in channel)) {\n          throw new Error(\"Delete Messages needs a guild text channel.\");\n        }\n        const amount = Math.min(Math.max(Math.trunc(num(node.config[\"amount\"], 10)), 1), 100);\n        await channel.bulkDelete(amount, true);\n        return \"out\";\n      }\n\n      case \"add-role\":\n      case \"remove-role\": {\n        const member = await this.requireMember(node, ctx, \"Role change\");\n        const role = await resolveRole(cfg(node, \"role\", ctx), ctx);\n        if (role === null) throw new Error(`Role \"${cfg(node, \"role\", ctx)}\" was not found.`);\n        if (node.type === \"add-role\") await member.roles.add(role);\n        else await member.roles.remove(role);\n        return \"out\";\n      }\n\n      case \"move-member\": {\n        const member = await this.requireMember(node, ctx, \"Move Member\");\n        const channelId = await resolveVoiceChannelId(cfg(node, \"channel\", ctx), ctx);\n        if (channelId === null) throw new Error(\"Move Member could not find that voice channel.\");\n        await member.voice.setChannel(channelId);\n        return \"out\";\n      }\n\n      /* ----------------------------- logic ----------------------------- */\n      case \"if-else\":\n        return (await this.evaluateIf(node, ctx)) ? \"true\" : \"false\";\n\n      case \"check-permission\": {\n        const member = await resolveMember(cfg(node, \"user\", ctx), ctx);\n        const name = text(node.config[\"permission\"]) as keyof typeof PermissionFlagsBits;\n        const bit = PermissionFlagsBits[name];\n        if (member === null || bit === undefined) return \"false\";\n        return member.permissions.has(bit) ? \"true\" : \"false\";\n      }\n\n      case \"check-role\": {\n        const member = await resolveMember(cfg(node, \"user\", ctx), ctx);\n        const role = await resolveRole(cfg(node, \"role\", ctx), ctx);\n        if (member === null || role === null) return \"false\";\n        return member.roles.cache.has(role.id) ? \"true\" : \"false\";\n      }\n\n      case \"check-channel\": {\n        const wanted = cfg(node, \"channel\", ctx);\n        const id = extractId(wanted);\n        if (ctx.channel === null) return \"false\";\n        if (id !== null) return ctx.channel.id === id ? \"true\" : \"false\";\n        const name = wanted.replace(/^#/, \"\").toLowerCase();\n        const actual = \"name\" in ctx.channel ? String(ctx.channel.name ?? \"\").toLowerCase() : \"\";\n        return actual === name ? \"true\" : \"false\";\n      }\n\n      case \"compare\": {\n        const left = Number(cfg(node, \"left\", ctx));\n        const right = Number(cfg(node, \"right\", ctx));\n        if (!Number.isFinite(left) || !Number.isFinite(right)) return \"false\";\n        switch (text(node.config[\"operator\"])) {\n          case \"gte\":\n            return left >= right ? \"true\" : \"false\";\n          case \"lt\":\n            return left < right ? \"true\" : \"false\";\n          case \"lte\":\n            return left <= right ? \"true\" : \"false\";\n          case \"gt\":\n          default:\n            return left > right ? \"true\" : \"false\";\n        }\n      }\n\n      case \"contains\":\n        return cfg(node, \"text\", ctx)\n          .toLowerCase()\n          .includes(cfg(node, \"value\", ctx).toLowerCase())\n          ? \"true\"\n          : \"false\";\n\n      case \"equals\":\n        return cfg(node, \"left\", ctx) === cfg(node, \"right\", ctx) ? \"true\" : \"false\";\n\n      case \"random\":\n        return Math.random() * 100 < num(node.config[\"chance\"], 50) ? \"a\" : \"b\";\n\n      case \"delay\": {\n        const seconds = Math.min(Math.max(num(node.config[\"seconds\"], 0), 0), MAX_DELAY_SECONDS);\n        if (seconds > 0) await sleep(seconds * 1000);\n        return \"out\";\n      }\n\n      case \"cooldown\": {\n        const seconds = Math.max(num(node.config[\"seconds\"], 0), 0);\n        const scope = text(node.config[\"scope\"]) || \"user\";\n        const subject =\n          scope === \"channel\" ? ctx.channel?.id : scope === \"server\" ? ctx.guild?.id : ctx.user?.id;\n        const key = `${node.id}:${scope}:${subject ?? \"global\"}`;\n        const now = Date.now();\n        const until = this.cooldowns.get(key) ?? 0;\n        if (until > now) return \"blocked\";\n        this.cooldowns.set(key, now + seconds * 1000);\n        return \"out\";\n      }\n\n      /* -------------------------- discord data -------------------------- */\n      case \"get-user\": {\n        const user = await resolveUser(cfg(node, \"user\", ctx), ctx);\n        if (user === null) throw new Error(\"Get User could not resolve that user.\");\n        const key = cfg(node, \"storeAs\", ctx) || \"targetUser\";\n        ctx.vars.set(key, `<@${user.id}>`);\n        ctx.vars.set(`${key}.id`, user.id);\n        ctx.vars.set(`${key}.username`, user.username);\n        ctx.vars.set(`${key}.tag`, user.tag);\n        ctx.vars.set(`${key}.avatar`, user.displayAvatarURL());\n        return \"out\";\n      }\n\n      case \"get-member\": {\n        const member = await resolveMember(cfg(node, \"user\", ctx), ctx);\n        if (member === null) throw new Error(\"Get Member could not resolve that member.\");\n        const key = cfg(node, \"storeAs\", ctx) || \"member\";\n        ctx.vars.set(key, `<@${member.id}>`);\n        ctx.vars.set(`${key}.id`, member.id);\n        ctx.vars.set(`${key}.displayName`, member.displayName);\n        ctx.vars.set(`${key}.joinedAt`, member.joinedAt?.toISOString() ?? \"\");\n        ctx.vars.set(`${key}.roles`, member.roles.cache.map((role) => role.name).join(\", \"));\n        return \"out\";\n      }\n\n      case \"get-server\": {\n        const guild = this.requireGuild(ctx, \"Get Server\");\n        const key = cfg(node, \"storeAs\", ctx) || \"server\";\n        ctx.vars.set(key, guild.name);\n        ctx.vars.set(`${key}.id`, guild.id);\n        ctx.vars.set(`${key}.name`, guild.name);\n        ctx.vars.set(`${key}.memberCount`, String(guild.memberCount));\n        return \"out\";\n      }\n\n      case \"get-channel\": {\n        const channel = await resolveChannel(cfg(node, \"channel\", ctx), ctx);\n        if (channel === null) throw new Error(\"Get Channel could not resolve that channel.\");\n        const key = cfg(node, \"storeAs\", ctx) || \"channel\";\n        ctx.vars.set(key, `<#${channel.id}>`);\n        ctx.vars.set(`${key}.id`, channel.id);\n        ctx.vars.set(`${key}.name`, \"name\" in channel ? String(channel.name ?? \"\") : \"\");\n        return \"out\";\n      }\n\n      case \"get-role\": {\n        const role = await resolveRole(cfg(node, \"role\", ctx), ctx);\n        if (role === null) throw new Error(\"Get Role could not resolve that role.\");\n        const key = cfg(node, \"storeAs\", ctx) || \"role\";\n        ctx.vars.set(key, `<@&${role.id}>`);\n        ctx.vars.set(`${key}.id`, role.id);\n        ctx.vars.set(`${key}.name`, role.name);\n        return \"out\";\n      }\n\n      /* ---------------------------- variables --------------------------- */\n      case \"set-variable\":\n        ctx.vars.set(cfg(node, \"name\", ctx), cfg(node, \"value\", ctx));\n        return \"out\";\n\n      case \"get-variable\":\n        ctx.vars.set(\"value\", ctx.vars.get(cfg(node, \"name\", ctx)) ?? \"\");\n        return \"out\";\n\n      case \"increment\":\n      case \"decrement\": {\n        const name = cfg(node, \"name\", ctx);\n        const step = num(node.config[\"amount\"], 1) * (node.type === \"increment\" ? 1 : -1);\n        const current = Number(ctx.vars.get(name) ?? 0);\n        ctx.vars.set(name, String((Number.isFinite(current) ? current : 0) + step));\n        return \"out\";\n      }\n\n      case \"format-text\":\n        ctx.vars.set(cfg(node, \"storeAs\", ctx) || \"text\", cfg(node, \"template\", ctx));\n        return \"out\";\n\n      case \"global-set\":\n        GLOBALS.set(cfg(node, \"name\", ctx), cfg(node, \"value\", ctx));\n        return \"out\";\n\n      case \"global-get\":\n        ctx.vars.set(\n          cfg(node, \"storeAs\", ctx) || \"shared\",\n          GLOBALS.get(cfg(node, \"name\", ctx)) ?? \"\",\n        );\n        return \"out\";\n\n      /* ------------------------ extra message actions ------------------- */\n      case \"add-reaction\": {\n        const target = await this.resolveTargetMessage(node, ctx, \"Add Reaction\");\n        await target.react(cfg(node, \"emoji\", ctx));\n        return \"out\";\n      }\n\n      case \"remove-reactions\": {\n        const target = await this.resolveTargetMessage(node, ctx, \"Remove Reactions\");\n        const emoji = cfg(node, \"emoji\", ctx);\n        if (emoji === \"\") await target.reactions.removeAll();\n        else {\n          const reaction = target.reactions.cache.find((r) =>\n            matchesEmoji(emoji, r.emoji.name ?? \"\"),\n          );\n          if (reaction !== undefined) await reaction.remove();\n        }\n        return \"out\";\n      }\n\n      case \"pin-message\": {\n        const target = await this.resolveTargetMessage(node, ctx, \"Pin Message\");\n        await target.pin();\n        return \"out\";\n      }\n\n      case \"unpin-message\": {\n        const target = await this.resolveTargetMessage(node, ctx, \"Unpin Message\");\n        await target.unpin();\n        return \"out\";\n      }\n\n      case \"publish-message\": {\n        const target = await this.resolveTargetMessage(node, ctx, \"Publish Announcement\");\n        if (target.crosspostable) await target.crosspost();\n        return \"out\";\n      }\n\n      case \"get-message\": {\n        const channel = await resolveChannel(cfg(node, \"channel\", ctx), ctx);\n        if (channel === null || !(\"messages\" in channel)) {\n          throw new Error(\"Get Message needs a text channel.\");\n        }\n        const target = await channel.messages.fetch(cfg(node, \"messageId\", ctx));\n        const key = cfg(node, \"storeAs\", ctx) || \"fetched\";\n        ctx.vars.set(key, target.content);\n        ctx.vars.set(`${key}.id`, target.id);\n        ctx.vars.set(`${key}.author`, target.author.tag);\n        ctx.vars.set(`${key}.authorId`, target.author.id);\n        ctx.vars.set(`${key}.url`, target.url);\n        return \"out\";\n      }\n\n      case \"send-file\": {\n        const channel = await resolveChannel(cfg(node, \"channel\", ctx), ctx);\n        if (channel === null || !(\"send\" in channel)) throw new Error(\"Send File needs a channel.\");\n        const url = cfg(node, \"url\", ctx);\n        if (url === \"\") throw new Error(\"Send File needs a file URL.\");\n        const content = cfg(node, \"content\", ctx);\n        await channel.send({\n          ...(content === \"\" ? {} : { content: content.slice(0, 2000) }),\n          files: [url],\n        } as MessageCreateOptions);\n        return \"out\";\n      }\n\n      case \"start-typing\": {\n        const channel = await resolveChannel(cfg(node, \"channel\", ctx), ctx);\n        if (channel !== null && \"sendTyping\" in channel) await channel.sendTyping();\n        return \"out\";\n      }\n\n      case \"defer-reply\": {\n        const interaction = ctx.interaction;\n        if (interaction === null) throw new Error(\"Defer Reply needs an interaction trigger.\");\n        if (!interaction.replied && !interaction.deferred) {\n          await interaction.deferReply({ ephemeral: truthy(node.config[\"ephemeral\"]) });\n        }\n        ctx.acknowledged = true;\n        return \"out\";\n      }\n\n      case \"show-modal\": {\n        const interaction = ctx.interaction;\n        if (interaction === null) throw new Error(\"Show Modal needs an interaction trigger.\");\n        if (interaction.replied || interaction.deferred) {\n          throw new Error(\"Show Modal must run before the interaction is answered or deferred.\");\n        }\n        const rows: {\n          type: 1;\n          components: {\n            type: 4;\n            custom_id: string;\n            label: string;\n            style: 1 | 2;\n            required: boolean;\n          }[];\n        }[] = [];\n\n        for (const index of [1, 2, 3]) {\n          const id = cfg(node, `field${index}Id`, ctx);\n          const label = cfg(node, `field${index}Label`, ctx);\n          if (id === \"\" || label === \"\") continue;\n          rows.push({\n            type: 1,\n            components: [\n              {\n                type: 4,\n                custom_id: id,\n                label: label.slice(0, 45),\n                style: text(node.config[`field${index}Style`]) === \"paragraph\" ? 2 : 1,\n                required: index === 1 ? truthy(node.config[\"field1Required\"]) : false,\n              },\n            ],\n          });\n        }\n        if (rows.length === 0) throw new Error(\"Show Modal needs at least one field.\");\n        if (!(\"showModal\" in interaction)) {\n          throw new Error(\"Show Modal cannot run on a modal submit interaction.\");\n        }\n        await interaction.showModal({\n          custom_id: cfg(node, \"customId\", ctx) || \"modal\",\n          title: cfg(node, \"title\", ctx).slice(0, 45) || \"Form\",\n          components: rows,\n        });\n        ctx.acknowledged = true;\n        return \"out\";\n      }\n\n      /* ------------------------- server management ---------------------- */\n      case \"create-channel\": {\n        const guild = this.requireGuild(ctx, \"Create Channel\");\n        const parentRaw = cfg(node, \"parent\", ctx);\n        const parent =\n          parentRaw === \"\"\n            ? null\n            : (guild.channels.cache.find(\n                (channel) =>\n                  channel.id === extractId(parentRaw) ||\n                  channel.name.toLowerCase() === parentRaw.toLowerCase(),\n              ) ?? null);\n        const topic = cfg(node, \"topic\", ctx);\n        const created = await guild.channels.create({\n          name: cfg(node, \"name\", ctx),\n          type: channelTypeFor(text(node.config[\"channelType\"])),\n          ...(parent === null ? {} : { parent: parent.id }),\n          ...(topic === \"\" ? {} : { topic }),\n        });\n        const key = cfg(node, \"storeAs\", ctx) || \"created\";\n        ctx.vars.set(key, `<#${created.id}>`);\n        ctx.vars.set(`${key}.id`, created.id);\n        ctx.vars.set(`${key}.name`, created.name);\n        return \"out\";\n      }\n\n      case \"delete-channel\": {\n        const guild = this.requireGuild(ctx, \"Delete Channel\");\n        const target = findGuildChannel(guild, cfg(node, \"channel\", ctx));\n        if (target === null) throw new Error(\"Delete Channel could not find that channel.\");\n        await target.delete(cfg(node, \"reason\", ctx) || undefined);\n        return \"out\";\n      }\n\n      case \"rename-channel\": {\n        const guild = this.requireGuild(ctx, \"Rename Channel\");\n        const raw = cfg(node, \"channel\", ctx);\n        const target = raw === \"\" ? currentGuildChannel(guild, ctx) : findGuildChannel(guild, raw);\n        if (target === null) throw new Error(\"Rename Channel could not find that channel.\");\n        await target.setName(cfg(node, \"name\", ctx));\n        return \"out\";\n      }\n\n      case \"set-channel-topic\": {\n        const channel = await resolveChannel(cfg(node, \"channel\", ctx), ctx);\n        if (channel === null || !(\"setTopic\" in channel)) {\n          throw new Error(\"Set Channel Topic needs a text channel.\");\n        }\n        await channel.setTopic(cfg(node, \"topic\", ctx));\n        return \"out\";\n      }\n\n      case \"set-slowmode\": {\n        const channel = await resolveChannel(cfg(node, \"channel\", ctx), ctx);\n        if (channel === null || !(\"setRateLimitPerUser\" in channel)) {\n          throw new Error(\"Set Slowmode needs a text channel.\");\n        }\n        const seconds = Math.min(Math.max(Math.trunc(num(node.config[\"seconds\"], 0)), 0), 21600);\n        await channel.setRateLimitPerUser(seconds);\n        return \"out\";\n      }\n\n      case \"set-channel-permission\": {\n        const guild = this.requireGuild(ctx, \"Set Channel Permission\");\n        const raw = cfg(node, \"channel\", ctx);\n        const target = raw === \"\" ? currentGuildChannel(guild, ctx) : findGuildChannel(guild, raw);\n        if (target === null || !(\"permissionOverwrites\" in target)) {\n          throw new Error(\"Set Channel Permission could not find that channel.\");\n        }\n        const subjectRaw = cfg(node, \"target\", ctx);\n        const role = await resolveRole(subjectRaw, ctx);\n        const member = role === null ? await resolveMember(subjectRaw, ctx) : null;\n        const subjectId = role?.id ?? member?.id;\n        if (subjectId === undefined) {\n          throw new Error(\"Set Channel Permission could not resolve that role or member.\");\n        }\n        const permission = text(node.config[\"permission\"]) as keyof typeof PermissionFlagsBits;\n        if (PermissionFlagsBits[permission] === undefined) {\n          throw new Error(`\"${permission}\" is not a Discord permission name.`);\n        }\n        const mode = text(node.config[\"mode\"]) || \"deny\";\n        await target.permissionOverwrites.edit(subjectId, {\n          [permission]: mode === \"allow\" ? true : mode === \"reset\" ? null : false,\n        });\n        return \"out\";\n      }\n\n      case \"create-thread\": {\n        const name = cfg(node, \"name\", ctx);\n        const messageId = cfg(node, \"messageId\", ctx);\n        const channel = await resolveChannel(cfg(node, \"channel\", ctx), ctx);\n        if (channel === null || !(\"threads\" in channel)) {\n          throw new Error(\"Create Thread needs a text channel.\");\n        }\n        const thread =\n          messageId === \"\"\n            ? await channel.threads.create({ name })\n            : await (await channel.messages.fetch(messageId)).startThread({ name });\n        const key = cfg(node, \"storeAs\", ctx) || \"thread\";\n        ctx.vars.set(key, `<#${thread.id}>`);\n        ctx.vars.set(`${key}.id`, thread.id);\n        return \"out\";\n      }\n\n      case \"archive-thread\":\n      case \"lock-thread\": {\n        const channel = await resolveChannel(cfg(node, \"channel\", ctx), ctx);\n        if (channel === null || !channel.isThread()) {\n          throw new Error(\"This node needs a thread channel.\");\n        }\n        if (node.type === \"archive-thread\") await channel.setArchived(true);\n        else await channel.setLocked(truthy(node.config[\"locked\"]));\n        return \"out\";\n      }\n\n      case \"create-invite\": {\n        const guild = this.requireGuild(ctx, \"Create Invite\");\n        const raw = cfg(node, \"channel\", ctx);\n        const target = raw === \"\" ? currentGuildChannel(guild, ctx) : findGuildChannel(guild, raw);\n        if (target === null || !(\"createInvite\" in target)) {\n          throw new Error(\"Create Invite could not find an invitable channel.\");\n        }\n        const invite = await target.createInvite({\n          maxAge: Math.max(Math.trunc(num(node.config[\"maxAge\"], 0)), 0),\n          maxUses: Math.max(Math.trunc(num(node.config[\"maxUses\"], 0)), 0),\n        });\n        const key = cfg(node, \"storeAs\", ctx) || \"invite\";\n        ctx.vars.set(key, invite.url);\n        ctx.vars.set(`${key}.code`, invite.code);\n        return \"out\";\n      }\n\n      case \"create-role\": {\n        const guild = this.requireGuild(ctx, \"Create Role\");\n        const colour = cfg(node, \"color\", ctx);\n        const role = await guild.roles.create({\n          name: cfg(node, \"name\", ctx),\n          hoist: truthy(node.config[\"hoist\"]),\n          mentionable: truthy(node.config[\"mentionable\"]),\n          ...(colour === \"\" ? {} : { color: colour as `#${string}` }),\n        });\n        const key = cfg(node, \"storeAs\", ctx) || \"role\";\n        ctx.vars.set(key, `<@&${role.id}>`);\n        ctx.vars.set(`${key}.id`, role.id);\n        ctx.vars.set(`${key}.name`, role.name);\n        return \"out\";\n      }\n\n      case \"edit-role\": {\n        const role = await resolveRole(cfg(node, \"role\", ctx), ctx);\n        if (role === null) throw new Error(\"Edit Role could not find that role.\");\n        const name = cfg(node, \"name\", ctx);\n        const colour = cfg(node, \"color\", ctx);\n        await role.edit({\n          ...(name === \"\" ? {} : { name }),\n          ...(colour === \"\" ? {} : { color: colour as `#${string}` }),\n          hoist: truthy(node.config[\"hoist\"]),\n          mentionable: truthy(node.config[\"mentionable\"]),\n        });\n        return \"out\";\n      }\n\n      case \"delete-role\": {\n        const role = await resolveRole(cfg(node, \"role\", ctx), ctx);\n        if (role === null) throw new Error(\"Delete Role could not find that role.\");\n        await role.delete();\n        return \"out\";\n      }\n\n      case \"set-nickname\": {\n        const member = await this.requireMember(node, ctx, \"Set Nickname\");\n        const nickname = cfg(node, \"nickname\", ctx);\n        await member.setNickname(nickname === \"\" ? null : nickname.slice(0, 32));\n        return \"out\";\n      }\n\n      case \"unban-user\": {\n        const guild = this.requireGuild(ctx, \"Unban User\");\n        const id = extractId(cfg(node, \"user\", ctx));\n        if (id === null) throw new Error(\"Unban User needs a user ID.\");\n        await guild.bans.remove(id, cfg(node, \"reason\", ctx) || undefined);\n        return \"out\";\n      }\n\n      case \"set-presence\": {\n        const activityText = cfg(node, \"activityText\", ctx);\n        ctx.client.user?.setPresence({\n          status: presenceStatus(text(node.config[\"status\"])),\n          activities:\n            activityText === \"\"\n              ? []\n              : [{ name: activityText, type: activityTypeFor(text(node.config[\"activityType\"])) }],\n        });\n        return \"out\";\n      }\n\n      /* ------------------------------- voice ---------------------------- */\n      case \"voice-mute\": {\n        const member = await this.requireMember(node, ctx, \"Server Mute\");\n        await member.voice.setMute(\n          truthy(node.config[\"muted\"]),\n          cfg(node, \"reason\", ctx) || undefined,\n        );\n        return \"out\";\n      }\n\n      case \"voice-deafen\": {\n        const member = await this.requireMember(node, ctx, \"Server Deafen\");\n        await member.voice.setDeaf(\n          truthy(node.config[\"deafened\"]),\n          cfg(node, \"reason\", ctx) || undefined,\n        );\n        return \"out\";\n      }\n\n      case \"disconnect-member\": {\n        const member = await this.requireMember(node, ctx, \"Disconnect From Voice\");\n        await member.voice.disconnect();\n        return \"out\";\n      }\n\n      /* ---------------------------- extra logic ------------------------- */\n      case \"switch\": {\n        const value = cfg(node, \"value\", ctx);\n        for (const index of [1, 2, 3, 4]) {\n          const candidate = cfg(node, `case${index}`, ctx);\n          if (candidate !== \"\" && candidate === value) return `case${index}`;\n        }\n        return \"default\";\n      }\n\n      case \"regex-match\": {\n        const pattern = text(node.config[\"pattern\"]);\n        const flags = text(node.config[\"flags\"]).replace(/[^gimsuy]/g, \"\");\n        let match: RegExpExecArray | null = null;\n        try {\n          match = new RegExp(pattern, flags).exec(cfg(node, \"text\", ctx));\n        } catch {\n          throw new Error(`\"${pattern}\" is not a valid pattern.`);\n        }\n        if (match === null) return \"false\";\n        ctx.vars.set(cfg(node, \"storeAs\", ctx) || \"match\", match[0]);\n        return \"true\";\n      }\n\n      case \"check-bot-permission\": {\n        const guild = this.requireGuild(ctx, \"Bot Has Permission?\");\n        const name = text(node.config[\"permission\"]) as keyof typeof PermissionFlagsBits;\n        const bit = PermissionFlagsBits[name];\n        if (bit === undefined) return \"false\";\n        const me = guild.members.me ?? (await guild.members.fetchMe());\n        return me.permissions.has(bit) ? \"true\" : \"false\";\n      }\n\n      case \"check-context\": {\n        const wantsGuild = text(node.config[\"context\"]) !== \"dm\";\n        return (ctx.guild !== null) === wantsGuild ? \"true\" : \"false\";\n      }\n\n      case \"is-bot\": {\n        const user = await resolveUser(cfg(node, \"user\", ctx), ctx);\n        return user?.bot === true ? \"true\" : \"false\";\n      }\n\n      case \"has-attachment\":\n        return (ctx.message?.attachments.size ?? 0) > 0 ? \"true\" : \"false\";\n\n      case \"for-each\": {\n        const separator = text(node.config[\"separator\"]) || \",\";\n        const itemVar = cfg(node, \"itemVar\", ctx) || \"item\";\n        const items = cfg(node, \"list\", ctx)\n          .split(separator)\n          .map((entry) => entry.trim())\n          .filter((entry) => entry !== \"\");\n        for (const item of items) {\n          ctx.vars.set(itemVar, item);\n          await this.walk(ctx.flow, node.id, \"each\", ctx);\n        }\n        return \"out\";\n      }\n\n      case \"stop-flow\":\n        return null;\n\n      /* -------------------------- utility and web ----------------------- */\n      case \"http-request\": {\n        const url = cfg(node, \"url\", ctx);\n        const key = cfg(node, \"storeAs\", ctx) || \"response\";\n        let headers: Record<string, string> = {};\n        const rawHeaders = cfg(node, \"headers\", ctx);\n        if (rawHeaders !== \"\") {\n          try {\n            headers = JSON.parse(rawHeaders) as Record<string, string>;\n          } catch {\n            throw new Error(\"HTTP Request headers must be valid JSON.\");\n          }\n        }\n        const method = (text(node.config[\"method\"]) || \"GET\").toUpperCase();\n        const body = cfg(node, \"body\", ctx);\n        try {\n          const response = await fetch(url, {\n            method,\n            headers,\n            ...(method === \"GET\" || method === \"DELETE\" || body === \"\" ? {} : { body }),\n          });\n          const responseText = (await response.text()).slice(0, 20000);\n          ctx.vars.set(key, responseText);\n          ctx.vars.set(`${key}.body`, responseText);\n          ctx.vars.set(`${key}.status`, String(response.status));\n          return response.ok ? \"out\" : \"failed\";\n        } catch (error) {\n          ctx.vars.set(`${key}.error`, describeError(error, 200));\n          return \"failed\";\n        }\n      }\n\n      case \"parse-json\": {\n        const path = text(node.config[\"path\"]);\n        let value: unknown;\n        try {\n          value = JSON.parse(cfg(node, \"source\", ctx));\n        } catch {\n          throw new Error(\"Read JSON Value received text that is not valid JSON.\");\n        }\n        for (const part of path.split(\".\").filter((segment) => segment !== \"\")) {\n          if (value === null || typeof value !== \"object\") {\n            value = undefined;\n            break;\n          }\n          value = (value as Record<string, unknown>)[part];\n        }\n        ctx.vars.set(\n          cfg(node, \"storeAs\", ctx) || \"value\",\n          value === undefined || value === null\n            ? \"\"\n            : typeof value === \"object\"\n              ? JSON.stringify(value)\n              : String(value),\n        );\n        return \"out\";\n      }\n\n      case \"random-number\": {\n        const min = Math.trunc(num(node.config[\"min\"], 1));\n        const max = Math.trunc(num(node.config[\"max\"], 100));\n        const low = Math.min(min, max);\n        const high = Math.max(min, max);\n        const value = low + Math.floor(Math.random() * (high - low + 1));\n        ctx.vars.set(cfg(node, \"storeAs\", ctx) || \"number\", String(value));\n        return \"out\";\n      }\n\n      case \"random-choice\": {\n        const separator = text(node.config[\"separator\"]) || \",\";\n        const items = cfg(node, \"list\", ctx)\n          .split(separator)\n          .map((entry) => entry.trim())\n          .filter((entry) => entry !== \"\");\n        const picked =\n          items.length === 0 ? \"\" : (items[Math.floor(Math.random() * items.length)] as string);\n        ctx.vars.set(cfg(node, \"storeAs\", ctx) || \"choice\", picked);\n        return \"out\";\n      }\n\n      case \"math\": {\n        const left = Number(cfg(node, \"left\", ctx)) || 0;\n        const right = Number(cfg(node, \"right\", ctx)) || 0;\n        let result: number;\n        switch (text(node.config[\"operator\"])) {\n          case \"subtract\":\n            result = left - right;\n            break;\n          case \"multiply\":\n            result = left * right;\n            break;\n          case \"divide\":\n            result = right === 0 ? 0 : left / right;\n            break;\n          case \"modulo\":\n            result = right === 0 ? 0 : left % right;\n            break;\n          case \"round\":\n            result = Math.round(left);\n            break;\n          default:\n            result = left + right;\n        }\n        ctx.vars.set(cfg(node, \"storeAs\", ctx) || \"result\", String(result));\n        return \"out\";\n      }\n\n      case \"string-op\": {\n        const source = cfg(node, \"text\", ctx);\n        const value = cfg(node, \"value\", ctx);\n        const extra = cfg(node, \"extra\", ctx);\n        let result: string;\n        switch (text(node.config[\"operation\"])) {\n          case \"lowercase\":\n            result = source.toLowerCase();\n            break;\n          case \"trim\":\n            result = source.trim();\n            break;\n          case \"replace\":\n            result = value === \"\" ? source : source.split(value).join(extra);\n            break;\n          case \"slice\":\n            result = source.slice(Number(value) || 0, extra === \"\" ? undefined : Number(extra));\n            break;\n          case \"length\":\n            result = String(source.length);\n            break;\n          default:\n            result = source.toUpperCase();\n        }\n        ctx.vars.set(cfg(node, \"storeAs\", ctx) || \"text\", result);\n        return \"out\";\n      }\n\n      case \"timestamp-format\": {\n        const offset = Math.trunc(num(node.config[\"offsetSeconds\"], 0));\n        const style = text(node.config[\"style\"]) || \"R\";\n        const unix = Math.floor(Date.now() / 1000) + offset;\n        ctx.vars.set(cfg(node, \"storeAs\", ctx) || \"time\", `<t:${unix}:${style}>`);\n        return \"out\";\n      }\n\n      case \"log-event\": {\n        const level = text(node.config[\"level\"]);\n        this.emit({\n          event: \"flow.log\",\n          level: level === \"error\" ? \"error\" : level === \"warning\" ? \"warning\" : \"info\",\n          description: cfg(node, \"message\", ctx),\n          at: nowIso(),\n        });\n        return \"out\";\n      }\n\n      /* ------------------------ extra discord data ---------------------- */\n      case \"count-members\": {\n        const guild = this.requireGuild(ctx, \"Count Members\");\n        const roleRaw = cfg(node, \"role\", ctx);\n        const key = cfg(node, \"storeAs\", ctx) || \"memberCount\";\n        if (roleRaw === \"\") {\n          ctx.vars.set(key, String(guild.memberCount));\n          return \"out\";\n        }\n        const role = await resolveRole(roleRaw, ctx);\n        if (role === null) throw new Error(\"Count Members could not find that role.\");\n        const members = await guild.members.fetch();\n        ctx.vars.set(key, String(members.filter((member) => member.roles.cache.has(role.id)).size));\n        return \"out\";\n      }\n\n      case \"list-roles\": {\n        const guild = this.requireGuild(ctx, \"List Roles\");\n        ctx.vars.set(\n          cfg(node, \"storeAs\", ctx) || \"roles\",\n          guild.roles.cache.map((role) => role.name).join(\", \"),\n        );\n        return \"out\";\n      }\n\n      case \"list-channels\": {\n        const guild = this.requireGuild(ctx, \"List Channels\");\n        ctx.vars.set(\n          cfg(node, \"storeAs\", ctx) || \"channels\",\n          guild.channels.cache.map((channel) => channel.name).join(\", \"),\n        );\n        return \"out\";\n      }\n\n      default:\n        // The compiler strips unsupported nodes, so this only fires when a new\n        // node type ships in the builder before the runtime learns it.\n        logger.warn(`Flow node type \"${node.type}\" is not implemented; skipping.`);\n        return \"out\";\n    }\n  }\n\n  private async evaluateIf(node: CompiledNode, ctx: RunContext): Promise<boolean> {\n    const operator = text(node.config[\"operator\"]);\n    const left = cfg(node, \"variable\", ctx);\n    const right = cfg(node, \"value\", ctx);\n\n    if (operator === \"has-role\" || operator === \"not-has-role\") {\n      const member = await resolveMember(left, ctx);\n      const role = await resolveRole(right, ctx);\n      const has = member !== null && role !== null && member.roles.cache.has(role.id);\n      return operator === \"has-role\" ? has : !has;\n    }\n\n    switch (operator) {\n      case \"not-equals\":\n        return left !== right;\n      case \"contains\":\n        return left.toLowerCase().includes(right.toLowerCase());\n      case \"not-contains\":\n        return !left.toLowerCase().includes(right.toLowerCase());\n      case \"gt\":\n        return Number(left) > Number(right);\n      case \"lt\":\n        return Number(left) < Number(right);\n      case \"equals\":\n      default:\n        return left === right;\n    }\n  }\n\n  /* ---------------------------- helpers ----------------------------- */\n\n  private requireGuild(ctx: RunContext, label: string): Guild {\n    if (ctx.guild === null) throw new Error(`${label} can only run inside a server.`);\n    return ctx.guild;\n  }\n\n  private async requireMember(\n    node: CompiledNode,\n    ctx: RunContext,\n    label: string,\n  ): Promise<GuildMember> {\n    this.requireGuild(ctx, label);\n    const member = await resolveMember(cfg(node, \"user\", ctx), ctx);\n    if (member === null) throw new Error(`${label} could not resolve that member.`);\n    return member;\n  }\n\n  /** The message a node acts on: an explicit ID, or the message that triggered the run. */\n  private async resolveTargetMessage(\n    node: CompiledNode,\n    ctx: RunContext,\n    label: string,\n  ): Promise<Message> {\n    const id = cfg(node, \"messageId\", ctx);\n    if (id === \"\") {\n      if (ctx.message === null)\n        throw new Error(`${label} needs a message context or a message ID.`);\n      return ctx.message;\n    }\n    const channel = ctx.channel ?? ctx.message?.channel ?? null;\n    if (channel === null || !(\"messages\" in channel)) {\n      throw new Error(`${label} needs a text channel context.`);\n    }\n    return await channel.messages.fetch(id);\n  }\n\n  /** reply → followUp → channel.send, matching what Discord allows at that point. */\n  private async respond(ctx: RunContext, payload: FlowPayload, ephemeral: boolean): Promise<void> {\n    const interaction = ctx.interaction;\n\n    if (interaction !== null) {\n      const body = { ...payload, ephemeral } as InteractionReplyOptions;\n      if (interaction.deferred && !interaction.replied) {\n        await interaction.editReply({\n          content: payload.content ?? null,\n          embeds: payload.embeds ?? [],\n          components: payload.components ?? [],\n        } as MessageEditOptions);\n      } else if (interaction.replied || ctx.acknowledged) {\n        await interaction.followUp(body);\n      } else {\n        await interaction.reply(body);\n      }\n      ctx.acknowledged = true;\n      return;\n    }\n\n    if (ctx.message !== null) {\n      await ctx.message.reply(payload as MessageCreateOptions);\n      return;\n    }\n\n    if (ctx.channel !== null && \"send\" in ctx.channel) {\n      await ctx.channel.send(payload as MessageCreateOptions);\n      return;\n    }\n\n    throw new Error(\"Reply has no interaction, message or channel to answer in.\");\n  }\n\n  /* ------------------------------ cron ------------------------------ */\n\n  private startCron(): void {\n    this.cronTimer = setInterval(() => {\n      const now = new Date();\n      const minute = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}-${now.getUTCHours()}-${now.getUTCMinutes()}`;\n      if (minute === this.lastCronMinute) return;\n      this.lastCronMinute = minute;\n\n      void this.runTriggers(\n        \"scheduled-event\",\n        (trigger) => cronMatches(text(trigger.config[\"cron\"]), now),\n        {},\n      );\n    }, 30_000);\n    this.cronTimer.unref();\n  }\n}\n\n/* ------------------------------------------------------------------ *\n * Small pure helpers\n * ------------------------------------------------------------------ */\n\nfunction channelTypeFor(\n  value: string,\n):\n  | ChannelType.GuildText\n  | ChannelType.GuildVoice\n  | ChannelType.GuildCategory\n  | ChannelType.GuildAnnouncement\n  | ChannelType.GuildStageVoice\n  | ChannelType.GuildForum {\n  switch (value) {\n    case \"voice\":\n      return ChannelType.GuildVoice;\n    case \"category\":\n      return ChannelType.GuildCategory;\n    case \"announcement\":\n      return ChannelType.GuildAnnouncement;\n    case \"stage\":\n      return ChannelType.GuildStageVoice;\n    case \"forum\":\n      return ChannelType.GuildForum;\n    default:\n      return ChannelType.GuildText;\n  }\n}\n\nfunction presenceStatus(value: string): PresenceStatusData {\n  switch (value) {\n    case \"idle\":\n      return \"idle\";\n    case \"dnd\":\n      return \"dnd\";\n    case \"invisible\":\n      return \"invisible\";\n    default:\n      return \"online\";\n  }\n}\n\nfunction activityTypeFor(\n  value: string,\n):\n  | ActivityType.Playing\n  | ActivityType.Watching\n  | ActivityType.Listening\n  | ActivityType.Competing\n  | ActivityType.Custom {\n  switch (value) {\n    case \"watching\":\n      return ActivityType.Watching;\n    case \"listening\":\n      return ActivityType.Listening;\n    case \"competing\":\n      return ActivityType.Competing;\n    case \"custom\":\n      return ActivityType.Custom;\n    default:\n      return ActivityType.Playing;\n  }\n}\n\nfunction findGuildChannel(guild: Guild, raw: string): GuildBasedChannel | null {\n  const id = extractId(raw);\n  if (id !== null) return guild.channels.cache.get(id) ?? null;\n  const name = raw.replace(/^#/, \"\").toLowerCase();\n  return guild.channels.cache.find((channel) => channel.name.toLowerCase() === name) ?? null;\n}\n\nfunction currentGuildChannel(guild: Guild, ctx: RunContext): GuildBasedChannel | null {\n  const id = ctx.channel?.id;\n  return id === undefined ? null : (guild.channels.cache.get(id) ?? null);\n}\n\nfunction messageChannelMatches(filter: string, message: Message): boolean {\n  const wanted = filter.trim();\n  if (wanted === \"\") return true;\n  const id = extractId(wanted);\n  if (id !== null) return message.channelId === id;\n  const name = wanted.replace(/^#/, \"\").toLowerCase();\n  return \"name\" in message.channel && String(message.channel.name ?? \"\").toLowerCase() === name;\n}\n\nfunction voiceChannelMatches(\n  filter: string,\n  channel: { id: string; name: string } | null,\n): boolean {\n  const wanted = filter.trim();\n  if (wanted === \"\") return true;\n  if (channel === null) return false;\n  const id = extractId(wanted);\n  if (id !== null) return channel.id === id;\n  return channel.name.toLowerCase() === wanted.replace(/^#/, \"\").toLowerCase();\n}\n\nfunction interactionContext(\n  interaction: RepliableInteraction,\n): Partial<Omit<RunContext, \"client\" | \"flow\" | \"vars\" | \"steps\" | \"acknowledged\">> {\n  return {\n    interaction,\n    guild: interaction.guild,\n    member:\n      interaction.member instanceof Object && \"roles\" in interaction.member\n        ? (interaction.member as GuildMember)\n        : null,\n    user: interaction.user,\n    channel: interaction.channel,\n    message: \"message\" in interaction ? ((interaction.message as Message | null) ?? null) : null,\n  };\n}\n\nfunction matchesEmoji(configured: string, actual: string): boolean {\n  const wanted = configured.trim();\n  if (wanted === \"\") return true;\n  return wanted === actual || wanted.includes(actual);\n}\n\nfunction durationToMs(value: string): number | null {\n  const match = /^(\\d+)(s|m|h|d)$/.exec(value.trim());\n  if (match === null) return null;\n  const amount = Number(match[1]);\n  switch (match[2]) {\n    case \"s\":\n      return amount * 1_000;\n    case \"m\":\n      return amount * 60_000;\n    case \"h\":\n      return amount * 3_600_000;\n    case \"d\":\n      return amount * 86_400_000;\n    default:\n      return null;\n  }\n}\n\nfunction deleteHistorySeconds(value: string): number {\n  switch (value) {\n    case \"1h\":\n      return 3_600;\n    case \"1d\":\n      return 86_400;\n    case \"7d\":\n      return 604_800;\n    default:\n      return 0;\n  }\n}\n\n/**\n * Minimal cron matcher: `minute hour day month weekday`, each field either \"*\"\n * or a plain number. Ranges/steps/lists are intentionally NOT supported (the\n * builder warns about this) rather than being silently mis-evaluated.\n */\nexport function cronMatches(expression: string, date: Date): boolean {\n  const parts = expression.trim().split(/\\s+/);\n  if (parts.length !== 5) return false;\n\n  const actual = [\n    date.getUTCMinutes(),\n    date.getUTCHours(),\n    date.getUTCDate(),\n    date.getUTCMonth() + 1,\n    date.getUTCDay(),\n  ];\n\n  for (let index = 0; index < 5; index += 1) {\n    const field = parts[index] as string;\n    if (field === \"*\") continue;\n    const value = Number(field);\n    if (!Number.isInteger(value) || value !== actual[index]) return false;\n  }\n  return true;\n}\n","runtime/src/flow-contract.ts":"/* AUTO-GENERATED FILE — do not edit.\n * Source: src/lib/flow/contract.ts (web app)\n * Regenerate: npm run sync:contract (inside runtime/)\n */\n/**\n * SHARED FLOW CONTRACT — single source of truth for what a Bottly flow means.\n *\n * This file is deliberately dependency-free (no imports at all) because it is\n * copied verbatim into the bot runtime as `runtime/src/flow-contract.ts` by\n * `runtime/scripts/sync-contract.mjs`. Web app and runtime therefore validate\n * and interpret the exact same graph semantics.\n *\n * Pipeline:\n *   Flow Builder → Flow JSON → validateFlow() → compileFlow() → CompiledFlow\n *   → runtime executor → discord.js → Discord API\n *\n * Every node's discord.js mapping, required gateway intents and required bot\n * permissions are declared in NODE_SPECS below. Nodes marked \"unsupported\" are\n * NOT executed by the runtime and are reported as validation warnings — they\n * are never silently faked.\n */\n\nexport const FLOW_CONTRACT_VERSION = 1 as const;\n\n/* ------------------------------------------------------------------ *\n * Graph shapes (structurally identical to src/types/flow.ts)\n * ------------------------------------------------------------------ */\n\nexport type ConfigValue = string | number | boolean;\n\nexport interface RawFlowNode {\n  id: string;\n  data: {\n    type: string;\n    title?: string;\n    enabled?: boolean;\n    config?: Record<string, ConfigValue>;\n    embed?: unknown;\n    components?: unknown;\n    options?: unknown;\n  };\n}\n\nexport interface RawFlowEdge {\n  id: string;\n  source: string;\n  target: string;\n  sourceHandle?: string | null | undefined;\n  targetHandle?: string | null | undefined;\n}\n\nexport interface RawFlow {\n  id: string;\n  name: string;\n  description?: string;\n  nodes: RawFlowNode[];\n  edges: RawFlowEdge[];\n}\n\n/* ------------------------------------------------------------------ *\n * Node specifications\n * ------------------------------------------------------------------ */\n\n/** How faithfully the runtime implements a node against discord.js. */\nexport type NodeSupport = \"full\" | \"partial\" | \"unsupported\";\n\nexport type NodeKind = \"trigger\" | \"action\" | \"condition\";\n\n/**\n * Context a node needs from the event that started the flow.\n *  interaction  — a repliable interaction (slash/button/select/modal)\n *  message      — a Message object\n *  guild        — the event happened in a guild, not a DM\n *  member       — a GuildMember for the acting user\n */\nexport type ContextKind = \"interaction\" | \"message\" | \"guild\" | \"member\";\n\nexport interface NodeSpec {\n  kind: NodeKind;\n  support: NodeSupport;\n  /** The discord.js API this node actually calls. Documentation, not code. */\n  discord: string;\n  /** GatewayIntentBits names required for this node to work. */\n  intents: string[];\n  /** PermissionFlagsBits names the BOT needs in the guild. */\n  permissions: string[];\n  /** Context the node reads from the trigger. */\n  requires: ContextKind[];\n  /** Context a trigger provides to everything downstream. */\n  provides?: ContextKind[];\n  /** True when the node acknowledges a Discord interaction. */\n  acknowledges?: boolean;\n  /** Output handles, in the order the builder draws them. */\n  outputs: string[];\n  /** Config keys that must be non-empty. */\n  required?: string[];\n  /** Why the node is partial/unsupported. Surfaced in the builder. */\n  note?: string;\n}\n\nconst INTERACTION_CTX: ContextKind[] = [\"interaction\"];\n\nexport const NODE_SPECS: Record<string, NodeSpec> = {\n  /* ----------------------------- Triggers ---------------------------- */\n  \"slash-command\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord:\n      \"Client#on(Events.InteractionCreate) filtered by ChatInputCommandInteraction#commandName; registered through REST Routes.applicationCommands()\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [\"interaction\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n    required: [\"name\", \"description\"],\n  },\n  \"button-click\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Events.InteractionCreate + ButtonInteraction#customId\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [\"interaction\", \"guild\", \"member\", \"message\"],\n    outputs: [\"out\"],\n    required: [\"customId\"],\n  },\n  \"dropdown-select\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord:\n      \"Events.InteractionCreate + AnySelectMenuInteraction#customId (string/user/role/channel/mentionable)\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [\"interaction\", \"guild\", \"member\", \"message\"],\n    outputs: [\"out\"],\n    required: [\"customId\"],\n  },\n  \"modal-submit\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Events.InteractionCreate + ModalSubmitInteraction#customId\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [\"interaction\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n    required: [\"customId\"],\n  },\n  \"message-received\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.MessageCreate)\",\n    intents: [\"Guilds\", \"GuildMessages\", \"MessageContent\"],\n    permissions: [],\n    requires: [],\n    provides: [\"message\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"member-joined\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.GuildMemberAdd)\",\n    intents: [\"Guilds\", \"GuildMembers\"],\n    permissions: [],\n    requires: [],\n    provides: [\"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"member-left\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.GuildMemberRemove)\",\n    intents: [\"Guilds\", \"GuildMembers\"],\n    permissions: [],\n    requires: [],\n    provides: [\"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"reaction-added\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.MessageReactionAdd)\",\n    intents: [\"Guilds\", \"GuildMessageReactions\"],\n    permissions: [],\n    requires: [],\n    provides: [\"message\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"reaction-removed\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.MessageReactionRemove)\",\n    intents: [\"Guilds\", \"GuildMessageReactions\"],\n    permissions: [],\n    requires: [],\n    provides: [\"message\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"bot-ready\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#once(Events.ClientReady)\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [],\n    outputs: [\"out\"],\n  },\n  \"scheduled-event\": {\n    kind: \"trigger\",\n    support: \"partial\",\n    discord:\n      \"setInterval inside the runtime — only the 5-field cron subset '*' and fixed numbers is evaluated\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [],\n    outputs: [\"out\"],\n    required: [\"cron\"],\n    note: \"Cron runs at minute resolution while the bot is online. Ranges, steps and lists (1-5, */2, 1,3) are not evaluated.\",\n  },\n\n  /* ----------------------------- Messages ---------------------------- */\n  \"send-message\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"TextChannel#send() — or Interaction#reply({ ephemeral }) when Ephemeral is on\",\n    intents: [\"Guilds\"],\n    permissions: [\"ViewChannel\", \"SendMessages\"],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"content\"],\n  },\n  reply: {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Interaction#reply()/followUp() or Message#reply()\",\n    intents: [\"Guilds\"],\n    permissions: [\"SendMessages\"],\n    requires: [],\n    acknowledges: true,\n    outputs: [\"out\"],\n    required: [\"content\"],\n  },\n  \"send-embed\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"EmbedBuilder + TextChannel#send({ embeds, components })\",\n    intents: [\"Guilds\"],\n    permissions: [\"ViewChannel\", \"SendMessages\", \"EmbedLinks\"],\n    requires: [],\n    outputs: [\"out\"],\n  },\n  \"edit-message\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"TextChannel#messages.fetch() then Message#edit()\",\n    intents: [\"Guilds\"],\n    permissions: [\"ViewChannel\", \"ReadMessageHistory\"],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"messageId\"],\n  },\n  \"delete-message\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Message#delete()\",\n    intents: [\"Guilds\"],\n    permissions: [\"ManageMessages\", \"ReadMessageHistory\"],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"messageId\"],\n  },\n  \"send-dm\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"User#createDM() then DMChannel#send()\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"user\"],\n  },\n\n  /* ---------------------------- Components --------------------------- */\n  // Components are authored on the message nodes themselves (the Components\n  // editor writes node.data.components). The standalone component nodes are\n  // builder-only scaffolding and are skipped by the runtime.\n  \"component-button\": componentNode(\"ButtonBuilder / ActionRowBuilder\"),\n  \"component-dropdown\": componentNode(\"StringSelectMenuBuilder\"),\n  \"component-string-select\": componentNode(\"StringSelectMenuBuilder\"),\n  \"component-user-select\": componentNode(\"UserSelectMenuBuilder\"),\n  \"component-role-select\": componentNode(\"RoleSelectMenuBuilder\"),\n  \"component-channel-select\": componentNode(\"ChannelSelectMenuBuilder\"),\n  \"component-mentionable-select\": componentNode(\"MentionableSelectMenuBuilder\"),\n  \"component-modal\": {\n    kind: \"action\",\n    support: \"unsupported\",\n    discord: \"ModalBuilder + Interaction#showModal()\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: INTERACTION_CTX,\n    outputs: [\"out\"],\n    note: \"Opening a modal from a flow step is not implemented by the runtime yet. The node is skipped at runtime.\",\n  },\n  \"component-text-input\": {\n    kind: \"action\",\n    support: \"unsupported\",\n    discord: \"TextInputBuilder (modal field)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    note: \"Modal fields belong to a modal; standalone text-input nodes are skipped at runtime.\",\n  },\n\n  /* ---------------------------- Moderation --------------------------- */\n  \"ban-user\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMemberManager#ban({ reason, deleteMessageSeconds })\",\n    intents: [\"Guilds\"],\n    permissions: [\"BanMembers\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\"],\n  },\n  \"kick-user\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMember#kick(reason)\",\n    intents: [\"Guilds\"],\n    permissions: [\"KickMembers\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\"],\n  },\n  \"timeout-user\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMember#timeout(ms, reason)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ModerateMembers\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"duration\"],\n  },\n  \"warn-user\": {\n    kind: \"action\",\n    support: \"partial\",\n    discord: \"User#send() for the DM — Bottly has no warning store, so nothing is persisted\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\"],\n    note: \"Sends the warning DM (when enabled) and logs a runtime event. Warnings are not counted or stored.\",\n  },\n  \"delete-messages\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"TextChannel#bulkDelete(amount, true)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ManageMessages\", \"ReadMessageHistory\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"amount\"],\n  },\n  \"add-role\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMemberRoleManager#add(role)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"role\"],\n  },\n  \"remove-role\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMemberRoleManager#remove(role)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"role\"],\n  },\n  \"move-member\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMember#voice.setChannel(channel)\",\n    intents: [\"Guilds\", \"GuildVoiceStates\"],\n    permissions: [\"MoveMembers\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"channel\"],\n  },\n\n  /* ------------------------------- Logic ----------------------------- */\n  \"if-else\": {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"Pure runtime logic on resolved variables\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n  },\n  \"check-permission\": {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"GuildMember#permissions.has(PermissionFlagsBits[...])\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"true\", \"false\"],\n    required: [\"permission\"],\n  },\n  \"check-role\": {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"GuildMemberRoleManager#cache lookup by id or name\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"true\", \"false\"],\n    required: [\"role\"],\n  },\n  \"check-channel\": {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"Compares the event channel id/name\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n    required: [\"channel\"],\n  },\n  compare: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"Numeric comparison of resolved values\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n  },\n  contains: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"String comparison of resolved values\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n  },\n  equals: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"String comparison of resolved values\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n  },\n  random: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"Math.random() branch\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"a\", \"b\"],\n  },\n  delay: {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"await setTimeout(ms) inside the flow run\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n  },\n  cooldown: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"In-memory per user/channel/guild rate limit inside the runtime\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\", \"blocked\"],\n    note: \"The cooldown map lives in the runtime process and resets when the bot restarts.\",\n  },\n\n  /* ---------------------------- Discord data -------------------------- */\n  \"get-user\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"UserManager#fetch(id)\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"user\", \"storeAs\"],\n  },\n  \"get-member\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMemberManager#fetch(id)\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"storeAs\"],\n  },\n  \"get-server\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Interaction/Message#guild\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"storeAs\"],\n  },\n  \"get-channel\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"ChannelManager#fetch(id)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ViewChannel\"],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"channel\", \"storeAs\"],\n  },\n  \"get-role\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"RoleManager#fetch() / cache lookup\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"role\", \"storeAs\"],\n  },\n\n  /* ----------------------------- Variables ---------------------------- */\n  \"set-variable\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime-local flow variable (per run)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n    note: \"Variables live for the duration of one flow run; they are not persisted between events.\",\n  },\n  \"get-variable\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime-local flow variable (per run)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n  },\n  increment: {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime-local flow variable (per run)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n  },\n  decrement: {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime-local flow variable (per run)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n  },\n  \"format-text\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Template interpolation of flow variables\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"storeAs\"],\n  },\n\n  /* --------------------------- Extra triggers -------------------------- */\n  \"message-edited\": trg(\n    \"Client#on(Events.MessageUpdate)\",\n    [\"GuildMessages\", \"MessageContent\"],\n    [\"message\", \"guild\", \"member\"],\n  ),\n  \"message-deleted\": trg(\n    \"Client#on(Events.MessageDelete)\",\n    [\"GuildMessages\", \"MessageContent\"],\n    [\"message\", \"guild\", \"member\"],\n  ),\n  \"dm-received\": trg(\n    \"Client#on(Events.MessageCreate) filtered to DM channels\",\n    [\"DirectMessages\", \"MessageContent\"],\n    [\"message\"],\n  ),\n  \"voice-join\": trg(\n    \"Client#on(Events.VoiceStateUpdate) — joined a channel\",\n    [\"GuildVoiceStates\"],\n    [\"guild\", \"member\"],\n  ),\n  \"voice-leave\": trg(\n    \"Client#on(Events.VoiceStateUpdate) — left a channel\",\n    [\"GuildVoiceStates\"],\n    [\"guild\", \"member\"],\n  ),\n  \"voice-move\": trg(\n    \"Client#on(Events.VoiceStateUpdate) — moved channel\",\n    [\"GuildVoiceStates\"],\n    [\"guild\", \"member\"],\n  ),\n  \"channel-created\": trg(\"Client#on(Events.ChannelCreate)\", [], [\"guild\"]),\n  \"channel-deleted\": trg(\"Client#on(Events.ChannelDelete)\", [], [\"guild\"]),\n  \"thread-created\": trg(\"Client#on(Events.ThreadCreate)\", [], [\"guild\"]),\n  \"role-created\": trg(\"Client#on(Events.GuildRoleCreate)\", [], [\"guild\"]),\n  \"role-deleted\": trg(\"Client#on(Events.GuildRoleDelete)\", [], [\"guild\"]),\n  \"member-banned\": trg(\"Client#on(Events.GuildBanAdd)\", [\"GuildModeration\"], [\"guild\"]),\n  \"member-unbanned\": trg(\"Client#on(Events.GuildBanRemove)\", [\"GuildModeration\"], [\"guild\"]),\n  \"member-updated\": trg(\n    \"Client#on(Events.GuildMemberUpdate)\",\n    [\"GuildMembers\"],\n    [\"guild\", \"member\"],\n  ),\n  \"invite-created\": trg(\"Client#on(Events.InviteCreate)\", [\"GuildInvites\"], [\"guild\"]),\n  \"typing-start\": trg(\"Client#on(Events.TypingStart)\", [\"GuildMessageTyping\"], [\"guild\", \"member\"]),\n  \"guild-joined\": trg(\"Client#on(Events.GuildCreate)\", [], [\"guild\"]),\n\n  /* --------------------------- Extra messages -------------------------- */\n  \"add-reaction\": act(\"Message#react()\", {\n    permissions: [\"AddReactions\", \"ReadMessageHistory\"],\n    required: [\"emoji\"],\n  }),\n  \"remove-reactions\": act(\"MessageReaction#remove() / Message#reactions.removeAll()\", {\n    permissions: [\"ManageMessages\"],\n  }),\n  \"pin-message\": act(\"Message#pin()\", { permissions: [\"ManageMessages\"] }),\n  \"unpin-message\": act(\"Message#unpin()\", { permissions: [\"ManageMessages\"] }),\n  \"publish-message\": act(\"Message#crosspost()\", { permissions: [\"ManageMessages\"] }),\n  \"get-message\": act(\"TextChannel#messages.fetch()\", {\n    permissions: [\"ReadMessageHistory\"],\n    required: [\"messageId\", \"storeAs\"],\n  }),\n  \"send-file\": act(\"TextChannel#send({ files: [url] })\", {\n    permissions: [\"ViewChannel\", \"SendMessages\", \"AttachFiles\"],\n    required: [\"url\"],\n  }),\n  \"start-typing\": act(\"TextChannel#sendTyping()\", { permissions: [\"ViewChannel\", \"SendMessages\"] }),\n  \"defer-reply\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"RepliableInteraction#deferReply()\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: INTERACTION_CTX,\n    acknowledges: true,\n    outputs: [\"out\"],\n  },\n  \"show-modal\": {\n    kind: \"action\",\n    support: \"full\",\n    discord:\n      \"Interaction#showModal(new ModalBuilder() with TextInputBuilder rows) — answer it with a Modal Submit trigger\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: INTERACTION_CTX,\n    acknowledges: true,\n    outputs: [\"out\"],\n    required: [\"customId\", \"title\", \"field1Id\", \"field1Label\"],\n  },\n\n  /* -------------------------- Server management ------------------------ */\n  \"create-channel\": act(\"Guild#channels.create()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n    required: [\"name\"],\n  }),\n  \"delete-channel\": act(\"GuildChannel#delete()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n    required: [\"channel\"],\n  }),\n  \"rename-channel\": act(\"GuildChannel#setName()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n    required: [\"name\"],\n  }),\n  \"set-channel-topic\": act(\"TextChannel#setTopic()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n  }),\n  \"set-slowmode\": act(\"TextChannel#setRateLimitPerUser()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n  }),\n  \"set-channel-permission\": act(\"GuildChannel#permissionOverwrites.edit()\", {\n    permissions: [\"ManageRoles\", \"ManageChannels\"],\n    requires: [\"guild\"],\n    required: [\"target\", \"permission\"],\n  }),\n  \"create-thread\": act(\"TextChannel#threads.create() / Message#startThread()\", {\n    permissions: [\"CreatePublicThreads\"],\n    requires: [\"guild\"],\n    required: [\"name\"],\n  }),\n  \"archive-thread\": act(\"ThreadChannel#setArchived(true)\", {\n    permissions: [\"ManageThreads\"],\n    requires: [\"guild\"],\n  }),\n  \"lock-thread\": act(\"ThreadChannel#setLocked()\", {\n    permissions: [\"ManageThreads\"],\n    requires: [\"guild\"],\n  }),\n  \"create-invite\": act(\"GuildChannel#createInvite()\", {\n    permissions: [\"CreateInstantInvite\"],\n    requires: [\"guild\"],\n    required: [\"storeAs\"],\n  }),\n  \"create-role\": act(\"Guild#roles.create()\", {\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    required: [\"name\", \"storeAs\"],\n  }),\n  \"edit-role\": act(\"Role#edit()\", {\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    required: [\"role\"],\n  }),\n  \"delete-role\": act(\"Role#delete()\", {\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    required: [\"role\"],\n  }),\n  \"set-nickname\": act(\"GuildMember#setNickname()\", {\n    permissions: [\"ManageNicknames\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n  \"unban-user\": act(\"Guild#bans.remove()\", {\n    permissions: [\"BanMembers\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n  \"set-presence\": act(\"Client#user.setPresence()\", { intents: [] }),\n\n  /* -------------------------------- Voice ------------------------------ */\n  \"voice-mute\": act(\"GuildMember#voice.setMute()\", {\n    permissions: [\"MuteMembers\"],\n    intents: [\"GuildVoiceStates\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n  \"voice-deafen\": act(\"GuildMember#voice.setDeaf()\", {\n    permissions: [\"DeafenMembers\"],\n    intents: [\"GuildVoiceStates\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n  \"disconnect-member\": act(\"GuildMember#voice.disconnect()\", {\n    permissions: [\"MoveMembers\"],\n    intents: [\"GuildVoiceStates\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n\n  /* ----------------------------- Extra logic --------------------------- */\n  switch: cond(\"Runtime string comparison\", [\"case1\", \"case2\", \"case3\", \"case4\", \"default\"], {\n    required: [\"value\"],\n  }),\n  \"regex-match\": cond(\"RegExp#exec() inside the runtime\", [\"true\", \"false\"], {\n    required: [\"pattern\"],\n  }),\n  \"check-bot-permission\": cond(\n    \"GuildMember#permissions.has() for the bot member\",\n    [\"true\", \"false\"],\n    { requires: [\"guild\"], required: [\"permission\"] },\n  ),\n  \"check-context\": cond(\"Checks whether the event carries a Guild\", [\"true\", \"false\"], {}),\n  \"is-bot\": cond(\"User#bot\", [\"true\", \"false\"], {}),\n  \"has-attachment\": cond(\"Message#attachments.size\", [\"true\", \"false\"], { requires: [\"message\"] }),\n  \"for-each\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime loop over a split string; the 'each' branch runs once per item\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"each\", \"out\"],\n    required: [\"list\", \"itemVar\"],\n    note: \"Loops are capped by the runtime step limit (200 steps per run).\",\n  },\n  \"stop-flow\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime control — ends this branch\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [],\n  },\n\n  /* --------------------------- Utility and web ------------------------- */\n  \"http-request\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Node fetch() from the bot process; response stored as {name.status}/{name.body}\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\", \"failed\"],\n    required: [\"url\", \"storeAs\"],\n  },\n  \"parse-json\": act(\"JSON.parse() + dotted path lookup\", { required: [\"source\", \"storeAs\"] }),\n  \"random-number\": act(\"Runtime Math.random()\", { required: [\"storeAs\"] }),\n  \"random-choice\": act(\"Runtime Math.random() over a split list\", {\n    required: [\"list\", \"storeAs\"],\n  }),\n  math: act(\"Runtime arithmetic\", { required: [\"storeAs\"] }),\n  \"string-op\": act(\"Runtime string transform\", { required: [\"storeAs\"] }),\n  \"timestamp-format\": act(\"Discord <t:unix:style> markdown\", { required: [\"storeAs\"] }),\n  \"log-event\": act(\"Runtime log stream shown in the Bottly console\", { required: [\"message\"] }),\n  \"global-set\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime key/value store shared across runs of this bot process\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n    note: \"Shared values live in the running bot process; they reset when the bot restarts.\",\n  },\n  \"global-get\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime key/value store shared across runs of this bot process\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\", \"storeAs\"],\n  },\n\n  /* -------------------------- Extra Discord data ----------------------- */\n  \"count-members\": act(\"Guild#members.fetch() + filter\", {\n    intents: [\"GuildMembers\"],\n    requires: [\"guild\"],\n    required: [\"storeAs\"],\n  }),\n  \"list-roles\": act(\"Guild#roles.cache\", { requires: [\"guild\"], required: [\"storeAs\"] }),\n  \"list-channels\": act(\"Guild#channels.cache\", { requires: [\"guild\"], required: [\"storeAs\"] }),\n};\n\ninterface SpecOptions {\n  intents?: string[];\n  permissions?: string[];\n  requires?: ContextKind[];\n  required?: string[];\n  note?: string;\n}\n\nfunction act(discord: string, options: SpecOptions = {}): NodeSpec {\n  return {\n    kind: \"action\",\n    support: \"full\",\n    discord,\n    intents: options.intents ?? [\"Guilds\"],\n    permissions: options.permissions ?? [],\n    requires: options.requires ?? [],\n    outputs: [\"out\"],\n    ...(options.required ? { required: options.required } : {}),\n    ...(options.note ? { note: options.note } : {}),\n  };\n}\n\nfunction cond(discord: string, outputs: string[], options: SpecOptions): NodeSpec {\n  return {\n    kind: \"condition\",\n    support: \"full\",\n    discord,\n    intents: options.intents ?? [],\n    permissions: [],\n    requires: options.requires ?? [],\n    outputs,\n    ...(options.required ? { required: options.required } : {}),\n  };\n}\n\nfunction trg(discord: string, intents: string[], provides: ContextKind[]): NodeSpec {\n  return {\n    kind: \"trigger\",\n    support: \"full\",\n    discord,\n    intents: [\"Guilds\", ...intents],\n    permissions: [],\n    requires: [],\n    provides,\n    outputs: [\"out\"],\n  };\n}\n\nfunction componentNode(discord: string): NodeSpec {\n  return {\n    kind: \"action\",\n    support: \"unsupported\",\n    discord,\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    note: \"Standalone component nodes are builder scaffolding. Attach components with the Components editor on a Send Message / Send Embed / Reply node — those are sent for real.\",\n  };\n}\n\nexport function getNodeSpec(type: string): NodeSpec | null {\n  return Object.prototype.hasOwnProperty.call(NODE_SPECS, type)\n    ? (NODE_SPECS[type] as NodeSpec)\n    : null;\n}\n\n/* ------------------------------------------------------------------ *\n * Compiled flow — exactly what the runtime executes\n * ------------------------------------------------------------------ */\n\nexport interface CompiledCommandOption {\n  name: string;\n  description: string;\n  type: string;\n  required: boolean;\n  autocomplete: boolean;\n}\n\nexport interface CompiledCommand {\n  name: string;\n  description: string;\n  nodeId: string;\n  options: CompiledCommandOption[];\n}\n\nexport interface CompiledNode {\n  id: string;\n  type: string;\n  title: string;\n  config: Record<string, ConfigValue>;\n  embed: unknown;\n  components: unknown;\n  /** handle -> ordered target node ids */\n  next: Record<string, string[]>;\n}\n\nexport interface CompiledTrigger {\n  nodeId: string;\n  type: string;\n  config: Record<string, ConfigValue>;\n}\n\nexport interface CompiledFlow {\n  contractVersion: typeof FLOW_CONTRACT_VERSION;\n  flowId: string;\n  flowName: string;\n  /** GatewayIntentBits names the runtime must request. */\n  intents: string[];\n  triggers: CompiledTrigger[];\n  commands: CompiledCommand[];\n  nodes: Record<string, CompiledNode>;\n}\n\n/* ------------------------------------------------------------------ *\n * Validation\n * ------------------------------------------------------------------ */\n\nexport type IssueLevel = \"error\" | \"warning\";\n\nexport interface FlowIssue {\n  level: IssueLevel;\n  code: string;\n  message: string;\n  nodeId?: string;\n}\n\nconst CONTEXT_LABEL: Record<ContextKind, string> = {\n  interaction: \"a slash command, button, select menu or modal trigger\",\n  message: \"a message context (Message Received, Reaction or a component trigger)\",\n  guild: \"a server (guild) context — it cannot run in DMs\",\n  member: \"a member context\",\n};\n\nconst COMMAND_NAME_PATTERN = /^[-_\\p{Ll}\\p{Lm}\\p{Lo}\\p{N}]{1,32}$/u;\n\nfunction str(value: ConfigValue | undefined): string {\n  return value === undefined || value === null ? \"\" : String(value).trim();\n}\n\nfunction nodeLabel(node: RawFlowNode): string {\n  return str(node.data.title as ConfigValue) || node.data.type;\n}\n\n/** Handles that leave a node, mapped to their targets. */\nfunction outgoing(edges: RawFlowEdge[], nodeId: string): Record<string, string[]> {\n  const out: Record<string, string[]> = {};\n  for (const edge of edges) {\n    if (edge.source !== nodeId) continue;\n    const handle = edge.sourceHandle ?? \"out\";\n    const list = out[handle] ?? [];\n    list.push(edge.target);\n    out[handle] = list;\n  }\n  return out;\n}\n\n/**\n * Full static analysis of a flow: structure, required fields, intents,\n * permissions, interaction-acknowledgement and trigger-context reachability.\n */\nexport function validateFlow(flow: RawFlow): FlowIssue[] {\n  const issues: FlowIssue[] = [];\n  const nodes = flow.nodes.filter((node) => node.data.enabled !== false);\n  const byId = new Map<string, RawFlowNode>();\n  for (const node of nodes) byId.set(node.id, node);\n\n  const triggers = nodes.filter((node) => getNodeSpec(node.data.type)?.kind === \"trigger\");\n\n  if (nodes.length === 0) {\n    issues.push({ level: \"error\", code: \"empty\", message: \"This flow has no nodes yet.\" });\n    return issues;\n  }\n  if (triggers.length === 0) {\n    issues.push({\n      level: \"error\",\n      code: \"no-trigger\",\n      message: \"Add a trigger node — without one the bot has no reason to run this flow.\",\n    });\n  }\n\n  const seenCommands = new Set<string>();\n  const seenCustomIds = new Set<string>();\n\n  for (const node of nodes) {\n    const spec = getNodeSpec(node.data.type);\n    const config = node.data.config ?? {};\n\n    if (!spec) {\n      issues.push({\n        level: \"error\",\n        code: \"unknown-node\",\n        nodeId: node.id,\n        message: `\"${node.data.type}\" is not a node this bot runtime knows how to run.`,\n      });\n      continue;\n    }\n\n    if (spec.support === \"unsupported\") {\n      issues.push({\n        level: \"warning\",\n        code: \"unsupported-node\",\n        nodeId: node.id,\n        message: `${nodeLabel(node)} is skipped at runtime. ${spec.note ?? \"\"}`.trim(),\n      });\n    } else if (spec.support === \"partial\" && spec.note) {\n      issues.push({\n        level: \"warning\",\n        code: \"partial-node\",\n        nodeId: node.id,\n        message: `${nodeLabel(node)}: ${spec.note}`,\n      });\n    }\n\n    for (const key of spec.required ?? []) {\n      if (str(config[key]) === \"\") {\n        issues.push({\n          level: \"error\",\n          code: \"missing-field\",\n          nodeId: node.id,\n          message: `${nodeLabel(node)} needs a value for \"${key}\".`,\n        });\n      }\n    }\n\n    if (node.data.type === \"slash-command\") {\n      const name = str(config[\"name\"]).toLowerCase();\n      if (name !== \"\" && !COMMAND_NAME_PATTERN.test(name)) {\n        issues.push({\n          level: \"error\",\n          code: \"bad-command-name\",\n          nodeId: node.id,\n          message: `\"${name}\" is not a valid slash command name. Use 1-32 lowercase letters, numbers, - or _.`,\n        });\n      }\n      if (name !== \"\" && seenCommands.has(name)) {\n        issues.push({\n          level: \"error\",\n          code: \"duplicate-command\",\n          nodeId: node.id,\n          message: `Two triggers register /${name}. Discord only allows one command per name.`,\n        });\n      }\n      seenCommands.add(name);\n\n      if (str(config[\"description\"]).length > 100) {\n        issues.push({\n          level: \"error\",\n          code: \"long-description\",\n          nodeId: node.id,\n          message: \"Slash command descriptions are limited to 100 characters by Discord.\",\n        });\n      }\n    }\n\n    if (\n      node.data.type === \"button-click\" ||\n      node.data.type === \"dropdown-select\" ||\n      node.data.type === \"modal-submit\"\n    ) {\n      const customId = str(config[\"customId\"]);\n      if (customId.length > 100) {\n        issues.push({\n          level: \"error\",\n          code: \"long-custom-id\",\n          nodeId: node.id,\n          message: \"Discord custom IDs are limited to 100 characters.\",\n        });\n      }\n      if (customId !== \"\" && seenCustomIds.has(customId)) {\n        issues.push({\n          level: \"warning\",\n          code: \"duplicate-custom-id\",\n          nodeId: node.id,\n          message: `Another trigger already listens for \"${customId}\"; both will run.`,\n        });\n      }\n      seenCustomIds.add(customId);\n    }\n\n    if (node.data.type === \"delete-messages\") {\n      const amount = Number(config[\"amount\"] ?? 0);\n      if (!Number.isFinite(amount) || amount < 1 || amount > 100) {\n        issues.push({\n          level: \"error\",\n          code: \"bulk-delete-range\",\n          nodeId: node.id,\n          message: \"Bulk delete accepts between 1 and 100 messages (Discord limit).\",\n        });\n      }\n    }\n\n    if (spec.permissions.length > 0) {\n      issues.push({\n        level: \"warning\",\n        code: \"needs-permission\",\n        nodeId: node.id,\n        message: `${nodeLabel(node)} requires the bot to have ${spec.permissions.join(\", \")} in the target channel/server.`,\n      });\n    }\n  }\n\n  // Reachability + context analysis per trigger.\n  for (const trigger of triggers) {\n    const triggerSpec = getNodeSpec(trigger.data.type);\n    if (!triggerSpec) continue;\n    const provided = new Set<ContextKind>(triggerSpec.provides ?? []);\n\n    let acknowledgedDepth = -1;\n    let depth = 0;\n    let frontier = [trigger.id];\n    const visited = new Set<string>([trigger.id]);\n\n    while (frontier.length > 0) {\n      const nextFrontier: string[] = [];\n      depth += 1;\n\n      for (const id of frontier) {\n        const handles = outgoing(flow.edges, id);\n        for (const targets of Object.values(handles)) {\n          for (const targetId of targets) {\n            if (visited.has(targetId)) continue;\n            visited.add(targetId);\n            const target = byId.get(targetId);\n            if (!target) continue;\n            const spec = getNodeSpec(target.data.type);\n            if (!spec) continue;\n\n            for (const need of spec.requires) {\n              if (!provided.has(need)) {\n                issues.push({\n                  level: \"error\",\n                  code: \"missing-context\",\n                  nodeId: target.id,\n                  message: `${nodeLabel(target)} needs ${CONTEXT_LABEL[need]}, but \"${nodeLabel(trigger)}\" does not provide it.`,\n                });\n              }\n            }\n\n            if (spec.acknowledges && acknowledgedDepth < 0) acknowledgedDepth = depth;\n            nextFrontier.push(targetId);\n          }\n        }\n      }\n      frontier = nextFrontier;\n    }\n\n    // Interaction triggers must be answered; Discord invalidates the token after 3s.\n    if (provided.has(\"interaction\")) {\n      const answered = [...visited].some((id) => {\n        const node = byId.get(id);\n        if (!node) return false;\n        const spec = getNodeSpec(node.data.type);\n        return Boolean(spec?.acknowledges) || node.data.type === \"send-message\";\n      });\n      if (!answered) {\n        issues.push({\n          level: \"warning\",\n          code: \"unacknowledged-interaction\",\n          nodeId: trigger.id,\n          message:\n            'This interaction is never answered. Discord shows \"The application did not respond\" unless a Reply node runs. The runtime defers the reply automatically, but add a Reply to control what the user sees.',\n        });\n      }\n      const hasDelay = [...visited].some((id) => byId.get(id)?.data.type === \"delay\");\n      if (hasDelay && acknowledgedDepth < 0) {\n        issues.push({\n          level: \"warning\",\n          code: \"delay-before-ack\",\n          nodeId: trigger.id,\n          message:\n            \"A Delay runs before this interaction is answered. Discord's interaction token expires after 3 seconds, so reply (or defer) first.\",\n        });\n      }\n    }\n\n    for (const node of nodes) {\n      if (node.id === trigger.id) continue;\n      const spec = getNodeSpec(node.data.type);\n      if (spec?.kind === \"trigger\") continue;\n      if (!visited.has(node.id) && !isReachableFromAnyTrigger(flow, triggers, node.id)) {\n        issues.push({\n          level: \"warning\",\n          code: \"unreachable\",\n          nodeId: node.id,\n          message: `${nodeLabel(node)} is not connected to any trigger, so it never runs.`,\n        });\n      }\n    }\n    break; // unreachable analysis only needs to run once\n  }\n\n  return dedupeIssues(issues);\n}\n\nfunction isReachableFromAnyTrigger(\n  flow: RawFlow,\n  triggers: RawFlowNode[],\n  nodeId: string,\n): boolean {\n  for (const trigger of triggers) {\n    const visited = new Set<string>([trigger.id]);\n    const stack = [trigger.id];\n    while (stack.length > 0) {\n      const current = stack.pop() as string;\n      for (const targets of Object.values(outgoing(flow.edges, current))) {\n        for (const target of targets) {\n          if (target === nodeId) return true;\n          if (visited.has(target)) continue;\n          visited.add(target);\n          stack.push(target);\n        }\n      }\n    }\n  }\n  return false;\n}\n\nfunction dedupeIssues(issues: FlowIssue[]): FlowIssue[] {\n  const seen = new Set<string>();\n  const out: FlowIssue[] = [];\n  for (const issue of issues) {\n    const key = `${issue.level}|${issue.code}|${issue.nodeId ?? \"\"}|${issue.message}`;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    out.push(issue);\n  }\n  return out;\n}\n\n/* ------------------------------------------------------------------ *\n * Compilation\n * ------------------------------------------------------------------ */\n\nconst OPTION_TYPES = new Set([\n  \"string\",\n  \"integer\",\n  \"number\",\n  \"boolean\",\n  \"user\",\n  \"role\",\n  \"channel\",\n  \"mentionable\",\n  \"attachment\",\n]);\n\nfunction compileOptions(raw: unknown): CompiledCommandOption[] {\n  if (!Array.isArray(raw)) return [];\n  const options: CompiledCommandOption[] = [];\n  for (const entry of raw) {\n    if (typeof entry !== \"object\" || entry === null) continue;\n    const record = entry as Record<string, unknown>;\n    const name = String(record[\"name\"] ?? \"\")\n      .trim()\n      .toLowerCase();\n    if (name === \"\" || !COMMAND_NAME_PATTERN.test(name)) continue;\n    const type = String(record[\"type\"] ?? \"string\");\n    options.push({\n      name,\n      description: String(record[\"description\"] ?? \"\").slice(0, 100) || name,\n      type: OPTION_TYPES.has(type) ? type : \"string\",\n      required: record[\"required\"] === true,\n      autocomplete: record[\"autocomplete\"] === true,\n    });\n  }\n  // Discord requires required options before optional ones.\n  return [...options.filter((o) => o.required), ...options.filter((o) => !o.required)];\n}\n\nexport interface CompileResult {\n  compiled: CompiledFlow | null;\n  issues: FlowIssue[];\n}\n\n/**\n * Turns a builder graph into the executable form. Returns `compiled: null`\n * when validation produced errors — the runtime must never receive a graph\n * that the control plane knows is broken.\n */\nexport function compileFlow(flow: RawFlow): CompileResult {\n  const issues = validateFlow(flow);\n  if (issues.some((issue) => issue.level === \"error\")) return { compiled: null, issues };\n\n  const enabled = flow.nodes.filter((node) => node.data.enabled !== false);\n  const known = enabled.filter((node) => {\n    const spec = getNodeSpec(node.data.type);\n    return spec !== null && spec.support !== \"unsupported\";\n  });\n  const keep = new Set(known.map((node) => node.id));\n\n  const nodes: Record<string, CompiledNode> = {};\n  for (const node of known) {\n    const handles = outgoing(flow.edges, node.id);\n    const next: Record<string, string[]> = {};\n    for (const [handle, targets] of Object.entries(handles)) {\n      // Skipped (unsupported) nodes are bridged over, so the rest of the chain\n      // still runs instead of silently dead-ending.\n      const resolved: string[] = [];\n      for (const target of targets) resolved.push(...resolveTarget(flow, keep, target, new Set()));\n      if (resolved.length > 0) next[handle] = resolved;\n    }\n\n    nodes[node.id] = {\n      id: node.id,\n      type: node.data.type,\n      title: str(node.data.title as ConfigValue) || node.data.type,\n      config: node.data.config ?? {},\n      embed: node.data.embed ?? null,\n      components: node.data.components ?? null,\n      next,\n    };\n  }\n\n  const triggers: CompiledTrigger[] = [];\n  const commands: CompiledCommand[] = [];\n  const intents = new Set<string>([\"Guilds\"]);\n\n  for (const node of known) {\n    const spec = getNodeSpec(node.data.type) as NodeSpec;\n    for (const intent of spec.intents) intents.add(intent);\n\n    if (spec.kind !== \"trigger\") continue;\n    const config = node.data.config ?? {};\n    triggers.push({ nodeId: node.id, type: node.data.type, config });\n\n    if (node.data.type === \"slash-command\") {\n      commands.push({\n        name: str(config[\"name\"]).toLowerCase(),\n        description: str(config[\"description\"]).slice(0, 100) || \"A Bottly command\",\n        nodeId: node.id,\n        options: compileOptions(node.data.options),\n      });\n    }\n  }\n\n  return {\n    compiled: {\n      contractVersion: FLOW_CONTRACT_VERSION,\n      flowId: flow.id,\n      flowName: flow.name,\n      intents: [...intents],\n      triggers,\n      commands,\n      nodes,\n    },\n    issues,\n  };\n}\n\n/** Walks past skipped nodes to the first executable descendant(s). */\nfunction resolveTarget(\n  flow: RawFlow,\n  keep: Set<string>,\n  target: string,\n  seen: Set<string>,\n): string[] {\n  if (keep.has(target)) return [target];\n  if (seen.has(target)) return [];\n  seen.add(target);\n\n  const out: string[] = [];\n  for (const targets of Object.values(outgoing(flow.edges, target))) {\n    for (const next of targets) out.push(...resolveTarget(flow, keep, next, seen));\n  }\n  return out;\n}\n\n/** Node-support matrix, used by the docs page and the builder's help panel. */\nexport function supportMatrix(): {\n  type: string;\n  support: NodeSupport;\n  discord: string;\n  note: string;\n}[] {\n  return Object.entries(NODE_SPECS).map(([type, spec]) => ({\n    type,\n    support: spec.support,\n    discord: spec.discord,\n    note: spec.note ?? \"\",\n  }));\n}\n","runtime/src/http.ts":"/**\n * Control-plane facing HTTP API.\n *\n * Contract (see src/lib/runtime-client.server.ts in the web app):\n *   POST /v1/bots/:botId/start   body { botId, userId }  -> RuntimeStatusPayload\n *   POST /v1/bots/:botId/stop    body { botId, userId }  -> RuntimeStatusPayload\n *   GET  /v1/bots/:botId?userId= -> RuntimeStatusPayload\n *   GET  /healthz                -> liveness\n *\n * SECURITY: /v1/* is authenticated ONLY by BOT_RUNTIME_SHARED_SECRET — there is\n * no user session here. Anyone who can reach this port and holds the secret can\n * start or stop any bot, so also restrict it at the network layer (private\n * network, security group, or an authenticating proxy) and rotate the secret if\n * it is ever exposed. /healthz is unauthenticated by design and reveals nothing\n * beyond liveness unless the caller presents the secret.\n */\nimport http from \"node:http\";\nimport { timingSafeEqual } from \"node:crypto\";\nimport { config } from \"./config.js\";\nimport { logger } from \"./logger.js\";\nimport { describeError } from \"./redact.js\";\nimport {\n  CapacityError,\n  refreshBotPresence,\n  runtimeSnapshot,\n  startBot,\n  statusOf,\n  stopBot,\n} from \"./supervisor.js\";\nimport type { RuntimeStatusPayload } from \"./types.js\";\n\nconst MAX_BODY_BYTES = 64 * 1024;\nconst BEARER_PREFIX = \"Bearer \";\nconst BOT_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nclass HttpError extends Error {\n  readonly status: number;\n\n  constructor(status: number, message: string) {\n    super(message);\n    this.name = \"HttpError\";\n    this.status = status;\n  }\n}\n\nfunction sendJson(res: http.ServerResponse, status: number, body: unknown): void {\n  const payload = JSON.stringify(body);\n  res.writeHead(status, {\n    \"content-type\": \"application/json; charset=utf-8\",\n    \"cache-control\": \"no-store\",\n    \"content-length\": Buffer.byteLength(payload).toString(),\n  });\n  res.end(payload);\n}\n\n/** Length check first, then a constant-time compare of equal-length buffers. */\nfunction secretMatches(provided: string, expected: string): boolean {\n  const left = Buffer.from(provided, \"utf8\");\n  const right = Buffer.from(expected, \"utf8\");\n  if (left.byteLength !== right.byteLength) return false;\n  return timingSafeEqual(left, right);\n}\n\nfunction isAuthorized(req: http.IncomingMessage): boolean {\n  const header = req.headers[\"authorization\"];\n  if (typeof header !== \"string\" || !header.startsWith(BEARER_PREFIX)) return false;\n  return secretMatches(header.slice(BEARER_PREFIX.length), config.inboundSecret);\n}\n\nfunction requireAuth(req: http.IncomingMessage): void {\n  if (!isAuthorized(req)) throw new HttpError(401, \"Unauthorized.\");\n}\n\nasync function readJsonBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {\n  const chunks: Buffer[] = [];\n  let size = 0;\n\n  for await (const chunk of req as AsyncIterable<Buffer>) {\n    size += chunk.byteLength;\n    if (size > MAX_BODY_BYTES) throw new HttpError(413, \"Request body is too large.\");\n    chunks.push(chunk);\n  }\n\n  if (size === 0) return {};\n\n  let parsed: unknown;\n  try {\n    parsed = JSON.parse(Buffer.concat(chunks).toString(\"utf8\"));\n  } catch {\n    throw new HttpError(400, \"Request body must be valid JSON.\");\n  }\n\n  if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n    throw new HttpError(400, \"Request body must be a JSON object.\");\n  }\n  return parsed as Record<string, unknown>;\n}\n\nfunction requireBotId(raw: string | undefined): string {\n  const botId = raw === undefined ? \"\" : decodeURIComponent(raw);\n  if (!BOT_ID_PATTERN.test(botId)) throw new HttpError(400, \"Invalid bot id.\");\n  return botId;\n}\n\nfunction requireUserId(raw: unknown): string {\n  if (typeof raw !== \"string\" || !UUID_PATTERN.test(raw)) {\n    throw new HttpError(400, \"A valid userId (UUID) is required.\");\n  }\n  return raw;\n}\n\nfunction withBotId(\n  botId: string,\n  status: RuntimeStatusPayload,\n): RuntimeStatusPayload & {\n  botId: string;\n} {\n  return { botId, ...status };\n}\n\nasync function handleCommand(\n  req: http.IncomingMessage,\n  botId: string,\n  action: \"start\" | \"stop\",\n): Promise<RuntimeStatusPayload> {\n  const body = await readJsonBody(req);\n  const userId = requireUserId(body[\"userId\"]);\n\n  // The body carries botId too; a mismatch means a malformed caller, not a bot.\n  const bodyBotId = body[\"botId\"];\n  if (typeof bodyBotId === \"string\" && bodyBotId !== botId) {\n    throw new HttpError(400, \"botId in the body does not match the URL.\");\n  }\n\n  logger.info(`${action} requested for bot ${botId}.`);\n  return action === \"start\" ? startBot(userId, botId) : stopBot(userId, botId);\n}\n\nasync function route(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {\n  const method = req.method ?? \"GET\";\n  const url = new URL(req.url ?? \"/\", \"http://runtime.invalid\");\n  const segments = url.pathname.split(\"/\").filter((part) => part.length > 0);\n\n  if (segments.length === 1 && segments[0] === \"healthz\") {\n    if (method !== \"GET\") throw new HttpError(405, \"Method not allowed.\");\n    // Detail only for authenticated callers; bare liveness for load balancers.\n    const detail = isAuthorized(req)\n      ? { ...runtimeSnapshot(), uptimeSeconds: Math.round(process.uptime()) }\n      : {};\n    sendJson(res, 200, { ok: true, ...detail });\n    return;\n  }\n\n  if (segments[0] !== \"v1\" || segments[1] !== \"bots\") {\n    throw new HttpError(404, \"Not found.\");\n  }\n\n  requireAuth(req);\n\n  const botId = requireBotId(segments[2]);\n\n  // GET /v1/bots/:botId?userId=\n  if (segments.length === 3 && method === \"GET\") {\n    const userId = requireUserId(url.searchParams.get(\"userId\"));\n    sendJson(res, 200, withBotId(botId, statusOf(userId, botId)));\n    return;\n  }\n\n  // POST /v1/bots/:botId/start | /stop\n  if (segments.length === 4 && method === \"POST\") {\n    const action = segments[3];\n\n    // POST /v1/bots/:botId/presence — re-apply the saved presence live.\n    if (action === \"presence\") {\n      const body = await readJsonBody(req);\n      const userId = requireUserId(body[\"userId\"]);\n      const applied = await refreshBotPresence(userId, botId);\n      sendJson(res, 200, { ...withBotId(botId, statusOf(userId, botId)), applied });\n      return;\n    }\n\n    if (action !== \"start\" && action !== \"stop\") throw new HttpError(404, \"Not found.\");\n    const status = await handleCommand(req, botId, action);\n    sendJson(res, 200, withBotId(botId, status));\n    return;\n  }\n\n  if (segments.length === 3 || segments.length === 4) {\n    throw new HttpError(405, \"Method not allowed.\");\n  }\n  throw new HttpError(404, \"Not found.\");\n}\n\nexport function createRuntimeServer(): http.Server {\n  const server = http.createServer((req, res) => {\n    void route(req, res).catch((error: unknown) => {\n      if (res.headersSent) {\n        res.destroy();\n        return;\n      }\n      if (error instanceof HttpError) {\n        sendJson(res, error.status, { ok: false, error: error.message });\n        return;\n      }\n      if (error instanceof CapacityError) {\n        sendJson(res, 503, { ok: false, error: error.message });\n        return;\n      }\n      logger.error(`Unhandled request error: ${describeError(error)}`, error);\n      sendJson(res, 500, { ok: false, error: \"Internal runtime error.\" });\n    });\n  });\n\n  server.headersTimeout = 15_000;\n  server.requestTimeout = 30_000;\n  server.keepAliveTimeout = 20_000;\n  return server;\n}\n","runtime/src/index.ts":"/**\n * Entry point. Boots the HTTP API, then drains gateway connections on shutdown\n * so no bot is left reported as \"online\" after the process is gone.\n */\nimport { config } from \"./config.js\";\nimport { createRuntimeServer } from \"./http.js\";\nimport { logger } from \"./logger.js\";\nimport { describeError } from \"./redact.js\";\nimport { shutdownAll, startSweeper } from \"./supervisor.js\";\n\nconst SHUTDOWN_TIMEOUT_MS = 15_000;\n\nconst server = createRuntimeServer();\nconst stopSweeper = startSweeper();\n\nlet shuttingDown = false;\n\nasync function shutdown(signal: string, exitCode = 0): Promise<void> {\n  if (shuttingDown) return;\n  shuttingDown = true;\n  logger.info(`Received ${signal}, shutting down.`);\n\n  stopSweeper();\n  server.close();\n\n  const timeout = setTimeout(() => {\n    logger.error(\"Shutdown timed out; exiting anyway.\");\n    process.exit(exitCode || 1);\n  }, SHUTDOWN_TIMEOUT_MS);\n  timeout.unref();\n\n  try {\n    await shutdownAll();\n  } catch (error) {\n    logger.error(`Error during shutdown: ${describeError(error)}`);\n  }\n\n  clearTimeout(timeout);\n  process.exit(exitCode);\n}\n\nserver.on(\"error\", (error: Error) => {\n  logger.error(`HTTP server error: ${describeError(error)}`);\n  process.exit(1);\n});\n\nserver.listen(config.port, config.host, () => {\n  logger.info(\n    `Bot runtime listening on http://${config.host}:${config.port} ` +\n      `(control plane: ${config.controlPlaneUrl}, max bots: ${config.maxConcurrentBots}).`,\n  );\n});\n\nprocess.on(\"SIGTERM\", () => void shutdown(\"SIGTERM\"));\nprocess.on(\"SIGINT\", () => void shutdown(\"SIGINT\"));\n\nprocess.on(\"unhandledRejection\", (reason: unknown) => {\n  // Reporting and gateway callbacks are fire-and-forget; log instead of dying.\n  logger.error(`Unhandled promise rejection: ${describeError(reason)}`, reason);\n});\n\nprocess.on(\"uncaughtException\", (error: Error) => {\n  logger.error(`Uncaught exception: ${describeError(error)}`, error);\n  void shutdown(\"uncaughtException\", 1);\n});\n","runtime/src/logger.ts":"/**\n * Minimal levelled logger.\n *\n * Reads its own env var so it has no dependency on config.ts (which logs while\n * validating). Never log a bot token, a decrypted secret, or a raw callback\n * secret — use scrubSecrets() from ./redact.ts for anything Discord-derived.\n */\ntype Level = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nconst ORDER: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 };\n\nfunction resolveThreshold(): number {\n  const raw = (process.env[\"BOT_RUNTIME_LOG_LEVEL\"] ?? \"info\").toLowerCase();\n  return ORDER[raw as Level] ?? ORDER.info;\n}\n\nconst threshold = resolveThreshold();\n\nfunction emit(level: Level, message: string, extra: unknown[]): void {\n  if (ORDER[level] < threshold) return;\n  const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${message}`;\n  if (level === \"error\") console.error(line, ...extra);\n  else if (level === \"warn\") console.warn(line, ...extra);\n  else console.log(line, ...extra);\n}\n\nexport const logger = {\n  debug: (message: string, ...extra: unknown[]) => emit(\"debug\", message, extra),\n  info: (message: string, ...extra: unknown[]) => emit(\"info\", message, extra),\n  warn: (message: string, ...extra: unknown[]) => emit(\"warn\", message, extra),\n  error: (message: string, ...extra: unknown[]) => emit(\"error\", message, extra),\n};\n","runtime/src/redact.ts":"/**\n * Defence in depth for log lines and user-visible error text.\n *\n * discord.js error messages and HTTP bodies can echo back credentials, and\n * everything we report ends up in bot_runtime_events, which the dashboard shows\n * to the bot owner. Scrub anything token-shaped before it leaves the process.\n */\n\n/** Discord bot tokens: three base64url segments separated by dots. */\nconst DISCORD_TOKEN = /[A-Za-z0-9_-]{20,}\\.[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{20,}/g;\n/** `Bot <token>` / `Bearer <token>` in copied headers. */\nconst AUTH_HEADER = /\\b(Bot|Bearer)\\s+[A-Za-z0-9._-]{8,}/gi;\n\nexport function scrubSecrets(value: string): string {\n  return value.replace(DISCORD_TOKEN, \"[redacted-token]\").replace(AUTH_HEADER, \"$1 [redacted]\");\n}\n\n/** Short, scrubbed, single-line description of an unknown thrown value. */\nexport function describeError(error: unknown, maxLength = 300): string {\n  const raw =\n    error instanceof Error\n      ? error.message || error.name\n      : typeof error === \"string\"\n        ? error\n        : \"Unknown error\";\n  return scrubSecrets(raw).replace(/\\s+/g, \" \").trim().slice(0, maxLength);\n}\n\n/** discord.js attaches stable string codes (TokenInvalid, DisallowedIntents, …). */\nexport function errorCode(error: unknown): string | null {\n  if (typeof error !== \"object\" || error === null) return null;\n  const code = (error as { code?: unknown }).code;\n  return typeof code === \"string\" ? code : null;\n}\n","runtime/src/session.ts":"/**\n * One bot = one BotSession = one Discord gateway connection.\n *\n * The session is the source of truth for that bot's state and pushes every\n * transition to the control plane, so the dashboard stays truthful without\n * anyone polling Discord. start()/stop() are serialised through a promise chain,\n * so double clicks and a stop landing mid-start cannot interleave.\n */\nimport { ActivityType, Client, Events, GatewayIntentBits, Partials } from \"discord.js\";\nimport type { BotPresenceConfig } from \"./deployment.js\";\n\n/** How often a running bot re-reads its saved presence from the dashboard. */\nconst PRESENCE_POLL_INTERVAL_MS = 30_000;\n\n/** Applies the owner's configured status/activity to the live gateway client. */\nfunction applyPresence(client: Client, presence: BotPresenceConfig | null): void {\n  if (!client.user) return;\n\n  // No presence configured: fall back to a plain online status.\n  if (!presence) {\n    try {\n      client.user.setPresence({ status: \"online\", activities: [] });\n    } catch (error) {\n      logger.warn(`Could not apply presence: ${describeError(error)}`);\n    }\n    return;\n  }\n\n  const status =\n    presence.status === \"idle\" || presence.status === \"dnd\" || presence.status === \"invisible\"\n      ? presence.status\n      : \"online\";\n\n  const typeMap: Record<string, ActivityType> = {\n    playing: ActivityType.Playing,\n    streaming: ActivityType.Streaming,\n    listening: ActivityType.Listening,\n    watching: ActivityType.Watching,\n    competing: ActivityType.Competing,\n    custom: ActivityType.Custom,\n  };\n  const type = typeMap[presence.activityType ?? \"playing\"] ?? ActivityType.Playing;\n  const name =\n    (type === ActivityType.Custom ? presence.activityState : presence.activityName)?.trim() ?? \"\";\n\n  try {\n    if (name) {\n      // Discord custom activities require a non-empty placeholder name and put\n      // the user-visible text in `state`; other activity types use `name`.\n      const activity: { name: string; type: ActivityType; url?: string; state?: string } = {\n        name: type === ActivityType.Custom ? \"Custom Status\" : name,\n        type,\n      };\n      if (type === ActivityType.Streaming && presence.streamUrl) activity.url = presence.streamUrl;\n      if (type === ActivityType.Custom) activity.state = name;\n      client.user.setPresence({ status, activities: [activity] });\n    } else {\n      client.user.setPresence({ status, activities: [] });\n    }\n  } catch (error) {\n    logger.warn(`Could not apply presence: ${describeError(error)}`);\n  }\n}\nimport { config } from \"./config.js\";\nimport { syncCommands } from \"./commands.js\";\nimport { ControlPlaneError, fetchEncryptedToken, reportRuntimeState } from \"./control-plane.js\";\nimport { decryptBotToken, isDecryptionFailure } from \"./crypto.js\";\nimport { fetchDeployment, type BotDeployment } from \"./deployment.js\";\nimport { FlowRunner } from \"./executor.js\";\nimport { logger } from \"./logger.js\";\nimport { describeError, errorCode, scrubSecrets } from \"./redact.js\";\nimport {\n  nowIso,\n  type RuntimeEvent,\n  type RuntimeState,\n  type RuntimeStatusPayload,\n} from \"./types.js\";\n\n/**\n * Turns intent NAMES (as computed by the flow compiler) into gateway bits.\n * Unknown names are dropped with a warning instead of crashing the bot.\n */\nfunction intentBits(names: string[]): number[] {\n  const table = GatewayIntentBits as unknown as Record<string, number | string | undefined>;\n  const bits = new Set<number>(config.intents);\n  for (const name of names) {\n    const bit = table[name];\n    if (typeof bit === \"number\") bits.add(bit);\n    else logger.warn(`Ignoring unknown gateway intent \"${name}\" from the published flow.`);\n  }\n  return [...bits];\n}\n\n/** Turns a token-acquisition failure into text that is safe to show the owner. */\nfunction describeTokenFailure(error: unknown): string {\n  if (error instanceof ControlPlaneError) {\n    switch (error.kind) {\n      case \"missing\":\n        return \"No verified bot token is stored for this bot. Add one in Settings, then start it again.\";\n      case \"unauthorized\":\n        // Operator misconfiguration: keep the detail in the logs only.\n        logger.error(`Control plane rejected the callback secret: ${error.message}`);\n        return \"Bot hosting is misconfigured on the server. Contact support.\";\n      case \"invalid\":\n        logger.error(`Malformed token payload from the control plane: ${error.message}`);\n        return \"The stored token could not be read. Save it again in Settings.\";\n      case \"unavailable\":\n      default:\n        return \"The runtime could not reach the control plane. Try again in a moment.\";\n    }\n  }\n\n  if (isDecryptionFailure(error)) {\n    logger.error(`Token decryption failed: ${describeError(error)}`);\n    return \"The stored token could not be decrypted. Save it again in Settings.\";\n  }\n\n  logger.error(`Unexpected token failure: ${describeError(error)}`);\n  return \"The runtime could not load this bot's token.\";\n}\n\n/** Same, for a failed gateway login. */\nfunction describeLoginFailure(error: unknown): string {\n  const code = errorCode(error);\n\n  if (code === \"TokenInvalid\") {\n    return \"Discord rejected the stored token. Reset it in the developer portal and save the new one.\";\n  }\n  if (code === \"DisallowedIntents\") {\n    return \"Discord refused the requested gateway intents. Enable the privileged intents for this application in the developer portal.\";\n  }\n  if (code === \"ShardingRequired\") {\n    return \"This bot is in too many servers for a single connection. Contact support to enable sharding.\";\n  }\n\n  return `Could not connect this bot to Discord: ${describeError(error, 200)}`;\n}\n\nexport class BotSession {\n  readonly botId: string;\n  readonly userId: string;\n\n  private state: RuntimeState = \"offline\";\n  private client: Client | null = null;\n  private startedAt: string | null = null;\n  private lastError: string | null = null;\n  private guildCount: number | null = null;\n  private username: string | null = null;\n\n  /** Published flow deployment this session is running, if any. */\n  private deployment: BotDeployment | null = null;\n  private runner: FlowRunner | null = null;\n\n  /** Set while a deliberate stop is in flight, so close events stay quiet. */\n  private stopping = false;\n  /** Serialises start/stop. Rejections are absorbed so the chain never dies. */\n  private chain: Promise<unknown> = Promise.resolve();\n  /** Periodic presence refresh, so dashboard edits reach VPS-hosted bots. */\n  private presenceTimer: ReturnType<typeof setInterval> | null = null;\n\n  constructor(botId: string, userId: string) {\n    this.botId = botId;\n    this.userId = userId;\n  }\n\n  status(): RuntimeStatusPayload {\n    return {\n      state: this.state,\n      startedAt: this.startedAt,\n      lastError: this.lastError,\n      guildCount: this.guildCount,\n      username: this.username,\n    };\n  }\n\n  /** True when this session holds no connection and can be dropped from the map. */\n  isIdle(): boolean {\n    return this.client === null && (this.state === \"offline\" || this.state === \"error\");\n  }\n\n  isActive(): boolean {\n    return this.state !== \"offline\";\n  }\n\n  start(): Promise<RuntimeStatusPayload> {\n    return this.enqueue(() => this.startNow());\n  }\n\n  stop(reason: \"command\" | \"shutdown\" = \"command\"): Promise<RuntimeStatusPayload> {\n    return this.enqueue(() => this.stopNow(reason));\n  }\n\n  /**\n   * Re-fetches the owner's saved presence from the control plane and applies\n   * it to the live gateway client — no restart needed. Returns false when the\n   * bot is not online or the refresh failed.\n   */\n  async refreshPresence(): Promise<boolean> {\n    if (this.state !== \"online\" || this.client === null) return false;\n    try {\n      const deployment = await fetchDeployment(this.botId, this.userId);\n      this.deployment = deployment;\n      applyPresence(this.client, deployment?.presence ?? null);\n      return true;\n    } catch (error) {\n      logger.warn(`Presence refresh failed for bot ${this.botId}: ${describeError(error)}`);\n      return false;\n    }\n  }\n\n  private startPresencePolling(): void {\n    this.stopPresencePolling();\n    this.presenceTimer = setInterval(() => {\n      void this.refreshPresence();\n    }, PRESENCE_POLL_INTERVAL_MS);\n    // Never keep the process alive just for polling.\n    this.presenceTimer.unref?.();\n  }\n\n  private stopPresencePolling(): void {\n    if (this.presenceTimer !== null) {\n      clearInterval(this.presenceTimer);\n      this.presenceTimer = null;\n    }\n  }\n\n  private enqueue(task: () => Promise<RuntimeStatusPayload>): Promise<RuntimeStatusPayload> {\n    // then(task, task): the next command runs whatever the previous one did.\n    const next = this.chain.then(task, task);\n    this.chain = next.catch(() => undefined);\n    return next;\n  }\n\n  private async startNow(): Promise<RuntimeStatusPayload> {\n    if (this.state === \"online\" || this.state === \"starting\") return this.status();\n\n    await this.teardownClient();\n    this.stopping = false;\n    this.state = \"starting\";\n    this.lastError = null;\n\n    await this.report([\n      {\n        event: \"runtime.starting\",\n        level: \"info\",\n        description: \"Runtime is connecting this bot to the Discord gateway.\",\n        at: nowIso(),\n      },\n    ]);\n\n    let token: string;\n    try {\n      const sealed = await fetchEncryptedToken(this.botId, this.userId);\n      token = await decryptBotToken(sealed);\n    } catch (error) {\n      return this.fail(describeTokenFailure(error));\n    }\n\n    // The published flow decides which gateway intents we must identify with,\n    // so it has to be loaded BEFORE the client is constructed.\n    try {\n      this.deployment = await fetchDeployment(this.botId, this.userId);\n    } catch (error) {\n      this.deployment = null;\n      logger.warn(\n        `Could not load the published flow for bot ${this.botId}: ${describeError(error)}`,\n      );\n      await this.report([\n        {\n          event: \"flow.load-failed\",\n          level: \"warning\",\n          description:\n            \"The published flow could not be loaded; the bot connects but will not respond. Publish the flow again.\",\n          at: nowIso(),\n        },\n      ]);\n    }\n\n    const client = new Client({\n      intents: intentBits(this.deployment?.intents ?? []),\n      // Reactions and member removals frequently arrive uncached.\n      partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.GuildMember],\n    });\n    this.client = client;\n    this.attach(client);\n\n    if (this.deployment !== null && this.deployment.flows.length > 0) {\n      const runner = new FlowRunner(client, this.deployment.flows, (event) => {\n        void this.report([event]);\n      });\n      runner.attach();\n      this.runner = runner;\n    }\n\n    try {\n      // Resolves once IDENTIFY is sent; ClientReady flips us to \"online\" after.\n      await client.login(token);\n    } catch (error) {\n      logger.error(`Login failed for bot ${this.botId}: ${describeError(error)}`);\n      await this.teardownClient();\n      return this.fail(describeLoginFailure(error));\n    } finally {\n      token = \"\";\n    }\n\n    logger.info(`Bot ${this.botId} identified with Discord, waiting for ready.`);\n    return this.status();\n  }\n\n  private async stopNow(reason: \"command\" | \"shutdown\"): Promise<RuntimeStatusPayload> {\n    if (this.client === null && this.state === \"offline\") return this.status();\n\n    this.stopping = true;\n    if (this.client !== null) this.state = \"stopping\";\n\n    await this.teardownClient();\n\n    this.state = \"offline\";\n    this.startedAt = null;\n    this.guildCount = null;\n    this.username = null;\n    this.lastError = null;\n    this.stopping = false;\n\n    await this.report([\n      reason === \"shutdown\"\n        ? {\n            event: \"runtime.shutdown\",\n            level: \"warning\",\n            description:\n              \"The runtime is restarting; this bot was disconnected and must be started again.\",\n            at: nowIso(),\n          }\n        : {\n            event: \"runtime.stop\",\n            level: \"warning\",\n            description: \"Runtime disconnected this bot from the Discord gateway.\",\n            at: nowIso(),\n          },\n    ]);\n\n    logger.info(`Bot ${this.botId} stopped (${reason}).`);\n    return this.status();\n  }\n\n  /** Moves to the error state, reports it, and returns the status to the caller. */\n  private async fail(message: string): Promise<RuntimeStatusPayload> {\n    this.state = \"error\";\n    this.startedAt = null;\n    this.guildCount = null;\n    this.username = null;\n    this.lastError = scrubSecrets(message);\n\n    await this.report([\n      {\n        event: \"runtime.error\",\n        level: \"error\",\n        description: this.lastError,\n        at: nowIso(),\n      },\n    ]);\n\n    return this.status();\n  }\n\n  private attach(client: Client): void {\n    client.once(Events.ClientReady, () => {\n      void this.onReady();\n    });\n\n    client.on(Events.Error, (error: Error) => {\n      void this.onClientError(error);\n    });\n\n    client.on(Events.ShardDisconnect, (event: { code: number }, shardId: number) => {\n      void this.onShardDisconnect(event.code, shardId);\n    });\n\n    client.on(Events.ShardReconnecting, (shardId: number) => {\n      void this.onShardReconnecting(shardId);\n    });\n\n    client.on(Events.ShardResume, (shardId: number) => {\n      void this.onShardResume(shardId);\n    });\n\n    // Guild counts change often; refresh locally and let the next status probe\n    // or state change carry the new number instead of spamming callbacks.\n    client.on(Events.GuildCreate, () => this.syncGuildCount());\n    client.on(Events.GuildDelete, () => this.syncGuildCount());\n  }\n\n  private syncGuildCount(): void {\n    if (this.client === null) return;\n    this.guildCount = this.client.guilds.cache.size;\n  }\n\n  private async onReady(): Promise<void> {\n    const client = this.client;\n    if (client === null) return;\n\n    this.state = \"online\";\n    this.startedAt = nowIso();\n    this.lastError = null;\n    this.username = client.user?.tag ?? null;\n    this.guildCount = client.guilds.cache.size;\n\n    logger.info(`Bot ${this.botId} online as ${this.username ?? \"unknown\"}.`);\n\n    applyPresence(client, this.deployment?.presence ?? null);\n    this.startPresencePolling();\n\n    const events: RuntimeEvent[] = [\n      {\n        event: \"gateway.ready\",\n        level: \"success\",\n        description: `Connected to Discord as ${this.username ?? \"unknown\"} in ${this.guildCount} server(s).`,\n        at: nowIso(),\n      },\n    ];\n\n    // Slash commands are (re)published on every start, so Discord always shows\n    // exactly the commands the published flow defines.\n    const flows = this.deployment?.flows ?? [];\n    if (flows.length > 0 && client.isReady()) {\n      try {\n        const result = await syncCommands(client, flows);\n        events.push({\n          event: \"commands.registered\",\n          level: result.failures > 0 ? \"warning\" : \"info\",\n          description:\n            `Registered ${result.commands} slash command(s) in ${result.guilds} server(s)` +\n            (result.failures > 0 ? `; ${result.failures} server(s) refused (missing scope).` : \".\"),\n          at: nowIso(),\n        });\n      } catch (error) {\n        events.push({\n          event: \"commands.error\",\n          level: \"error\",\n          description: `Could not register slash commands: ${describeError(error, 200)}`,\n          at: nowIso(),\n        });\n      }\n\n      events.push({\n        event: \"flow.loaded\",\n        level: \"info\",\n        description: `Running published flow version ${this.deployment?.version ?? 0} (${flows.length} flow(s)).`,\n        at: nowIso(),\n      });\n    }\n\n    await this.report(events);\n  }\n\n  private async onClientError(error: Error): Promise<void> {\n    const message = describeError(error, 200);\n    logger.warn(`Client error for bot ${this.botId}: ${message}`);\n    // discord.js recovers from most of these, so the state is left alone.\n    this.lastError = message;\n\n    await this.report([\n      {\n        event: \"gateway.error\",\n        level: \"error\",\n        description: message,\n        at: nowIso(),\n      },\n    ]);\n  }\n\n  private async onShardDisconnect(code: number, shardId: number): Promise<void> {\n    // A deliberate stop reports its own terminal state.\n    if (this.stopping) return;\n\n    logger.warn(`Bot ${this.botId} shard ${shardId} closed with code ${code} and will not resume.`);\n    await this.teardownClient();\n    await this.fail(\n      `Discord closed the gateway connection (code ${code}) and it cannot be resumed. Start the bot again.`,\n    );\n  }\n\n  private async onShardReconnecting(shardId: number): Promise<void> {\n    if (this.stopping || this.state === \"offline\") return;\n\n    // Truthful: mid-reconnect the bot is not serving traffic.\n    if (this.state === \"online\") this.state = \"starting\";\n\n    await this.report([\n      {\n        event: \"gateway.reconnecting\",\n        level: \"warning\",\n        description: `Lost the gateway connection (shard ${shardId}); reconnecting.`,\n        at: nowIso(),\n      },\n    ]);\n  }\n\n  private async onShardResume(shardId: number): Promise<void> {\n    if (this.stopping || this.client === null) return;\n\n    this.state = \"online\";\n    this.lastError = null;\n    this.syncGuildCount();\n\n    await this.report([\n      {\n        event: \"gateway.resumed\",\n        level: \"success\",\n        description: `Gateway session resumed (shard ${shardId}).`,\n        at: nowIso(),\n      },\n    ]);\n  }\n\n  private async teardownClient(): Promise<void> {\n    this.stopPresencePolling();\n    const client = this.client;\n    this.client = null;\n    this.runner?.detach();\n    this.runner = null;\n    if (client === null) return;\n\n    // Detach first: a destroyed client must not push more state.\n    client.removeAllListeners();\n    try {\n      // destroy() is sync in some 14.x releases and async in others.\n      await Promise.resolve(client.destroy());\n    } catch (error) {\n      logger.warn(`Error destroying client for bot ${this.botId}: ${describeError(error)}`);\n    }\n  }\n\n  private async report(events: RuntimeEvent[]): Promise<void> {\n    const status = this.status();\n    await reportRuntimeState({\n      botId: this.botId,\n      userId: this.userId,\n      state: status.state,\n      startedAt: status.startedAt,\n      lastError: status.lastError,\n      guildCount: status.guildCount,\n      username: status.username,\n      events,\n    });\n  }\n}\n","runtime/src/supervisor.ts":"/**\n * Owns the live BotSession map and the process-wide capacity limit.\n *\n * Sessions are keyed by userId + botId: bot ids are per-user strings from the\n * dashboard, so they are only unique within an owner.\n */\nimport { config } from \"./config.js\";\nimport { logger } from \"./logger.js\";\nimport { BotSession } from \"./session.js\";\nimport { OFFLINE_STATUS, type RuntimeStatusPayload } from \"./types.js\";\n\nconst SWEEP_INTERVAL_MS = 5 * 60_000;\n\nexport class CapacityError extends Error {\n  constructor(limit: number) {\n    super(`This runtime is already hosting its maximum of ${limit} bots.`);\n    this.name = \"CapacityError\";\n  }\n}\n\nconst sessions = new Map<string, BotSession>();\n\nfunction key(userId: string, botId: string): string {\n  return `${userId}:${botId}`;\n}\n\nfunction activeCount(): number {\n  let count = 0;\n  for (const session of sessions.values()) {\n    if (session.isActive()) count += 1;\n  }\n  return count;\n}\n\nexport function statusOf(userId: string, botId: string): RuntimeStatusPayload {\n  // An unknown bot is simply offline — the control plane treats this as \"not running\".\n  return sessions.get(key(userId, botId))?.status() ?? OFFLINE_STATUS;\n}\n\nexport async function startBot(userId: string, botId: string): Promise<RuntimeStatusPayload> {\n  const mapKey = key(userId, botId);\n  let session = sessions.get(mapKey);\n\n  if (!session) {\n    if (activeCount() >= config.maxConcurrentBots) {\n      throw new CapacityError(config.maxConcurrentBots);\n    }\n    session = new BotSession(botId, userId);\n    sessions.set(mapKey, session);\n  }\n\n  const status = await session.start();\n  // A failed start leaves nothing to hold on to.\n  if (session.isIdle()) sessions.delete(mapKey);\n  return status;\n}\n\nexport async function stopBot(userId: string, botId: string): Promise<RuntimeStatusPayload> {\n  const mapKey = key(userId, botId);\n  const session = sessions.get(mapKey);\n  if (!session) return OFFLINE_STATUS;\n\n  const status = await session.stop();\n  if (session.isIdle()) sessions.delete(mapKey);\n  return status;\n}\n\n/** Applies the owner's latest saved presence without restarting the bot. */\nexport async function refreshBotPresence(userId: string, botId: string): Promise<boolean> {\n  const session = sessions.get(key(userId, botId));\n  if (!session) return false;\n  return session.refreshPresence();\n}\n\nexport function runtimeSnapshot(): { total: number; active: number } {\n  return { total: sessions.size, active: activeCount() };\n}\n\n/** Drops sessions that hold no connection, so the map cannot grow without bound. */\nfunction sweep(): void {\n  let removed = 0;\n  for (const [mapKey, session] of sessions) {\n    if (session.isIdle()) {\n      sessions.delete(mapKey);\n      removed += 1;\n    }\n  }\n  if (removed > 0) logger.debug(`Swept ${removed} idle bot session(s).`);\n}\n\nexport function startSweeper(): () => void {\n  const timer = setInterval(sweep, SWEEP_INTERVAL_MS);\n  timer.unref();\n  return () => clearInterval(timer);\n}\n\n/** Disconnects everything and tells the control plane, so no bot is left \"online\". */\nexport async function shutdownAll(): Promise<void> {\n  const live = [...sessions.values()].filter((session) => !session.isIdle());\n  if (live.length === 0) return;\n\n  logger.info(`Disconnecting ${live.length} bot(s) before shutdown.`);\n  await Promise.allSettled(live.map((session) => session.stop(\"shutdown\")));\n  sessions.clear();\n}\n","runtime/src/types.ts":"/**\n * Wire types shared with the control plane.\n *\n * These MUST stay in sync with:\n *   src/types/runtime.ts                (dashboard-facing shapes)\n *   src/lib/runtime-client.server.ts    (runtimeStatusSchema — our HTTP responses)\n *   src/lib/bot-runtime-store.server.ts (callbackSchema — our callback payloads)\n */\n\nexport type RuntimeState = \"offline\" | \"starting\" | \"online\" | \"stopping\" | \"error\";\n\nexport type RuntimeEventLevel = \"info\" | \"success\" | \"warning\" | \"error\";\n\n/** Exactly the payload `runtimeStatusSchema` in the control plane parses. */\nexport interface RuntimeStatusPayload {\n  state: RuntimeState;\n  startedAt: string | null;\n  lastError: string | null;\n  guildCount: number | null;\n  username: string | null;\n}\n\n/** One activity entry pushed to /api/internal/runtime/events. */\nexport interface RuntimeEvent {\n  event: string;\n  level: RuntimeEventLevel;\n  description: string;\n  /** ISO 8601 timestamp on the runtime. */\n  at: string;\n}\n\n/** Full callback body accepted by the control plane. */\nexport interface RuntimeReport extends RuntimeStatusPayload {\n  botId: string;\n  userId: string;\n  events: RuntimeEvent[];\n}\n\n/** Encrypted token record as served by /api/internal/bots/:botId/token. */\nexport interface EncryptedBotToken {\n  ciphertext: string;\n  iv: string;\n  keyVersion: number;\n  applicationId: string | null;\n}\n\nexport const OFFLINE_STATUS: RuntimeStatusPayload = {\n  state: \"offline\",\n  startedAt: null,\n  lastError: null,\n  guildCount: null,\n  username: null,\n};\n\nexport function nowIso(): string {\n  return new Date().toISOString();\n}\n","runtime/tsconfig.json":"{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"lib\": [\"ES2023\"],\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"types\": [\"node\"],\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\",\n    \"strict\": true,\n    \"noUncheckedIndexedAccess\": true,\n    \"exactOptionalPropertyTypes\": true,\n    \"noImplicitOverride\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"noUnusedLocals\": true,\n    \"noUnusedParameters\": true,\n    \"verbatimModuleSyntax\": true,\n    \"isolatedModules\": true,\n    \"skipLibCheck\": true,\n    \"declaration\": false,\n    \"sourceMap\": true,\n    \"removeComments\": false\n  },\n  \"include\": [\"src/**/*.ts\"]\n}","runtime/Dockerfile":"# Bot runtime service.\n# Build from the repo root:  docker build -f runtime/Dockerfile -t bottly-runtime .\nFROM node:22-alpine AS build\nWORKDIR /app\nCOPY runtime/package.json ./\nRUN npm install --no-audit --no-fund\nCOPY runtime/tsconfig.json ./\nCOPY runtime/src ./src\nCOPY runtime/scripts ./scripts\n\n# sync-contract.mjs resolves the web app contract from\n# ../../src/lib/flow/contract.ts relative to runtime/scripts.\n# Because the runtime is copied to /app, that becomes /src/lib/flow/contract.ts.\nCOPY src/lib/flow/contract.ts /src/lib/flow/contract.ts\n\nRUN npm run build\n\nFROM node:22-alpine AS release\nWORKDIR /app\nENV NODE_ENV=production\nCOPY runtime/package.json ./\nRUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force\nCOPY --from=build /app/dist ./dist\n\n# Long-lived gateway connections: never run as root, and let the platform\n# restart the container on failure rather than swallowing crashes.\nUSER node\nEXPOSE 8787\nCMD [\"node\", \"dist/index.js\"]\n","src/lib/flow/contract.ts":"/**\n * SHARED FLOW CONTRACT — single source of truth for what a Bottly flow means.\n *\n * This file is deliberately dependency-free (no imports at all) because it is\n * copied verbatim into the bot runtime as `runtime/src/flow-contract.ts` by\n * `runtime/scripts/sync-contract.mjs`. Web app and runtime therefore validate\n * and interpret the exact same graph semantics.\n *\n * Pipeline:\n *   Flow Builder → Flow JSON → validateFlow() → compileFlow() → CompiledFlow\n *   → runtime executor → discord.js → Discord API\n *\n * Every node's discord.js mapping, required gateway intents and required bot\n * permissions are declared in NODE_SPECS below. Nodes marked \"unsupported\" are\n * NOT executed by the runtime and are reported as validation warnings — they\n * are never silently faked.\n */\n\nexport const FLOW_CONTRACT_VERSION = 1 as const;\n\n/* ------------------------------------------------------------------ *\n * Graph shapes (structurally identical to src/types/flow.ts)\n * ------------------------------------------------------------------ */\n\nexport type ConfigValue = string | number | boolean;\n\nexport interface RawFlowNode {\n  id: string;\n  data: {\n    type: string;\n    title?: string;\n    enabled?: boolean;\n    config?: Record<string, ConfigValue>;\n    embed?: unknown;\n    components?: unknown;\n    options?: unknown;\n  };\n}\n\nexport interface RawFlowEdge {\n  id: string;\n  source: string;\n  target: string;\n  sourceHandle?: string | null | undefined;\n  targetHandle?: string | null | undefined;\n}\n\nexport interface RawFlow {\n  id: string;\n  name: string;\n  description?: string;\n  nodes: RawFlowNode[];\n  edges: RawFlowEdge[];\n}\n\n/* ------------------------------------------------------------------ *\n * Node specifications\n * ------------------------------------------------------------------ */\n\n/** How faithfully the runtime implements a node against discord.js. */\nexport type NodeSupport = \"full\" | \"partial\" | \"unsupported\";\n\nexport type NodeKind = \"trigger\" | \"action\" | \"condition\";\n\n/**\n * Context a node needs from the event that started the flow.\n *  interaction  — a repliable interaction (slash/button/select/modal)\n *  message      — a Message object\n *  guild        — the event happened in a guild, not a DM\n *  member       — a GuildMember for the acting user\n */\nexport type ContextKind = \"interaction\" | \"message\" | \"guild\" | \"member\";\n\nexport interface NodeSpec {\n  kind: NodeKind;\n  support: NodeSupport;\n  /** The discord.js API this node actually calls. Documentation, not code. */\n  discord: string;\n  /** GatewayIntentBits names required for this node to work. */\n  intents: string[];\n  /** PermissionFlagsBits names the BOT needs in the guild. */\n  permissions: string[];\n  /** Context the node reads from the trigger. */\n  requires: ContextKind[];\n  /** Context a trigger provides to everything downstream. */\n  provides?: ContextKind[];\n  /** True when the node acknowledges a Discord interaction. */\n  acknowledges?: boolean;\n  /** Output handles, in the order the builder draws them. */\n  outputs: string[];\n  /** Config keys that must be non-empty. */\n  required?: string[];\n  /** Why the node is partial/unsupported. Surfaced in the builder. */\n  note?: string;\n}\n\nconst INTERACTION_CTX: ContextKind[] = [\"interaction\"];\n\nexport const NODE_SPECS: Record<string, NodeSpec> = {\n  /* ----------------------------- Triggers ---------------------------- */\n  \"slash-command\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord:\n      \"Client#on(Events.InteractionCreate) filtered by ChatInputCommandInteraction#commandName; registered through REST Routes.applicationCommands()\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [\"interaction\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n    required: [\"name\", \"description\"],\n  },\n  \"button-click\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Events.InteractionCreate + ButtonInteraction#customId\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [\"interaction\", \"guild\", \"member\", \"message\"],\n    outputs: [\"out\"],\n    required: [\"customId\"],\n  },\n  \"dropdown-select\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord:\n      \"Events.InteractionCreate + AnySelectMenuInteraction#customId (string/user/role/channel/mentionable)\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [\"interaction\", \"guild\", \"member\", \"message\"],\n    outputs: [\"out\"],\n    required: [\"customId\"],\n  },\n  \"modal-submit\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Events.InteractionCreate + ModalSubmitInteraction#customId\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [\"interaction\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n    required: [\"customId\"],\n  },\n  \"message-received\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.MessageCreate)\",\n    intents: [\"Guilds\", \"GuildMessages\", \"MessageContent\"],\n    permissions: [],\n    requires: [],\n    provides: [\"message\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"member-joined\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.GuildMemberAdd)\",\n    intents: [\"Guilds\", \"GuildMembers\"],\n    permissions: [],\n    requires: [],\n    provides: [\"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"member-left\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.GuildMemberRemove)\",\n    intents: [\"Guilds\", \"GuildMembers\"],\n    permissions: [],\n    requires: [],\n    provides: [\"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"reaction-added\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.MessageReactionAdd)\",\n    intents: [\"Guilds\", \"GuildMessageReactions\"],\n    permissions: [],\n    requires: [],\n    provides: [\"message\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"reaction-removed\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#on(Events.MessageReactionRemove)\",\n    intents: [\"Guilds\", \"GuildMessageReactions\"],\n    permissions: [],\n    requires: [],\n    provides: [\"message\", \"guild\", \"member\"],\n    outputs: [\"out\"],\n  },\n  \"bot-ready\": {\n    kind: \"trigger\",\n    support: \"full\",\n    discord: \"Client#once(Events.ClientReady)\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [],\n    outputs: [\"out\"],\n  },\n  \"scheduled-event\": {\n    kind: \"trigger\",\n    support: \"partial\",\n    discord:\n      \"setInterval inside the runtime — only the 5-field cron subset '*' and fixed numbers is evaluated\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    provides: [],\n    outputs: [\"out\"],\n    required: [\"cron\"],\n    note: \"Cron runs at minute resolution while the bot is online. Ranges, steps and lists (1-5, */2, 1,3) are not evaluated.\",\n  },\n\n  /* ----------------------------- Messages ---------------------------- */\n  \"send-message\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"TextChannel#send() — or Interaction#reply({ ephemeral }) when Ephemeral is on\",\n    intents: [\"Guilds\"],\n    permissions: [\"ViewChannel\", \"SendMessages\"],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"content\"],\n  },\n  reply: {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Interaction#reply()/followUp() or Message#reply()\",\n    intents: [\"Guilds\"],\n    permissions: [\"SendMessages\"],\n    requires: [],\n    acknowledges: true,\n    outputs: [\"out\"],\n    required: [\"content\"],\n  },\n  \"send-embed\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"EmbedBuilder + TextChannel#send({ embeds, components })\",\n    intents: [\"Guilds\"],\n    permissions: [\"ViewChannel\", \"SendMessages\", \"EmbedLinks\"],\n    requires: [],\n    outputs: [\"out\"],\n  },\n  \"edit-message\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"TextChannel#messages.fetch() then Message#edit()\",\n    intents: [\"Guilds\"],\n    permissions: [\"ViewChannel\", \"ReadMessageHistory\"],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"messageId\"],\n  },\n  \"delete-message\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Message#delete()\",\n    intents: [\"Guilds\"],\n    permissions: [\"ManageMessages\", \"ReadMessageHistory\"],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"messageId\"],\n  },\n  \"send-dm\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"User#createDM() then DMChannel#send()\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"user\"],\n  },\n\n  /* ---------------------------- Components --------------------------- */\n  // Components are authored on the message nodes themselves (the Components\n  // editor writes node.data.components). The standalone component nodes are\n  // builder-only scaffolding and are skipped by the runtime.\n  \"component-button\": componentNode(\"ButtonBuilder / ActionRowBuilder\"),\n  \"component-dropdown\": componentNode(\"StringSelectMenuBuilder\"),\n  \"component-string-select\": componentNode(\"StringSelectMenuBuilder\"),\n  \"component-user-select\": componentNode(\"UserSelectMenuBuilder\"),\n  \"component-role-select\": componentNode(\"RoleSelectMenuBuilder\"),\n  \"component-channel-select\": componentNode(\"ChannelSelectMenuBuilder\"),\n  \"component-mentionable-select\": componentNode(\"MentionableSelectMenuBuilder\"),\n  \"component-modal\": {\n    kind: \"action\",\n    support: \"unsupported\",\n    discord: \"ModalBuilder + Interaction#showModal()\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: INTERACTION_CTX,\n    outputs: [\"out\"],\n    note: \"Opening a modal from a flow step is not implemented by the runtime yet. The node is skipped at runtime.\",\n  },\n  \"component-text-input\": {\n    kind: \"action\",\n    support: \"unsupported\",\n    discord: \"TextInputBuilder (modal field)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    note: \"Modal fields belong to a modal; standalone text-input nodes are skipped at runtime.\",\n  },\n\n  /* ---------------------------- Moderation --------------------------- */\n  \"ban-user\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMemberManager#ban({ reason, deleteMessageSeconds })\",\n    intents: [\"Guilds\"],\n    permissions: [\"BanMembers\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\"],\n  },\n  \"kick-user\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMember#kick(reason)\",\n    intents: [\"Guilds\"],\n    permissions: [\"KickMembers\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\"],\n  },\n  \"timeout-user\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMember#timeout(ms, reason)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ModerateMembers\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"duration\"],\n  },\n  \"warn-user\": {\n    kind: \"action\",\n    support: \"partial\",\n    discord: \"User#send() for the DM — Bottly has no warning store, so nothing is persisted\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\"],\n    note: \"Sends the warning DM (when enabled) and logs a runtime event. Warnings are not counted or stored.\",\n  },\n  \"delete-messages\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"TextChannel#bulkDelete(amount, true)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ManageMessages\", \"ReadMessageHistory\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"amount\"],\n  },\n  \"add-role\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMemberRoleManager#add(role)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"role\"],\n  },\n  \"remove-role\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMemberRoleManager#remove(role)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"role\"],\n  },\n  \"move-member\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMember#voice.setChannel(channel)\",\n    intents: [\"Guilds\", \"GuildVoiceStates\"],\n    permissions: [\"MoveMembers\"],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"channel\"],\n  },\n\n  /* ------------------------------- Logic ----------------------------- */\n  \"if-else\": {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"Pure runtime logic on resolved variables\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n  },\n  \"check-permission\": {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"GuildMember#permissions.has(PermissionFlagsBits[...])\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"true\", \"false\"],\n    required: [\"permission\"],\n  },\n  \"check-role\": {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"GuildMemberRoleManager#cache lookup by id or name\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"true\", \"false\"],\n    required: [\"role\"],\n  },\n  \"check-channel\": {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"Compares the event channel id/name\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n    required: [\"channel\"],\n  },\n  compare: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"Numeric comparison of resolved values\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n  },\n  contains: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"String comparison of resolved values\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n  },\n  equals: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"String comparison of resolved values\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"true\", \"false\"],\n  },\n  random: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"Math.random() branch\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"a\", \"b\"],\n  },\n  delay: {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"await setTimeout(ms) inside the flow run\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n  },\n  cooldown: {\n    kind: \"condition\",\n    support: \"full\",\n    discord: \"In-memory per user/channel/guild rate limit inside the runtime\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\", \"blocked\"],\n    note: \"The cooldown map lives in the runtime process and resets when the bot restarts.\",\n  },\n\n  /* ---------------------------- Discord data -------------------------- */\n  \"get-user\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"UserManager#fetch(id)\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"user\", \"storeAs\"],\n  },\n  \"get-member\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"GuildMemberManager#fetch(id)\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"user\", \"storeAs\"],\n  },\n  \"get-server\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Interaction/Message#guild\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"storeAs\"],\n  },\n  \"get-channel\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"ChannelManager#fetch(id)\",\n    intents: [\"Guilds\"],\n    permissions: [\"ViewChannel\"],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"channel\", \"storeAs\"],\n  },\n  \"get-role\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"RoleManager#fetch() / cache lookup\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: [\"guild\"],\n    outputs: [\"out\"],\n    required: [\"role\", \"storeAs\"],\n  },\n\n  /* ----------------------------- Variables ---------------------------- */\n  \"set-variable\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime-local flow variable (per run)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n    note: \"Variables live for the duration of one flow run; they are not persisted between events.\",\n  },\n  \"get-variable\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime-local flow variable (per run)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n  },\n  increment: {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime-local flow variable (per run)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n  },\n  decrement: {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime-local flow variable (per run)\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n  },\n  \"format-text\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Template interpolation of flow variables\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"storeAs\"],\n  },\n\n  /* --------------------------- Extra triggers -------------------------- */\n  \"message-edited\": trg(\n    \"Client#on(Events.MessageUpdate)\",\n    [\"GuildMessages\", \"MessageContent\"],\n    [\"message\", \"guild\", \"member\"],\n  ),\n  \"message-deleted\": trg(\n    \"Client#on(Events.MessageDelete)\",\n    [\"GuildMessages\", \"MessageContent\"],\n    [\"message\", \"guild\", \"member\"],\n  ),\n  \"dm-received\": trg(\n    \"Client#on(Events.MessageCreate) filtered to DM channels\",\n    [\"DirectMessages\", \"MessageContent\"],\n    [\"message\"],\n  ),\n  \"voice-join\": trg(\n    \"Client#on(Events.VoiceStateUpdate) — joined a channel\",\n    [\"GuildVoiceStates\"],\n    [\"guild\", \"member\"],\n  ),\n  \"voice-leave\": trg(\n    \"Client#on(Events.VoiceStateUpdate) — left a channel\",\n    [\"GuildVoiceStates\"],\n    [\"guild\", \"member\"],\n  ),\n  \"voice-move\": trg(\n    \"Client#on(Events.VoiceStateUpdate) — moved channel\",\n    [\"GuildVoiceStates\"],\n    [\"guild\", \"member\"],\n  ),\n  \"channel-created\": trg(\"Client#on(Events.ChannelCreate)\", [], [\"guild\"]),\n  \"channel-deleted\": trg(\"Client#on(Events.ChannelDelete)\", [], [\"guild\"]),\n  \"thread-created\": trg(\"Client#on(Events.ThreadCreate)\", [], [\"guild\"]),\n  \"role-created\": trg(\"Client#on(Events.GuildRoleCreate)\", [], [\"guild\"]),\n  \"role-deleted\": trg(\"Client#on(Events.GuildRoleDelete)\", [], [\"guild\"]),\n  \"member-banned\": trg(\"Client#on(Events.GuildBanAdd)\", [\"GuildModeration\"], [\"guild\"]),\n  \"member-unbanned\": trg(\"Client#on(Events.GuildBanRemove)\", [\"GuildModeration\"], [\"guild\"]),\n  \"member-updated\": trg(\n    \"Client#on(Events.GuildMemberUpdate)\",\n    [\"GuildMembers\"],\n    [\"guild\", \"member\"],\n  ),\n  \"invite-created\": trg(\"Client#on(Events.InviteCreate)\", [\"GuildInvites\"], [\"guild\"]),\n  \"typing-start\": trg(\"Client#on(Events.TypingStart)\", [\"GuildMessageTyping\"], [\"guild\", \"member\"]),\n  \"guild-joined\": trg(\"Client#on(Events.GuildCreate)\", [], [\"guild\"]),\n\n  /* --------------------------- Extra messages -------------------------- */\n  \"add-reaction\": act(\"Message#react()\", {\n    permissions: [\"AddReactions\", \"ReadMessageHistory\"],\n    required: [\"emoji\"],\n  }),\n  \"remove-reactions\": act(\"MessageReaction#remove() / Message#reactions.removeAll()\", {\n    permissions: [\"ManageMessages\"],\n  }),\n  \"pin-message\": act(\"Message#pin()\", { permissions: [\"ManageMessages\"] }),\n  \"unpin-message\": act(\"Message#unpin()\", { permissions: [\"ManageMessages\"] }),\n  \"publish-message\": act(\"Message#crosspost()\", { permissions: [\"ManageMessages\"] }),\n  \"get-message\": act(\"TextChannel#messages.fetch()\", {\n    permissions: [\"ReadMessageHistory\"],\n    required: [\"messageId\", \"storeAs\"],\n  }),\n  \"send-file\": act(\"TextChannel#send({ files: [url] })\", {\n    permissions: [\"ViewChannel\", \"SendMessages\", \"AttachFiles\"],\n    required: [\"url\"],\n  }),\n  \"start-typing\": act(\"TextChannel#sendTyping()\", { permissions: [\"ViewChannel\", \"SendMessages\"] }),\n  \"defer-reply\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"RepliableInteraction#deferReply()\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: INTERACTION_CTX,\n    acknowledges: true,\n    outputs: [\"out\"],\n  },\n  \"show-modal\": {\n    kind: \"action\",\n    support: \"full\",\n    discord:\n      \"Interaction#showModal(new ModalBuilder() with TextInputBuilder rows) — answer it with a Modal Submit trigger\",\n    intents: [\"Guilds\"],\n    permissions: [],\n    requires: INTERACTION_CTX,\n    acknowledges: true,\n    outputs: [\"out\"],\n    required: [\"customId\", \"title\", \"field1Id\", \"field1Label\"],\n  },\n\n  /* -------------------------- Server management ------------------------ */\n  \"create-channel\": act(\"Guild#channels.create()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n    required: [\"name\"],\n  }),\n  \"delete-channel\": act(\"GuildChannel#delete()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n    required: [\"channel\"],\n  }),\n  \"rename-channel\": act(\"GuildChannel#setName()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n    required: [\"name\"],\n  }),\n  \"set-channel-topic\": act(\"TextChannel#setTopic()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n  }),\n  \"set-slowmode\": act(\"TextChannel#setRateLimitPerUser()\", {\n    permissions: [\"ManageChannels\"],\n    requires: [\"guild\"],\n  }),\n  \"set-channel-permission\": act(\"GuildChannel#permissionOverwrites.edit()\", {\n    permissions: [\"ManageRoles\", \"ManageChannels\"],\n    requires: [\"guild\"],\n    required: [\"target\", \"permission\"],\n  }),\n  \"create-thread\": act(\"TextChannel#threads.create() / Message#startThread()\", {\n    permissions: [\"CreatePublicThreads\"],\n    requires: [\"guild\"],\n    required: [\"name\"],\n  }),\n  \"archive-thread\": act(\"ThreadChannel#setArchived(true)\", {\n    permissions: [\"ManageThreads\"],\n    requires: [\"guild\"],\n  }),\n  \"lock-thread\": act(\"ThreadChannel#setLocked()\", {\n    permissions: [\"ManageThreads\"],\n    requires: [\"guild\"],\n  }),\n  \"create-invite\": act(\"GuildChannel#createInvite()\", {\n    permissions: [\"CreateInstantInvite\"],\n    requires: [\"guild\"],\n    required: [\"storeAs\"],\n  }),\n  \"create-role\": act(\"Guild#roles.create()\", {\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    required: [\"name\", \"storeAs\"],\n  }),\n  \"edit-role\": act(\"Role#edit()\", {\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    required: [\"role\"],\n  }),\n  \"delete-role\": act(\"Role#delete()\", {\n    permissions: [\"ManageRoles\"],\n    requires: [\"guild\"],\n    required: [\"role\"],\n  }),\n  \"set-nickname\": act(\"GuildMember#setNickname()\", {\n    permissions: [\"ManageNicknames\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n  \"unban-user\": act(\"Guild#bans.remove()\", {\n    permissions: [\"BanMembers\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n  \"set-presence\": act(\"Client#user.setPresence()\", { intents: [] }),\n\n  /* -------------------------------- Voice ------------------------------ */\n  \"voice-mute\": act(\"GuildMember#voice.setMute()\", {\n    permissions: [\"MuteMembers\"],\n    intents: [\"GuildVoiceStates\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n  \"voice-deafen\": act(\"GuildMember#voice.setDeaf()\", {\n    permissions: [\"DeafenMembers\"],\n    intents: [\"GuildVoiceStates\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n  \"disconnect-member\": act(\"GuildMember#voice.disconnect()\", {\n    permissions: [\"MoveMembers\"],\n    intents: [\"GuildVoiceStates\"],\n    requires: [\"guild\"],\n    required: [\"user\"],\n  }),\n\n  /* ----------------------------- Extra logic --------------------------- */\n  switch: cond(\"Runtime string comparison\", [\"case1\", \"case2\", \"case3\", \"case4\", \"default\"], {\n    required: [\"value\"],\n  }),\n  \"regex-match\": cond(\"RegExp#exec() inside the runtime\", [\"true\", \"false\"], {\n    required: [\"pattern\"],\n  }),\n  \"check-bot-permission\": cond(\n    \"GuildMember#permissions.has() for the bot member\",\n    [\"true\", \"false\"],\n    { requires: [\"guild\"], required: [\"permission\"] },\n  ),\n  \"check-context\": cond(\"Checks whether the event carries a Guild\", [\"true\", \"false\"], {}),\n  \"is-bot\": cond(\"User#bot\", [\"true\", \"false\"], {}),\n  \"has-attachment\": cond(\"Message#attachments.size\", [\"true\", \"false\"], { requires: [\"message\"] }),\n  \"for-each\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime loop over a split string; the 'each' branch runs once per item\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"each\", \"out\"],\n    required: [\"list\", \"itemVar\"],\n    note: \"Loops are capped by the runtime step limit (200 steps per run).\",\n  },\n  \"stop-flow\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime control — ends this branch\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [],\n  },\n\n  /* --------------------------- Utility and web ------------------------- */\n  \"http-request\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Node fetch() from the bot process; response stored as {name.status}/{name.body}\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\", \"failed\"],\n    required: [\"url\", \"storeAs\"],\n  },\n  \"parse-json\": act(\"JSON.parse() + dotted path lookup\", { required: [\"source\", \"storeAs\"] }),\n  \"random-number\": act(\"Runtime Math.random()\", { required: [\"storeAs\"] }),\n  \"random-choice\": act(\"Runtime Math.random() over a split list\", {\n    required: [\"list\", \"storeAs\"],\n  }),\n  math: act(\"Runtime arithmetic\", { required: [\"storeAs\"] }),\n  \"string-op\": act(\"Runtime string transform\", { required: [\"storeAs\"] }),\n  \"timestamp-format\": act(\"Discord <t:unix:style> markdown\", { required: [\"storeAs\"] }),\n  \"log-event\": act(\"Runtime log stream shown in the Bottly console\", { required: [\"message\"] }),\n  \"global-set\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime key/value store shared across runs of this bot process\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\"],\n    note: \"Shared values live in the running bot process; they reset when the bot restarts.\",\n  },\n  \"global-get\": {\n    kind: \"action\",\n    support: \"full\",\n    discord: \"Runtime key/value store shared across runs of this bot process\",\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    required: [\"name\", \"storeAs\"],\n  },\n\n  /* -------------------------- Extra Discord data ----------------------- */\n  \"count-members\": act(\"Guild#members.fetch() + filter\", {\n    intents: [\"GuildMembers\"],\n    requires: [\"guild\"],\n    required: [\"storeAs\"],\n  }),\n  \"list-roles\": act(\"Guild#roles.cache\", { requires: [\"guild\"], required: [\"storeAs\"] }),\n  \"list-channels\": act(\"Guild#channels.cache\", { requires: [\"guild\"], required: [\"storeAs\"] }),\n};\n\ninterface SpecOptions {\n  intents?: string[];\n  permissions?: string[];\n  requires?: ContextKind[];\n  required?: string[];\n  note?: string;\n}\n\nfunction act(discord: string, options: SpecOptions = {}): NodeSpec {\n  return {\n    kind: \"action\",\n    support: \"full\",\n    discord,\n    intents: options.intents ?? [\"Guilds\"],\n    permissions: options.permissions ?? [],\n    requires: options.requires ?? [],\n    outputs: [\"out\"],\n    ...(options.required ? { required: options.required } : {}),\n    ...(options.note ? { note: options.note } : {}),\n  };\n}\n\nfunction cond(discord: string, outputs: string[], options: SpecOptions): NodeSpec {\n  return {\n    kind: \"condition\",\n    support: \"full\",\n    discord,\n    intents: options.intents ?? [],\n    permissions: [],\n    requires: options.requires ?? [],\n    outputs,\n    ...(options.required ? { required: options.required } : {}),\n  };\n}\n\nfunction trg(discord: string, intents: string[], provides: ContextKind[]): NodeSpec {\n  return {\n    kind: \"trigger\",\n    support: \"full\",\n    discord,\n    intents: [\"Guilds\", ...intents],\n    permissions: [],\n    requires: [],\n    provides,\n    outputs: [\"out\"],\n  };\n}\n\nfunction componentNode(discord: string): NodeSpec {\n  return {\n    kind: \"action\",\n    support: \"unsupported\",\n    discord,\n    intents: [],\n    permissions: [],\n    requires: [],\n    outputs: [\"out\"],\n    note: \"Standalone component nodes are builder scaffolding. Attach components with the Components editor on a Send Message / Send Embed / Reply node — those are sent for real.\",\n  };\n}\n\nexport function getNodeSpec(type: string): NodeSpec | null {\n  return Object.prototype.hasOwnProperty.call(NODE_SPECS, type)\n    ? (NODE_SPECS[type] as NodeSpec)\n    : null;\n}\n\n/* ------------------------------------------------------------------ *\n * Compiled flow — exactly what the runtime executes\n * ------------------------------------------------------------------ */\n\nexport interface CompiledCommandOption {\n  name: string;\n  description: string;\n  type: string;\n  required: boolean;\n  autocomplete: boolean;\n}\n\nexport interface CompiledCommand {\n  name: string;\n  description: string;\n  nodeId: string;\n  options: CompiledCommandOption[];\n}\n\nexport interface CompiledNode {\n  id: string;\n  type: string;\n  title: string;\n  config: Record<string, ConfigValue>;\n  embed: unknown;\n  components: unknown;\n  /** handle -> ordered target node ids */\n  next: Record<string, string[]>;\n}\n\nexport interface CompiledTrigger {\n  nodeId: string;\n  type: string;\n  config: Record<string, ConfigValue>;\n}\n\nexport interface CompiledFlow {\n  contractVersion: typeof FLOW_CONTRACT_VERSION;\n  flowId: string;\n  flowName: string;\n  /** GatewayIntentBits names the runtime must request. */\n  intents: string[];\n  triggers: CompiledTrigger[];\n  commands: CompiledCommand[];\n  nodes: Record<string, CompiledNode>;\n}\n\n/* ------------------------------------------------------------------ *\n * Validation\n * ------------------------------------------------------------------ */\n\nexport type IssueLevel = \"error\" | \"warning\";\n\nexport interface FlowIssue {\n  level: IssueLevel;\n  code: string;\n  message: string;\n  nodeId?: string;\n}\n\nconst CONTEXT_LABEL: Record<ContextKind, string> = {\n  interaction: \"a slash command, button, select menu or modal trigger\",\n  message: \"a message context (Message Received, Reaction or a component trigger)\",\n  guild: \"a server (guild) context — it cannot run in DMs\",\n  member: \"a member context\",\n};\n\nconst COMMAND_NAME_PATTERN = /^[-_\\p{Ll}\\p{Lm}\\p{Lo}\\p{N}]{1,32}$/u;\n\nfunction str(value: ConfigValue | undefined): string {\n  return value === undefined || value === null ? \"\" : String(value).trim();\n}\n\nfunction nodeLabel(node: RawFlowNode): string {\n  return str(node.data.title as ConfigValue) || node.data.type;\n}\n\n/** Handles that leave a node, mapped to their targets. */\nfunction outgoing(edges: RawFlowEdge[], nodeId: string): Record<string, string[]> {\n  const out: Record<string, string[]> = {};\n  for (const edge of edges) {\n    if (edge.source !== nodeId) continue;\n    const handle = edge.sourceHandle ?? \"out\";\n    const list = out[handle] ?? [];\n    list.push(edge.target);\n    out[handle] = list;\n  }\n  return out;\n}\n\n/**\n * Full static analysis of a flow: structure, required fields, intents,\n * permissions, interaction-acknowledgement and trigger-context reachability.\n */\nexport function validateFlow(flow: RawFlow): FlowIssue[] {\n  const issues: FlowIssue[] = [];\n  const nodes = (flow.nodes ?? []).filter((node) => node?.data && node.data.enabled !== false);\n  const byId = new Map<string, RawFlowNode>();\n  for (const node of nodes) byId.set(node.id, node);\n\n  const triggers = nodes.filter((node) => getNodeSpec(node.data.type)?.kind === \"trigger\");\n\n  if (nodes.length === 0) {\n    issues.push({ level: \"error\", code: \"empty\", message: \"This flow has no nodes yet.\" });\n    return issues;\n  }\n  if (triggers.length === 0) {\n    issues.push({\n      level: \"error\",\n      code: \"no-trigger\",\n      message: \"Add a trigger node — without one the bot has no reason to run this flow.\",\n    });\n  }\n\n  const seenCommands = new Set<string>();\n  const seenCustomIds = new Set<string>();\n\n  for (const node of nodes) {\n    const spec = getNodeSpec(node.data.type);\n    const config = node.data.config ?? {};\n\n    if (!spec) {\n      issues.push({\n        level: \"error\",\n        code: \"unknown-node\",\n        nodeId: node.id,\n        message: `\"${node.data.type}\" is not a node this bot runtime knows how to run.`,\n      });\n      continue;\n    }\n\n    if (spec.support === \"unsupported\") {\n      issues.push({\n        level: \"warning\",\n        code: \"unsupported-node\",\n        nodeId: node.id,\n        message: `${nodeLabel(node)} is skipped at runtime. ${spec.note ?? \"\"}`.trim(),\n      });\n    } else if (spec.support === \"partial\" && spec.note) {\n      issues.push({\n        level: \"warning\",\n        code: \"partial-node\",\n        nodeId: node.id,\n        message: `${nodeLabel(node)}: ${spec.note}`,\n      });\n    }\n\n    for (const key of spec.required ?? []) {\n      if (str(config[key]) === \"\") {\n        issues.push({\n          level: \"error\",\n          code: \"missing-field\",\n          nodeId: node.id,\n          message: `${nodeLabel(node)} needs a value for \"${key}\".`,\n        });\n      }\n    }\n\n    if (node.data.type === \"slash-command\") {\n      const name = str(config[\"name\"]).toLowerCase();\n      if (name !== \"\" && !COMMAND_NAME_PATTERN.test(name)) {\n        issues.push({\n          level: \"error\",\n          code: \"bad-command-name\",\n          nodeId: node.id,\n          message: `\"${name}\" is not a valid slash command name. Use 1-32 lowercase letters, numbers, - or _.`,\n        });\n      }\n      if (name !== \"\" && seenCommands.has(name)) {\n        issues.push({\n          level: \"error\",\n          code: \"duplicate-command\",\n          nodeId: node.id,\n          message: `Two triggers register /${name}. Discord only allows one command per name.`,\n        });\n      }\n      seenCommands.add(name);\n\n      if (str(config[\"description\"]).length > 100) {\n        issues.push({\n          level: \"error\",\n          code: \"long-description\",\n          nodeId: node.id,\n          message: \"Slash command descriptions are limited to 100 characters by Discord.\",\n        });\n      }\n    }\n\n    if (\n      node.data.type === \"button-click\" ||\n      node.data.type === \"dropdown-select\" ||\n      node.data.type === \"modal-submit\"\n    ) {\n      const customId = str(config[\"customId\"]);\n      if (customId.length > 100) {\n        issues.push({\n          level: \"error\",\n          code: \"long-custom-id\",\n          nodeId: node.id,\n          message: \"Discord custom IDs are limited to 100 characters.\",\n        });\n      }\n      if (customId !== \"\" && seenCustomIds.has(customId)) {\n        issues.push({\n          level: \"warning\",\n          code: \"duplicate-custom-id\",\n          nodeId: node.id,\n          message: `Another trigger already listens for \"${customId}\"; both will run.`,\n        });\n      }\n      seenCustomIds.add(customId);\n    }\n\n    if (node.data.type === \"delete-messages\") {\n      const amount = Number(config[\"amount\"] ?? 0);\n      if (!Number.isFinite(amount) || amount < 1 || amount > 100) {\n        issues.push({\n          level: \"error\",\n          code: \"bulk-delete-range\",\n          nodeId: node.id,\n          message: \"Bulk delete accepts between 1 and 100 messages (Discord limit).\",\n        });\n      }\n    }\n\n    if (spec.permissions.length > 0) {\n      issues.push({\n        level: \"warning\",\n        code: \"needs-permission\",\n        nodeId: node.id,\n        message: `${nodeLabel(node)} requires the bot to have ${spec.permissions.join(\", \")} in the target channel/server.`,\n      });\n    }\n  }\n\n  // Reachability + context analysis per trigger.\n  for (const trigger of triggers) {\n    const triggerSpec = getNodeSpec(trigger.data.type);\n    if (!triggerSpec) continue;\n    const provided = new Set<ContextKind>(triggerSpec.provides ?? []);\n\n    let acknowledgedDepth = -1;\n    let depth = 0;\n    let frontier = [trigger.id];\n    const visited = new Set<string>([trigger.id]);\n\n    while (frontier.length > 0) {\n      const nextFrontier: string[] = [];\n      depth += 1;\n\n      for (const id of frontier) {\n        const handles = outgoing(flow.edges, id);\n        for (const targets of Object.values(handles)) {\n          for (const targetId of targets) {\n            if (visited.has(targetId)) continue;\n            visited.add(targetId);\n            const target = byId.get(targetId);\n            if (!target) continue;\n            const spec = getNodeSpec(target.data.type);\n            if (!spec) continue;\n\n            for (const need of spec.requires) {\n              if (!provided.has(need)) {\n                issues.push({\n                  level: \"error\",\n                  code: \"missing-context\",\n                  nodeId: target.id,\n                  message: `${nodeLabel(target)} needs ${CONTEXT_LABEL[need]}, but \"${nodeLabel(trigger)}\" does not provide it.`,\n                });\n              }\n            }\n\n            if (spec.acknowledges && acknowledgedDepth < 0) acknowledgedDepth = depth;\n            nextFrontier.push(targetId);\n          }\n        }\n      }\n      frontier = nextFrontier;\n    }\n\n    // Interaction triggers must be answered; Discord invalidates the token after 3s.\n    if (provided.has(\"interaction\")) {\n      const answered = [...visited].some((id) => {\n        const node = byId.get(id);\n        if (!node) return false;\n        const spec = getNodeSpec(node.data.type);\n        return Boolean(spec?.acknowledges) || node.data.type === \"send-message\";\n      });\n      if (!answered) {\n        issues.push({\n          level: \"warning\",\n          code: \"unacknowledged-interaction\",\n          nodeId: trigger.id,\n          message:\n            'This interaction is never answered. Discord shows \"The application did not respond\" unless a Reply node runs. The runtime defers the reply automatically, but add a Reply to control what the user sees.',\n        });\n      }\n      const hasDelay = [...visited].some((id) => byId.get(id)?.data.type === \"delay\");\n      if (hasDelay && acknowledgedDepth < 0) {\n        issues.push({\n          level: \"warning\",\n          code: \"delay-before-ack\",\n          nodeId: trigger.id,\n          message:\n            \"A Delay runs before this interaction is answered. Discord's interaction token expires after 3 seconds, so reply (or defer) first.\",\n        });\n      }\n    }\n\n    for (const node of nodes) {\n      if (node.id === trigger.id) continue;\n      const spec = getNodeSpec(node.data.type);\n      if (spec?.kind === \"trigger\") continue;\n      if (!visited.has(node.id) && !isReachableFromAnyTrigger(flow, triggers, node.id)) {\n        issues.push({\n          level: \"warning\",\n          code: \"unreachable\",\n          nodeId: node.id,\n          message: `${nodeLabel(node)} is not connected to any trigger, so it never runs.`,\n        });\n      }\n    }\n    break; // unreachable analysis only needs to run once\n  }\n\n  return dedupeIssues(issues);\n}\n\nfunction isReachableFromAnyTrigger(\n  flow: RawFlow,\n  triggers: RawFlowNode[],\n  nodeId: string,\n): boolean {\n  for (const trigger of triggers) {\n    const visited = new Set<string>([trigger.id]);\n    const stack = [trigger.id];\n    while (stack.length > 0) {\n      const current = stack.pop() as string;\n      for (const targets of Object.values(outgoing(flow.edges, current))) {\n        for (const target of targets) {\n          if (target === nodeId) return true;\n          if (visited.has(target)) continue;\n          visited.add(target);\n          stack.push(target);\n        }\n      }\n    }\n  }\n  return false;\n}\n\nfunction dedupeIssues(issues: FlowIssue[]): FlowIssue[] {\n  const seen = new Set<string>();\n  const out: FlowIssue[] = [];\n  for (const issue of issues) {\n    const key = `${issue.level}|${issue.code}|${issue.nodeId ?? \"\"}|${issue.message}`;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    out.push(issue);\n  }\n  return out;\n}\n\n/* ------------------------------------------------------------------ *\n * Compilation\n * ------------------------------------------------------------------ */\n\nconst OPTION_TYPES = new Set([\n  \"string\",\n  \"integer\",\n  \"number\",\n  \"boolean\",\n  \"user\",\n  \"role\",\n  \"channel\",\n  \"mentionable\",\n  \"attachment\",\n]);\n\nfunction compileOptions(raw: unknown): CompiledCommandOption[] {\n  if (!Array.isArray(raw)) return [];\n  const options: CompiledCommandOption[] = [];\n  for (const entry of raw) {\n    if (typeof entry !== \"object\" || entry === null) continue;\n    const record = entry as Record<string, unknown>;\n    const name = String(record[\"name\"] ?? \"\")\n      .trim()\n      .toLowerCase();\n    if (name === \"\" || !COMMAND_NAME_PATTERN.test(name)) continue;\n    const type = String(record[\"type\"] ?? \"string\");\n    options.push({\n      name,\n      description: String(record[\"description\"] ?? \"\").slice(0, 100) || name,\n      type: OPTION_TYPES.has(type) ? type : \"string\",\n      required: record[\"required\"] === true,\n      autocomplete: record[\"autocomplete\"] === true,\n    });\n  }\n  // Discord requires required options before optional ones.\n  return [...options.filter((o) => o.required), ...options.filter((o) => !o.required)];\n}\n\nexport interface CompileResult {\n  compiled: CompiledFlow | null;\n  issues: FlowIssue[];\n}\n\n/**\n * Turns a builder graph into the executable form. Returns `compiled: null`\n * when validation produced errors — the runtime must never receive a graph\n * that the control plane knows is broken.\n */\nexport function compileFlow(flow: RawFlow): CompileResult {\n  const issues = validateFlow(flow);\n  if (issues.some((issue) => issue.level === \"error\")) return { compiled: null, issues };\n\n  const enabled = (flow.nodes ?? []).filter((node) => node?.data && node.data.enabled !== false);\n  const known = enabled.filter((node) => {\n    const spec = getNodeSpec(node.data.type);\n    return spec !== null && spec.support !== \"unsupported\";\n  });\n  const keep = new Set(known.map((node) => node.id));\n\n  const nodes: Record<string, CompiledNode> = {};\n  for (const node of known) {\n    const handles = outgoing(flow.edges, node.id);\n    const next: Record<string, string[]> = {};\n    for (const [handle, targets] of Object.entries(handles)) {\n      // Skipped (unsupported) nodes are bridged over, so the rest of the chain\n      // still runs instead of silently dead-ending.\n      const resolved: string[] = [];\n      for (const target of targets) resolved.push(...resolveTarget(flow, keep, target, new Set()));\n      if (resolved.length > 0) next[handle] = resolved;\n    }\n\n    nodes[node.id] = {\n      id: node.id,\n      type: node.data.type,\n      title: str(node.data.title as ConfigValue) || node.data.type,\n      config: node.data.config ?? {},\n      embed: node.data.embed ?? null,\n      components: node.data.components ?? null,\n      next,\n    };\n  }\n\n  const triggers: CompiledTrigger[] = [];\n  const commands: CompiledCommand[] = [];\n  const intents = new Set<string>([\"Guilds\"]);\n\n  for (const node of known) {\n    const spec = getNodeSpec(node.data.type) as NodeSpec;\n    for (const intent of spec.intents) intents.add(intent);\n\n    if (spec.kind !== \"trigger\") continue;\n    const config = node.data.config ?? {};\n    triggers.push({ nodeId: node.id, type: node.data.type, config });\n\n    if (node.data.type === \"slash-command\") {\n      commands.push({\n        name: str(config[\"name\"]).toLowerCase(),\n        description: str(config[\"description\"]).slice(0, 100) || \"A Bottly command\",\n        nodeId: node.id,\n        options: compileOptions(node.data.options),\n      });\n    }\n  }\n\n  return {\n    compiled: {\n      contractVersion: FLOW_CONTRACT_VERSION,\n      flowId: flow.id,\n      flowName: flow.name,\n      intents: [...intents],\n      triggers,\n      commands,\n      nodes,\n    },\n    issues,\n  };\n}\n\n/** Walks past skipped nodes to the first executable descendant(s). */\nfunction resolveTarget(\n  flow: RawFlow,\n  keep: Set<string>,\n  target: string,\n  seen: Set<string>,\n): string[] {\n  if (keep.has(target)) return [target];\n  if (seen.has(target)) return [];\n  seen.add(target);\n\n  const out: string[] = [];\n  for (const targets of Object.values(outgoing(flow.edges, target))) {\n    for (const next of targets) out.push(...resolveTarget(flow, keep, next, seen));\n  }\n  return out;\n}\n\n/** Node-support matrix, used by the docs page and the builder's help panel. */\nexport function supportMatrix(): {\n  type: string;\n  support: NodeSupport;\n  discord: string;\n  note: string;\n}[] {\n  return Object.entries(NODE_SPECS).map(([type, spec]) => ({\n    type,\n    support: spec.support,\n    discord: spec.discord,\n    note: spec.note ?? \"\",\n  }));\n}\n"}}