diff --git a/.env.example b/.env.example index e88f474..d214be9 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,8 @@ VITE_BOT_API_URL= VITE_STATUS_API_KEY= # Deployment notification webhook DEPLOYMENT_WEBHOOK_URL= +# Top.gg Webhook Configuration +TOPGG_WEBHOOK_SECRET= +TOPGG_WEBHOOK_AUTH= +# Top.gg token to check vote status +TOPGG_TOKEN= \ No newline at end of file diff --git a/bun.lock b/bun.lock index 7fb067a..cce3034 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "@atproto/identity": "^0.4.9", "@discordjs/rest": "^2.6.0", "@fedify/fedify": "^1.8.12", + "@modelcontextprotocol/sdk": "^1.20.0", "@types/he": "^1.2.3", "@types/sanitize-html": "^2.16.0", "axios": "^1.12.2", @@ -173,6 +174,8 @@ "@logtape/logtape": ["@logtape/logtape@1.0.4", "", {}, "sha512-YvNVrXIxVpnY528zoiEjX8PqTfr0UCtKXyssvaWL8AE+OByFTCooKuKMdPlm6g65YUI9fPXrHn4UnogSskABnA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.20.0", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q=="], + "@multiformats/base-x": ["@multiformats/base-x@4.0.1", "", {}, "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw=="], "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], @@ -475,6 +478,10 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="], "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], @@ -595,6 +602,8 @@ "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "is-upper-case": ["is-upper-case@1.1.2", "", { "dependencies": { "upper-case": "^1.1.0" } }, "sha512-GQYSJMgfeAmVwh9ixyk888l7OIhNAGKtY6QA+IrWlu9MDTCaXmeozOZ2S9Knj7bQwBO/H6J2kb+pbyTUiMNbsw=="], @@ -681,9 +690,9 @@ "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], @@ -725,6 +734,8 @@ "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], "open-graph-scraper": ["open-graph-scraper@6.10.0", "", { "dependencies": { "chardet": "^2.1.0", "cheerio": "^1.0.0-rc.12", "iconv-lite": "^0.6.3", "undici": "^6.21.2" } }, "sha512-JTuaO/mWUPduYCIQvunmsQnfGpSRFUTEh4k5cW2KOafJxTm3Z99z25/c1oO9QnIh2DK7ol5plJAq3EUVy+5xyw=="], @@ -785,6 +796,8 @@ "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="], + "pkijs": ["pkijs@3.2.5", "", { "dependencies": { "@noble/hashes": "^1.4.0", "asn1js": "^3.0.5", "bytestreamjs": "^2.0.0", "pvtsutils": "^1.3.2", "pvutils": "^1.1.3", "tslib": "^2.6.3" } }, "sha512-WX0la7n7CbnguuaIQoT4Fc0IJckPDOUldzOwlZ0nwpOcySS+Six/tXBdc0RX17J5o1To0SAr3xDJjDLsOfDFQA=="], "plimit-lit": ["plimit-lit@1.6.1", "", { "dependencies": { "queue-lit": "^1.5.1" } }, "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA=="], @@ -825,7 +838,7 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="], + "raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="], "rdf-canonize": ["rdf-canonize@3.4.0", "", { "dependencies": { "setimmediate": "^1.0.5" } }, "sha512-fUeWjrkOO0t1rg7B2fdyDTvngj+9RlUyL92vOdiB7c0FPguWVsniIMjEtHH+meLBO9rzkUlUzBVXgWrjI8P9LA=="], @@ -843,6 +856,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1009,6 +1024,8 @@ "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], @@ -1025,6 +1042,8 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], + "@digitalbazaar/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@discordjs/ws/@discordjs/rest": ["@discordjs/rest@2.5.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-Tg9840IneBcbrAjcGaQzHUJWFNq1MMWZjTdjJ0WS/89IffaNKc++iOvffucPxQTF/gviO9+9r8kEPea1X5J2Dw=="], @@ -1035,6 +1054,8 @@ "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + "@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + "@types/body-parser/@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="], "@types/connect/@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="], @@ -1057,10 +1078,14 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "body-parser/raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "cheerio/htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], @@ -1081,16 +1106,44 @@ "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + + "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], + + "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + + "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "@modelcontextprotocol/sdk/express/qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "@modelcontextprotocol/sdk/express/send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + + "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + + "@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "@types/body-parser/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "@types/connect/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], @@ -1111,6 +1164,8 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1123,10 +1178,18 @@ "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], diff --git a/locales/en-US.json b/locales/en-US.json index db89cdc..b48cc26 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -174,7 +174,7 @@ "testfailed": "❌ API key test failed: {error}. Please check your credentials and try again.", "testsuccess": "✅ API key test successful! Credentials saved.", "process": { - "dailylimit": "You've reached your daily limit of AI requests", + "dailylimit": "You've reached your daily limit of AI requests. Vote for Aethel to get more requests: https://top.gg/bot/1371031984230371369/vote\nOr set up your own API key using the \"/ai\" command.", "noapikey": "Please set up your API key first" }, "errors": { diff --git a/migrations/010_create_voting_tables.sql b/migrations/010_create_voting_tables.sql new file mode 100644 index 0000000..70cb75f --- /dev/null +++ b/migrations/010_create_voting_tables.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS votes ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + server_id TEXT, + vote_timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + claimed BOOLEAN DEFAULT FALSE, + credits_awarded INTEGER DEFAULT 10, + UNIQUE(user_id, server_id, DATE(vote_timestamp)) +); + +CREATE INDEX idx_votes_user ON votes(user_id, claimed); +CREATE INDEX idx_votes_server ON votes(server_id, claimed) WHERE server_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS message_credits ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL, + server_id TEXT, + credits_remaining INTEGER NOT NULL DEFAULT 0, + last_reset TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_message_credits_user ON message_credits(user_id) WHERE server_id IS NULL; +CREATE UNIQUE INDEX idx_message_credits_server ON message_credits(user_id, server_id) WHERE server_id IS NOT NULL; diff --git a/package.json b/package.json index c2aeeaf..e8ba1d7 100644 --- a/package.json +++ b/package.json @@ -5,22 +5,25 @@ "type": "module", "main": "dist/index.js", "scripts": { - "start": "node ./dist/index.js", - "dev": "tsx watch src/index.ts", + "start": "bun run src/mcp-server/index.ts & node ./dist/index.js", + "dev": "bun --watch src/mcp-server/index.ts & tsx watch src/index.ts", "build": "tsc && node scripts/fix-imports.js", "migrate": "node scripts/run-migrations.js", "do": "pnpm run migrate && pnpm run build && pnpm run start", + "mcp-server": "bun run src/mcp-server/index.ts", + "mcp-server:dev": "bun --watch src/mcp-server/index.ts", "lint": "eslint ./src ./web/src --ext .ts,.tsx", "lint:fix": "eslint ./src ./web/src --ext .ts,.tsx --config eslint.config.cjs --fix", "lint:format": "eslint ./src ./web/src --ext .ts,.tsx --config eslint.config.cjs --format=codeframe", "format": "prettier --write \"**/*.{js,json,md,ts,tsx}\" --ignore-path .prettierignore", "format:check": "prettier --check \"**/*.{js,json,md,ts,tsx}\" --ignore-path .prettierignore", - "check": "pnpm run lint && pnpm run format:check" + "check": "bun run lint && bun run format:check" }, "dependencies": { "@atproto/identity": "^0.4.9", "@discordjs/rest": "^2.6.0", "@fedify/fedify": "^1.8.12", + "@modelcontextprotocol/sdk": "^1.20.0", "@types/he": "^1.2.3", "@types/sanitize-html": "^2.16.0", "axios": "^1.12.2", diff --git a/src/commands/utilities/ai.ts b/src/commands/utilities/ai.ts index 70d8607..c07227a 100644 --- a/src/commands/utilities/ai.ts +++ b/src/commands/utilities/ai.ts @@ -239,20 +239,9 @@ You can use tools by placing commands in {curly braces}. Available tools: - {joke: or {joke: {type: "general/knock-knock/programming/dad"}} } - Get a joke - {weather:{"location":"city"}} - Check weather, use if user asks for weather. - {wiki:{"search":"query"}} - Wikipedia search, if user asks for a wikipedia search, use this tool, and also use it if user asks something out of your dated knowledge. -- {reaction:"😀"} - React to the user's message with a unicode emoji -- {reaction:{"emoji":":thumbsup:"}} - React using a named emoji if available -- {reaction:{"emoji":"<:name:123456789012345678>"}} - React with a custom emoji by ID (or animated ) Use the wikipedia search when you want to look for information outside of your knowledge, state it came from Wikipedia if used. -**REACTION GUIDELINES:** -- When asked to react, ALWAYS use the {reaction:"emoji"} tool call -- Use reactions sparingly and only when it adds value to the conversation -- Add at most 1–2 reactions for a single message -- Do not include the reaction tool call text in your visible reply -- Common reactions: 😀 😄 👍 👎 ❤️ 🔥 ⭐ 🎉 👏 -- Example: If asked to react with thumbs up, use {reaction:"👍"} and respond normally - When you use a tool, you'll receive a JSON response with the command results.`; const modelSpecificInstructions = usingDefaultKey @@ -445,6 +434,37 @@ async function makeAIRequest( interaction?: ChatInputCommandInteraction, client?: BotClient, maxIterations = 3, +): Promise { + const maxRetries = 3; + let retryCount = 0; + + while (retryCount < maxRetries) { + try { + return await makeAIRequestInternal(config, conversation, interaction, client, maxIterations); + } catch (error) { + retryCount++; + logger.error(`AI API request failed (attempt ${retryCount}/${maxRetries}):`, error); + + if (retryCount >= maxRetries) { + logger.error('AI API request failed after all retries'); + return null; + } + + const waitTime = Math.pow(2, retryCount) * 1000; + logger.debug(`Retrying AI API request in ${waitTime}ms...`); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + } + } + + return null; +} + +async function makeAIRequestInternal( + config: ReturnType, + conversation: ConversationMessage[], + interaction?: ChatInputCommandInteraction, + client?: BotClient, + maxIterations = 3, ): Promise { try { const openAIClient = getOpenAIClient(config.finalApiKey!, config.finalApiUrl); @@ -606,23 +626,133 @@ async function makeAIRequest( completion = { choices: [{ message: { content: extracted } }] } as unknown; } else { - completion = (await openAIClient.chat.completions.create({ - model: config.finalModel, - messages: currentConversation as OpenAI.Chat.Completions.ChatCompletionMessageParam[], - max_tokens: maxTokens, - })) as unknown; + try { + logger.debug('Making OpenAI API call', { + model: config.finalModel, + messageCount: currentConversation.length, + maxTokens: maxTokens, + }); + + completion = await openAIClient.chat.completions.create({ + model: config.finalModel, + messages: currentConversation as OpenAI.Chat.Completions.ChatCompletionMessageParam[], + max_tokens: maxTokens, + }); + + logger.debug('OpenAI API call completed successfully'); + } catch (apiError) { + logger.error('OpenAI API call failed:', apiError); + if (apiError instanceof Error) { + logger.error('API Error details:', { + message: apiError.message, + stack: apiError.stack?.substring(0, 500), + }); + } + return null; + } + } + + if (!completion) { + logger.error('AI API returned null or undefined completion'); + return null; } const completionTyped = completion as { choices?: Array<{ message?: { content?: string; reasoning?: string } }>; + error?: { message?: string; type?: string; code?: string }; }; + + try { + interface CompletionData { + id?: string; + object?: string; + model?: string; + created?: number; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + } + + const completionData = completion as unknown as CompletionData; + + logger.debug('AI API response structure', { + completionType: typeof completion, + completionKeys: Object.keys(completionData).join(', '), + hasChoices: !!completionTyped.choices, + choicesLength: completionTyped.choices?.length || 0, + hasMessage: !!completionTyped.choices?.[0]?.message, + hasContent: !!completionTyped.choices?.[0]?.message?.content, + errorPresent: !!completionTyped.error, + errorType: completionTyped.error?.type || 'none', + errorMessage: completionTyped.error?.message || 'none', + }); + + interface ChoiceData { + message?: { + content?: string; + }; + finish_reason?: string; + } + + const simplifiedResponse = { + id: completionData?.id, + object: completionData?.object, + model: completionData?.model, + created: completionData?.created, + choices: completionTyped.choices?.map((choice: ChoiceData, index: number) => ({ + index, + message: { + content: choice.message?.content + ? choice.message.content.substring(0, 100) + + (choice.message.content.length > 100 ? '...' : '') + : '[NO CONTENT]', + hasReasoning: !!(choice as OpenAIMessageWithReasoning)?.reasoning, + hasContent: !!choice.message?.content, + }, + finish_reason: choice?.finish_reason, + })), + error: completionTyped.error, + usage: completionData?.usage, + }; + + logger.debug('Raw API response', simplifiedResponse); + } catch (jsonError) { + logger.error('Failed to log API response:', jsonError); + logger.debug('API response basic info:', { + type: typeof completion, + isObject: typeof completion === 'object', + isArray: Array.isArray(completion), + keys: + typeof completion === 'object' && completion !== null + ? Object.keys(completion).join(', ') + : 'N/A', + }); + } + const message = completionTyped.choices?.[0]?.message; - if (!message?.content) { - logger.error('No valid response content from AI API'); + + if (!message) { + if (completionTyped.error) { + logger.error('AI API returned an error:', { + message: completionTyped.error.message, + type: completionTyped.error.type, + code: completionTyped.error.code, + }); + } else if (!completionTyped.choices || completionTyped.choices.length === 0) { + logger.error('AI API returned no choices in the response'); + } else { + logger.error('No message in AI API response'); + } return null; } - let content = message.content; + let content = message.content || ''; + if (content === '[NO CONTENT]' || !content.trim()) { + logger.debug('AI API returned empty or [NO CONTENT] response, treating as valid but empty'); + content = ''; + } let reasoning = (message as OpenAIMessageWithReasoning)?.reasoning; let detectedCitations: string[] | undefined; try { @@ -696,7 +826,7 @@ async function makeAIRequest( if (toolCalls.length > 0 && interaction && client) { currentConversation.push({ role: 'assistant', - content: message.content, + content: content, }); for (const toolCall of toolCalls) { diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 651e8f3..8142809 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -1,10 +1,10 @@ -import { Message, ChannelType } from 'discord.js'; +import { Message, ChannelType, type ChatInputCommandInteraction } from 'discord.js'; import BotClient from '@/services/Client'; import logger from '@/utils/logger'; import { makeAIRequest, getApiConfiguration, - buildSystemPrompt, + buildSystemPrompt as originalBuildSystemPrompt, buildConversation, getUserCredentials, incrementAndCheckDailyLimit, @@ -12,16 +12,104 @@ import { splitResponseIntoChunks, processUrls, } from '@/commands/utilities/ai'; + +function buildSystemPrompt( + usingDefaultKey: boolean, + client?: BotClient, + model?: string, + username?: string, + interaction?: ChatInputCommandInteraction, + isServer?: boolean, + serverName?: string, +): string { + const basePrompt = originalBuildSystemPrompt( + usingDefaultKey, + client, + model, + username, + interaction, + isServer, + serverName, + ); + + const reactionInstructions = ` +**AVAILABLE TOOLS:** + +**REACTION TOOLS:** +- {reaction:"😀"} - React to the user's message with a unicode emoji +- {reaction:{"emoji":":thumbsup:"}} - React using a named emoji if available +- {reaction:{"emoji":"<:name:123456789012345678>"}} - React with a custom emoji by ID (or animated ) + +**REACTION GUIDELINES:** +- When asked to react, ALWAYS use the {reaction:"emoji"} tool call +- Use reactions sparingly and only when it adds value to the conversation +- Add at most 1–2 reactions for a single message +- Do not include the reaction tool call text in your visible reply +- Common reactions: 😀 😄 👍 👎 ❤️ 🔥 ⭐ 🎉 👏 +- Example: If asked to react with thumbs up, use {reaction:"👍"} and respond normally +- IMPORTANT: If you use a reaction tool, you MUST also provide a text response - never use ONLY a reaction tool +- The reaction tool is for adding emoji reactions, not for replacing your response + +**NEW MESSAGE TOOL - CRITICAL GUIDELINES:** +**WHAT IT DOES:** +- {newmessage:} splits your response into multiple Discord messages +- This simulates how real users send follow-up messages +- Use it to break up long responses or create natural conversation flow + +**WHEN TO USE IT:** +- Only when you have SUBSTANTIAL content to split (multiple paragraphs, distinct thoughts) +- When your response is naturally long and would benefit from being split +- DO NOT use it for short responses or single sentences + +**HOW TO USE IT CORRECTLY:** +- Place it BETWEEN meaningful parts of your response +- You MUST have content BEFORE and AFTER the tool +- CORRECT: "Here's my first point about this topic. {newmessage:} And here's my second point that continues the thought." +- CORRECT: "Let me explain this in parts. First, the background information. {newmessage:} Now, here's how it applies to your situation." + +**NEVER DO THESE - THEY ARE WRONG:** +- WRONG: "{newmessage:} Here's my response" (starts with the tool) +- WRONG: "Here's my response {newmessage:}" (ends with the tool) +- WRONG: "{newmessage:}" (tool by itself with no content) +- WRONG: Using it for responses under 200 characters +- WRONG: Using it to split single sentences or short phrases + +**VALIDATION CHECK:** +- Before using {newmessage:}, ask yourself: "Do I have meaningful content both before AND after this tool?" +- If the answer is NO, don't use the tool +- If your response is short, send it as one message +- The tool should feel natural and conversational, not forced + +**EXAMPLES OF PROPER USAGE:** +- "I've analyzed your code and found several issues. The first is a syntax error on line 23. {newmessage:} The second issue is a logical error in your loop condition that could cause an infinite loop." +- "Let me break down the solution for you. Step 1: Understand the problem by identifying the root cause. {newmessage:} Step 2: Implement the fix by refactoring the problematic function. {newmessage:} Step 3: Test your solution thoroughly before deploying." + +**IMPORTANT DISTINCTION:** +- \`{newmessage:}\` is a FORMATTING TOOL - it just splits your response into multiple messages +- \`{newmessage:}\` does NOT execute any real functionality like other tools +- You can use \`{newmessage:}\` without any other tool calls - it's just for message formatting +- Don't expect any feedback or results from \`{newmessage:}\` - it just creates a new message + +**AVOIDING TOOL LOOPS:** +- If you find yourself repeatedly trying to use tools but generating no content, STOP and respond normally +- If your tool usage isn't working as expected, provide a simple text response instead +- Never let tool usage prevent you from giving a helpful response +- When in doubt, respond with plain text rather than complex tool combinations +`; + + return basePrompt + reactionInstructions; +} + import { extractToolCalls as extractSlashToolCalls } from '@/utils/commandExecutor'; -import fetch from '@/utils/dynamicFetch'; -import { executeMessageToolCall, type MessageToolCall } from '@/utils/messageToolExecutor'; +import _fetch from '@/utils/dynamicFetch'; +import { executeMessageToolCall, type MessageToolCall } from '@/mcp-server/toolExecutor'; import type { ConversationMessage, AIResponse } from '@/commands/utilities/ai'; type ApiConfiguration = ReturnType; import { createMemoryManager } from '@/utils/memoryManager'; const serverConversations = createMemoryManager({ - maxSize: 1000, + maxSize: 5000, maxAge: 2 * 60 * 60 * 1000, cleanupInterval: 10 * 60 * 1000, }); @@ -34,7 +122,7 @@ const serverMessageContext = createMemoryManager< timestamp: number; }> >({ - maxSize: 1000, + maxSize: 5000, maxAge: 2 * 60 * 60 * 1000, cleanupInterval: 10 * 60 * 1000, }); @@ -154,6 +242,18 @@ export default class MessageCreateEvent { }`, ); + let replyContext = ''; + if (message.reference?.messageId) { + try { + const repliedTo = await message.channel.messages.fetch(message.reference.messageId); + if (repliedTo) { + replyContext = `[Replying to ${repliedTo.author.username}: ${repliedTo.content}]\n\n`; + } + } catch (error) { + logger.debug('Error fetching replied message:', error); + } + } + const systemPrompt = buildSystemPrompt( usingDefaultKey, this.client, @@ -164,6 +264,9 @@ export default class MessageCreateEvent { !isDM ? message.guild?.name : undefined, ); + const baseContent = isDM ? message.content : message.content.replace(/<@!?\d+>/g, '').trim(); + const messageWithContext = replyContext ? `${replyContext}${baseContent}` : baseContent; + let messageContent: | string | Array<{ @@ -173,7 +276,7 @@ export default class MessageCreateEvent { url: string; detail?: 'low' | 'high' | 'auto'; }; - }> = isDM ? message.content : message.content.replace(/<@!?\d+>/g, '').trim(); + }>; if (hasImages) { const imageAttachments = message.attachments.filter( @@ -191,13 +294,10 @@ export default class MessageCreateEvent { }; }> = []; - const cleanContent = isDM - ? message.content - : message.content.replace(/<@!?\d+>/g, '').trim(); - if (cleanContent.trim()) { + if (messageWithContext.trim()) { contentArray.push({ type: 'text', - text: cleanContent, + text: messageWithContext, }); } @@ -212,6 +312,8 @@ export default class MessageCreateEvent { }); messageContent = contentArray; + } else { + messageContent = messageWithContext; } let conversation: ConversationMessage[] = []; @@ -264,52 +366,61 @@ export default class MessageCreateEvent { const updatedConversation = buildConversation( filteredConversation, - messageContent, + messageWithContext, systemPrompt, ); - if (config.usingDefaultKey) { - const exemptUserId = process.env.AI_EXEMPT_USER_ID?.trim(); - const actorId = message.author.id; + const exemptUserId = process.env.AI_EXEMPT_USER_ID?.trim(); + const actorId = message.author.id; + const isExempt = actorId === exemptUserId; + + logger.debug( + `AI limit check - usingDefaultKey: ${config.usingDefaultKey}, exemptUserId: ${exemptUserId}, actorId: ${actorId}, isExempt: ${isExempt}, isDM: ${isDM}`, + ); + + if (config.usingDefaultKey && !isExempt) { + if (isDM) { + logger.debug(`Checking DM daily limit for user ${actorId}`); + const allowed = await incrementAndCheckDailyLimit(actorId, 50); + logger.debug(`DM daily limit check result for user ${actorId}: ${allowed}`); + if (!allowed) { + await message.reply( + "❌ You've reached your daily limit of 50 AI requests. " + + 'Vote for Aethel to get more requests: https://top.gg/bot/1371031984230371369/vote\n' + + 'Or set up your own API key using the `/ai` command.', + ); + return; + } + } else { + let serverLimit = 30; + try { + const memberCount = message.guild?.memberCount || 0; + if (memberCount >= 1000) { + serverLimit = 500; + } else if (memberCount >= 100) { + serverLimit = 150; + } - if (actorId !== exemptUserId) { - if (isDM) { - const allowed = await incrementAndCheckDailyLimit(actorId, 10); - if (!allowed) { + const serverAllowed = await incrementAndCheckServerDailyLimit( + message.guildId!, + serverLimit, + ); + if (!serverAllowed) { await message.reply( - "❌ You've reached your daily limit of AI requests. Please try again tomorrow or set up your own API key using the `/ai` command.", + `❌ This server has reached its daily limit of ${serverLimit} AI requests. ` + + `Vote for Aethel to get more requests: https://top.gg/bot/1371031984230371369/vote`, ); return; } - } else { - let serverLimit = 30; - try { - const memberCount = message.guild?.memberCount || 0; - if (memberCount >= 1000) { - serverLimit = 500; - } else if (memberCount >= 100) { - serverLimit = 150; - } - - const serverAllowed = await incrementAndCheckServerDailyLimit( - message.guildId!, - serverLimit, + } catch (error) { + logger.error('Error checking server member count:', error); + const serverAllowed = await incrementAndCheckServerDailyLimit(message.guildId!, 30); + if (!serverAllowed) { + await message.reply( + '❌ This server has reached its daily limit of AI requests. ' + + 'Vote for Aethel to get more requests: https://top.gg/bot/1371031984230371369/vote', ); - if (!serverAllowed) { - await message.reply( - `❌ This server has reached its daily limit of ${serverLimit} AI requests. Please try again tomorrow.`, - ); - return; - } - } catch (error) { - logger.error('Error checking server member count:', error); - const serverAllowed = await incrementAndCheckServerDailyLimit(message.guildId!, 30); - if (!serverAllowed) { - await message.reply( - '❌ This server has reached its daily limit of AI requests. Please try again tomorrow.', - ); - return; - } + return; } } } @@ -331,16 +442,21 @@ export default class MessageCreateEvent { if (!aiResponse && hasImages) { logger.warn(`Image model ${selectedModel} failed, falling back to text-only model`); - let fallbackContent = message.content; + let fallbackContent = messageWithContext; if (Array.isArray(messageContent)) { const textParts = messageContent - .filter((item) => item.type === 'text') - .map((item) => item.text) - .filter((text) => text && text.trim()); + .filter((item: { type: string; text?: string }) => item.type === 'text') + .map((item: { type: string; text?: string }) => item.text) + .filter((text: string | undefined) => text && text.trim()); const imageParts = messageContent - .filter((item) => item.type === 'image_url') - .map((item) => `[Image: ${item.image_url?.url}]`); + .filter( + (item: { type: string; image_url?: { url: string } }) => item.type === 'image_url', + ) + .map( + (item: { type: string; image_url?: { url: string } }) => + `[Image: ${item.image_url?.url}]`, + ); fallbackContent = [...textParts, ...imageParts].join(' ') || @@ -389,138 +505,56 @@ export default class MessageCreateEvent { const maxIterations = 3; let iteration = 0; + let lastToolResponse = ''; + while (aiResponse && iteration < maxIterations) { iteration++; const extraction = extractMessageToolCalls(aiResponse.content || ''); const toolCalls: MessageToolCall[] = extraction.toolCalls; - if (toolCalls.length === 0) { + const executableTools = toolCalls.filter((tc) => tc.name?.toLowerCase() !== 'newmessage'); + + if (executableTools.length === 0) { aiResponse.content = extraction.cleanContent; break; } + const currentToolResponse = JSON.stringify( + executableTools.map((tc) => ({ name: tc.name, args: tc.args })), + ); + if (currentToolResponse === lastToolResponse) { + logger.warn('AI stuck in tool loop, breaking out to prevent [NO CONTENT] responses'); + aiResponse.content = + extraction.cleanContent || + 'I apologize, but I seem to be having trouble with the tools. Let me respond normally.'; + break; + } + lastToolResponse = currentToolResponse; + conversationWithTools.push({ role: 'assistant', content: aiResponse.content }); - for (const tc of toolCalls) { + for (const tc of executableTools) { const name = tc.name?.toLowerCase(); try { - if (name === 'cat' || name === 'dog') { - const isCat = name === 'cat'; - const url = isCat - ? 'https://api.pur.cat/random-cat' - : 'https://api.erm.dog/random-dog'; - const res = await fetch(url); - let imageUrl = ''; - try { - const data = await res.json(); - imageUrl = data?.url || ''; - if (!imageUrl && !isCat) { - const res2 = await fetch(url); - imageUrl = await res2.text(); - } - } catch { - const res2 = await fetch(url); - imageUrl = await res2.text(); - } - const payload = imageUrl - ? { type: name, url: imageUrl } - : { type: name, error: 'no_image' }; - executedResults.push({ type: name, payload }); - conversationWithTools.push({ role: 'user', content: JSON.stringify(payload) }); - } else if (name === 'weather') { - const raw = (tc.args?.location as string) || (tc.args?.query as string) || ''; - const location = typeof raw === 'string' ? raw.trim() : ''; - const apiKey = process.env.OPENWEATHER_API_KEY; - let payload: Record = { type: 'weather', location }; - if (location && apiKey) { - try { - const resp = await fetch( - `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(location)}&appid=${apiKey}&units=imperial`, - ); - if (resp.ok) { - const data = await resp.json(); - payload = { - type: 'weather', - location: data.name || location, - temperature: `${Math.round(data.main?.temp)}°F`, - feels_like: `${Math.round(data.main?.feels_like)}°F`, - conditions: data.weather?.[0]?.description || 'Unknown', - humidity: `${data.main?.humidity}%`, - wind_speed: `${Math.round(data.wind?.speed)} mph`, - pressure: `${data.main?.pressure} hPa`, - }; - } else { - logger.error('[MessageCreate] Weather fetch failed', { - status: resp.status, - statusText: resp.statusText, - }); - payload = { - type: 'weather', - location, - error: `${resp.status} ${resp.statusText}`, - }; - } - } catch (e) { - logger.error('[MessageCreate] Weather fetch error', { - error: (e as Error)?.message, - }); - payload = { - type: 'weather', - location, - error: (e as Error)?.message || 'fetch_failed', - }; - } - } else { - logger.warn('[MessageCreate] Weather missing params or API key', { - locationPresent: !!location, - apiKeyPresent: !!apiKey, - }); - payload = { type: 'weather', location, error: 'missing_params' }; - } - executedResults.push({ type: 'weather', payload }); - conversationWithTools.push({ role: 'user', content: JSON.stringify(payload) }); - } else if (name === 'wiki') { - const query = (tc.args?.search as string) || (tc.args?.query as string) || ''; - const q = typeof query === 'string' ? query.trim() : ''; - let payload: Record = { type: 'wiki' }; - if (q) { - try { - const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(q)}`; - const res = await fetch(url); - if (res.ok) { - const data = await res.json(); - payload = { - type: 'wiki', - title: data.title || q, - extract: data.extract || '', - url: - data.content_urls?.desktop?.page || - `https://en.wikipedia.org/wiki/${encodeURIComponent(q)}`, - }; - } else { - payload = { type: 'wiki', error: `${res.status} ${res.statusText}` }; - } - } catch (e) { - payload = { type: 'wiki', error: (e as Error)?.message || 'fetch_failed' }; - } - } - executedResults.push({ type: 'wiki', payload }); - conversationWithTools.push({ role: 'user', content: JSON.stringify(payload) }); - } else if (name === 'reaction') { - const emoji = (tc.args?.emoji as string) || ''; - const payload = { type: 'reaction', emoji, deferred: true }; - executedResults.push({ type: 'reaction', payload }); - conversationWithTools.push({ role: 'user', content: JSON.stringify(payload) }); - - try { - await executeMessageToolCall(tc, message, this.client, { - originalMessage: message, - botMessage: undefined, - }); - } catch (error) { - logger.error('[MessageCreate] Iterative loop - reaction execution failed:', error); - } - } + const result = await executeMessageToolCall(tc, message, this.client, { + originalMessage: message, + botMessage: undefined, + }); + const payload = { + type: name, + success: result.success, + handled: result.handled, + error: result.error || null, + ...(result.result?.metadata || {}), + }; + + executedResults.push({ type: name, payload }); + conversationWithTools.push({ role: 'user', content: JSON.stringify(payload) }); + + logger.debug(`[MessageCreate] MCP tool ${name} executed:`, { + success: result.success, + handled: result.handled, + }); } catch (e) { conversationWithTools.push({ role: 'user', content: `[Tool ${tc.name} error]` }); logger.error('[MessageCreate] Tool execution threw exception', { @@ -532,6 +566,34 @@ export default class MessageCreateEvent { aiResponse = await makeAIRequest(config, conversationWithTools); if (!aiResponse) break; + + const hasNewMessageTool = toolCalls.some((tc) => tc.name?.toLowerCase() === 'newmessage'); + const cleanContent = extraction.cleanContent?.trim() || ''; + + if (hasNewMessageTool && !cleanContent && iteration >= 2) { + logger.warn('AI stuck in newmessage misuse loop, forcing normal response'); + aiResponse.content = + 'I apologize for the confusion. Let me respond clearly without using any tools.'; + break; + } + + const hasOnlyReactionTools = + executableTools.length > 0 && + executableTools.every((tc) => tc.name?.toLowerCase() === 'reaction') && + !cleanContent; + + if (hasOnlyReactionTools && iteration >= 2) { + logger.warn('AI stuck in reaction-only loop, forcing normal response'); + aiResponse.content = + 'I apologize for the confusion. Let me respond clearly without using any tools.'; + break; + } + + if (executableTools.length === 0 && hasNewMessageTool) { + logger.debug('Only newmessage formatting tools found, breaking iterative loop'); + aiResponse.content = extraction.cleanContent; + break; + } } if (!aiResponse) { @@ -567,7 +629,8 @@ export default class MessageCreateEvent { } if (!aiResponse.content || !aiResponse.content.trim()) { - aiResponse.content = '\u200b'; + logger.debug('AI response has no meaningful content, not sending message'); + return; } } @@ -654,12 +717,16 @@ export default class MessageCreateEvent { } } - const sent = await this.sendResponse(message, aiResponse); + const sent = await this.sendResponse(message, aiResponse, executedResults); const sentMessage: Message | undefined = sent as Message | undefined; if (extraction.toolCalls.length > 0) { const target = sentMessage || message; const executed: Array<{ name: string; success: boolean }> = []; + logger.debug( + `[MessageCreate] Final execution - processing ${extraction.toolCalls.length} tool calls:`, + extraction.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })), + ); for (const tc of extraction.toolCalls) { if (!tc || !tc.name) continue; const name = tc.name.toLowerCase(); @@ -674,97 +741,28 @@ export default class MessageCreateEvent { logger.error('Error executing message tool:', { name, err }); executed.push({ name, success: false }); } - } else if (name === 'cat' || name === 'dog') { - try { - const isCat = name === 'cat'; - const url = isCat - ? 'https://api.pur.cat/random-cat' - : 'https://api.erm.dog/random-dog'; - const res = await fetch(url); - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); - let imageUrl = ''; - try { - const data = await res.json(); - imageUrl = data?.url || ''; - if (!imageUrl && !isCat) { - const res2 = await fetch(url); - imageUrl = await res2.text(); - } - } catch { - const res2 = await fetch(url); - imageUrl = await res2.text(); - } - if (imageUrl && imageUrl.startsWith('http')) { - await target.reply({ content: '', files: [imageUrl] }); - executed.push({ name, success: true }); - } else { - executed.push({ name, success: false }); - } - } catch (err) { - logger.error('Error executing image tool:', { name, err }); - executed.push({ name, success: false }); - } - } else if (name === 'weather') { + } else { try { - const raw = (tc.args?.location as string) || (tc.args?.query as string) || ''; - const location = typeof raw === 'string' ? raw.trim() : ''; - if (!location) { - executed.push({ name, success: false }); - } else { - const apiKey = process.env.OPENWEATHER_API_KEY; - if (!apiKey) { - executed.push({ name, success: false }); - } else { - const resp = await fetch( - `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent( - location, - )}&appid=${apiKey}&units=imperial`, - ); - if (!resp.ok) { - executed.push({ name, success: false }); - } else { - const data = await resp.json(); - const temp = Math.round(data.main?.temp); - const feels = Math.round(data.main?.feels_like); - const cond = data.weather?.[0]?.description || 'Unknown'; - const hum = data.main?.humidity; - const wind = Math.round(data.wind?.speed); - const pres = data.main?.pressure; - await target.reply( - `Weather for ${data.name || location}: ${temp}°F (feels ${feels}°F), ${cond}. Humidity ${hum}%, Wind ${wind} mph, Pressure ${pres} hPa.`, - ); - executed.push({ name, success: true }); - } + const result = await executeMessageToolCall(tc, target, this.client, { + originalMessage: message, + botMessage: sentMessage, + }); + + if (name === 'cat' || name === 'dog') { + const imageUrl = result.result?.metadata?.url as string; + if (imageUrl && imageUrl.startsWith('http')) { + await target.reply({ content: '', files: [imageUrl] }); } - } - } catch (err) { - logger.error('Error executing weather tool:', { err }); - executed.push({ name, success: false }); - } - } else if (name === 'wiki') { - try { - const query = (tc.args?.search as string) || (tc.args?.query as string) || ''; - const q = typeof query === 'string' ? query.trim() : ''; - if (!q) { - executed.push({ name, success: false }); - } else { - const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(q)}`; - const res = await fetch(url); - if (!res.ok) { - executed.push({ name, success: false }); - } else { - const data = await res.json(); - const title = data.title || q; - const extract = data.extract || 'No summary available.'; - const pageUrl = - data.content_urls?.desktop?.page || - `https://en.wikipedia.org/wiki/${encodeURIComponent(q)}`; - await target.reply(`${title}\n${extract}\n<${pageUrl}>`); - executed.push({ name, success: true }); + } else if (name === 'weather' || name === 'wiki') { + const textContent = result.result?.content?.find((c) => c.type === 'text')?.text; + if (textContent) { + await target.reply(textContent); } } + + executed.push({ name, success: result.success }); } catch (err) { - logger.error('Error executing wiki tool:', { err }); + logger.error(`Error executing MCP tool ${name}:`, { err }); executed.push({ name, success: false }); } } @@ -786,7 +784,7 @@ export default class MessageCreateEvent { const userMessage: ConversationMessage = { role: 'user', - content: messageContent, + content: messageWithContext, username: message.author.username, }; const assistantMessage: ConversationMessage = { @@ -823,7 +821,11 @@ export default class MessageCreateEvent { } } - private async sendResponse(message: Message, aiResponse: AIResponse): Promise { + private async sendResponse( + message: Message, + aiResponse: AIResponse, + executedResults?: Array<{ type: string; payload: Record }>, + ): Promise { let fullResponse = ''; if (aiResponse.reasoning) { @@ -832,33 +834,147 @@ export default class MessageCreateEvent { fullResponse += aiResponse.content; + const imageFiles: string[] = []; + if (executedResults) { + for (const result of executedResults) { + if ( + (result.type === 'cat' || result.type === 'dog') && + result.payload.url && + typeof result.payload.url === 'string' + ) { + imageFiles.push(result.payload.url); + } + } + } + if (!fullResponse || !fullResponse.trim()) { - fullResponse = '\u200b'; + logger.debug('AI response has no meaningful content, not sending message'); + return; } - const maxLength = 2000; - if (fullResponse.length <= maxLength) { - const sent = await message.reply({ - content: fullResponse, + const newMessageOnlyRegex = /^\s*\{newmessage:\}\s*$/; + if (newMessageOnlyRegex.test(fullResponse)) { + logger.warn('AI misused newmessage tool - sent only {newmessage:} with no content'); + return; + } + + const newMessageRegex = /\{newmessage:\}/g; + if (newMessageRegex.test(fullResponse)) { + const parts = fullResponse.split(/\{newmessage:\}/); + + if (parts[0].trim() === '') { + logger.warn('AI misused newmessage tool - started response with {newmessage:}'); + parts.shift(); + if (parts.length === 0) { + return await message.reply({ + content: fullResponse.replace(/\{newmessage:\}/g, '').trim() || '\u200b', + allowedMentions: { parse: ['users'] as const }, + }); + } + } + + const first = await message.reply({ + content: parts[0].trim() || '\u200b', + files: imageFiles.length > 0 ? imageFiles : undefined, allowedMentions: { parse: ['users'] as const }, }); - return sent; + + for (let i = 1; i < parts.length; i++) { + if ('send' in message.channel && parts[i].trim()) { + const delay = Math.floor(Math.random() * 900) + 300; + await new Promise((resolve) => setTimeout(resolve, delay)); + + await message.channel.send({ + content: parts[i].trim(), + allowedMentions: { parse: ['users'] as const }, + }); + } + } + return first; } - const chunks = splitResponseIntoChunks(fullResponse, maxLength); + const conversationChunks = this.splitIntoConversationalChunks(fullResponse); const first = await message.reply({ - content: chunks[0], + content: conversationChunks[0], + files: imageFiles.length > 0 ? imageFiles : undefined, allowedMentions: { parse: ['users'] as const }, }); - for (let i = 1; i < chunks.length; i++) { + for (let i = 1; i < conversationChunks.length; i++) { if ('send' in message.channel) { + const delay = Math.floor(Math.random() * 900) + 300; + await new Promise((resolve) => setTimeout(resolve, delay)); + await message.channel.send({ - content: chunks[i], + content: conversationChunks[i], allowedMentions: { parse: ['users'] as const }, }); } } return first; } + + private splitIntoConversationalChunks(text: string): string[] { + if (!text || text.length <= 200) { + return [text]; + } + + const chunks: string[] = []; + const paragraphs = text.split(/\n\n+/); + + for (const paragraph of paragraphs) { + if (paragraph.length < 200) { + chunks.push(paragraph); + } else { + const sentences = paragraph.split(/(?<=[.!?])\s+/); + + let currentChunk = ''; + for (const sentence of sentences) { + if (currentChunk.length + sentence.length > 200 && currentChunk.length > 0) { + chunks.push(currentChunk); + currentChunk = sentence; + } else { + if (currentChunk && !currentChunk.endsWith('\n')) { + currentChunk += ' '; + } + currentChunk += sentence; + } + + const hasEndPunctuation = /[.!?]$/.test(sentence); + const breakChance = hasEndPunctuation ? 0.7 : 0.3; + + if (currentChunk.length > 100 && Math.random() < breakChance) { + chunks.push(currentChunk); + currentChunk = ''; + } + } + + if (currentChunk) { + chunks.push(currentChunk); + } + } + } + + const fillerMessages = ['hmm', 'let me think', 'one sec', 'actually', 'wait', 'so basically']; + + if (chunks.length > 1 && Math.random() < 0.3) { + const position = Math.floor(Math.random() * (chunks.length - 1)) + 1; + const filler = fillerMessages[Math.floor(Math.random() * fillerMessages.length)]; + chunks.splice(position, 0, filler); + } + + const maxLength = 2000; + const finalChunks: string[] = []; + + for (const chunk of chunks) { + if (chunk.length <= maxLength) { + finalChunks.push(chunk); + } else { + // Fall back to the original splitting method for any chunks that are still too long + finalChunks.push(...splitResponseIntoChunks(chunk, maxLength)); + } + } + + return finalChunks; + } } diff --git a/src/events/ready.ts b/src/events/ready.ts index 716176e..e9e36a6 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -16,7 +16,7 @@ export default class ReadyEvent { await client.application?.commands.fetch({ withLocalizations: true }); await loadActiveReminders(client); - const { sendDeploymentNotification } = await import('@/utils/sendDeploymentNotification.js'); + const { sendDeploymentNotification } = await import('@/utils/sendDeploymentNotification'); await sendDeploymentNotification(this.startTime); } catch (error) { logger.error('Error during ready event:', error); diff --git a/src/index.ts b/src/index.ts index 617e0a2..3ffc475 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import authRoutes from './routes/auth'; import todosRoutes from './routes/todos'; import apiKeysRoutes from './routes/apiKeys'; import remindersRoutes from './routes/reminders'; +import voteWebhookRoutes from './routes/voteWebhook'; import { resetOldStrikes } from './utils/userStrikes'; import logger from './utils/logger'; @@ -85,7 +86,7 @@ app.use(async (req, res, next) => { res.on('finish', () => { const durMs = Number(process.hrtime.bigint() - start) / 1e6; const safePath = req.baseUrl ? `${req.baseUrl}${req.path}` : req.path; - logger.info(`API [${req.method}] ${safePath} ${res.statusCode} ${durMs.toFixed(1)}ms`); + logger.debug(`API [${req.method}] ${safePath} ${res.statusCode} ${durMs.toFixed(1)}ms`); }); next(); }); @@ -93,7 +94,8 @@ app.use(async (req, res, next) => { app.use('/api/auth', authRoutes); app.use('/api/todos', todosRoutes); app.use('/api/user/api-keys', apiKeysRoutes); -app.use('/api/reminders', remindersRoutes); +app.use('/api/reminders', authenticateApiKey, remindersRoutes); +app.use('/api', voteWebhookRoutes); app.use('/api/status', authenticateApiKey, status(bot)); @@ -121,7 +123,7 @@ setInterval( const server = app.listen(PORT, async () => { logger.debug('Aethel is live on', `http://localhost:${PORT}`); - const { sendDeploymentNotification } = await import('./utils/sendDeploymentNotification.js'); + const { sendDeploymentNotification } = await import('./utils/sendDeploymentNotification'); await sendDeploymentNotification(startTime); }); diff --git a/src/mcp-server/client.ts b/src/mcp-server/client.ts new file mode 100644 index 0000000..5501ed1 --- /dev/null +++ b/src/mcp-server/client.ts @@ -0,0 +1,112 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import logger from '../utils/logger.js'; + +export interface MCPToolResult { + content: Array<{ + type: 'text' | 'image_url'; + text?: string; + image_url?: { url: string }; + }>; + metadata: Record; +} + +export class MCPToolClient { + private client: Client | null = null; + private transport: StdioClientTransport | null = null; + private isConnected = false; + + async connect(): Promise { + if (this.isConnected) return; + + try { + this.transport = new StdioClientTransport({ + command: 'bun', + args: ['run', 'src/mcp-server/index.ts'], + }); + this.client = new Client( + { + name: 'aethel-discord-bot', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + await this.client.connect(this.transport); + this.isConnected = true; + + logger.info('[MCP Client] Connected to MCP server'); + } catch (error) { + logger.error('[MCP Client] Failed to connect:', error); + throw error; + } + } + + async disconnect(): Promise { + if (!this.isConnected || !this.client) return; + + try { + await this.client.close(); + this.isConnected = false; + logger.info('[MCP Client] Disconnected from MCP server'); + } catch (error) { + logger.error('[MCP Client] Error during disconnect:', error); + } + } + + async executeTool(name: string, args: Record = {}): Promise { + if (!this.isConnected || !this.client) { + throw new Error('MCP client not connected'); + } + + try { + logger.debug(`[MCP Client] Executing tool: ${name}`, args); + + const result = await this.client.callTool({ + name, + arguments: args, + }); + + logger.debug(`[MCP Client] Tool ${name} completed successfully`); + + return { + content: result.content as Array<{ + type: 'text' | 'image_url'; + text?: string; + image_url?: { url: string }; + }>, + metadata: result.metadata as Record, + }; + } catch (error) { + logger.error(`[MCP Client] Tool ${name} failed:`, error); + throw error; + } + } + + async listTools(): Promise> { + if (!this.isConnected || !this.client) { + throw new Error('MCP client not connected'); + } + + try { + const result = await this.client.listTools(); + return result.tools.map((tool) => ({ + name: tool.name, + description: tool.description || 'No description available', + })); + } catch (error) { + logger.error('[MCP Client] Failed to list tools:', error); + throw error; + } + } + + isReady(): boolean { + return this.isConnected && this.client !== null; + } +} + +export const mcpClient = new MCPToolClient(); diff --git a/src/mcp-server/index.ts b/src/mcp-server/index.ts new file mode 100644 index 0000000..67b51fe --- /dev/null +++ b/src/mcp-server/index.ts @@ -0,0 +1,351 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import fetch from '../utils/dynamicFetch.js'; +import logger from '../utils/logger.js'; + +const tools: Tool[] = [ + { + name: 'cat', + description: 'Get a random cat image', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, + { + name: 'dog', + description: 'Get a random dog image', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, + { + name: 'weather', + description: 'Get weather information for a location', + inputSchema: { + type: 'object', + properties: { + location: { + type: 'string', + description: 'The location to get weather for', + }, + }, + required: ['location'], + }, + }, + { + name: 'wiki', + description: 'Search Wikipedia for information', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query for Wikipedia', + }, + }, + required: ['query'], + }, + }, + { + name: 'reaction', + description: 'React to a Discord message with an emoji', + inputSchema: { + type: 'object', + properties: { + emoji: { + type: 'string', + description: 'The emoji to react with', + }, + target: { + type: 'string', + description: 'Target message (user, bot, or message_id)', + enum: ['user', 'bot'], + }, + }, + required: ['emoji'], + }, + }, + { + name: 'newmessage', + description: 'Split response into multiple Discord messages', + inputSchema: { + type: 'object', + properties: {}, + required: [], + }, + }, +]; + +function formatToolResponse( + content: string, + metadata: Record, + showSystemMessage = true, +) { + if (showSystemMessage) { + return { + content: [ + { + type: 'text', + text: `[SYSTEM] ${content}`, + }, + ], + metadata: { + ...metadata, + isSystem: true, + }, + }; + } + + return { + content: [], + metadata: { + ...metadata, + isSystem: false, + }, + }; +} + +async function executeTool(name: string, args: Record) { + switch (name) { + case 'cat': { + try { + const res = await fetch('https://api.pur.cat/random-cat'); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + + const data = await res.json(); + return formatToolResponse(`Here's a cute cat for you! 🐱\n\nImage URL: ${data.url}`, { + type: 'cat', + url: data.url, + title: data.title, + subreddit: data.subreddit, + source: 'pur.cat', + }); + } catch (error) { + throw new Error( + `Failed to fetch cat image: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + case 'dog': { + try { + const headers = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Accept-Encoding': 'gzip, deflate, br', + Connection: 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Cache-Control': 'max-age=0', + }; + + const res = await fetch('https://api.erm.dog/random-dog', { headers }); + if (!res.ok) throw new Error(`API request failed with status ${res.status}`); + + const data = await res.json(); + + if (!data || !data.url) { + throw new Error('Invalid response format from dog API'); + } + + return formatToolResponse(`Here's a cute dog for you! 🐶\n\nImage URL: ${data.url}`, { + type: 'dog', + url: data.url, + title: data.title || 'Random Dog', + subreddit: data.subreddit || 'dogpictures', + source: 'erm.dog', + }); + } catch (error) { + throw new Error( + `Failed to fetch dog image: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + case 'weather': { + const location = args.location as string; + if (!location) { + throw new Error('Location is required for weather tool'); + } + + const apiKey = process.env.OPENWEATHER_API_KEY; + if (!apiKey) { + throw new Error('OpenWeather API key not configured'); + } + + try { + const res = await fetch( + `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(location)}&appid=${apiKey}&units=imperial`, + ); + + if (!res.ok) { + throw new Error(`Weather API error: ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + const temp = Math.round(data.main?.temp); + const feels = Math.round(data.main?.feels_like); + const conditions = data.weather?.[0]?.description || 'Unknown'; + const humidity = data.main?.humidity; + const wind = Math.round(data.wind?.speed); + const pressure = data.main?.pressure; + + return formatToolResponse( + `Weather for ${data.name || location}: ${temp}°F (feels ${feels}°F), ${conditions}. Humidity ${humidity}%, Wind ${wind} mph, Pressure ${pressure} hPa.`, + { + type: 'weather', + location: data.name || location, + temperature: temp, + feels_like: feels, + conditions, + humidity, + wind_speed: wind, + pressure, + }, + ); + } catch (error) { + throw new Error( + `Failed to get weather: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + case 'wiki': { + const query = args.query as string; + if (!query) { + throw new Error('Query is required for wiki tool'); + } + + try { + const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(query)}`; + const res = await fetch(url); + + if (!res.ok) { + throw new Error(`Wikipedia API error: ${res.status} ${res.statusText}`); + } + + const data = await res.json(); + const title = data.title || query; + const extract = data.extract || 'No summary available.'; + const pageUrl = + data.content_urls?.desktop?.page || + `https://en.wikipedia.org/wiki/${encodeURIComponent(query)}`; + + return formatToolResponse(`${title}\n\n${extract}\n\nSource: ${pageUrl}`, { + type: 'wiki', + title, + extract, + url: pageUrl, + }); + } catch (error) { + throw new Error( + `Failed to search Wikipedia: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + case 'reaction': { + const emoji = args.emoji as string; + const target = (args.target as string) || 'user'; + + if (!emoji) { + throw new Error('Emoji is required for reaction tool'); + } + + return { + content: [], + metadata: { + type: 'reaction', + emoji, + target, + success: true, + handled: true, + isSystem: false, + }, + }; + } + + case 'newmessage': { + return { + content: [], + metadata: { + type: 'newmessage', + success: true, + handled: true, + isSystem: false, + }, + }; + } + + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +const server = new Server( + { + name: 'aethel-discord-tools', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + }, +); + +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools }; +}); +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + logger.debug(`[MCP Server] Executing tool: ${name}`, args); + const result = await executeTool(name, args || {}); + logger.debug(`[MCP Server] Tool ${name} completed successfully`); + + return { + content: result.content, + metadata: result.metadata, + }; + } catch (error) { + logger.error(`[MCP Server] Tool ${name} failed:`, error); + throw error; + } +}); + +async function startServer() { + const transport = new StdioServerTransport(); + await server.connect(transport); + logger.info('[MCP Server] Aethel Discord Tools MCP Server started'); +} + +process.on('SIGINT', async () => { + logger.info('[MCP Server] Shutting down...'); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + logger.info('[MCP Server] Shutting down...'); + process.exit(0); +}); + +startServer().catch((error) => { + logger.error('[MCP Server] Failed to start:', error); + process.exit(1); +}); diff --git a/src/mcp-server/toolExecutor.ts b/src/mcp-server/toolExecutor.ts new file mode 100644 index 0000000..8370c63 --- /dev/null +++ b/src/mcp-server/toolExecutor.ts @@ -0,0 +1,158 @@ +import { Message } from 'discord.js'; +import BotClient from '../services/Client.js'; +import { mcpClient, MCPToolResult } from './client.js'; +import logger from '../utils/logger.js'; + +export interface MessageToolCall { + name: string; + args: Record; +} + +export async function executeMessageToolCall( + toolCall: MessageToolCall, + message: Message, + client: BotClient, + opts?: { originalMessage?: Message; botMessage?: Message }, +): Promise<{ + success: boolean; + type: string; + handled: boolean; + error?: string; + result?: MCPToolResult; +}> { + const name = (toolCall.name || '').toLowerCase(); + + try { + if (!mcpClient.isReady()) { + await mcpClient.connect(); + } + const result = await mcpClient.executeTool(name, toolCall.args || {}); + + if (name === 'reaction') { + return await handleReactionTool(result, message, client, opts); + } + + if (name === 'newmessage') { + return { + success: true, + type: 'newmessage', + handled: true, + result, + }; + } + + return { + success: true, + type: name, + handled: true, + result, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`[MCP Tool Executor] Error executing tool ${name}:`, errorMessage); + + return { + success: false, + type: name, + handled: false, + error: errorMessage, + }; + } +} + +async function handleReactionTool( + result: MCPToolResult, + message: Message, + client: BotClient, + opts?: { originalMessage?: Message; botMessage?: Message }, +): Promise<{ + success: boolean; + type: string; + handled: boolean; + error?: string; + result?: MCPToolResult; +}> { + try { + const emoji = result.metadata.emoji as string; + const target = (result.metadata.target as string) || 'user'; + + if (!emoji) { + return { + success: false, + type: 'reaction', + handled: false, + error: 'Missing emoji', + result, + }; + } + + const normalizedEmoji = normalizeEmoji(emoji); + + let targetMsg: Message = message; + if (target === 'user' && opts?.originalMessage) { + targetMsg = opts.originalMessage; + } else if (target === 'bot' && opts?.botMessage) { + targetMsg = opts.botMessage; + } + + await targetMsg.react(normalizedEmoji); + + return { + success: true, + type: 'reaction', + handled: true, + result, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error('[MCP Tool Executor] Reaction tool failed:', errorMessage); + + return { + success: false, + type: 'reaction', + handled: false, + error: errorMessage, + result, + }; + } +} + +function normalizeEmoji(input: string): string { + if (!input) return ''; + + const customMatch = input.match(//); + if (customMatch && customMatch[1]) { + return customMatch[0]; + } + + const shortcode = input.match(/^:([a-z0-9_+-]+):$/i)?.[1]; + if (shortcode) { + const map: Record = { + thumbsup: '👍', + thumbsdown: '👎', + '+1': '👍', + '-1': '👎', + thumbs_up: '👍', + thumbs_down: '👎', + heart: '❤️', + smile: '😄', + grin: '😁', + joy: '😂', + cry: '😢', + sob: '😭', + clap: '👏', + fire: '🔥', + star: '⭐', + eyes: '👀', + tada: '🎉', + }; + const key = shortcode.toLowerCase(); + return map[key] || input; + } + + return input; +} + +mcpClient.connect().catch((error) => { + logger.error('[MCP Tool Executor] Failed to initialize MCP client:', error); +}); diff --git a/src/middlewares/verifyApiKey.ts b/src/middlewares/verifyApiKey.ts index 24c5089..16f4761 100644 --- a/src/middlewares/verifyApiKey.ts +++ b/src/middlewares/verifyApiKey.ts @@ -1,7 +1,8 @@ import * as config from '@/config'; import { RequestHandler } from 'express'; +import { createHmac } from 'crypto'; -const authenticateApiKey: RequestHandler = (req, res, next) => { +export const authenticateApiKey: RequestHandler = (req, res, next) => { const apiKey = req.headers['x-api-key']; if (!apiKey || typeof apiKey !== 'string') { res.status(401).json({ error: 'Unauthorized: Missing API key' }); @@ -28,4 +29,32 @@ const authenticateApiKey: RequestHandler = (req, res, next) => { next(); }; +export const authenticateTopGG: RequestHandler = (req, res, next) => { + try { + const authHeader = req.headers.authorization; + const signature = req.headers['x-signature-sha256']; + + if (!authHeader || !signature || typeof signature !== 'string') { + return res.status(401).json({ error: 'Missing authentication headers' }); + } + + const hmac = createHmac('sha256', process.env.TOPGG_WEBHOOK_SECRET || ''); + const digest = hmac.update(JSON.stringify(req.body)).digest('hex'); + + if (signature !== digest) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + const [scheme, token] = authHeader.split(' '); + if (scheme !== 'Bearer' || token !== process.env.TOPGG_WEBHOOK_AUTH) { + return res.status(401).json({ error: 'Invalid authorization header' }); + } + + next(); + } catch (error) { + console.error('Top.gg webhook auth error:', error); + return res.status(500).json({ error: 'Authentication error' }); + } +}; + export default authenticateApiKey; diff --git a/src/routes/voteWebhook.ts b/src/routes/voteWebhook.ts new file mode 100644 index 0000000..502b81a --- /dev/null +++ b/src/routes/voteWebhook.ts @@ -0,0 +1,40 @@ +import { Router } from 'express'; +import { recordVote } from '../utils/voteManager'; +import logger from '../utils/logger'; +import { authenticateTopGG } from '../middlewares/verifyApiKey'; + +const router = Router(); + +router.all('/webhooks/topgg', authenticateTopGG, async (req, res) => { + if (req.method === 'GET') { + return res.status(200).json({ success: true, message: 'Webhook is active' }); + } + try { + const { user, type } = req.body; + + if (type === 'upvote') { + const userId = user; + + const result = await recordVote(userId); + + if (result.success) { + logger.info( + `Processed vote from user ${userId}. Credits awarded: ${result.creditsAwarded}`, + ); + return res.status(200).json({ success: true, message: 'Vote processed successfully' }); + } + return res.status(200).json({ + success: false, + message: 'Vote already processed', + nextVote: result.nextVoteAvailable, + }); + } + + return res.status(400).json({ success: false, message: 'Invalid vote type' }); + } catch (error) { + logger.error('Error processing vote webhook:', error); + return res.status(500).json({ success: false, message: 'Internal server error' }); + } +}); + +export default router; diff --git a/src/types/base.ts b/src/types/base.ts index 5676790..c23c502 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -1,9 +1,9 @@ export interface RandomReddit { response_time_ms: number; - source: 'reddit'; + source: string; subreddit: string; title: string; - upvotes: 71; + upvotes: number; url: string; } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 72f26b2..80d2f4d 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -58,9 +58,17 @@ const logger = winston.createLogger({ new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), - winston.format.printf(({ timestamp, level, message, service, environment, version }) => { - return `${timestamp} [${service}] [${environment}] [${version}] ${level}: ${message}`; - }), + winston.format.printf( + ({ timestamp, level, message, service, environment, version, ...metadata }) => { + let logMessage = `${timestamp} [${service}] [${environment}] [${version}] ${level}: ${message}`; + + if (Object.keys(metadata).length > 0) { + logMessage += ` ${JSON.stringify(metadata)}`; + } + + return logMessage; + }, + ), ), }), ], diff --git a/src/utils/messageToolExecutor.ts b/src/utils/messageToolExecutor.ts index c8caf19..def439b 100644 --- a/src/utils/messageToolExecutor.ts +++ b/src/utils/messageToolExecutor.ts @@ -98,21 +98,20 @@ export async function executeMessageToolCall( } try { - await targetMsg.react(emoji); + await targetMsg.react(emoji).catch(() => { + return targetMsg.react(emojiRaw); + }); + return { success: true, type: 'reaction', handled: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug('[MessageToolExecutor] Failed to add reaction (silent):', { + emoji: emojiRaw, + error: errorMessage, + }); return { success: true, type: 'reaction', handled: true }; - } catch (_err) { - try { - await targetMsg.react(emojiRaw); - return { success: true, type: 'reaction', handled: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error('[MessageToolExecutor] Failed to add reaction:', { - emoji: emojiRaw, - error: errorMessage, - }); - return { success: false, type: 'reaction', handled: false, error: errorMessage }; - } } + } else if (name === 'newmessage') { + return { success: true, type: 'newmessage', handled: true }; } return { success: false, type: name || 'unknown', handled: false, error: 'Unknown tool' }; diff --git a/src/utils/topgg.ts b/src/utils/topgg.ts new file mode 100644 index 0000000..2f79ad1 --- /dev/null +++ b/src/utils/topgg.ts @@ -0,0 +1,45 @@ +import fetch from 'node-fetch'; + +const TOPGG_API = 'https://top.gg/api/'; + +interface TopGGVote { + voted: number; + voteCount: number; + voteReset: number; +} + +export async function checkVoteStatus( + userId: string, +): Promise<{ hasVoted: boolean; nextVote: Date; voteCount: number }> { + if (!process.env.TOPGG_TOKEN) { + throw new Error('Top.gg token is not configured'); + } + + try { + const response = await fetch(`${TOPGG_API}/users/${userId}/vote`, { + headers: { + Authorization: process.env.TOPGG_TOKEN, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Top.gg API error: ${response.statusText}`); + } + + const data = (await response.json()) as TopGGVote; + + return { + hasVoted: data.voted === 1, + nextVote: new Date(data.voteReset * 1000), + voteCount: data.voteCount, + }; + } catch (error) { + console.error('Error checking Top.gg vote status:', error); + throw new Error('Failed to verify vote status. Please try again later.'); + } +} + +export function getVoteLink(): string { + return 'https://top.gg/bot/${process.env.CLIENT_ID}/vote'; +} diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 46d1f37..55a6ac9 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -118,35 +118,42 @@ function isValidDomain(domain: string): boolean { return validator.isFQDN(domain, { require_tld: true }); } -function normalizeInput(text: string): string { - let normalized = text.toLowerCase(); - normalized = normalized.replace(/([a-z])\1{2,}/g, '$1'); - normalized = normalized - .replace(/[@4]/g, 'a') - .replace(/[3]/g, 'e') - .replace(/[1!]/g, 'i') - .replace(/[0]/g, 'o') +function _normalizeText(text: string): string { + if (!text) return ''; + + return text + .toLowerCase() + .replace(/([a-z])\1{2,}/g, '$1') + .replace(/[^\w\s]/g, '') + .replace(/@/g, 'a') + .replace(/4/g, 'a') + .replace(/3/g, 'e') + .replace(/1|!/g, 'i') + .replace(/0/g, 'o') .replace(/[5$]/g, 's') - .replace(/[7]/g, 't'); - return normalized; + .replace(/7/g, 't') + .trim(); } export function getUnallowedWordCategory(text: string): string | null { - const normalized = normalizeInput(text); - for (const [category, words] of Object.entries(UNALLOWED_WORDS)) { - for (const word of words as string[]) { - if (category === 'slurs') { - if (normalized.includes(word)) { - return category; - } - } else { - const pattern = new RegExp(`(?:^|\\W)${word}[a-z]{0,2}(?:\\W|$)`, 'i'); - if (pattern.test(normalized)) { - return category; - } + if (!text || typeof text !== 'string') return null; + + const words = text + .toLowerCase() + .split(/[\s"'.,?!;:]+/) + .map((word) => word.replace(/[^\w\s]/g, '')) + .filter((word) => word.length > 0); + + for (const word of words) { + if (word.length <= 2) continue; + + for (const [category, wordList] of Object.entries(UNALLOWED_WORDS)) { + if ((wordList as string[]).some((badWord) => word.toLowerCase() === badWord.toLowerCase())) { + return category; } } } + return null; } diff --git a/src/utils/voteManager.ts b/src/utils/voteManager.ts new file mode 100644 index 0000000..dccbcbf --- /dev/null +++ b/src/utils/voteManager.ts @@ -0,0 +1,271 @@ +import pool from './pgClient'; +import { Client, GatewayIntentBits } from 'discord.js'; +import { checkVoteStatus } from './topgg'; + +const VOTE_CREDITS = 10; +const VOTE_COOLDOWN_HOURS = 12; + +export interface VoteResult { + success: boolean; + creditsAwarded: number; + nextVoteAvailable: Date; +} + +export interface CreditsInfo { + remaining: number; + lastReset: Date; +} + +export async function hasVotedToday( + userId: string, + serverId?: string, +): Promise<{ hasVoted: boolean; nextVote: Date }> { + try { + const localResult = await pool.query<{ vote_timestamp: Date }>( + `SELECT vote_timestamp FROM votes + WHERE user_id = $1 + AND (server_id = $2 OR ($2 IS NULL AND server_id IS NULL)) + ORDER BY vote_timestamp DESC + LIMIT 1`, + [userId, serverId], + ); + + if (localResult.rows.length > 0) { + const lastVote = new Date(localResult.rows[0].vote_timestamp); + const nextVote = new Date(lastVote.getTime() + VOTE_COOLDOWN_HOURS * 60 * 60 * 1000); + + if (Date.now() < nextVote.getTime()) { + return { hasVoted: true, nextVote }; + } + } + + if (process.env.TOPGG_TOKEN) { + const voteStatus = await checkVoteStatus(userId); + if (voteStatus.hasVoted) { + await recordVoteInDatabase(userId, serverId); + return { hasVoted: true, nextVote: voteStatus.nextVote }; + } + return { hasVoted: false, nextVote: voteStatus.nextVote }; + } + + return { hasVoted: false, nextVote: new Date() }; + } catch (error) { + console.error('Error checking vote status:', error); + const result = await pool.query( + `SELECT 1 FROM votes + WHERE user_id = $1 + AND (server_id = $2 OR ($2 IS NULL AND server_id IS NULL)) + AND vote_timestamp > NOW() - INTERVAL '${VOTE_COOLDOWN_HOURS} hours'`, + [userId, serverId], + ); + return { + hasVoted: result.rows.length > 0, + nextVote: new Date(Date.now() + VOTE_COOLDOWN_HOURS * 60 * 60 * 1000), + }; + } +} + +async function recordVoteInDatabase(userId: string, serverId?: string): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + await client.query( + `INSERT INTO votes (user_id, server_id, credits_awarded) + VALUES ($1, $2, $3)`, + [userId, serverId || null, VOTE_CREDITS], + ); + + const query = serverId + ? `INSERT INTO message_credits (user_id, server_id, credits_remaining, last_reset) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id, server_id) WHERE server_id IS NOT NULL + DO UPDATE SET + credits_remaining = message_credits.credits_remaining + $3, + last_reset = NOW() + RETURNING credits_remaining` + : `INSERT INTO message_credits (user_id, credits_remaining, last_reset) + VALUES ($1, $3, NOW()) + ON CONFLICT (user_id) WHERE server_id IS NULL + DO UPDATE SET + credits_remaining = message_credits.credits_remaining + $3, + last_reset = NOW() + RETURNING credits_remaining`; + + await client.query(query, [userId, serverId, VOTE_CREDITS].filter(Boolean)); + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +export async function recordVote( + userId: string, + serverId?: string, +): Promise<{ success: boolean; creditsAwarded: number; nextVoteAvailable: Date }> { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const existingVote = await client.query( + `SELECT vote_timestamp FROM votes + WHERE user_id = $1 + AND (server_id = $2 OR ($2 IS NULL AND server_id IS NULL)) + AND vote_timestamp >= $3 + ORDER BY vote_timestamp DESC + LIMIT 1`, + [userId, serverId || null, today], + ); + + if (existingVote.rows.length > 0) { + return { + success: false, + creditsAwarded: 0, + nextVoteAvailable: new Date( + existingVote.rows[0].vote_timestamp.getTime() + VOTE_COOLDOWN_HOURS * 60 * 60 * 1000, + ), + }; + } + + const voteStatus = await checkVoteStatus(userId); + if (!voteStatus.hasVoted) { + return { + success: false, + creditsAwarded: 0, + nextVoteAvailable: voteStatus.nextVote, + }; + } + + await client.query( + `INSERT INTO votes (user_id, server_id, credits_awarded) + VALUES ($1, $2, $3)`, + [userId, serverId || null, VOTE_CREDITS], + ); + + await client.query( + `INSERT INTO message_credits (user_id, credits_remaining, last_reset) + VALUES ($1, $2, NOW()) + ON CONFLICT (user_id) WHERE server_id IS NULL + DO UPDATE SET + credits_remaining = message_credits.credits_remaining + $2, + last_reset = NOW() + RETURNING credits_remaining`, + [userId, VOTE_CREDITS], + ); + + const clientBot = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], + }); + + try { + await clientBot.login(process.env.TOKEN); + const user = await clientBot.users.fetch(userId); + + if (user) { + const guilds = await clientBot.guilds.fetch(); + await Promise.all( + guilds.map(async (guild) => { + try { + const fullGuild = await guild.fetch(); + const member = await fullGuild.members.fetch(userId).catch(() => null); + + if (member) { + await client.query( + `INSERT INTO message_credits (user_id, server_id, credits_remaining, last_reset) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id, server_id) WHERE server_id IS NOT NULL + DO UPDATE SET + credits_remaining = message_credits.credits_remaining + $3, + last_reset = NOW() + RETURNING credits_remaining`, + [userId, guild.id, VOTE_CREDITS], + ); + } + } catch (error) { + console.error(`Error processing guild ${guild.id}:`, error); + } + }), + ); + } + } catch (error) { + console.error('Error in vote processing:', error); + } finally { + clientBot.destroy().catch(console.error); + } + + await client.query('COMMIT'); + + return { + success: true, + creditsAwarded: serverId ? VOTE_CREDITS * 2 : VOTE_CREDITS, + nextVoteAvailable: voteStatus.nextVote, + }; + } catch (error) { + await client.query('ROLLBACK'); + console.error('Error recording vote:', error); + throw new Error('Failed to record your vote. Please try again later.'); + } finally { + client.release(); + } +} + +export async function getRemainingCredits(userId: string, serverId?: string): Promise { + try { + const query = serverId + ? 'SELECT credits_remaining as "creditsRemaining", last_reset as "lastReset" FROM message_credits WHERE user_id = $1 AND server_id = $2' + : 'SELECT credits_remaining as "creditsRemaining", last_reset as "lastReset" FROM message_credits WHERE user_id = $1 AND server_id IS NULL'; + + const result = await pool.query(query, [userId, serverId].filter(Boolean)); + + if (result.rows.length === 0) { + return { + remaining: 0, + lastReset: new Date(), + }; + } + + return { + remaining: result.rows[0].creditsRemaining, + lastReset: result.rows[0].lastReset, + }; + } catch (error) { + console.error('Error getting remaining credits:', error); + throw new Error('Failed to get remaining credits'); + } +} + +export async function canUseAIFeature( + userId: string, + serverId?: string, +): Promise<{ canUse: boolean; remainingCredits: number }> { + try { + const credits = await getRemainingCredits(userId, serverId); + if (credits.remaining > 0) { + const updateQuery = serverId + ? 'UPDATE message_credits SET credits_remaining = credits_remaining - 1 WHERE user_id = $1 AND server_id = $2 RETURNING credits_remaining' + : 'UPDATE message_credits SET credits_remaining = credits_remaining - 1 WHERE user_id = $1 AND server_id IS NULL RETURNING credits_remaining'; + + const result = await pool.query(updateQuery, [userId, serverId].filter(Boolean)); + + return { + canUse: true, + remainingCredits: result.rows[0]?.credits_remaining || 0, + }; + } + + return { + canUse: false, + remainingCredits: 0, + }; + } catch (error) { + console.error('Error checking AI feature usage:', error); + throw new Error('Failed to check AI feature usage'); + } +}