From 2c25e31dbf0cd063f35d89daefa159dfd9de0f30 Mon Sep 17 00:00:00 2001 From: its-a-feature Date: Thu, 21 May 2026 22:26:17 -0700 Subject: [PATCH] initial chat feature --- MythicReactUI/package-lock.json | 1464 ++++++++++++++- MythicReactUI/package.json | 2 + MythicReactUI/src/cache.js | 32 +- MythicReactUI/src/components/App.js | 17 +- .../src/components/TopAppBarVertical.js | 156 +- .../src/components/pages/Chat/Chat.js | 1584 +++++++++++++++++ .../ConsumingServicesTable.js | 24 + .../PayloadTypesC2Profiles.js | 20 +- .../SettingsOperatorUIConfigDialog.js | 57 +- MythicReactUI/src/themes/GlobalStyles.js | 494 ++++- hasura-docker/metadata/actions.graphql | 99 ++ hasura-docker/metadata/actions.yaml | 101 ++ .../default/tables/public_chat_channel.yaml | 194 ++ .../default/tables/public_chat_message.yaml | 197 ++ .../tables/public_chat_read_state.yaml | 125 ++ .../default/tables/public_chat_request.yaml | 130 ++ .../databases/default/tables/tables.yaml | 4 + .../src/authentication/mythicjwt/scopes.go | 10 + .../authentication/mythicjwt/scopes_test.go | 22 + .../database/migrations/003003020_3.3.20.sql | 228 +++ mythic-docker/src/database/structs/Chat.go | 111 ++ .../src/rabbitmq/check_container_status.go | 2 + mythic-docker/src/rabbitmq/constants.go | 4 + mythic-docker/src/rabbitmq/initialize.go | 2 + .../src/rabbitmq/recv_chat_response.go | 304 ++++ .../rabbitmq/recv_consuming_container_sync.go | 1 + .../src/rabbitmq/send_chat_request.go | 51 + .../src/rabbitmq/utils_rabbitmq_routing.go | 11 +- .../src/webserver/controllers/chat_webhook.go | 1177 ++++++++++++ .../src/webserver/controllers/login.go | 8 + mythic-docker/src/webserver/initialize.go | 19 + 31 files changed, 6616 insertions(+), 34 deletions(-) create mode 100644 MythicReactUI/src/components/pages/Chat/Chat.js create mode 100644 hasura-docker/metadata/databases/default/tables/public_chat_channel.yaml create mode 100644 hasura-docker/metadata/databases/default/tables/public_chat_message.yaml create mode 100644 hasura-docker/metadata/databases/default/tables/public_chat_read_state.yaml create mode 100644 hasura-docker/metadata/databases/default/tables/public_chat_request.yaml create mode 100644 mythic-docker/src/database/structs/Chat.go create mode 100644 mythic-docker/src/rabbitmq/recv_chat_response.go create mode 100644 mythic-docker/src/rabbitmq/send_chat_request.go create mode 100644 mythic-docker/src/webserver/controllers/chat_webhook.go diff --git a/MythicReactUI/package-lock.json b/MythicReactUI/package-lock.json index 69cb58ef..0e64b11f 100644 --- a/MythicReactUI/package-lock.json +++ b/MythicReactUI/package-lock.json @@ -45,6 +45,7 @@ "react-colorful": "^5.6.1", "react-dom": "^18.3.1", "react-draggable": "^4.4.6", + "react-markdown": "^10.1.0", "react-moment": "^1.1.3", "react-router-dom": "^7.13.1", "react-scrollbar-size": "^5.0.0", @@ -54,6 +55,7 @@ "react-virtualized": "^9.22.6", "react-virtualized-auto-sizer": "^1.0.26", "react-window": "^1.8.11", + "remark-gfm": "^4.0.1", "save-svg-as-png": "^1.4.17", "semver": "^7.6.2", "sql.js": "^1.13.0", @@ -4653,6 +4655,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -4679,6 +4690,15 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -4728,6 +4748,15 @@ "@types/node": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -4792,6 +4821,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4799,6 +4837,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.14.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", @@ -4964,6 +5008,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -5243,7 +5293,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, "license": "ISC" }, "node_modules/@webassemblyjs/ast": { @@ -6438,6 +6487,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -7035,6 +7094,16 @@ "node": ">=4" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7062,6 +7131,46 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-types": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", @@ -7368,6 +7477,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -8421,6 +8540,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -8533,6 +8665,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -8607,6 +8748,19 @@ "dev": true, "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -9900,6 +10054,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", @@ -10093,6 +10257,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -11162,6 +11332,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -11327,6 +11537,16 @@ "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-webpack-plugin": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz", @@ -11664,6 +11884,12 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -11707,6 +11933,30 @@ "node": ">= 10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -11860,6 +12110,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -11954,6 +12214,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -13799,6 +14069,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -13877,6 +14157,16 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -13899,6 +14189,288 @@ "safe-buffer": "^5.1.2" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", @@ -13971,6 +14543,569 @@ "node": ">= 0.6" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -14709,6 +15844,31 @@ "node": ">= 0.10" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -16498,6 +17658,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -16969,6 +18139,33 @@ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-moment": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/react-moment/-/react-moment-1.1.3.tgz", @@ -17479,6 +18676,72 @@ "node": ">= 0.10" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -18598,6 +19861,16 @@ "dev": true, "license": "MIT" }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spdy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", @@ -18907,6 +20180,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -19009,6 +20296,24 @@ "webpack": "^5.0.0" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-components": { "version": "6.1.17", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.17.tgz", @@ -19774,6 +21079,26 @@ "node": ">=8" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", @@ -20095,6 +21420,37 @@ "node": ">=4" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unified/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -20108,6 +21464,74 @@ "node": ">=8" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -20312,6 +21736,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -21331,6 +22783,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/MythicReactUI/package.json b/MythicReactUI/package.json index ab5ea4e1..232e8d50 100644 --- a/MythicReactUI/package.json +++ b/MythicReactUI/package.json @@ -50,6 +50,7 @@ "react-colorful": "^5.6.1", "react-dom": "^18.3.1", "react-draggable": "^4.4.6", + "react-markdown": "^10.1.0", "react-moment": "^1.1.3", "react-router-dom": "^7.13.1", "react-scrollbar-size": "^5.0.0", @@ -59,6 +60,7 @@ "react-virtualized": "^9.22.6", "react-virtualized-auto-sizer": "^1.0.26", "react-window": "^1.8.11", + "remark-gfm": "^4.0.1", "save-svg-as-png": "^1.4.17", "semver": "^7.6.2", "sql.js": "^1.13.0", diff --git a/MythicReactUI/src/cache.js b/MythicReactUI/src/cache.js index 0385b82e..e608f173 100644 --- a/MythicReactUI/src/cache.js +++ b/MythicReactUI/src/cache.js @@ -77,7 +77,7 @@ export const taskingContextFieldsOptions = ["impersonation_context", "cwd", "use export const defaultShortcuts = [ "ActiveCallbacks", "Payloads", "PayloadTypesAndC2", "Operations", "SearchFiles", "SearchProxies", - "CreatePayload", "Eventing", + "CreatePayload", "Eventing", "Chat", ].sort(); export const operatorSettingDefaults = { fontSize: 13, @@ -154,6 +154,30 @@ export const operatorSettingDefaults = { dark: "#e5e7eb", light: "#111827", }, + chatMessageOperatorBackground: { + dark: "#18191c", + light: "#ffffff", + }, + chatMessageSelfBackground: { + dark: "#202123", + light: "#f2f3f2", + }, + chatMessageAIBackground: { + dark: "#232220", + light: "#f3f2ef", + }, + chatMessageSystemBackground: { + dark: "#252320", + light: "#f7f3eb", + }, + chatMarkdownSurfaceBackground: { + dark: "#17181a", + light: "#f3f4f5", + }, + chatMarkdownSurfaceStrongBackground: { + dark: "#2b2c2f", + light: "#e8e9eb", + }, sectionHeaderAccent: { dark: "#8ab4f8", light: "#2563eb", @@ -182,10 +206,6 @@ export const operatorSettingDefaults = { dark: "#394c5d", light: "#d3d7e8", }, - speedDialAction: { - dark: "#495054", - light: "#ffffff", - }, chartSeries1: { dark: "#09bdff", light: "#09bdff", @@ -338,7 +358,7 @@ export const successfulRefresh = (data) => { let now = new Date(); let serverNow = new Date(data.user.current_utc_time); const difference = (serverNow.getTime() - now.getTime()) ; - let me = {...meState().user}; + let me = {...meState().user, ...(data.user || {})}; me.server_skew = difference; me.login_time = now; meState({ diff --git a/MythicReactUI/src/components/App.js b/MythicReactUI/src/components/App.js index fbfca27b..22808f41 100644 --- a/MythicReactUI/src/components/App.js +++ b/MythicReactUI/src/components/App.js @@ -46,6 +46,7 @@ const Reporting = lazyNamed(() => import('./pages/Reporting/Reporting'), 'Report const MitreAttack = lazyNamed(() => import('./pages/MITRE_ATTACK/MitreAttack'), 'MitreAttack'); const Tags = lazyNamed(() => import('./pages/Tags/Tags'), 'Tags'); const Eventing = lazyNamed(() => import('./pages/Eventing/Eventing'), 'Eventing'); +const Chat = lazyNamed(() => import('./pages/Chat/Chat'), 'Chat'); const Jupyter = lazyNamed(() => import('./pages/Jupyter/Jupyter'), 'Jupyter'); const Hasura = lazyNamed(() => import('./pages/Hasura/Hasura'), 'Hasura'); @@ -163,6 +164,16 @@ const getModernThemeAdditions = (themeMode, preferences = operatorSettingDefault hover: tableRowHoverColor, selected: tableSelectedColor, }, + chat: { + message: { + operatorBackground: getColor("chatMessageOperatorBackground"), + selfBackground: getColor("chatMessageSelfBackground"), + aiBackground: getColor("chatMessageAIBackground"), + systemBackground: getColor("chatMessageSystemBackground"), + markdownSurface: getColor("chatMarkdownSurfaceBackground"), + markdownSurfaceStrong: getColor("chatMarkdownSurfaceStrongBackground"), + }, + }, table: { header: tableHeaderColor, headerHover: tableRowHoverColor, @@ -709,8 +720,6 @@ export function App(props) { preferences?.palette?.borderColor?.light || operatorSettingDefaults.palette.borderColor.light, graphGroupRGBA: themeMode === 'dark' ? `${preferences?.palette?.graphGroupColor?.dark || operatorSettingDefaults.palette.graphGroupColor.dark}80` : `${preferences?.palette?.graphGroupColor?.light || operatorSettingDefaults.palette.graphGroupColor.light}80`, - speedDialAction: themeMode === 'dark' ? preferences?.palette?.speedDialAction?.dark || operatorSettingDefaults.palette.speedDialAction.dark : - preferences?.palette?.speedDialAction?.light || operatorSettingDefaults.palette.speedDialAction.light, }, folderColor: themeMode === 'dark' ? preferences?.palette?.folderColor?.dark || operatorSettingDefaults.palette.folderColor.dark : preferences?.palette?.folderColor?.light || operatorSettingDefaults.palette.folderColor.light, @@ -829,8 +838,6 @@ export function App(props) { operatorSettingDefaults.palette.borderColor.light, graphGroupRGBA: themeMode === 'dark' ? `${operatorSettingDefaults.palette.graphGroupColor.dark}80` : `${operatorSettingDefaults.palette.graphGroupColor.light}80`, - speedDialAction: themeMode === 'dark' ? operatorSettingDefaults.palette.speedDialAction.dark : - operatorSettingDefaults.palette.speedDialAction.light, }, folderColor: themeMode === 'dark' ? operatorSettingDefaults.palette.folderColor.dark : operatorSettingDefaults.palette.folderColor.light, @@ -1071,6 +1078,8 @@ export function App(props) { element={}/> }/> + }/> }/> }/> diff --git a/MythicReactUI/src/components/TopAppBarVertical.js b/MythicReactUI/src/components/TopAppBarVertical.js index 0e625a5e..7518ae96 100644 --- a/MythicReactUI/src/components/TopAppBarVertical.js +++ b/MythicReactUI/src/components/TopAppBarVertical.js @@ -10,6 +10,7 @@ import ManageAccountsTwoToneIcon from '@mui/icons-material/ManageAccountsTwoTone import { Link } from 'react-router-dom'; import List from '@mui/material/List'; import Divider from '@mui/material/Divider'; +import Badge from '@mui/material/Badge'; import SportsScoreIcon from '@mui/icons-material/SportsScore'; import ListItem from '@mui/material/ListItem'; import ListItemIcon from '@mui/material/ListItemIcon'; @@ -47,8 +48,9 @@ import LocalOfferTwoToneIcon from '@mui/icons-material/LocalOfferTwoTone'; import LightModeTwoToneIcon from '@mui/icons-material/LightModeTwoTone'; import DarkModeTwoToneIcon from '@mui/icons-material/DarkModeTwoTone'; import PlayCircleFilledTwoToneIcon from '@mui/icons-material/PlayCircleFilledTwoTone'; +import ForumTwoToneIcon from '@mui/icons-material/ForumTwoTone'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import {useQuery, gql} from '@apollo/client'; +import {useQuery, useSubscription, gql} from '@apollo/client'; import ConfirmationNumberIcon from '@mui/icons-material/ConfirmationNumber'; import AccountTreeIcon from '@mui/icons-material/AccountTree'; import AssignmentIcon from '@mui/icons-material/Assignment'; @@ -74,6 +76,7 @@ import {reorder} from "./MythicComponents/MythicDraggableList"; import { useNavigate } from 'react-router-dom'; import LogoutIcon from '@mui/icons-material/Logout'; import TuneIcon from '@mui/icons-material/Tune'; +import {getSkewedNow} from "./utilities/Time"; import { MythicDialogBody, MythicDialogButton, @@ -241,6 +244,79 @@ query getGlobalSettings { } } `; + +const CHAT_UNREAD_STATUS_QUERY = gql` +query ChatUnreadStatus { + chat_channel(where: {last_message_id: {_is_null: false}}) { + id + archived + last_message_id + updated_at + } + chat_read_state { + channel_id + last_read_message_id + updated_at + } +} +`; + +const CHAT_UNREAD_CHANNELS_STREAM_SUBSCRIPTION = gql` +subscription ChatUnreadChannelsStream($now: timestamp!) { + chat_channel_stream(batch_size: 50, cursor: {initial_value: {updated_at: $now}, ordering: ASC}, where: {last_message_id: {_is_null: false}}) { + id + archived + last_message_id + updated_at + } +} +`; + +const CHAT_UNREAD_READ_STATE_STREAM_SUBSCRIPTION = gql` +subscription ChatUnreadReadStateStream($now: timestamp!) { + chat_read_state_stream(batch_size: 50, cursor: {initial_value: {updated_at: $now}, ordering: ASC}) { + channel_id + last_read_message_id + updated_at + } +} +`; + +const unreadTimestampValue = (timestamp) => { + if(!timestamp){ return 0; } + const value = new Date(timestamp).getTime(); + return Number.isNaN(value) ? 0 : value; +}; + +const mergeUnreadChannels = (current, incoming) => { + if(!incoming || incoming.length === 0){ return current; } + const rowsByID = new Map((current || []).map((channel) => [channel.id, channel])); + incoming.forEach((channel) => { + const existing = rowsByID.get(channel.id); + if(!existing || unreadTimestampValue(channel.updated_at) >= unreadTimestampValue(existing.updated_at)){ + rowsByID.set(channel.id, {...existing, ...channel}); + } + }); + return [...rowsByID.values()]; +}; + +const mergeUnreadReadStates = (current, incoming) => { + if(!incoming || incoming.length === 0){ return current; } + return incoming.reduce((prev, readState) => ({ + ...prev, + [readState.channel_id]: Math.max(prev[readState.channel_id] || 0, readState.last_read_message_id || 0), + }), current); +}; + +const getUnreadChatCount = (channels, readState) => { + return (channels || []).reduce((count, channel) => { + if(channel.archived){ return count; } + const latestMessageID = channel.last_message_id || 0; + const lastReadMessageID = readState[channel.id] || 0; + return latestMessageID > lastReadMessageID ? count + 1 : count; + }, 0); +}; + const Dashboard = () => { const theme = useTheme(); return ( @@ -492,6 +568,80 @@ const Eventing = () => { ) } +const Chat = ({me}) => { + const theme = useTheme(); + const streamStart = React.useRef(getSkewedNow().toISOString()); + const [channels, setChannels] = React.useState([]); + const [readState, setReadState] = React.useState({}); + const {data: initialData, error: initialError} = useQuery(CHAT_UNREAD_STATUS_QUERY, { + skip: !me?.user?.current_operation_id, + fetchPolicy: "no-cache", + }); + React.useEffect(() => { + streamStart.current = getSkewedNow().toISOString(); + setChannels([]); + setReadState({}); + }, [me?.user?.current_operation_id]); + React.useEffect(() => { + if(initialData?.chat_channel){ + setChannels((prev) => mergeUnreadChannels(prev, initialData.chat_channel)); + } + if(initialData?.chat_read_state){ + setReadState((prev) => mergeUnreadReadStates(prev, initialData.chat_read_state)); + } + }, [initialData]); + const {error: channelStreamError} = useSubscription(CHAT_UNREAD_CHANNELS_STREAM_SUBSCRIPTION, { + variables: {now: streamStart.current}, + skip: !me?.user?.current_operation_id, + fetchPolicy: "no-cache", + onData: ({data}) => { + const updates = data.data?.chat_channel_stream || []; + if(updates.length > 0){ + setChannels((prev) => mergeUnreadChannels(prev, updates)); + } + }, + onError: (errorData) => { + console.log("chat unread channel stream error"); + console.error(errorData); + }, + }); + const {error: readStateStreamError} = useSubscription(CHAT_UNREAD_READ_STATE_STREAM_SUBSCRIPTION, { + variables: {now: streamStart.current}, + skip: !me?.user?.current_operation_id, + fetchPolicy: "no-cache", + onData: ({data}) => { + const updates = data.data?.chat_read_state_stream || []; + if(updates.length > 0){ + setReadState((prev) => mergeUnreadReadStates(prev, updates)); + } + }, + onError: (errorData) => { + console.log("chat unread status error"); + console.error(errorData); + }, + }); + const error = initialError || channelStreamError || readStateStreamError; + const unreadCount = React.useMemo(() => getUnreadChatCount(channels, readState), [channels, readState]); + const tooltipTitle = error ? "Operation Chat unread status unavailable" : + unreadCount > 0 ? `Operation Chat (${unreadCount} unread ${unreadCount === 1 ? "chat" : "chats"})` : "Operation Chat"; + return ( + + + + + + + + + + + ) +} const JupyterNotebook = () => { return ( @@ -586,7 +736,7 @@ const BrowserScripts = () => { const AllSettingOptions = [ "Dashboard", "ActiveCallbacks", "Payloads", "SearchCallbacks", "SearchTasks", "SearchPayloads", "SearchFiles", "SearchScreenshots", "SearchCredentials", "SearchKeylogs", "SearchArtifacts", "SearchTokens", "SearchProxies", - "SearchProcesses", "SearchTags", "Mitre", "Reporting", "Tags", "Eventing", "JupyterNotebook", + "SearchProcesses", "SearchTags", "Mitre", "Reporting", "Tags", "Eventing", "Chat", "JupyterNotebook", "GraphQL", "CreatePayload", "CreateWrapper", "PayloadTypesAndC2", "Operations", "BrowserScripts" ].sort(); @@ -789,6 +939,8 @@ export function TopAppBarVertical(props) { return case "Eventing": return + case "Chat": + return case "JupyterNotebook": return case "GraphQL": diff --git a/MythicReactUI/src/components/pages/Chat/Chat.js b/MythicReactUI/src/components/pages/Chat/Chat.js new file mode 100644 index 00000000..3d56edaa --- /dev/null +++ b/MythicReactUI/src/components/pages/Chat/Chat.js @@ -0,0 +1,1584 @@ +import React from 'react'; +import {gql, useLazyQuery, useMutation, useQuery, useSubscription} from '@apollo/client'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import {alpha, useTheme} from '@mui/material/styles'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Divider from '@mui/material/Divider'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import IconButton from '@mui/material/IconButton'; +import InputLabel from '@mui/material/InputLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import Switch from '@mui/material/Switch'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import AddIcon from '@mui/icons-material/Add'; +import ArchiveIcon from '@mui/icons-material/Archive'; +import CampaignTwoToneIcon from '@mui/icons-material/CampaignTwoTone'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import ForumTwoToneIcon from '@mui/icons-material/ForumTwoTone'; +import LockIcon from '@mui/icons-material/Lock'; +import LockOpenIcon from '@mui/icons-material/LockOpen'; +import RestartAltIcon from '@mui/icons-material/RestartAlt'; +import SearchIcon from '@mui/icons-material/Search'; +import SendIcon from '@mui/icons-material/Send'; +import SmartToyTwoToneIcon from '@mui/icons-material/SmartToyTwoTone'; +import StopCircleIcon from '@mui/icons-material/StopCircle'; +import UnarchiveIcon from '@mui/icons-material/Unarchive'; +import {MythicPageBody} from "../../MythicComponents/MythicPageBody"; +import {MythicPageHeader, MythicPageHeaderChip} from "../../MythicComponents/MythicPageHeader"; +import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip"; +import {MeContext} from "../../App"; +import {snackActions} from "../../utilities/Snackbar"; +import {getSkewedNow, toLocalTime} from "../../utilities/Time"; + +const CHAT_MESSAGE_LIMIT = 250; +const CHAT_REQUEST_LIMIT = 50; + +const CHAT_CHANNEL_FIELDS = gql` +fragment ChatChannelFields on chat_channel { + id + name + slug + description + channel_type + archived + locked + locked_by + last_message_id + chat_container_id + chat_model + updated_at + chat_container { + id + name + container_running + deleted + } + locked_operator { + username + } +} +`; + +const CHAT_CONTAINER_FIELDS = gql` +fragment ChatContainerFields on consuming_container { + id + name + description + container_running + deleted + subscriptions + updated_at +} +`; + +const CHAT_MESSAGE_FIELDS = gql` +fragment ChatMessageFields on chat_message { + id + channel_id + operator_id + author_type + sender_display_name + message + edited + deleted + status + created_at + updated_at + operator { + username + } + chat_container { + name + } +} +`; + +const CHAT_REQUEST_FIELDS = gql` +fragment ChatRequestFields on chat_request { + id + channel_id + request_message_id + response_message_id + status + error + created_by + updated_at +} +`; + +const CHAT_CHANNELS_QUERY = gql` +${CHAT_CHANNEL_FIELDS} +query ChatChannels { + chat_channel(order_by: [{archived: asc}, {channel_type: asc}, {name: asc}]) { + ...ChatChannelFields + } +} +`; + +const CHAT_CHANNELS_STREAM_SUBSCRIPTION = gql` +${CHAT_CHANNEL_FIELDS} +subscription ChatChannelsStream($now: timestamp!) { + chat_channel_stream(batch_size: 50, cursor: {initial_value: {updated_at: $now}, ordering: ASC}) { + ...ChatChannelFields + } +} +`; + +const CHAT_READ_STATE_QUERY = gql` +query ChatReadState { + chat_read_state { + channel_id + last_read_message_id + updated_at + } +} +`; + +const CHAT_READ_STATE_STREAM_SUBSCRIPTION = gql` +subscription ChatReadStateStream($now: timestamp!) { + chat_read_state_stream(batch_size: 50, cursor: {initial_value: {updated_at: $now}, ordering: ASC}) { + channel_id + last_read_message_id + updated_at + } +} +`; + +const CHAT_CONTAINERS_QUERY = gql` +${CHAT_CONTAINER_FIELDS} +query ChatContainers { + consuming_container(where: {type: {_eq: "chat"}}, order_by: {name: asc}) { + ...ChatContainerFields + } +} +`; + +const CHAT_CONTAINERS_STREAM_SUBSCRIPTION = gql` +${CHAT_CONTAINER_FIELDS} +subscription ChatContainersStream($now: timestamptz!) { + consuming_container_stream(batch_size: 50, cursor: {initial_value: {updated_at: $now}, ordering: ASC}, where: {type: {_eq: "chat"}}) { + ...ChatContainerFields + } +} +`; + +const CHAT_CURRENT_OPERATOR_QUERY = gql` +query ChatCurrentOperator($operator_id: Int!, $operation_id: Int!) { + operator_by_pk(id: $operator_id) { + id + admin + username + } + operatoroperation(where: {operator_id: {_eq: $operator_id}, operation_id: {_eq: $operation_id}}, limit: 1) { + id + view_mode + } +} +`; + +const CHAT_MESSAGES_QUERY = gql` +${CHAT_MESSAGE_FIELDS} +query ChatMessages($channel_id: Int!, $limit: Int!) { + chat_message(where: {channel_id: {_eq: $channel_id}}, order_by: {id: desc}, limit: $limit) { + ...ChatMessageFields + } +} +`; + +const CHAT_MESSAGES_STREAM_SUBSCRIPTION = gql` +${CHAT_MESSAGE_FIELDS} +subscription ChatMessagesStream($channel_id: Int!, $now: timestamp!) { + chat_message_stream(batch_size: 50, cursor: {initial_value: {updated_at: $now}, ordering: ASC}, where: {channel_id: {_eq: $channel_id}}) { + ...ChatMessageFields + } +} +`; + +const CHAT_REQUESTS_QUERY = gql` +${CHAT_REQUEST_FIELDS} +query ChatRequests($channel_id: Int!, $limit: Int!) { + chat_request(where: {channel_id: {_eq: $channel_id}}, order_by: {id: desc}, limit: $limit) { + ...ChatRequestFields + } +} +`; + +const CHAT_REQUESTS_STREAM_SUBSCRIPTION = gql` +${CHAT_REQUEST_FIELDS} +subscription ChatRequestsStream($channel_id: Int!, $now: timestamp!) { + chat_request_stream(batch_size: 25, cursor: {initial_value: {updated_at: $now}, ordering: ASC}, where: {channel_id: {_eq: $channel_id}}) { + ...ChatRequestFields + } +} +`; + +const CREATE_CHANNEL = gql` +mutation CreateChatChannel($name: String!, $description: String, $channel_type: String, $chat_container_id: Int, $chat_model: String, $locked: Boolean, $ai_metadata: jsonb) { + chatCreateChannel(name: $name, description: $description, channel_type: $channel_type, chat_container_id: $chat_container_id, chat_model: $chat_model, locked: $locked, ai_metadata: $ai_metadata) { + status + error + id + channel_id + } +} +`; + +const UPDATE_CHANNEL = gql` +mutation UpdateChatChannel($channel_id: Int!, $name: String, $description: String, $archived: Boolean, $locked: Boolean, $chat_model: String, $ai_metadata: jsonb) { + chatUpdateChannel(channel_id: $channel_id, name: $name, description: $description, archived: $archived, locked: $locked, chat_model: $chat_model, ai_metadata: $ai_metadata) { + status + error + channel_id + } +} +`; + +const CREATE_MESSAGE = gql` +mutation CreateChatMessage($channel_id: Int!, $message: String!, $system_message: Boolean = false, $all_operations: Boolean = false) { + chatCreateMessage(channel_id: $channel_id, message: $message, system_message: $system_message, all_operations: $all_operations) { + status + error + message_id + request_id + response_message_id + } +} +`; + +const EDIT_MESSAGE = gql` +mutation EditChatMessage($message_id: Int!, $message: String!) { + chatEditMessage(message_id: $message_id, message: $message) { + status + error + message_id + } +} +`; + +const DELETE_MESSAGE = gql` +mutation DeleteChatMessage($message_id: Int!) { + chatDeleteMessage(message_id: $message_id) { + status + error + message_id + } +} +`; + +const CANCEL_REQUEST = gql` +mutation CancelChatRequest($request_id: Int!) { + chatCancelRequest(request_id: $request_id) { + status + error + request_id + } +} +`; + +const RETRY_REQUEST = gql` +mutation RetryChatRequest($request_id: Int!) { + chatRetryRequest(request_id: $request_id) { + status + error + request_id + message_id + response_message_id + } +} +`; + +const MARK_READ = gql` +mutation MarkChatRead($channel_id: Int!, $last_read_message_id: Int) { + chatMarkRead(channel_id: $channel_id, last_read_message_id: $last_read_message_id) { + status + error + } +} +`; + +const CHAT_SEARCH = gql` +query ChatSearch($query: String!, $channel_id: Int, $limit: Int, $offset: Int) { + chatSearch(query: $query, channel_id: $channel_id, limit: $limit, offset: $offset) { + status + error + results { + id + channel_id + channel_name + channel_slug + channel_type + author_type + sender_display_name + message + edited + status + created_at + rank + } + } +} +`; + +const allowedLinkSchemes = ["http:", "https:", "mailto:"]; + +const isGeneralChatChannel = (channel) => channel?.channel_type === "standard" && channel?.slug === "general"; + +const formatTimestamp = (timestamp, viewUTCTime) => { + if(!timestamp){ return ""; } + return toLocalTime(timestamp, viewUTCTime); +}; + +const timestampValue = (timestamp) => { + if(!timestamp){ return 0; } + const value = new Date(timestamp).getTime(); + return Number.isNaN(value) ? 0 : value; +}; + +const newerOrEqual = (incoming, existing) => { + if(!existing){ return true; } + const incomingUpdatedAt = timestampValue(incoming.updated_at); + const existingUpdatedAt = timestampValue(existing.updated_at); + return incomingUpdatedAt === 0 || existingUpdatedAt === 0 || incomingUpdatedAt >= existingUpdatedAt; +}; + +const shouldKeepExistingStreamingMessage = (incoming, existing) => { + if(!incoming || !existing || incoming.id !== existing.id){ + return false; + } + const incomingUpdatedAt = timestampValue(incoming.updated_at); + const existingUpdatedAt = timestampValue(existing.updated_at); + if(incomingUpdatedAt === 0 || incomingUpdatedAt !== existingUpdatedAt){ + return false; + } + if(incoming.deleted || incoming.edited || existing.deleted || existing.edited){ + return false; + } + if(existing.author_type !== "ai" || !["pending", "streaming"].includes(existing.status)){ + return false; + } + return (incoming.message || "").length < (existing.message || "").length; +}; + +const sortChannels = (a, b) => { + if(a.archived !== b.archived){ return a.archived ? 1 : -1; } + if(a.channel_type !== b.channel_type){ return a.channel_type.localeCompare(b.channel_type); } + return (a.name || "").localeCompare(b.name || ""); +}; + +const sortContainers = (a, b) => (a.name || "").localeCompare(b.name || ""); +const sortByID = (a, b) => a.id - b.id; + +const mergeRowsByID = (current, incoming, sortFunction, limit) => { + if(!incoming || incoming.length === 0){ + return current; + } + const rowsByID = new Map((current || []).map((row) => [row.id, row])); + incoming.forEach((row) => { + const existing = rowsByID.get(row.id); + if(newerOrEqual(row, existing)){ + const mergedRow = {...existing, ...row}; + if(shouldKeepExistingStreamingMessage(row, existing)){ + mergedRow.message = existing.message; + } + rowsByID.set(row.id, mergedRow); + } + }); + const merged = [...rowsByID.values()].sort(sortFunction); + return limit ? merged.slice(-limit) : merged; +}; + +const mergeReadStateRows = (current, incoming) => { + if(!incoming || incoming.length === 0){ + return current; + } + return incoming.reduce((prev, currentState) => { + const existing = prev[currentState.channel_id] || 0; + return { + ...prev, + [currentState.channel_id]: Math.max(existing, currentState.last_read_message_id || 0), + }; + }, current); +}; + +const markdownPlugins = [remarkGfm]; +const markdownTableAlignments = ["left", "right", "center"]; +const getMarkdownTableAlign = (align) => markdownTableAlignments.includes(align) ? align : "left"; + +const MarkdownHeading = ({level, children}) => ( + + {children} + +); + +const markdownComponents = { + p: ({children}) => {children}, + h1: ({children}) => {children}, + h2: ({children}) => {children}, + h3: ({children}) => {children}, + h4: ({children}) => {children}, + h5: ({children}) => {children}, + h6: ({children}) => {children}, + ul: ({children}) =>
    {children}
, + ol: ({children}) =>
    {children}
, + blockquote: ({children}) => {children}, + hr: () =>
, + table: ({children}) => ( + + {children}
+
+ ), + thead: ({children}) => {children}, + tbody: ({children}) => {children}, + tr: ({children}) => {children}, + th: ({children, align}) => {children}, + td: ({children, align}) => {children}, + pre: ({children}) => { + let language = ""; + React.Children.forEach(children, (child) => { + const className = child?.props?.className || ""; + const match = className.match(/language-([^ ]+)/); + if(match){ + language = match[1]; + } + }); + return ( + + {language && {language}} +
{children}
+
+ ); + }, + code: ({className, children}) => ( + {children} + ), + a: ({href, children}) => { + if(!href){ + return children; + } + try{ + const url = new URL(href, window.location.origin); + if(!allowedLinkSchemes.includes(url.protocol)){ + return children; + } + }catch(error){ + return children; + } + return {children}; + }, +}; + +const MarkdownMessage = ({message}) => { + if(!message){ + return null; + } + return ( + + + {message} + + + ); +}; + +const channelDisplayName = (channel) => `# ${channel?.name || ""}`; + +const parseChatContainerModels = (container) => { + if(!container || !Array.isArray(container.subscriptions)){ + return []; + } + return container.subscriptions.map((subscription) => { + let model = subscription; + if(typeof subscription === "string"){ + try{ + model = JSON.parse(subscription); + } catch(error){ + model = {name: subscription, description: ""}; + } + } + if(typeof model !== "object" || model === null){ + return {name: `${model}`, description: ""}; + } + const modelName = model.name || model.Name || ""; + return { + name: modelName === "" ? "" : `${modelName}`, + description: model.description || model.Description || model.type || "", + metadata: model.metadata || model.Metadata || {}, + }; + }).filter((model) => model.name); +}; + +const ChatEmptyState = ({icon, title, detail}) => ( + + {icon} + {title} + {detail && {detail}} + +); + +const ChannelButton = ({channel, selected, unread, onSelect}) => { + const theme = useTheme(); + const isAI = channel.channel_type === "ai"; + const accentColor = isAI ? theme.palette.info.main : theme.palette.primary.main; + const secondary = channel.description || (isAI ? channel.chat_container?.name || channel.chat_model || "" : ""); + const states = [ + channel.archived ? {label: "Archived", className: "mythic-chat-channel-state-archived"} : null, + channel.locked ? {label: "Locked", className: "mythic-chat-channel-state-locked"} : null, + isAI && channel.chat_container && !channel.chat_container.container_running ? {label: "Offline", className: "mythic-chat-channel-state-offline"} : null, + ].filter(Boolean); + return ( + + ); +}; + +const MessageBubble = ({message, request, me, onEdit, onDelete, onCancel, onRetry, editing, editText, setEditText, saveEdit, cancelEdit}) => { + const theme = useTheme(); + const isMine = message.operator_id === me?.user?.user_id; + const isAI = message.author_type === "ai"; + const isSystem = message.author_type === "system"; + const canEdit = isMine && message.author_type === "operator" && !message.deleted; + const canDelete = !message.deleted && (isMine || message.author_type !== "operator"); + const streaming = message.status === "pending" || message.status === "streaming"; + const softBorderColor = theme.table?.borderSoft || theme.borderColor || theme.palette.divider; + const chatMessageColors = theme.chat?.message || {}; + const markdownSurface = chatMessageColors.markdownSurface || (theme.palette.mode === "dark" ? alpha(theme.palette.common.black, 0.24) : alpha(theme.palette.common.black, 0.045)); + const markdownSurfaceStrong = chatMessageColors.markdownSurfaceStrong || (theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.08) : alpha(theme.palette.common.black, 0.06)); + const messageBackgroundColor = isSystem ? chatMessageColors.systemBackground : + isAI ? chatMessageColors.aiBackground : + isMine ? chatMessageColors.selfBackground : chatMessageColors.operatorBackground; + return ( + + + + + {isAI && } + {message.sender_display_name || message.operator?.username || "unknown"} + {message.edited && !message.deleted && } + {streaming && } + + + {formatTimestamp(message.created_at, me?.user?.view_utc_time)} + {request && streaming && + + onCancel(request.id)}> + + + + } + {request && (message.status === "error" || message.status === "cancelled") && + + onRetry(request.id)}> + + + + } + {canEdit && + + onEdit(message)}> + + + + } + {canDelete && + + onDelete(message.id)}> + + + + } + + + {editing ? ( + + setEditText(e.target.value)} + size="small" + /> + + + + + + ) : ( + + )} + {request?.error && + {request.error} + } + + + ); +}; + +const ChatCreateDialog = ({open, onClose, onCreate, chatContainers}) => { + const theme = useTheme(); + const [name, setName] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [channelType, setChannelType] = React.useState("standard"); + const [containerID, setContainerID] = React.useState(""); + const [model, setModel] = React.useState(""); + const [locked, setLocked] = React.useState(true); + React.useEffect(() => { + if(open){ + setName(""); + setDescription(""); + setChannelType("standard"); + setContainerID(""); + setModel(""); + setLocked(true); + } + }, [open]); + const selectedContainer = React.useMemo(() => ( + chatContainers.find((container) => `${container.id}` === `${containerID}`) + ), [chatContainers, containerID]); + const selectedContainerModels = React.useMemo(() => ( + parseChatContainerModels(selectedContainer) + ), [selectedContainer]); + React.useEffect(() => { + if(channelType !== "ai" || !containerID){ + return; + } + if(model && !selectedContainerModels.some((containerModel) => containerModel.name === model)){ + setModel(""); + return; + } + if(model === "" && selectedContainerModels.length === 1){ + setModel(selectedContainerModels[0].name); + } + }, [channelType, containerID, model, selectedContainerModels]); + const changeChannelType = (event) => { + const nextType = event.target.value; + setChannelType(nextType); + if(nextType !== "ai"){ + setContainerID(""); + setModel(""); + } + }; + const changeContainer = (event) => { + const nextContainerID = event.target.value; + const nextContainer = chatContainers.find((container) => `${container.id}` === `${nextContainerID}`); + const nextModels = parseChatContainerModels(nextContainer); + setContainerID(nextContainerID); + setModel(nextModels.length === 1 ? nextModels[0].name : ""); + }; + const createDisabled = name.trim() === "" || + (channelType === "ai" && (!containerID || selectedContainerModels.length === 0 || model === "")); + const submit = () => { + onCreate({ + name, + description, + channel_type: channelType, + chat_container_id: channelType === "ai" ? Number(containerID) : null, + chat_model: channelType === "ai" ? model : "", + locked: channelType === "ai" ? locked : false, + ai_metadata: {}, + }); + }; + return ( + + {channelType === "ai" ? "New AI Chat" : "New Channel"} + + + Type + + + setName(e.target.value)} autoFocus /> + setDescription(e.target.value)} /> + {channelType === "ai" && + <> + + Container + + + {containerID !== "" && + <> + {selectedContainerModels.length > 0 ? ( + + Model + + + ) : ( + + No models reported + + This chat container needs to report at least one model before it can be used for a new AI chat. + + + )} + + } + + setLocked(e.target.checked)} />} + label="Lock this AI chat" + /> + + Locked AI chats remain visible to everyone in the operation, but only the lock owner and operation admins or leads can send prompts into that session. + + + + } + + + + + + + ); +}; + +const ChatSearchDialog = ({open, onClose, onSearch, searchText, setSearchText, results, onSelectResult, viewUTCTime}) => ( + + Search Chat + + + setSearchText(e.target.value)} + onKeyDown={(e) => { + if(e.key === "Enter"){ + onSearch(); + } + }} + /> + + + + {(results || []).map((result) => ( + + ))} + + + + + + +); + +const ChatEditChannelDialog = ({open, channel, onClose, onSave}) => { + const [name, setName] = React.useState(""); + const [description, setDescription] = React.useState(""); + const isGeneralChannel = isGeneralChatChannel(channel); + React.useEffect(() => { + if(open && channel){ + setName(channel.name || ""); + setDescription(channel.description || ""); + } + }, [open, channel]); + const submit = () => { + if(!channel){ + return; + } + const update = { + channel_id: channel.id, + description, + }; + if(!isGeneralChatChannel(channel)){ + update.name = name.trim(); + } + onSave(update); + }; + return ( + + Edit Channel + + setName(e.target.value)} + /> + setDescription(e.target.value)} + /> + + + + + + + ); +}; + +const ChatSystemMessageDialog = ({open, selectedChannel, isMythicAdmin, onClose, onSend}) => { + const [message, setMessage] = React.useState(""); + const [allOperations, setAllOperations] = React.useState(false); + React.useEffect(() => { + if(open){ + setMessage(""); + setAllOperations(false); + } + }, [open]); + const submit = () => { + const trimmed = message.trim(); + if(trimmed === "" || !selectedChannel){ + return; + } + onSend({message: trimmed, all_operations: isMythicAdmin && allOperations}).then(({data}) => { + if(data?.chatCreateMessage?.status === "success"){ + onClose(); + } + }).catch(() => {}); + }; + const destination = isMythicAdmin && allOperations ? "All operations" : + selectedChannel ? channelDisplayName(selectedChannel) : "No channel"; + const sendDisabled = message.trim() === "" || !selectedChannel || (selectedChannel.archived && !(isMythicAdmin && allOperations)); + return ( + + System Message + + + + {destination} + + setMessage(e.target.value)} + /> + {isMythicAdmin && + + setAllOperations(e.target.checked)} />} + label="Send to all operations" + /> + + } + + + + + + + ); +}; + +export function Chat({me}) { + const theme = useTheme(); + const meContext = React.useContext(MeContext); + const currentMe = me || meContext; + const [selectedChannelID, setSelectedChannelID] = React.useState(null); + const [showArchived, setShowArchived] = React.useState(false); + const [composer, setComposer] = React.useState(""); + const [createOpen, setCreateOpen] = React.useState(false); + const [editChannelOpen, setEditChannelOpen] = React.useState(false); + const [systemMessageOpen, setSystemMessageOpen] = React.useState(false); + const [searchOpen, setSearchOpen] = React.useState(false); + const [searchText, setSearchText] = React.useState(""); + const [editingID, setEditingID] = React.useState(null); + const [editText, setEditText] = React.useState(""); + const messagesEndRef = React.useRef(null); + const streamStart = React.useRef(getSkewedNow().toISOString()); + const [baseChannels, setBaseChannels] = React.useState([]); + const [allChatContainers, setAllChatContainers] = React.useState([]); + const [readState, setReadState] = React.useState({}); + const [messages, setMessages] = React.useState([]); + const [requests, setRequests] = React.useState([]); + const selectedChannelIDRef = React.useRef(selectedChannelID); + const selectedChannelStreamStart = React.useMemo(() => getSkewedNow().toISOString(), [selectedChannelID]); + selectedChannelIDRef.current = selectedChannelID; + + const {data: channelData} = useQuery(CHAT_CHANNELS_QUERY, {fetchPolicy: "no-cache"}); + const {data: readStateData} = useQuery(CHAT_READ_STATE_QUERY, {fetchPolicy: "no-cache"}); + const {data: containerData} = useQuery(CHAT_CONTAINERS_QUERY, {fetchPolicy: "no-cache"}); + const {data: currentOperatorData} = useQuery(CHAT_CURRENT_OPERATOR_QUERY, { + variables: { + operator_id: currentMe?.user?.user_id || 0, + operation_id: currentMe?.user?.current_operation_id || 0, + }, + skip: !currentMe?.user?.user_id || !currentMe?.user?.current_operation_id, + fetchPolicy: "no-cache", + onError: (error) => console.log(error), + }); + + React.useEffect(() => { + if(channelData?.chat_channel){ + setBaseChannels((prev) => mergeRowsByID(prev, channelData.chat_channel, sortChannels)); + } + }, [channelData]); + React.useEffect(() => { + if(readStateData?.chat_read_state){ + setReadState((prev) => mergeReadStateRows(prev, readStateData.chat_read_state)); + } + }, [readStateData]); + React.useEffect(() => { + if(containerData?.consuming_container){ + setAllChatContainers((prev) => mergeRowsByID(prev, containerData.consuming_container, sortContainers)); + } + }, [containerData]); + + useSubscription(CHAT_CHANNELS_STREAM_SUBSCRIPTION, { + variables: {now: streamStart.current}, + fetchPolicy: "no-cache", + onData: ({data}) => { + const updates = data.data?.chat_channel_stream || []; + if(updates.length > 0){ + setBaseChannels((prev) => mergeRowsByID(prev, updates, sortChannels)); + } + }, + onError: (error) => console.log(error), + }); + useSubscription(CHAT_READ_STATE_STREAM_SUBSCRIPTION, { + variables: {now: streamStart.current}, + fetchPolicy: "no-cache", + onData: ({data}) => { + const updates = data.data?.chat_read_state_stream || []; + if(updates.length > 0){ + setReadState((prev) => mergeReadStateRows(prev, updates)); + } + }, + onError: (error) => console.log(error), + }); + useSubscription(CHAT_CONTAINERS_STREAM_SUBSCRIPTION, { + variables: {now: streamStart.current}, + fetchPolicy: "no-cache", + onData: ({data}) => { + const updates = data.data?.consuming_container_stream || []; + if(updates.length > 0){ + setAllChatContainers((prev) => mergeRowsByID(prev, updates, sortContainers)); + } + }, + onError: (error) => console.log(error), + }); + + const chatContainers = React.useMemo(() => { + return allChatContainers.filter((container) => !container.deleted).sort(sortContainers); + }, [allChatContainers]); + const containerByID = React.useMemo(() => { + return chatContainers.reduce((prev, container) => { + prev[container.id] = container; + return prev; + }, {}); + }, [chatContainers]); + const channels = React.useMemo(() => { + return baseChannels.map((channel) => ({ + ...channel, + chat_container: containerByID[channel.chat_container_id] || channel.chat_container, + })).sort(sortChannels); + }, [baseChannels, containerByID]); + + const selectedChannel = React.useMemo(() => { + return channels.find((channel) => channel.id === selectedChannelID) || null; + }, [channels, selectedChannelID]); + const generalChannel = React.useMemo(() => { + return channels.find((channel) => isGeneralChatChannel(channel)) || null; + }, [channels]); + const currentOperatorViewMode = currentOperatorData?.operatoroperation?.[0]?.view_mode || currentMe?.user?.view_mode || ""; + const isMythicAdmin = Boolean(currentMe?.user?.admin || currentOperatorData?.operator_by_pk?.admin); + const canCreateSystemMessage = isMythicAdmin || currentOperatorViewMode === "lead"; + const selectedChannelIsGeneral = isGeneralChatChannel(selectedChannel); + + React.useEffect(() => { + if(!selectedChannelID && channels.length > 0){ + const active = channels.find((channel) => !channel.archived) || channels[0]; + setSelectedChannelID(active.id); + } + }, [channels, selectedChannelID]); + + React.useEffect(() => { + setMessages([]); + setRequests([]); + }, [selectedChannelID]); + + const {data: messageData} = useQuery(CHAT_MESSAGES_QUERY, { + variables: {channel_id: selectedChannelID || 0, limit: CHAT_MESSAGE_LIMIT}, + skip: !selectedChannelID, + fetchPolicy: "no-cache", + }); + const {data: requestData} = useQuery(CHAT_REQUESTS_QUERY, { + variables: {channel_id: selectedChannelID || 0, limit: CHAT_REQUEST_LIMIT}, + skip: !selectedChannelID, + fetchPolicy: "no-cache", + }); + React.useEffect(() => { + const currentChannelID = selectedChannelIDRef.current; + if(currentChannelID && messageData?.chat_message){ + const rows = messageData.chat_message.filter((message) => message.channel_id === currentChannelID); + setMessages((prev) => mergeRowsByID(prev, rows, sortByID, CHAT_MESSAGE_LIMIT)); + } + }, [messageData, selectedChannelID]); + React.useEffect(() => { + const currentChannelID = selectedChannelIDRef.current; + if(currentChannelID && requestData?.chat_request){ + const rows = requestData.chat_request.filter((request) => request.channel_id === currentChannelID); + setRequests((prev) => mergeRowsByID(prev, rows, sortByID, CHAT_REQUEST_LIMIT)); + } + }, [requestData, selectedChannelID]); + useSubscription(CHAT_MESSAGES_STREAM_SUBSCRIPTION, { + variables: {channel_id: selectedChannelID || 0, now: selectedChannelStreamStart}, + skip: !selectedChannelID, + fetchPolicy: "no-cache", + onData: ({data}) => { + const currentChannelID = selectedChannelIDRef.current; + const updates = (data.data?.chat_message_stream || []).filter((message) => message.channel_id === currentChannelID); + if(updates.length > 0){ + setMessages((prev) => mergeRowsByID(prev, updates, sortByID, CHAT_MESSAGE_LIMIT)); + } + }, + onError: (error) => console.log(error), + }); + useSubscription(CHAT_REQUESTS_STREAM_SUBSCRIPTION, { + variables: {channel_id: selectedChannelID || 0, now: selectedChannelStreamStart}, + skip: !selectedChannelID, + fetchPolicy: "no-cache", + onData: ({data}) => { + const currentChannelID = selectedChannelIDRef.current; + const updates = (data.data?.chat_request_stream || []).filter((request) => request.channel_id === currentChannelID); + if(updates.length > 0){ + setRequests((prev) => mergeRowsByID(prev, updates, sortByID, CHAT_REQUEST_LIMIT)); + } + }, + onError: (error) => console.log(error), + }); + const requestsByResponseID = React.useMemo(() => { + return requests.reduce((prev, request) => { + prev[request.response_message_id] = request; + return prev; + }, {}); + }, [requests]); + + const [createChannel] = useMutation(CREATE_CHANNEL, { + onCompleted: (data) => { + if(data.chatCreateChannel.status === "success"){ + setSelectedChannelID(data.chatCreateChannel.channel_id); + setCreateOpen(false); + } else { + snackActions.error(data.chatCreateChannel.error); + } + }, + onError: (error) => snackActions.error(error.message), + }); + const [updateChannel] = useMutation(UPDATE_CHANNEL, { + onCompleted: (data) => { + if(data.chatUpdateChannel.status !== "success"){ + snackActions.error(data.chatUpdateChannel.error); + } + }, + onError: (error) => snackActions.error(error.message), + }); + const [createMessage] = useMutation(CREATE_MESSAGE, { + onCompleted: (data) => { + if(data.chatCreateMessage.status !== "success"){ + snackActions.error(data.chatCreateMessage.error); + } + }, + onError: (error) => snackActions.error(error.message), + }); + const [editMessage] = useMutation(EDIT_MESSAGE, { + onCompleted: (data) => { + if(data.chatEditMessage.status === "success"){ + setEditingID(null); + setEditText(""); + } else { + snackActions.error(data.chatEditMessage.error); + } + }, + onError: (error) => snackActions.error(error.message), + }); + const [deleteMessage] = useMutation(DELETE_MESSAGE, { + onCompleted: (data) => { + if(data.chatDeleteMessage.status !== "success"){ + snackActions.error(data.chatDeleteMessage.error); + } + }, + onError: (error) => snackActions.error(error.message), + }); + const [cancelRequest] = useMutation(CANCEL_REQUEST, { + onCompleted: (data) => { + if(data.chatCancelRequest.status !== "success"){ + snackActions.error(data.chatCancelRequest.error); + } + }, + onError: (error) => snackActions.error(error.message), + }); + const [retryRequest] = useMutation(RETRY_REQUEST, { + onCompleted: (data) => { + if(data.chatRetryRequest.status !== "success"){ + snackActions.error(data.chatRetryRequest.error); + } + }, + onError: (error) => snackActions.error(error.message), + }); + const [markRead] = useMutation(MARK_READ); + const [runSearch, {data: searchData}] = useLazyQuery(CHAT_SEARCH, { + fetchPolicy: "no-cache", + onCompleted: (data) => { + if(data.chatSearch.status !== "success"){ + snackActions.error(data.chatSearch.error); + } + }, + onError: (error) => snackActions.error(error.message), + }); + + React.useEffect(() => { + messagesEndRef.current?.scrollIntoView({block: "end"}); + const lastMessage = messages[messages.length - 1]; + if(selectedChannelID && lastMessage){ + markRead({variables: {channel_id: selectedChannelID, last_read_message_id: lastMessage.id}}).catch(() => {}); + } + }, [messages, selectedChannelID, markRead]); + + const standardChannels = channels.filter((channel) => channel.channel_type === "standard" && (showArchived || !channel.archived)); + const aiChannels = channels.filter((channel) => channel.channel_type === "ai" && (showArchived || !channel.archived)); + const channelHasUnread = React.useCallback((channel) => { + const latestMessageID = channel.last_message_id || 0; + if(latestMessageID === 0 || channel.id === selectedChannelID){ + return false; + } + return (readState[channel.id] || 0) < latestMessageID; + }, [readState, selectedChannelID]); + const activeAIRequest = React.useMemo(() => { + if(selectedChannel?.channel_type !== "ai"){ + return null; + } + return requests.find((request) => request.channel_id === selectedChannel.id && ["pending", "streaming"].includes(request.status)) || null; + }, [requests, selectedChannel]); + + const submitMessage = () => { + const message = composer.trim(); + if(!message || !selectedChannel){ + return; + } + createMessage({variables: {channel_id: selectedChannel.id, message}}); + setComposer(""); + }; + const submitSystemMessage = ({message, all_operations}) => { + if(!selectedChannel){ + return Promise.resolve({data: {chatCreateMessage: {status: "error", error: "No channel selected"}}}); + } + return createMessage({ + variables: { + channel_id: selectedChannel.id, + message, + system_message: true, + all_operations, + }, + }).then((result) => { + if(result.data?.chatCreateMessage?.status === "success"){ + snackActions.success("System message sent"); + if(all_operations && generalChannel){ + setSelectedChannelID(generalChannel.id); + } + } + return result; + }); + }; + const disabledReason = React.useMemo(() => { + if(!selectedChannel){ return "No channel selected"; } + if(selectedChannel.archived){ return "Archived"; } + if(selectedChannel.channel_type === "ai" && selectedChannel.locked && selectedChannel.locked_by !== currentMe?.user?.user_id){ + return `Locked by ${selectedChannel.locked_operator?.username || "another operator"}`; + } + if(activeAIRequest){ + return "AI response in progress"; + } + if(selectedChannel.channel_type === "ai" && selectedChannel.chat_container && !selectedChannel.chat_container.container_running){ + return "AI container offline"; + } + return ""; + }, [selectedChannel, currentMe, activeAIRequest]); + const composerDisabled = disabledReason !== ""; + const onCreateChannel = (variables) => createChannel({variables}); + const toggleArchive = () => { + if(selectedChannel && !isGeneralChatChannel(selectedChannel)){ + updateChannel({variables: {channel_id: selectedChannel.id, archived: !selectedChannel.archived}}); + } + }; + const toggleLock = () => { + if(selectedChannel?.channel_type === "ai"){ + updateChannel({variables: {channel_id: selectedChannel.id, locked: !selectedChannel.locked}}); + } + }; + const saveChannelDetails = (variables) => { + updateChannel({variables}).then(({data}) => { + if(data?.chatUpdateChannel?.status === "success"){ + setEditChannelOpen(false); + } + }).catch(() => {}); + }; + const beginEdit = (message) => { + setEditingID(message.id); + setEditText(message.message); + }; + const saveEdit = () => { + if(editingID && editText.trim()){ + editMessage({variables: {message_id: editingID, message: editText}}); + } + }; + const runChatSearch = () => { + if(searchText.trim()){ + runSearch({variables: {query: searchText.trim(), limit: 50}}); + } + }; + const selectSearchResult = (result) => { + setSelectedChannelID(result.channel_id); + setSearchOpen(false); + }; + const metaChips = ( + <> + !channel.archived).length} active`} /> + + + ); + + return ( + + } + meta={metaChips} + actions={ + + + + + } + /> + + + + + Channels + + + setShowArchived(e.target.checked)} />} + label={Show archived} + /> + + + Standard + {standardChannels.length === 0 ? ( + No standard channels + ) : ( + standardChannels.map((channel) => ( + + )) + )} + + + + AI + {aiChannels.length === 0 ? ( + No AI chats + ) : ( + aiChannels.map((channel) => ( + + )) + )} + + + + + + + {selectedChannel?.channel_type === "ai" ? : } + + + {selectedChannel ? channelDisplayName(selectedChannel) : "Chat"} + + {selectedChannel?.description || selectedChannel?.chat_container?.name || ""} + + + + + {selectedChannel && + + setEditChannelOpen(true)}> + + + + } + {selectedChannel?.channel_type === "ai" && + + + {selectedChannel.locked ? : } + + + } + {selectedChannel && + + + + {selectedChannel.archived ? : } + + + + } + + + + {!selectedChannel ? ( + } + title="No channel selected" + /> + ) : messages.length === 0 ? ( + : } + title="No messages yet" + detail={selectedChannel.archived ? "This channel is archived." : "No message history for this channel."} + /> + ) : ( + messages.map((message) => ( + deleteMessage({variables: {message_id: messageID}})} + onCancel={(requestID) => cancelRequest({variables: {request_id: requestID}})} + onRetry={(requestID) => retryRequest({variables: {request_id: requestID}})} + editing={editingID === message.id} + editText={editText} + setEditText={setEditText} + saveEdit={saveEdit} + cancelEdit={() => {setEditingID(null); setEditText("");}} + /> + )) + )} +
+ + + setComposer(e.target.value)} + onKeyDown={(e) => { + if(e.nativeEvent?.isComposing){ + return; + } + if(e.key === "Enter" && !e.shiftKey){ + e.preventDefault(); + submitMessage(); + } + }} + size="small" + /> + {canCreateSystemMessage && + + + setSystemMessageOpen(true)} + > + + + + + } + + + + + + + setCreateOpen(false)} + onCreate={onCreateChannel} + chatContainers={chatContainers} + /> + setEditChannelOpen(false)} + onSave={saveChannelDetails} + /> + setSystemMessageOpen(false)} + onSend={submitSystemMessage} + /> + setSearchOpen(false)} + onSearch={runChatSearch} + searchText={searchText} + setSearchText={setSearchText} + results={searchData?.chatSearch?.results || []} + onSelectResult={selectSearchResult} + viewUTCTime={currentMe?.user?.view_utc_time} + /> + + ); +} diff --git a/MythicReactUI/src/components/pages/PayloadTypesC2Profiles/ConsumingServicesTable.js b/MythicReactUI/src/components/pages/PayloadTypesC2Profiles/ConsumingServicesTable.js index 76ae9d2b..195e925d 100644 --- a/MythicReactUI/src/components/pages/PayloadTypesC2Profiles/ConsumingServicesTable.js +++ b/MythicReactUI/src/components/pages/PayloadTypesC2Profiles/ConsumingServicesTable.js @@ -141,6 +141,7 @@ export const ConsumingServicesTableRow = ({service, showDeleted}) => { } break; case "auth": + case "chat": try{ const newSubs = service.subscriptions.map( s => { try{ @@ -300,6 +301,17 @@ export const ConsumingServicesTableRow = ({service, showDeleted}) => { {label: "Identity Providers", value: getSubscriptionNames(w), render: renderIdentityProviderMetadata(w)}, ], }); + case "chat": + return renderBaseRow({ + w, + typeLabel: "Chat", + metadataItems: [ + {label: "Type", value: w.type}, + {label: "Version", value: w.semver, chip: true}, + {label: "Models", value: getSubscriptionNames(w)}, + ], + hasDetails: true, + }); default: return null; } @@ -320,6 +332,18 @@ export const ConsumingServicesTableRow = ({service, showDeleted}) => { ); case "auth": return null; + case "chat": + return ( + + ({ + title: subscription.name || subscription, + description: subscription.description || subscription.type || "", + }))} + emptyText="No chat models registered." + /> + + ); default: return null; } diff --git a/MythicReactUI/src/components/pages/PayloadTypesC2Profiles/PayloadTypesC2Profiles.js b/MythicReactUI/src/components/pages/PayloadTypesC2Profiles/PayloadTypesC2Profiles.js index b0796aba..2bc295de 100644 --- a/MythicReactUI/src/components/pages/PayloadTypesC2Profiles/PayloadTypesC2Profiles.js +++ b/MythicReactUI/src/components/pages/PayloadTypesC2Profiles/PayloadTypesC2Profiles.js @@ -135,7 +135,7 @@ function useCustomSubscription({customSubscription, dataKey}){ return customData } -const tabTypes = ["Payload Types", "C2 Profiles", "Translators", "Command Augmentation", "3rd Party", "Webhooks", "Loggers", "Eventing", "Auth", "Browsers"]; +const tabTypes = ["Payload Types", "C2 Profiles", "Translators", "Command Augmentation", "3rd Party", "Webhooks", "Loggers", "Eventing", "Auth", "Chat", "Browsers"]; const filterDeleted = (c, showDeleted) => { if(showDeleted){ @@ -197,6 +197,8 @@ export function PayloadTypesC2Profiles({me}){ return visibleData.filter(c => c.__typename === "consuming_container" && c.type === "eventing"); case "Auth": return visibleData.filter(c => c.__typename === "consuming_container" && c.type === "auth"); + case "Chat": + return visibleData.filter(c => c.__typename === "consuming_container" && c.type === "chat"); case "Browsers": return visibleData.filter(c => c.__typename === "custombrowser"); default: @@ -235,6 +237,9 @@ export function PayloadTypesC2Profiles({me}){ return case 9: + return + case 10: return default: @@ -314,6 +319,9 @@ export function PayloadTypesC2Profiles({me}){ case "Auth": return ; + case "Chat": + return ; case "Browsers": return ; @@ -399,6 +407,13 @@ const ContainersTabAuthLabel = (props) => { } {...props}/> ) } +const ContainersTabChatLabel = (props) => { + return ( + 0 ? " (" + props.containers.length + ") " : "")} iconComponent={ + <> + } {...props}/> + ) +} const ContainersTabCommandAugmentLabel = (props) => { return ( 0 ? " (" + props.containers.length + ") " : "")} iconComponent={ @@ -515,6 +530,9 @@ You can easily create your own containers, or you can use github.com/MythicAgent message = `Mythic supports basic username/password authentication to users within its database. You can extend this auth capability to support your own LDAP, SSO, or otherwise customized authentication flow with auth containers.`; break; + case "Chat": + message = `Chat containers connect operation chat channels to AI chat services.`; + break; } return (
diff --git a/MythicReactUI/src/components/pages/Settings/SettingsOperatorUIConfigDialog.js b/MythicReactUI/src/components/pages/Settings/SettingsOperatorUIConfigDialog.js index 41aa55da..b7ec971d 100644 --- a/MythicReactUI/src/components/pages/Settings/SettingsOperatorUIConfigDialog.js +++ b/MythicReactUI/src/components/pages/Settings/SettingsOperatorUIConfigDialog.js @@ -294,11 +294,22 @@ const COLOR_EDITOR_SECTIONS = [ ], }, { - title: "Graphs and Floating Controls", - description: "Graph grouping surfaces and legacy floating action controls.", + title: "Chat", + description: "Chat message bubbles and markdown block surfaces.", + colors: [ + {name: "chatMessageOperatorBackground", display: "Other Messages", description: "Message bubble background for operator messages from other users.", preview: "chat"}, + {name: "chatMessageSelfBackground", display: "My Messages", description: "Message bubble background for messages sent by the current operator.", preview: "chat"}, + {name: "chatMessageAIBackground", display: "AI Messages", description: "Message bubble background for AI-generated messages.", preview: "chat"}, + {name: "chatMessageSystemBackground", display: "System Messages", description: "Message bubble background for chat system messages.", preview: "chat"}, + {name: "chatMarkdownSurfaceBackground", display: "Markdown Surface", description: "Background for inline code, fenced code blocks, blockquotes, and markdown table headers.", preview: "chat"}, + {name: "chatMarkdownSurfaceStrongBackground", display: "Markdown Label Surface", description: "Background for compact markdown labels such as fenced-code language tags.", preview: "chat"}, + ], + }, + { + title: "Graphs", + description: "Graph grouping surfaces.", colors: [ {name: "graphGroupColor", display: "Graph Group", description: "Grouped node backgrounds in graph-style views.", preview: "graph"}, - {name: "speedDialAction", display: "Floating Action", description: "Floating action buttons where SpeedDial controls are still used.", preview: "floatingAction"}, ], }, { @@ -517,7 +528,6 @@ const ColorUsagePreview = ({option, palette, mode}) => { const subtleAccentGradientStart = getPaletteValue(palette, "subtleAccentGradientStart", mode); const subtleAccentGradientEnd = getPaletteValue(palette, "subtleAccentGradientEnd", mode); const graphGroup = getPaletteValue(palette, "graphGroupColor", mode); - const speedDialAction = getPaletteValue(palette, "speedDialAction", mode); const chartSeriesColors = Array.from({length: 10}, (_, index) => getPaletteValue(palette, `chartSeries${index + 1}`, mode)); const navTop = getPaletteValue(palette, "navBarColor", mode); const navBottom = getPaletteValue(palette, "navBarBottomColor", mode); @@ -536,6 +546,12 @@ const ColorUsagePreview = ({option, palette, mode}) => { const extra = getPaletteValue(palette, "taskContextExtraColor", mode); const folder = getPaletteValue(palette, "folderColor", mode); const emptyFolder = getPaletteValue(palette, "emptyFolderColor", mode); + const chatOperatorBackground = getPaletteValue(palette, "chatMessageOperatorBackground", mode); + const chatSelfBackground = getPaletteValue(palette, "chatMessageSelfBackground", mode); + const chatAIBackground = getPaletteValue(palette, "chatMessageAIBackground", mode); + const chatSystemBackground = getPaletteValue(palette, "chatMessageSystemBackground", mode); + const chatMarkdownSurface = getPaletteValue(palette, "chatMarkdownSurfaceBackground", mode); + const chatMarkdownSurfaceStrong = getPaletteValue(palette, "chatMarkdownSurfaceStrongBackground", mode); const previewColor = getPaletteValue(palette, option.name, mode); const shellSx = { border: `1px solid ${addAlpha(border, "99")}`, @@ -637,12 +653,6 @@ const ColorUsagePreview = ({option, palette, mode}) => { Grouped graph node ); - case "floatingAction": - return ( - - - - ); case "chart": return ( @@ -661,6 +671,33 @@ const ColorUsagePreview = ({option, palette, mode}) => { ); + case "chat": + return ( + + + + Other operator + + + My message + + + AI response + + + markdown block + + + + + + System + + + + + + ); case "task": return ( diff --git a/MythicReactUI/src/themes/GlobalStyles.js b/MythicReactUI/src/themes/GlobalStyles.js index ce3e42ce..8fc5f6e8 100644 --- a/MythicReactUI/src/themes/GlobalStyles.js +++ b/MythicReactUI/src/themes/GlobalStyles.js @@ -13,6 +13,7 @@ const getSubtleAccentGradient = (props) => props.theme.gradients?.subtleAccent | `linear-gradient(135deg, ${alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.13 : 0.075)} 0%, ${alpha(props.theme.palette.background.paper, 0)} 62%)`; const getSubtleAccentHorizontalGradient = (props) => props.theme.gradients?.subtleAccentHorizontal || `linear-gradient(90deg, ${alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.12 : 0.07)} 0%, ${alpha(props.theme.palette.background.paper, 0)} 100%)`; +const getSoftBorderColor = (props) => props.theme.table?.borderSoft || props.theme.borderColor; export const GlobalStyles = createGlobalStyle` body { @@ -131,13 +132,6 @@ tspan { min-height: unset; max-width: unset; } -.MuiSpeedDialAction-staticTooltipLabel { - white-space: nowrap; - max-width: none; -} -.MuiSpeedDialAction-fab { - background-color: ${(props) => props.theme.palette.speedDialAction}; -} .MuiTooltip-tooltip { background-color: ${(props) => props.theme.palette.background.contrast}; color: ${(props) => props.theme.palette.text.contrast}; @@ -4655,6 +4649,7 @@ tspan { line-height: 1.4; margin-top: 0.25rem; overflow-wrap: anywhere; + white-space: pre-wrap; } .mythic-metadata-grid { display: grid; @@ -9985,7 +9980,7 @@ tspan { line-height: 1.35; margin: 0; overflow: hidden; - white-space: normal; + white-space: pre-wrap; } .mythic-installed-service-browser-metadata { display: flex; @@ -10111,6 +10106,7 @@ tspan { font-weight: 650; line-height: 1.35; overflow-wrap: anywhere; + white-space: pre-wrap; } .mythic-installed-service-definition-action { align-items: center; @@ -10479,6 +10475,488 @@ tspan { color: inherit; font-size: 1rem; } +.mythic-chat-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: 8px; + min-height: 0; + flex: 1 1 auto; + border-radius: 8px; + overflow: visible; +} +.mythic-chat-sidebar { + background-color: ${(props) => alpha(props.theme.palette.background.paper, props.theme.palette.mode === "dark" ? 0.72 : 0.86)}; + border: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.18)}; + display: flex; + flex-direction: column; + min-height: 0; + border-radius: 8px; + box-shadow: inset 0 1px 0 ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.12)}; + overflow: hidden; +} +.mythic-chat-sidebar-toolbar { + background-color: ${(props) => props.theme.pageHeader?.main || props.theme.palette.background.paper}; + border-bottom: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.18)}; + color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary}; + min-height: 48px; +} +.mythic-chat-sidebar-toolbar, +.mythic-chat-conversation-header, +.mythic-chat-composer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; +} +.mythic-chat-sidebar-heading { + align-items: center; + display: flex; + gap: 8px; + min-width: 0; +} +.mythic-chat-sidebar-heading .MuiTypography-root { + font-weight: 750; +} +.mythic-chat-sidebar-count { + background-color: ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.1)} !important; + border: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.24)} !important; + color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary} !important; + font-size: 0.68rem !important; + font-weight: 800 !important; + height: 22px !important; +} +.mythic-chat-conversation-header { + background-color: ${(props) => props.theme.pageHeader?.main || props.theme.palette.background.paper}; + border-bottom: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.18)}; + box-shadow: inset 0 1px 0 ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.12)}; + color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary}; + min-height: 54px; +} +.mythic-chat-composer { + box-shadow: inset 0 1px 0 ${getSoftBorderColor}; + align-items: center; +} +.mythic-chat-channel-section { + display: flex; + flex-direction: column; + gap: 5px; + padding: 8px; + overflow-y: auto; +} +.mythic-chat-channel-section > .MuiTypography-caption { + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; +} +.mythic-chat-channel-button { + align-items: center; + border: 1px solid transparent; + border-radius: 7px; + cursor: pointer; + display: grid; + gap: 8px; + grid-template-columns: 26px minmax(0, 1fr) auto; + min-height: 42px; + padding: 6px 8px; + text-align: left; + transition: background-color 120ms ease, border-color 120ms ease; +} +.mythic-chat-channel-button:hover { + background-color: ${(props) => props.theme.surfaces?.hover || props.theme.palette.action.hover} !important; + border-color: ${getSoftBorderColor} !important; +} +.mythic-chat-channel-button-selected { + box-shadow: inset 3px 0 0 var(--mythic-chat-channel-accent); +} +.mythic-chat-channel-icon, +.mythic-chat-author, +.mythic-chat-search-channel { + align-items: center; + display: inline-flex; + gap: 5px; +} +.mythic-chat-channel-icon { + color: var(--mythic-chat-channel-accent); + justify-content: center; +} +.mythic-chat-channel-button-archived .mythic-chat-channel-icon { + color: var(--mythic-chat-channel-warning); +} +.mythic-chat-channel-button-archived .mythic-chat-channel-name, +.mythic-chat-channel-button-archived .mythic-chat-channel-meta { + color: var(--mythic-chat-channel-muted); +} +.mythic-chat-channel-main { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} +.mythic-chat-channel-name { + font-weight: 650; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mythic-chat-channel-meta { + font-size: 0.74rem; + opacity: 0.72; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mythic-chat-channel-states { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.mythic-chat-channel-state, +.mythic-chat-unread-badge { + align-items: center; + background: ${(props) => props.theme.palette.action.selected}; + border-radius: 999px; + display: inline-flex; + font-size: 0.66rem; + font-weight: 800; + line-height: 1.2; + padding: 2px 6px; + white-space: nowrap; +} +.mythic-chat-channel-state-archived { + color: var(--mythic-chat-channel-warning); + box-shadow: inset 0 0 0 1px var(--mythic-chat-channel-warning); +} +.mythic-chat-channel-state-locked { + color: var(--mythic-chat-channel-warning); +} +.mythic-chat-channel-state-offline { + color: var(--mythic-chat-channel-error); +} +.mythic-chat-unread-badge { + align-self: center; + color: var(--mythic-chat-channel-error); + box-shadow: inset 0 0 0 1px var(--mythic-chat-channel-error); +} +.mythic-chat-main { + background-color: ${(props) => props.theme.palette.background.paper}; + border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor}; + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + border-radius: 8px; + overflow: hidden; +} +.mythic-chat-conversation-icon { + align-items: center; + border: 1px solid; + border-radius: 7px; + display: inline-flex; + flex: 0 0 auto; + height: 32px; + justify-content: center; + width: 32px; +} +.mythic-chat-conversation-title { + font-weight: 800 !important; + line-height: 1.2 !important; +} +.mythic-chat-conversation-subtitle { + opacity: 0.82; +} +.mythic-chat-header-actions { + align-items: center; + display: flex; + gap: 5px; +} +.mythic-chat-messages { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow-y: auto; + padding: 10px; +} +.mythic-chat-message-row { + display: flex; + justify-content: flex-start; +} +.mythic-chat-message-row-mine { + justify-content: flex-end; +} +.mythic-chat-message { + border: 1px solid var(--mythic-chat-markdown-border); + border-radius: 8px; + max-width: 100%; + min-width: 0; + overflow: hidden; + padding: 0; + width: fit-content; +} +.mythic-chat-message-header { + align-items: center; + background: var(--mythic-chat-markdown-surface); + border-bottom: 1px solid var(--mythic-chat-markdown-border); + display: flex; + gap: 8px; + justify-content: space-between; + margin: 0; + padding: 7px 10px; +} +.mythic-chat-message-system .mythic-chat-message-header { + background: ${(props) => `linear-gradient(135deg, ${alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.34 : 0.24)} 0%, ${alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.18 : 0.12)} 58%, ${alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.08 : 0.06)} 100%)`}; + border-bottom-color: ${(props) => alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.36 : 0.28)}; +} +.mythic-chat-author { + font-weight: 750; + min-width: 0; +} +.mythic-chat-author > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mythic-chat-message-actions { + align-items: center; + display: flex; + gap: 2px; + white-space: nowrap; + +} +.mythic-chat-message-actions .MuiTypography-caption { + font-size: 0.72rem; + font-weight: 650; +} +.mythic-chat-message-actions .MuiIconButton-root, +.mythic-chat-header-actions .MuiIconButton-root { + border-radius: 6px; + height: 28px; + width: 28px; +} +.mythic-chat-paragraph { + margin: 0 0 6px 0; + overflow-wrap: anywhere; + white-space: normal; +} +.mythic-chat-paragraph:last-child { + margin-bottom: 0; +} +.mythic-chat-markdown { + overflow-wrap: anywhere; + padding: 8px 10px 9px; +} +.mythic-chat-edit-box { + padding: 8px 10px 9px; +} +.mythic-chat-message > .MuiTypography-caption { + padding: 0 10px 8px; +} +.mythic-chat-heading { + font-weight: 800; + line-height: 1.25; + margin: 0 0 6px 0; +} +.mythic-chat-heading-1, +.mythic-chat-heading-2 { + font-size: 1rem; +} +.mythic-chat-heading-3, +.mythic-chat-heading-4, +.mythic-chat-heading-5, +.mythic-chat-heading-6 { + font-size: 0.9rem; +} +.mythic-chat-list { + margin: 4px 0 8px 0; + padding-left: 1.25rem; +} +.mythic-chat-list li { + margin: 2px 0; + padding-left: 0.15rem; +} +.mythic-chat-blockquote { + background: var(--mythic-chat-markdown-surface); + border: 1px solid var(--mythic-chat-markdown-border); + border-left: 3px solid var(--mythic-chat-markdown-border); + border-radius: 7px; + color: inherit; + margin: 6px 0; + padding: 4px 8px 4px 10px; +} +.mythic-chat-rule { + border: 0; + border-top: 1px solid var(--mythic-chat-markdown-border); + margin: 8px 0; +} +.mythic-chat-inline-code { + background: var(--mythic-chat-markdown-surface); + border: 1px solid var(--mythic-chat-markdown-border); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.88em; + padding: 1px 4px; +} +.mythic-chat-code-block { + margin: 6px 0; + position: relative; +} +.mythic-chat-code-block pre { + background: var(--mythic-chat-markdown-surface); + border: 1px solid var(--mythic-chat-markdown-border); + border-radius: 7px; + margin: 0; + overflow-x: auto; + padding: 10px; +} +.mythic-chat-code-block code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.86rem; +} +.mythic-chat-code-language { + background: var(--mythic-chat-markdown-surface-strong); + border: 1px solid var(--mythic-chat-markdown-border); + border-radius: 4px; + font-size: 0.68rem; + font-weight: 700; + padding: 1px 5px; + position: absolute; + right: 6px; + top: 5px; +} +.mythic-chat-table-wrap { + margin: 6px 0; + max-width: 100%; + overflow-x: auto; + width: fit-content; +} +.mythic-chat-table { + font-size: 0.82rem; + min-width: 100%; + width: 100%; +} +.mythic-chat-table .MuiTableCell-root { + font-size: 0.82rem; + vertical-align: top; +} +.mythic-chat-edit-box { + display: flex; + flex-direction: column; + gap: 6px; +} +.mythic-chat-edit-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} +.mythic-chat-dialog-content { + padding-top: 20px !important; +} +.mythic-chat-composer .MuiFormControl-root { + flex: 1 1 auto; +} +.mythic-chat-composer .MuiOutlinedInput-root { + align-items: center; + background: transparent; + padding-left: 0; +} +.mythic-chat-composer .MuiOutlinedInput-notchedOutline { + border: 0 !important; +} +.mythic-chat-composer .MuiInputBase-input { + padding-left: 0 !important; +} +.mythic-chat-send-button .MuiSvgIcon-root { + display: block; +} +.mythic-chat-system-button.MuiIconButton-root { + border-radius: ${(props) => props.theme.shape.borderRadius}px; + flex: 0 0 auto; + height: 38px; + width: 38px; +} +.mythic-chat-system-destination { + align-items: center; + background-color: ${(props) => props.theme.surfaces?.hover || props.theme.palette.action.hover}; + border: 1px solid ${getSoftBorderColor}; + border-radius: 7px; + display: flex; + gap: 8px; + min-width: 0; + padding: 8px 10px; +} +.mythic-chat-system-destination .MuiSvgIcon-root { + color: ${(props) => props.theme.palette.warning.main}; +} +.mythic-chat-system-options { + background-color: ${(props) => props.theme.surfaces?.hover || props.theme.palette.action.hover}; + border: 1px solid ${getSoftBorderColor}; + border-radius: 7px; + padding: 4px 10px; +} +.mythic-chat-dialog-content .MuiFormControl-root { + margin-top: 4px; +} +.mythic-chat-empty-state { + align-items: center; + align-self: center; + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 6px; + justify-content: center; + min-height: 12rem; + opacity: 0.72; + text-align: center; +} +.mythic-chat-empty-list { + background: ${(props) => props.theme.palette.action.hover}; + border-radius: 7px; + font-size: 0.76rem; + opacity: 0.72; + padding: 9px; +} +.mythic-chat-search-results { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 55vh; + overflow-y: auto; +} +.mythic-chat-search-result { + background: transparent; + border: 1px solid ${getSoftBorderColor}; + border-radius: 7px; + color: inherit; + cursor: pointer; + display: grid; + gap: 3px; + padding: 8px; + text-align: left; +} +.mythic-chat-search-message { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mythic-chat-search-meta { + font-size: 0.74rem; + opacity: 0.72; +} +@media (max-width: 900px) { + .mythic-chat-layout { + grid-template-columns: 1fr; + } + .mythic-chat-sidebar { + max-height: 220px; + } + .mythic-chat-message { + max-width: 100%; + min-width: 0; + } +} .ace_editor{ background-color: ${(props) => props.theme.outputBackgroundColor + "20"}; } diff --git a/hasura-docker/metadata/actions.graphql b/hasura-docker/metadata/actions.graphql index 726b23df..39d1945e 100644 --- a/hasura-docker/metadata/actions.graphql +++ b/hasura-docker/metadata/actions.graphql @@ -51,6 +51,80 @@ type Query { ): ConfigCheckOutput } +type Mutation { + chatCancelRequest( + request_id: Int! + ): ChatActionOutput +} + +type Mutation { + chatCreateChannel( + name: String! + description: String + channel_type: String + chat_container_id: Int + chat_model: String + locked: Boolean + ai_metadata: jsonb + ): ChatActionOutput +} + +type Mutation { + chatCreateMessage( + channel_id: Int! + message: String! + system_message: Boolean + all_operations: Boolean + ): ChatActionOutput +} + +type Mutation { + chatDeleteMessage( + message_id: Int! + ): ChatActionOutput +} + +type Mutation { + chatEditMessage( + message_id: Int! + message: String! + ): ChatActionOutput +} + +type Mutation { + chatMarkRead( + channel_id: Int! + last_read_message_id: Int + ): ChatActionOutput +} + +type Mutation { + chatRetryRequest( + request_id: Int! + ): ChatActionOutput +} + +type Query { + chatSearch( + query: String! + channel_id: Int + limit: Int + offset: Int + ): ChatActionOutput +} + +type Mutation { + chatUpdateChannel( + channel_id: Int! + name: String + description: String + archived: Boolean + locked: Boolean + chat_model: String + ai_metadata: jsonb + ): ChatActionOutput +} + type Mutation { consumingServicesTestLog( service_type: String! @@ -1327,3 +1401,28 @@ type custombrowserExportFunctionOutput { error: String } +type ChatActionOutput { + status: String! + error: String + id: Int + channel_id: Int + message_id: Int + request_id: Int + response_message_id: Int + results: [ChatSearchResult] +} + +type ChatSearchResult { + id: Int! + channel_id: Int! + channel_name: String! + channel_slug: String! + channel_type: String! + author_type: String! + sender_display_name: String! + message: String! + edited: Boolean! + status: String! + created_at: String! + rank: Float! +} diff --git a/hasura-docker/metadata/actions.yaml b/hasura-docker/metadata/actions.yaml index 28c44535..299085ae 100644 --- a/hasura-docker/metadata/actions.yaml +++ b/hasura-docker/metadata/actions.yaml @@ -91,6 +91,107 @@ actions: - role: mythic_admin - role: developer comment: Using the UUID of a payload, ask's that payload's C2 Profiles to check if the Payload's configuration matches with the configuration for the profile itself. + - name: chatCancelRequest + definition: + kind: synchronous + handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_cancel_request_webhook' + forward_client_headers: true + permissions: + - role: operator + - role: operation_admin + - role: mythic_admin + - role: developer + comment: Cancel an in-flight AI chat request + - name: chatCreateChannel + definition: + kind: synchronous + handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_create_channel_webhook' + forward_client_headers: true + permissions: + - role: operator + - role: operation_admin + - role: mythic_admin + - role: developer + comment: Create a standard or AI chat channel in the current operation + - name: chatCreateMessage + definition: + kind: synchronous + handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_create_message_webhook' + forward_client_headers: true + permissions: + - role: operator + - role: operation_admin + - role: mythic_admin + - role: developer + comment: Create a chat message, submit an AI chat prompt, or submit an admin-only system message across one or all operations + - name: chatDeleteMessage + definition: + kind: synchronous + handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_delete_message_webhook' + forward_client_headers: true + permissions: + - role: operator + - role: operation_admin + - role: mythic_admin + - role: developer + comment: Delete a chat message visible in the current operation + - name: chatEditMessage + definition: + kind: synchronous + handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_edit_message_webhook' + forward_client_headers: true + permissions: + - role: operator + - role: operation_admin + - role: mythic_admin + - role: developer + comment: Edit an operator-authored chat message + - name: chatMarkRead + definition: + kind: synchronous + handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_mark_read_webhook' + forward_client_headers: true + permissions: + - role: spectator + - role: operator + - role: operation_admin + - role: mythic_admin + - role: developer + comment: Mark a chat channel as read for the current operator + - name: chatRetryRequest + definition: + kind: synchronous + handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_retry_request_webhook' + forward_client_headers: true + permissions: + - role: operator + - role: operation_admin + - role: mythic_admin + - role: developer + comment: Retry an AI chat request in the same AI channel + - name: chatSearch + definition: + kind: "" + handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_search_webhook' + forward_client_headers: true + permissions: + - role: spectator + - role: operator + - role: operation_admin + - role: mythic_admin + - role: developer + comment: Search operation chat messages visible to the current token + - name: chatUpdateChannel + definition: + kind: synchronous + handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_update_channel_webhook' + forward_client_headers: true + permissions: + - role: operator + - role: operation_admin + - role: mythic_admin + - role: developer + comment: Update, archive, or lock a chat channel - name: consumingServicesTestLog definition: kind: synchronous diff --git a/hasura-docker/metadata/databases/default/tables/public_chat_channel.yaml b/hasura-docker/metadata/databases/default/tables/public_chat_channel.yaml new file mode 100644 index 00000000..641a0b5e --- /dev/null +++ b/hasura-docker/metadata/databases/default/tables/public_chat_channel.yaml @@ -0,0 +1,194 @@ +table: + name: chat_channel + schema: public +object_relationships: + - name: archived_operator + using: + foreign_key_constraint_on: archived_by + - name: chat_container + using: + foreign_key_constraint_on: chat_container_id + - name: creator + using: + foreign_key_constraint_on: created_by + - name: locked_operator + using: + foreign_key_constraint_on: locked_by + - name: operation + using: + foreign_key_constraint_on: operation_id +array_relationships: + - name: messages + using: + foreign_key_constraint_on: + column: channel_id + table: + name: chat_message + schema: public + - name: read_states + using: + foreign_key_constraint_on: + column: channel_id + table: + name: chat_read_state + schema: public + - name: requests + using: + foreign_key_constraint_on: + column: channel_id + table: + name: chat_request + schema: public +select_permissions: + - role: mythic_admin + permission: + columns: + - ai_metadata + - archived + - archived_at + - archived_by + - channel_type + - chat_container_id + - chat_model + - created_at + - created_by + - description + - id + - locked + - locked_at + - locked_by + - last_message_id + - name + - operation_id + - slug + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: operation_admin + permission: + columns: + - ai_metadata + - archived + - archived_at + - archived_by + - channel_type + - chat_container_id + - chat_model + - created_at + - created_by + - description + - id + - locked + - locked_at + - locked_by + - last_message_id + - name + - operation_id + - slug + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: operator + permission: + columns: + - ai_metadata + - archived + - archived_at + - archived_by + - channel_type + - chat_container_id + - chat_model + - created_at + - created_by + - description + - id + - locked + - locked_at + - locked_by + - last_message_id + - name + - operation_id + - slug + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: spectator + permission: + columns: + - ai_metadata + - archived + - archived_at + - archived_by + - channel_type + - chat_container_id + - chat_model + - created_at + - created_by + - description + - id + - locked + - locked_at + - locked_by + - last_message_id + - name + - operation_id + - slug + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true diff --git a/hasura-docker/metadata/databases/default/tables/public_chat_message.yaml b/hasura-docker/metadata/databases/default/tables/public_chat_message.yaml new file mode 100644 index 00000000..748ffd43 --- /dev/null +++ b/hasura-docker/metadata/databases/default/tables/public_chat_message.yaml @@ -0,0 +1,197 @@ +table: + name: chat_message + schema: public +object_relationships: + - name: apitoken + using: + foreign_key_constraint_on: apitokens_id + - name: channel + using: + foreign_key_constraint_on: channel_id + - name: chat_container + using: + foreign_key_constraint_on: chat_container_id + - name: deleted_operator + using: + foreign_key_constraint_on: deleted_by + - name: operation + using: + foreign_key_constraint_on: operation_id + - name: operator + using: + foreign_key_constraint_on: operator_id +array_relationships: + - name: requests_as_prompt + using: + foreign_key_constraint_on: + column: request_message_id + table: + name: chat_request + schema: public + - name: requests_as_response + using: + foreign_key_constraint_on: + column: response_message_id + table: + name: chat_request + schema: public + - name: read_states + using: + foreign_key_constraint_on: + column: last_read_message_id + table: + name: chat_read_state + schema: public +select_permissions: + - role: mythic_admin + permission: + columns: + - apitokens_id + - author_type + - channel_id + - chat_container_id + - created_at + - deleted + - deleted_at + - deleted_by + - edited + - edited_at + - id + - message + - metadata + - operation_id + - operator_id + - sender_display_name + - status + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - channel: + _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: operation_admin + permission: + columns: + - apitokens_id + - author_type + - channel_id + - chat_container_id + - created_at + - deleted + - deleted_at + - deleted_by + - edited + - edited_at + - id + - message + - metadata + - operation_id + - operator_id + - sender_display_name + - status + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - channel: + _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: operator + permission: + columns: + - apitokens_id + - author_type + - channel_id + - chat_container_id + - created_at + - deleted + - deleted_at + - deleted_by + - edited + - edited_at + - id + - message + - metadata + - operation_id + - operator_id + - sender_display_name + - status + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - channel: + _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: spectator + permission: + columns: + - apitokens_id + - author_type + - channel_id + - chat_container_id + - created_at + - deleted + - deleted_at + - deleted_by + - edited + - edited_at + - id + - message + - metadata + - operation_id + - operator_id + - sender_display_name + - status + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - channel: + _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true diff --git a/hasura-docker/metadata/databases/default/tables/public_chat_read_state.yaml b/hasura-docker/metadata/databases/default/tables/public_chat_read_state.yaml new file mode 100644 index 00000000..f79567ca --- /dev/null +++ b/hasura-docker/metadata/databases/default/tables/public_chat_read_state.yaml @@ -0,0 +1,125 @@ +table: + name: chat_read_state + schema: public +object_relationships: + - name: channel + using: + foreign_key_constraint_on: channel_id + - name: last_read_message + using: + foreign_key_constraint_on: last_read_message_id + - name: operation + using: + foreign_key_constraint_on: operation_id + - name: operator + using: + foreign_key_constraint_on: operator_id +select_permissions: + - role: mythic_admin + permission: + columns: + - channel_id + - last_read_message_id + - operation_id + - operator_id + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - operator_id: + _eq: X-Hasura-User-Id + - channel: + _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: operation_admin + permission: + columns: + - channel_id + - last_read_message_id + - operation_id + - operator_id + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - operator_id: + _eq: X-Hasura-User-Id + - channel: + _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: operator + permission: + columns: + - channel_id + - last_read_message_id + - operation_id + - operator_id + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - operator_id: + _eq: X-Hasura-User-Id + - channel: + _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: spectator + permission: + columns: + - channel_id + - last_read_message_id + - operation_id + - operator_id + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - operator_id: + _eq: X-Hasura-User-Id + - channel: + _or: + - _and: + - channel_type: + _eq: standard + - operation_id: + _lte: X-Hasura-Scope-chat-read-operation + - _and: + - channel_type: + _eq: ai + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true diff --git a/hasura-docker/metadata/databases/default/tables/public_chat_request.yaml b/hasura-docker/metadata/databases/default/tables/public_chat_request.yaml new file mode 100644 index 00000000..dab0de05 --- /dev/null +++ b/hasura-docker/metadata/databases/default/tables/public_chat_request.yaml @@ -0,0 +1,130 @@ +table: + name: chat_request + schema: public +object_relationships: + - name: channel + using: + foreign_key_constraint_on: channel_id + - name: chat_container + using: + foreign_key_constraint_on: chat_container_id + - name: creator + using: + foreign_key_constraint_on: created_by + - name: operation + using: + foreign_key_constraint_on: operation_id + - name: request_message + using: + foreign_key_constraint_on: request_message_id + - name: response_message + using: + foreign_key_constraint_on: response_message_id + - name: retry_of + using: + foreign_key_constraint_on: retry_of_id +select_permissions: + - role: mythic_admin + permission: + columns: + - cancelled_at + - channel_id + - chat_container_id + - completed_at + - context_snapshot + - created_at + - created_by + - error + - id + - model + - operation_id + - request_message_id + - response_message_id + - retry_of_id + - status + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: operation_admin + permission: + columns: + - cancelled_at + - channel_id + - chat_container_id + - completed_at + - context_snapshot + - created_at + - created_by + - error + - id + - model + - operation_id + - request_message_id + - response_message_id + - retry_of_id + - status + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: operator + permission: + columns: + - cancelled_at + - channel_id + - chat_container_id + - completed_at + - context_snapshot + - created_at + - created_by + - error + - id + - model + - operation_id + - request_message_id + - response_message_id + - retry_of_id + - status + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true + - role: spectator + permission: + columns: + - cancelled_at + - channel_id + - chat_container_id + - completed_at + - context_snapshot + - created_at + - created_by + - error + - id + - model + - operation_id + - request_message_id + - response_message_id + - retry_of_id + - status + - updated_at + filter: + _and: + - operation_id: + _eq: X-Hasura-current-operation-id + - operation_id: + _lte: X-Hasura-Scope-chat-ai-read-operation + allow_aggregations: true diff --git a/hasura-docker/metadata/databases/default/tables/tables.yaml b/hasura-docker/metadata/databases/default/tables/tables.yaml index 894525fd..019126e5 100644 --- a/hasura-docker/metadata/databases/default/tables/tables.yaml +++ b/hasura-docker/metadata/databases/default/tables/tables.yaml @@ -14,6 +14,10 @@ - "!include public_callbackgraphedge.yaml" - "!include public_callbackport.yaml" - "!include public_callbacktoken.yaml" +- "!include public_chat_channel.yaml" +- "!include public_chat_message.yaml" +- "!include public_chat_read_state.yaml" +- "!include public_chat_request.yaml" - "!include public_command.yaml" - "!include public_commandparameters.yaml" - "!include public_consuming_container.yaml" diff --git a/mythic-docker/src/authentication/mythicjwt/scopes.go b/mythic-docker/src/authentication/mythicjwt/scopes.go index 339ca05b..7aad1c63 100644 --- a/mythic-docker/src/authentication/mythicjwt/scopes.go +++ b/mythic-docker/src/authentication/mythicjwt/scopes.go @@ -18,6 +18,12 @@ const ( SCOPE_CALLBACK_READ = "callback.read" SCOPE_CALLBACK_WRITE = "callback.write" + SCOPE_CHAT_READ = "chat.read" + SCOPE_CHAT_WRITE = "chat.write" + + SCOPE_CHAT_AI_READ = "chat-ai.read" + SCOPE_CHAT_AI_WRITE = "chat-ai.write" + SCOPE_CONTAINER_FILE_READ = "container_file.read" SCOPE_CONTAINER_FILE_WRITE = "container_file.write" @@ -85,6 +91,10 @@ var scopeDefinitions = []ScopeDefinition{ {Name: SCOPE_C2_WRITE, DisplayName: "Write C2 profiles", Description: "Start, stop, update, or interact with C2 profile containers. Includes read access.", Resource: "c2", Access: "write", Includes: []string{SCOPE_C2_READ}}, {Name: SCOPE_CALLBACK_READ, DisplayName: "Read callbacks", Description: "View callbacks and callback metadata for accessible operations.", Resource: "callback", Access: "read"}, {Name: SCOPE_CALLBACK_WRITE, DisplayName: "Write callbacks", Description: "Create or update callback state. Includes read access.", Resource: "callback", Access: "write", Includes: []string{SCOPE_CALLBACK_READ}}, + {Name: SCOPE_CHAT_READ, DisplayName: "Read operation chat", Description: "View and search standard chat channels and messages for accessible operations.", Resource: "chat", Access: "read"}, + {Name: SCOPE_CHAT_WRITE, DisplayName: "Write operation chat", Description: "Create standard chat channels and post, edit, or delete standard chat messages. Includes read access.", Resource: "chat", Access: "write", Includes: []string{SCOPE_CHAT_READ}}, + {Name: SCOPE_CHAT_AI_READ, DisplayName: "Read AI chat", Description: "View and search AI-backed chat sessions and messages for accessible operations.", Resource: "chat-ai", Access: "read"}, + {Name: SCOPE_CHAT_AI_WRITE, DisplayName: "Write AI chat", Description: "Create AI chat sessions, post prompts, and receive AI chat responses. Includes read access.", Resource: "chat-ai", Access: "write", Includes: []string{SCOPE_CHAT_AI_READ}}, {Name: SCOPE_CONTAINER_FILE_READ, DisplayName: "Read container files", Description: "List and download files exposed by Mythic service containers.", Resource: "container_file", Access: "read"}, {Name: SCOPE_CONTAINER_FILE_WRITE, DisplayName: "Write container files", Description: "Write or remove files exposed by Mythic service containers. Includes read access.", Resource: "container_file", Access: "write", Includes: []string{SCOPE_CONTAINER_FILE_READ}}, {Name: SCOPE_CREDENTIAL_READ, DisplayName: "Read credentials", Description: "View credential records for accessible operations.", Resource: "credential", Access: "read"}, diff --git a/mythic-docker/src/authentication/mythicjwt/scopes_test.go b/mythic-docker/src/authentication/mythicjwt/scopes_test.go index fdb95e7a..0cf2e0ea 100644 --- a/mythic-docker/src/authentication/mythicjwt/scopes_test.go +++ b/mythic-docker/src/authentication/mythicjwt/scopes_test.go @@ -103,6 +103,18 @@ func TestAllowsScope(t *testing.T) { required: SCOPE_FILE_WRITE, want: true, }, + { + name: "hyphenated resource wildcard grants ai chat scope", + granted: []string{"chat-ai.*"}, + required: SCOPE_CHAT_AI_WRITE, + want: true, + }, + { + name: "chat write does not grant ai chat write", + granted: []string{SCOPE_CHAT_WRITE}, + required: SCOPE_CHAT_AI_WRITE, + want: false, + }, { name: "file write grants upload", granted: []string{SCOPE_FILE_WRITE}, @@ -232,6 +244,16 @@ func TestHasuraScopeRequirementsUseScalarClaims(t *testing.T) { wantAnchor: "operation", wantClaim: "x-hasura-scope-operation-write-operation", }, + { + scope: SCOPE_CHAT_READ, + wantAnchor: "operation", + wantClaim: "x-hasura-scope-chat-read-operation", + }, + { + scope: SCOPE_CHAT_AI_WRITE, + wantAnchor: "operation", + wantClaim: "x-hasura-scope-chat-ai-write-operation", + }, } for _, tt := range tests { diff --git a/mythic-docker/src/database/migrations/003003020_3.3.20.sql b/mythic-docker/src/database/migrations/003003020_3.3.20.sql index cbc79063..6c21a9ad 100644 --- a/mythic-docker/src/database/migrations/003003020_3.3.20.sql +++ b/mythic-docker/src/database/migrations/003003020_3.3.20.sql @@ -92,6 +92,225 @@ create index if not exists operationeventlog_unresolved_warning_source_operation on "public"."operationeventlog" using btree (source, operation_id, "level") where warning=true and resolved=false and deleted=false; +create table if not exists "public"."chat_channel" ( + id integer generated by default as identity primary key, + operation_id integer not null references "public"."operation"(id) on delete cascade, + name text not null, + slug text not null, + description text not null default '', + channel_type text not null default 'standard', + created_by integer not null references "public"."operator"(id) on delete restrict, + archived boolean not null default false, + archived_by integer references "public"."operator"(id) on delete set null, + archived_at timestamp without time zone, + locked boolean not null default false, + locked_by integer references "public"."operator"(id) on delete set null, + locked_at timestamp without time zone, + chat_container_id integer references "public"."consuming_container"(id) on delete set null, + chat_model text not null default '', + ai_metadata jsonb not null default jsonb_build_object(), + created_at timestamp without time zone not null default now(), + updated_at timestamp without time zone not null default now(), + constraint chat_channel_type_check check (channel_type in ('standard', 'ai')), + constraint chat_channel_ai_container_check check (channel_type <> 'ai' or chat_container_id is not null) +); + +create unique index if not exists chat_channel_operation_slug_unique +on "public"."chat_channel" using btree (operation_id, lower(slug)); + +create index if not exists chat_channel_operation_type_archived_idx +on "public"."chat_channel" using btree (operation_id, channel_type, archived, name); + +create or replace trigger set_public_chat_channel_updated_at +before update on "public"."chat_channel" +for each row execute function public.set_current_timestamp_updated_at(); + +create table if not exists "public"."chat_message" ( + id integer generated by default as identity primary key, + operation_id integer not null references "public"."operation"(id) on delete cascade, + channel_id integer not null references "public"."chat_channel"(id) on delete cascade, + operator_id integer references "public"."operator"(id) on delete set null, + apitokens_id integer references "public"."apitokens"(id) on delete set null, + author_type text not null default 'operator', + chat_container_id integer references "public"."consuming_container"(id) on delete set null, + sender_display_name text not null default '', + message text not null default '', + deleted boolean not null default false, + search_vector tsvector generated always as ( + to_tsvector('simple', case when deleted then '' else coalesce(message, '') end) + ) stored, + edited boolean not null default false, + edited_at timestamp without time zone, + deleted_by integer references "public"."operator"(id) on delete set null, + deleted_at timestamp without time zone, + status text not null default 'complete', + metadata jsonb not null default jsonb_build_object(), + created_at timestamp without time zone not null default now(), + updated_at timestamp without time zone not null default now(), + constraint chat_message_author_type_check check (author_type in ('operator', 'ai', 'system')), + constraint chat_message_status_check check (status in ('pending', 'streaming', 'complete', 'error', 'cancelled')) +); + +create index if not exists chat_message_operation_channel_id_idx +on "public"."chat_message" using btree (operation_id, channel_id, id desc); + +create index if not exists chat_message_operation_updated_idx +on "public"."chat_message" using btree (operation_id, updated_at desc); + +create index if not exists chat_message_operator_idx +on "public"."chat_message" using btree (operator_id); + +create index if not exists chat_message_search_vector_idx +on "public"."chat_message" using gin (search_vector); + +create or replace trigger set_public_chat_message_updated_at +before update on "public"."chat_message" +for each row execute function public.set_current_timestamp_updated_at(); + +alter table "public"."chat_channel" + add column if not exists last_message_id integer; + +update "public"."chat_channel" channel +set last_message_id = latest.id +from ( + select distinct on (channel_id) + channel_id, + id + from "public"."chat_message" + order by channel_id, id desc +) latest +where channel.id = latest.channel_id +and channel.last_message_id is distinct from latest.id; + +create index if not exists chat_channel_operation_unread_idx +on "public"."chat_channel" using btree (operation_id, archived, last_message_id); + +-- +migrate StatementBegin +create or replace function public.update_chat_channel_last_message_id() returns trigger + language plpgsql + as $$ +begin + update "public"."chat_channel" + set last_message_id = new.id, + updated_at = now() + where id = new.channel_id + and (last_message_id is null or last_message_id < new.id); + + return new; +end; +$$; +-- +migrate StatementEnd + +create or replace trigger update_chat_channel_last_message_id_trigger +after insert on "public"."chat_message" +for each row execute function public.update_chat_channel_last_message_id(); + +create table if not exists "public"."chat_request" ( + id integer generated by default as identity primary key, + operation_id integer not null references "public"."operation"(id) on delete cascade, + channel_id integer not null references "public"."chat_channel"(id) on delete cascade, + request_message_id integer not null references "public"."chat_message"(id) on delete cascade, + response_message_id integer not null references "public"."chat_message"(id) on delete cascade, + chat_container_id integer not null references "public"."consuming_container"(id) on delete restrict, + model text not null default '', + status text not null default 'pending', + error text not null default '', + context_snapshot jsonb not null default jsonb_build_object(), + retry_of_id integer references "public"."chat_request"(id) on delete set null, + created_by integer not null references "public"."operator"(id) on delete restrict, + created_at timestamp without time zone not null default now(), + updated_at timestamp without time zone not null default now(), + completed_at timestamp without time zone, + cancelled_at timestamp without time zone, + constraint chat_request_status_check check (status in ('pending', 'streaming', 'complete', 'error', 'cancelled')) +); + +create index if not exists chat_request_operation_channel_idx +on "public"."chat_request" using btree (operation_id, channel_id, id desc); + +create index if not exists chat_request_response_message_id_idx +on "public"."chat_request" using btree (response_message_id); + +create or replace trigger set_public_chat_request_updated_at +before update on "public"."chat_request" +for each row execute function public.set_current_timestamp_updated_at(); + +create table if not exists "public"."chat_read_state" ( + operation_id integer not null references "public"."operation"(id) on delete cascade, + channel_id integer not null references "public"."chat_channel"(id) on delete cascade, + operator_id integer not null references "public"."operator"(id) on delete cascade, + last_read_message_id integer references "public"."chat_message"(id) on delete set null, + updated_at timestamp without time zone not null default now(), + primary key (operator_id, channel_id) +); + +create index if not exists chat_read_state_operation_operator_idx +on "public"."chat_read_state" using btree (operation_id, operator_id); + +create or replace trigger set_public_chat_read_state_updated_at +before update on "public"."chat_read_state" +for each row execute function public.set_current_timestamp_updated_at(); + +-- +migrate StatementBegin +create or replace function public.create_default_chat_channel_for_operation() returns trigger + language plpgsql + as $$ +begin + insert into "public"."chat_channel" ( + operation_id, + name, + slug, + description, + channel_type, + created_by + ) + values ( + new.id, + 'general', + 'general', + 'Default operation chat channel', + 'standard', + new.admin_id + ) + on conflict do nothing; + + return new; +end; +$$; +-- +migrate StatementEnd + +create or replace trigger create_default_chat_channel_for_operation_trigger +after insert on "public"."operation" +for each row execute function public.create_default_chat_channel_for_operation(); + +insert into "public"."chat_channel" ( + operation_id, + name, + slug, + description, + channel_type, + created_by +) +select + operation.id, + 'general', + 'general', + 'Default operation chat channel', + 'standard', + operation.admin_id +from "public"."operation" +on conflict do nothing; + +update "public"."chat_channel" +set + name = 'general', + archived = false, + archived_by = null, + archived_at = null +where channel_type = 'standard' + and lower(slug) = 'general' + and (name <> 'general' or archived = true or archived_by is not null or archived_at is not null); + alter table "public"."task" add column if not exists subtask_callback_function_started boolean not null default false, add column if not exists group_callback_function_started boolean not null default false, @@ -227,6 +446,15 @@ drop index if exists "public"."mythictree_operation_tree_timestamp_idx"; drop index if exists "public"."mythictree_operation_tree_host_full_callback_idx"; drop index if exists "public"."mythictree_operation_tree_host_parent_callback_idx"; drop index if exists "public"."operationeventlog_unresolved_warning_source_operation_level_idx"; +drop index if exists "public"."chat_channel_operation_unread_idx"; +drop trigger if exists update_chat_channel_last_message_id_trigger on "public"."chat_message"; +drop function if exists public.update_chat_channel_last_message_id(); +drop trigger if exists create_default_chat_channel_for_operation_trigger on "public"."operation"; +drop function if exists public.create_default_chat_channel_for_operation(); +drop table if exists "public"."chat_read_state"; +drop table if exists "public"."chat_request"; +drop table if exists "public"."chat_message"; +drop table if exists "public"."chat_channel"; alter table "public"."task" drop column if exists completed_callback_function_started, drop column if exists group_callback_function_started, diff --git a/mythic-docker/src/database/structs/Chat.go b/mythic-docker/src/database/structs/Chat.go new file mode 100644 index 00000000..80d81117 --- /dev/null +++ b/mythic-docker/src/database/structs/Chat.go @@ -0,0 +1,111 @@ +package databaseStructs + +import ( + "database/sql" + "time" + + "github.com/its-a-feature/Mythic/utils/structs" +) + +const ( + ChatChannelTypeStandard = "standard" + ChatChannelTypeAI = "ai" + + ChatMessageAuthorOperator = "operator" + ChatMessageAuthorAI = "ai" + ChatMessageAuthorSystem = "system" + + ChatMessageStatusPending = "pending" + ChatMessageStatusStreaming = "streaming" + ChatMessageStatusComplete = "complete" + ChatMessageStatusError = "error" + ChatMessageStatusCancelled = "cancelled" +) + +type ChatChannel struct { + ID int `db:"id" json:"id"` + OperationID int `db:"operation_id" json:"operation_id"` + Operation Operation `db:"operation" json:"operation,omitempty"` + Name string `db:"name" json:"name"` + Slug string `db:"slug" json:"slug"` + Description string `db:"description" json:"description"` + ChannelType string `db:"channel_type" json:"channel_type"` + CreatedBy int `db:"created_by" json:"created_by"` + Creator Operator `db:"creator" json:"creator,omitempty"` + Archived bool `db:"archived" json:"archived"` + ArchivedBy structs.NullInt64 `db:"archived_by" json:"archived_by"` + ArchivedAt sql.NullTime `db:"archived_at" json:"archived_at"` + Locked bool `db:"locked" json:"locked"` + LockedBy structs.NullInt64 `db:"locked_by" json:"locked_by"` + LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` + LastMessageID structs.NullInt64 `db:"last_message_id" json:"last_message_id"` + ChatContainerID structs.NullInt64 `db:"chat_container_id" json:"chat_container_id"` + ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"` + ChatModel string `db:"chat_model" json:"chat_model"` + AIMetadata MythicJSONText `db:"ai_metadata" json:"ai_metadata"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +type ChatMessage struct { + ID int `db:"id" json:"id"` + OperationID int `db:"operation_id" json:"operation_id"` + Operation Operation `db:"operation" json:"operation,omitempty"` + ChannelID int `db:"channel_id" json:"channel_id"` + Channel ChatChannel `db:"channel" json:"channel,omitempty"` + OperatorID structs.NullInt64 `db:"operator_id" json:"operator_id"` + Operator Operator `db:"operator" json:"operator,omitempty"` + APITokensID structs.NullInt64 `db:"apitokens_id" json:"api_tokens_id" mapstructure:"apitokens_id"` + APIToken Apitokens `db:"apitoken" json:"apitoken,omitempty"` + AuthorType string `db:"author_type" json:"author_type"` + ChatContainerID structs.NullInt64 `db:"chat_container_id" json:"chat_container_id"` + ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"` + SenderDisplayName string `db:"sender_display_name" json:"sender_display_name"` + Message string `db:"message" json:"message"` + Edited bool `db:"edited" json:"edited"` + EditedAt sql.NullTime `db:"edited_at" json:"edited_at"` + Deleted bool `db:"deleted" json:"deleted"` + DeletedBy structs.NullInt64 `db:"deleted_by" json:"deleted_by"` + DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"` + Status string `db:"status" json:"status"` + Metadata MythicJSONText `db:"metadata" json:"metadata"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +type ChatRequest struct { + ID int `db:"id" json:"id"` + OperationID int `db:"operation_id" json:"operation_id"` + Operation Operation `db:"operation" json:"operation,omitempty"` + ChannelID int `db:"channel_id" json:"channel_id"` + Channel ChatChannel `db:"channel" json:"channel,omitempty"` + RequestMessageID int `db:"request_message_id" json:"request_message_id"` + RequestMessage ChatMessage `db:"request_message" json:"request_message,omitempty"` + ResponseMessageID int `db:"response_message_id" json:"response_message_id"` + ResponseMessage ChatMessage `db:"response_message" json:"response_message,omitempty"` + ChatContainerID int `db:"chat_container_id" json:"chat_container_id"` + ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"` + Model string `db:"model" json:"model"` + Status string `db:"status" json:"status"` + Error string `db:"error" json:"error"` + ContextSnapshot MythicJSONText `db:"context_snapshot" json:"context_snapshot"` + RetryOfID structs.NullInt64 `db:"retry_of_id" json:"retry_of_id"` + CreatedBy int `db:"created_by" json:"created_by"` + Creator Operator `db:"creator" json:"creator,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"` + CancelledAt sql.NullTime `db:"cancelled_at" json:"cancelled_at"` +} + +type ChatReadState struct { + OperationID int `db:"operation_id" json:"operation_id"` + Operation Operation `db:"operation" json:"operation,omitempty"` + ChannelID int `db:"channel_id" json:"channel_id"` + Channel ChatChannel `db:"channel" json:"channel,omitempty"` + OperatorID int `db:"operator_id" json:"operator_id"` + Operator Operator `db:"operator" json:"operator,omitempty"` + LastReadMessageID structs.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"` + LastReadMessage ChatMessage `db:"last_read_message" json:"last_read_message,omitempty"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/mythic-docker/src/rabbitmq/check_container_status.go b/mythic-docker/src/rabbitmq/check_container_status.go index ec4e5668..272f2833 100644 --- a/mythic-docker/src/rabbitmq/check_container_status.go +++ b/mythic-docker/src/rabbitmq/check_container_status.go @@ -205,6 +205,8 @@ func CreateGraphQLSpectatorAPITokenAndSendOnStartMessage(containerName string) { mythicjwt.SCOPE_FILE_WRITE, mythicjwt.SCOPE_TAG_WRITE, mythicjwt.SCOPE_CALLBACK_WRITE, + mythicjwt.SCOPE_CHAT_AI_READ, + mythicjwt.SCOPE_CHAT_AI_WRITE, } statement, err := database.DB.PrepareNamed(`INSERT INTO apitokens (token_value, operator_id, token_type, active, "name", created_by, task_id, callback_id, scopes) diff --git a/mythic-docker/src/rabbitmq/constants.go b/mythic-docker/src/rabbitmq/constants.go index e6e04bb8..b3def864 100644 --- a/mythic-docker/src/rabbitmq/constants.go +++ b/mythic-docker/src/rabbitmq/constants.go @@ -89,6 +89,9 @@ const ( CONTAINER_ON_START = "container_on_start" CONTAINER_ON_START_RESPONSE = "container_on_start_response" + // CHAT + CHAT_RESPONSE_ROUTING_KEY = "chat_response" + // CUSTOM_BROWSER routes CUSTOMBROWSER_SYNC_ROUTING_KEY = "custombrowser_sync" CUSTOMBROWSER_EXPORT_FUNCTION = "custombrowser_exportfunction" @@ -124,6 +127,7 @@ const ( // PT_TASK_PROCESS_RESPONSE = "pt_task_process_response" PT_COMMAND_HELP_FUNCTION = "pt_command_help_function" + CHAT_REQUEST = "chat_request" ) // Routes where container is consuming messages and responding back to Mythic diff --git a/mythic-docker/src/rabbitmq/initialize.go b/mythic-docker/src/rabbitmq/initialize.go index 6b6ccb5c..4732e41d 100644 --- a/mythic-docker/src/rabbitmq/initialize.go +++ b/mythic-docker/src/rabbitmq/initialize.go @@ -31,6 +31,7 @@ type DirectQueueStruct struct { RoutingKey string Handler QueueHandler Scopes []string + Sequential bool } type channelMutex struct { @@ -90,6 +91,7 @@ func (r *rabbitMQConnection) startListeners() { directQueue.Queue, directQueue.RoutingKey, directQueue.Handler, + directQueue.Sequential, exclusiveQueue) } go checkContainerStatus() diff --git a/mythic-docker/src/rabbitmq/recv_chat_response.go b/mythic-docker/src/rabbitmq/recv_chat_response.go new file mode 100644 index 00000000..d9c8d626 --- /dev/null +++ b/mythic-docker/src/rabbitmq/recv_chat_response.go @@ -0,0 +1,304 @@ +package rabbitmq + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/its-a-feature/Mythic/authentication/mythicjwt" + "github.com/its-a-feature/Mythic/database" + databaseStructs "github.com/its-a-feature/Mythic/database/structs" + "github.com/its-a-feature/Mythic/logging" + amqp "github.com/rabbitmq/amqp091-go" +) + +const chatStreamFlushInterval = 500 * time.Millisecond + +type ChatContainerResponseMessage struct { + OperationID int `json:"operation_id" mapstructure:"operation_id"` + RequestID int `json:"request_id" mapstructure:"request_id"` + ResponseMessageID int `json:"response_message_id" mapstructure:"response_message_id"` + Content string `json:"content" mapstructure:"content"` + IsDelta bool `json:"is_delta" mapstructure:"is_delta"` + Complete bool `json:"complete" mapstructure:"complete"` + Status string `json:"status" mapstructure:"status"` + Error string `json:"error" mapstructure:"error"` + Metadata map[string]interface{} `json:"metadata" mapstructure:"metadata"` +} + +type chatResponseRequest struct { + ID int `db:"id"` + OperationID int `db:"operation_id"` + ResponseMessageID int `db:"response_message_id"` + Status string `db:"status"` +} + +type chatStreamBuffer struct { + OperationID int + Pending string + Timer *time.Timer +} + +var ( + chatStreamBuffers = map[int]*chatStreamBuffer{} + chatStreamBuffersLock sync.Mutex + chatResponseLocks sync.Map +) + +func init() { + RabbitMQConnection.AddDirectQueue(DirectQueueStruct{ + Exchange: MYTHIC_EXCHANGE, + Queue: CHAT_RESPONSE_ROUTING_KEY, + RoutingKey: CHAT_RESPONSE_ROUTING_KEY, + Handler: processChatContainerResponse, + Scopes: []string{mythicjwt.SCOPE_CHAT_AI_WRITE}, + Sequential: true, + }) +} + +func processChatContainerResponse(msg amqp.Delivery) { + incomingMessage := ChatContainerResponseMessage{} + if err := json.Unmarshal(msg.Body, &incomingMessage); err != nil { + logging.LogError(err, "Failed to process chat container response message") + return + } + authContext, err := GetRabbitMQAuthContextFromHeaders(msg.Headers) + if err != nil { + logging.LogError(err, "Failed to get chat response auth headers") + return + } + if err = applyChatContainerResponse(incomingMessage, authContext); err != nil { + logging.LogError(err, "Failed to apply chat container response", + "request_id", incomingMessage.RequestID, "response_message_id", incomingMessage.ResponseMessageID) + } +} + +func applyChatContainerResponse(incomingMessage ChatContainerResponseMessage, authContext RabbitMQAuthContext) error { + request, err := getChatResponseRequest(incomingMessage, authContext.OperationID) + if err != nil { + return err + } + if request.Status == databaseStructs.ChatMessageStatusCancelled { + return nil + } + if incomingMessage.OperationID > 0 && incomingMessage.OperationID != request.OperationID { + return fmt.Errorf("chat response operation %d does not match request operation %d", + incomingMessage.OperationID, request.OperationID) + } + if authContext.OperationID > 0 && authContext.OperationID != request.OperationID { + return fmt.Errorf("chat response auth operation %d does not match request operation %d", + authContext.OperationID, request.OperationID) + } + if incomingMessage.ResponseMessageID > 0 && incomingMessage.ResponseMessageID != request.ResponseMessageID { + return fmt.Errorf("chat response message %d does not match request response message %d", + incomingMessage.ResponseMessageID, request.ResponseMessageID) + } + + chatResponseLock := getChatResponseLock(request.ResponseMessageID) + chatResponseLock.Lock() + defer chatResponseLock.Unlock() + + if request, err = getChatResponseRequest(ChatContainerResponseMessage{RequestID: request.ID}, request.OperationID); err != nil { + return err + } + if isTerminalChatResponseStatus(request.Status) { + return nil + } + + status := normalizeChatContainerResponseStatus(incomingMessage) + if incomingMessage.Content != "" { + if incomingMessage.IsDelta { + if err = queueChatResponseDelta(request.ResponseMessageID, request.OperationID, incomingMessage.Content); err != nil { + return err + } + } else { + if err = flushChatResponseMessageLocked(request.ResponseMessageID, request.OperationID, false); err != nil { + return err + } + if err = setChatResponseMessageContent(request.ResponseMessageID, request.OperationID, incomingMessage.Content); err != nil { + return err + } + } + if status == "" { + status = databaseStructs.ChatMessageStatusStreaming + } + } + + if status == databaseStructs.ChatMessageStatusComplete || + status == databaseStructs.ChatMessageStatusError || + status == databaseStructs.ChatMessageStatusCancelled { + if err = flushChatResponseMessageLocked(request.ResponseMessageID, request.OperationID, true); err != nil { + return err + } + } + if status == "" && len(incomingMessage.Metadata) > 0 { + status = request.Status + } + if status != "" { + if err = setChatResponseStatus(request, status, incomingMessage.Error, incomingMessage.Metadata); err != nil { + return err + } + if isTerminalChatResponseStatus(status) { + chatResponseLocks.Delete(request.ResponseMessageID) + } + } + return nil +} + +func getChatResponseRequest(incomingMessage ChatContainerResponseMessage, operationID int) (chatResponseRequest, error) { + request := chatResponseRequest{} + if incomingMessage.RequestID <= 0 && incomingMessage.ResponseMessageID <= 0 { + return request, errors.New("chat response requires request_id or response_message_id") + } + whereClause := "id=$1" + arg := incomingMessage.RequestID + if incomingMessage.RequestID <= 0 { + whereClause = "response_message_id=$1" + arg = incomingMessage.ResponseMessageID + } + sqlStatement := fmt.Sprintf(`SELECT id, operation_id, response_message_id, status + FROM chat_request + WHERE %s`, whereClause) + args := []interface{}{arg} + if operationID > 0 { + sqlStatement += " AND operation_id=$2" + args = append(args, operationID) + } + if err := database.DB.Get(&request, sqlStatement, args...); err != nil { + return request, err + } + return request, nil +} + +func normalizeChatContainerResponseStatus(incomingMessage ChatContainerResponseMessage) string { + status := strings.ToLower(strings.TrimSpace(incomingMessage.Status)) + switch status { + case databaseStructs.ChatMessageStatusStreaming, + databaseStructs.ChatMessageStatusComplete, + databaseStructs.ChatMessageStatusError, + databaseStructs.ChatMessageStatusCancelled: + return status + } + if incomingMessage.Error != "" { + return databaseStructs.ChatMessageStatusError + } + if incomingMessage.Complete { + return databaseStructs.ChatMessageStatusComplete + } + return "" +} + +func isTerminalChatResponseStatus(status string) bool { + return status == databaseStructs.ChatMessageStatusComplete || + status == databaseStructs.ChatMessageStatusError || + status == databaseStructs.ChatMessageStatusCancelled +} + +func getChatResponseLock(responseMessageID int) *sync.Mutex { + lock, _ := chatResponseLocks.LoadOrStore(responseMessageID, &sync.Mutex{}) + return lock.(*sync.Mutex) +} + +func queueChatResponseDelta(responseMessageID int, operationID int, delta string) error { + chatStreamBuffersLock.Lock() + defer chatStreamBuffersLock.Unlock() + buffer, ok := chatStreamBuffers[responseMessageID] + if !ok { + buffer = &chatStreamBuffer{OperationID: operationID} + chatStreamBuffers[responseMessageID] = buffer + } + if buffer.OperationID > 0 && operationID > 0 && buffer.OperationID != operationID { + return fmt.Errorf("chat response buffer operation %d does not match incoming operation %d for response message %d", + buffer.OperationID, operationID, responseMessageID) + } + if buffer.OperationID == 0 { + buffer.OperationID = operationID + } + buffer.Pending += delta + if buffer.Timer == nil { + buffer.Timer = time.AfterFunc(chatStreamFlushInterval, func() { + if err := flushChatResponseMessage(responseMessageID, operationID, false); err != nil { + logging.LogError(err, "Failed to flush chat response buffer", "response_message_id", responseMessageID) + } + }) + } + return nil +} + +func flushChatResponseMessage(responseMessageID int, operationID int, final bool) error { + chatResponseLock := getChatResponseLock(responseMessageID) + chatResponseLock.Lock() + defer chatResponseLock.Unlock() + return flushChatResponseMessageLocked(responseMessageID, operationID, final) +} + +func flushChatResponseMessageLocked(responseMessageID int, operationID int, final bool) error { + chatStreamBuffersLock.Lock() + buffer, ok := chatStreamBuffers[responseMessageID] + if !ok { + chatStreamBuffersLock.Unlock() + return nil + } + if buffer.OperationID > 0 && operationID > 0 && buffer.OperationID != operationID { + chatStreamBuffersLock.Unlock() + return fmt.Errorf("chat response buffer operation %d does not match flush operation %d for response message %d", + buffer.OperationID, operationID, responseMessageID) + } + pending := buffer.Pending + buffer.Pending = "" + if buffer.Timer != nil { + buffer.Timer.Stop() + buffer.Timer = nil + } + if final { + delete(chatStreamBuffers, responseMessageID) + } + chatStreamBuffersLock.Unlock() + + if pending == "" { + return nil + } + _, err := database.DB.Exec(`UPDATE chat_message + SET message = message || $2, + status = CASE WHEN status IN ('pending', 'streaming') THEN 'streaming' ELSE status END + WHERE id=$1 AND operation_id=$3 AND deleted=false`, responseMessageID, pending, operationID) + return err +} + +func setChatResponseMessageContent(responseMessageID int, operationID int, content string) error { + _, err := database.DB.Exec(`UPDATE chat_message + SET message=$3, + status = CASE WHEN status IN ('pending', 'streaming') THEN 'streaming' ELSE status END + WHERE id=$1 AND operation_id=$2 AND deleted=false`, responseMessageID, operationID, content) + return err +} + +func setChatResponseStatus(request chatResponseRequest, status string, responseError string, metadata map[string]interface{}) error { + if len(metadata) == 0 { + metadata = map[string]interface{}{} + } + metadataJSON := GetMythicJSONTextFromStruct(metadata) + if responseError != "" { + metadataJSON = GetMythicJSONTextFromStruct(map[string]interface{}{ + "container_metadata": metadata, + "error": responseError, + }) + } + _, err := database.DB.Exec(`UPDATE chat_message + SET status=$3, + metadata = metadata || $4::jsonb + WHERE id=$1 AND operation_id=$2 AND deleted=false`, request.ResponseMessageID, request.OperationID, status, metadataJSON.String()) + if err != nil { + return err + } + _, err = database.DB.Exec(`UPDATE chat_request + SET status=$2, + error=$3, + completed_at=CASE WHEN $2='complete' THEN now() ELSE completed_at END, + cancelled_at=CASE WHEN $2='cancelled' THEN now() ELSE cancelled_at END + WHERE id=$1 AND operation_id=$4 AND status <> 'cancelled'`, request.ID, status, responseError, request.OperationID) + return err +} diff --git a/mythic-docker/src/rabbitmq/recv_consuming_container_sync.go b/mythic-docker/src/rabbitmq/recv_consuming_container_sync.go index 2b9c2cbb..0f6015fa 100644 --- a/mythic-docker/src/rabbitmq/recv_consuming_container_sync.go +++ b/mythic-docker/src/rabbitmq/recv_consuming_container_sync.go @@ -38,6 +38,7 @@ const ( CONSUMING_SERVICES_TYPE_WEBHOOK = "webhook" CONSUMING_SERVICES_TYPE_EVENTING = "eventing" CONSUMING_SERVICES_TYPE_SCRIPTING = "scripting" + CONSUMING_SERVICES_TYPE_CHAT = "chat" ) func init() { diff --git a/mythic-docker/src/rabbitmq/send_chat_request.go b/mythic-docker/src/rabbitmq/send_chat_request.go new file mode 100644 index 00000000..eca7c235 --- /dev/null +++ b/mythic-docker/src/rabbitmq/send_chat_request.go @@ -0,0 +1,51 @@ +package rabbitmq + +import ( + "time" + + "github.com/its-a-feature/Mythic/logging" +) + +type ChatContainerContextMessage struct { + ID int `db:"id" json:"id" mapstructure:"id"` + AuthorType string `db:"author_type" json:"author_type" mapstructure:"author_type"` + SenderDisplayName string `db:"sender_display_name" json:"sender_display_name" mapstructure:"sender_display_name"` + Message string `db:"message" json:"message" mapstructure:"message"` + CreatedAt time.Time `db:"created_at" json:"created_at" mapstructure:"created_at"` +} + +type ChatContainerRequestMessage struct { + ContainerName string `json:"container_name" mapstructure:"container_name"` + OperationID int `json:"operation_id" mapstructure:"operation_id"` + ChannelID int `json:"channel_id" mapstructure:"channel_id"` + ChannelName string `json:"channel_name" mapstructure:"channel_name"` + ChannelSlug string `json:"channel_slug" mapstructure:"channel_slug"` + RequestID int `json:"request_id" mapstructure:"request_id"` + RequestMessageID int `json:"request_message_id" mapstructure:"request_message_id"` + ResponseMessageID int `json:"response_message_id" mapstructure:"response_message_id"` + Model string `json:"model" mapstructure:"model"` + Prompt string `json:"prompt" mapstructure:"prompt"` + Context []ChatContainerContextMessage `json:"context" mapstructure:"context"` + Secrets map[string]interface{} `json:"secrets" mapstructure:"secrets"` +} + +func (r *rabbitMQConnection) SendChatContainerRequest(containerName string, chatMessage ChatContainerRequestMessage, authContext RabbitMQAuthContext) error { + chatMessage.ContainerName = containerName + headers, err := GenerateRabbitMQAuthTokenHeader(authContext) + if err != nil { + logging.LogError(err, "Failed to generate auth context for chat request") + return err + } + if err = r.SendStructMessage( + MYTHIC_EXCHANGE, + GetChatContainerRequestRoutingKey(containerName), + "", + chatMessage, + false, + headers, + ); err != nil { + logging.LogError(err, "Failed to send chat request", "container", containerName, "request_id", chatMessage.RequestID) + return err + } + return nil +} diff --git a/mythic-docker/src/rabbitmq/utils_rabbitmq_routing.go b/mythic-docker/src/rabbitmq/utils_rabbitmq_routing.go index e93f7adb..1be159a1 100644 --- a/mythic-docker/src/rabbitmq/utils_rabbitmq_routing.go +++ b/mythic-docker/src/rabbitmq/utils_rabbitmq_routing.go @@ -59,6 +59,9 @@ func GetPtTaskProcessResponseRoutingKey(container string) string { func GetPtCommandHelpRoutingKey(container string) string { return fmt.Sprintf("%s_%s", container, PT_COMMAND_HELP_FUNCTION) } +func GetChatContainerRequestRoutingKey(container string) string { + return fmt.Sprintf("%s_%s", container, CHAT_REQUEST) +} // c2 rpc routing key functions func GetC2RPCOpsecChecksRoutingKey(container string) string { @@ -519,7 +522,7 @@ func (r *rabbitMQConnection) SendRPCMessage(exchange string, queue string, body logging.LogError(finalError, "failed 3 times") return nil, finalError } -func (r *rabbitMQConnection) ReceiveFromMythicDirectExchange(exchange string, queue string, routingKey string, handler QueueHandler, exclusiveQueue bool) { +func (r *rabbitMQConnection) ReceiveFromMythicDirectExchange(exchange string, queue string, routingKey string, handler QueueHandler, sequential bool, exclusiveQueue bool) { // exchange is a direct exchange // queue is where the messages get sent to (local name) // routingKey is the specific direct topic we're interested in for the exchange @@ -603,7 +606,11 @@ func (r *rabbitMQConnection) ReceiveFromMythicDirectExchange(exchange string, qu logging.LogError(err, "RabbitMQ direct exchange auth check failed", "queue", queue) continue } - go handler(d) + if sequential { + handler(d) + } else { + go handler(d) + } } forever <- true }() diff --git a/mythic-docker/src/webserver/controllers/chat_webhook.go b/mythic-docker/src/webserver/controllers/chat_webhook.go new file mode 100644 index 00000000..0fbaa0fa --- /dev/null +++ b/mythic-docker/src/webserver/controllers/chat_webhook.go @@ -0,0 +1,1177 @@ +package webcontroller + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/its-a-feature/Mythic/authentication" + "github.com/its-a-feature/Mythic/authentication/mythicjwt" + "github.com/its-a-feature/Mythic/database" + databaseStructs "github.com/its-a-feature/Mythic/database/structs" + "github.com/its-a-feature/Mythic/logging" + "github.com/its-a-feature/Mythic/rabbitmq" + "github.com/lib/pq" +) + +const ( + chatContextMessageLimit = 40 + chatGeneralChannelName = "general" +) + +var chatSlugInvalidCharacters = regexp.MustCompile(`[^a-z0-9]+`) + +type ChatActionResponse struct { + Status string `json:"status"` + Error string `json:"error,omitempty"` + ID int `json:"id,omitempty"` + ChannelID int `json:"channel_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + RequestID int `json:"request_id,omitempty"` + ResponseMessageID int `json:"response_message_id,omitempty"` + Results []ChatSearchResult `json:"results,omitempty"` +} + +type CreateChatChannelInput struct { + Input CreateChatChannel `json:"input" binding:"required"` +} + +type CreateChatChannel struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + ChannelType string `json:"channel_type"` + ChatContainerID *int `json:"chat_container_id"` + ChatModel string `json:"chat_model"` + Locked bool `json:"locked"` + AIMetadata interface{} `json:"ai_metadata"` +} + +type UpdateChatChannelInput struct { + Input UpdateChatChannel `json:"input" binding:"required"` +} + +type UpdateChatChannel struct { + ChannelID int `json:"channel_id" binding:"required"` + Name *string `json:"name"` + Description *string `json:"description"` + Archived *bool `json:"archived"` + Locked *bool `json:"locked"` + ChatModel *string `json:"chat_model"` + AIMetadata interface{} `json:"ai_metadata"` +} + +type CreateChatMessageInput struct { + Input CreateChatMessage `json:"input" binding:"required"` +} + +type CreateChatMessage struct { + ChannelID int `json:"channel_id" binding:"required"` + Message string `json:"message" binding:"required"` + SystemMessage bool `json:"system_message"` + AllOperations bool `json:"all_operations"` +} + +type EditChatMessageInput struct { + Input EditChatMessage `json:"input" binding:"required"` +} + +type EditChatMessage struct { + MessageID int `json:"message_id" binding:"required"` + Message string `json:"message" binding:"required"` +} + +type DeleteChatMessageInput struct { + Input DeleteChatMessage `json:"input" binding:"required"` +} + +type DeleteChatMessage struct { + MessageID int `json:"message_id" binding:"required"` +} + +type MarkChatReadInput struct { + Input MarkChatRead `json:"input" binding:"required"` +} + +type MarkChatRead struct { + ChannelID int `json:"channel_id" binding:"required"` + LastReadMessageID *int `json:"last_read_message_id"` +} + +type ChatSearchInput struct { + Input ChatSearch `json:"input" binding:"required"` +} + +type ChatSearch struct { + Query string `json:"query" binding:"required"` + ChannelID *int `json:"channel_id"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type ChatSearchResult struct { + ID int `db:"id" json:"id"` + ChannelID int `db:"channel_id" json:"channel_id"` + ChannelName string `db:"channel_name" json:"channel_name"` + ChannelSlug string `db:"channel_slug" json:"channel_slug"` + ChannelType string `db:"channel_type" json:"channel_type"` + AuthorType string `db:"author_type" json:"author_type"` + SenderDisplayName string `db:"sender_display_name" json:"sender_display_name"` + Message string `db:"message" json:"message"` + Edited bool `db:"edited" json:"edited"` + Status string `db:"status" json:"status"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + Rank float64 `db:"rank" json:"rank"` +} + +type ChatRequestActionInput struct { + Input ChatRequestAction `json:"input" binding:"required"` +} + +type ChatRequestAction struct { + RequestID int `json:"request_id" binding:"required"` +} + +func CreateChatChannelWebhook(c *gin.Context) { + var input CreateChatChannelInput + if !bindChatInput(c, &input) { + return + } + operatorOperation, ok := chatOperatorOperation(c) + if !ok { + return + } + channelType := strings.ToLower(strings.TrimSpace(input.Input.ChannelType)) + if channelType == "" { + channelType = databaseStructs.ChatChannelTypeStandard + } + if channelType != databaseStructs.ChatChannelTypeStandard && channelType != databaseStructs.ChatChannelTypeAI { + chatRespondError(c, "unknown chat channel type") + return + } + if !chatRequireScope(c, chatScopeForChannelType(channelType, true)) { + return + } + name := strings.TrimSpace(input.Input.Name) + if name == "" { + chatRespondError(c, "channel name is required") + return + } + slug, err := uniqueChatSlug(operatorOperation.CurrentOperation.ID, slugifyChatChannelName(name), 0) + if err != nil { + logging.LogError(err, "Failed to generate unique chat slug") + chatRespondError(c, err.Error()) + return + } + + var chatContainerID interface{} + if channelType == databaseStructs.ChatChannelTypeAI { + if input.Input.ChatContainerID == nil || *input.Input.ChatContainerID <= 0 { + chatRespondError(c, "AI chat channels require a chat_container_id") + return + } + if _, err = getChatContainer(*input.Input.ChatContainerID); err != nil { + logging.LogError(err, "Failed to find chat container") + chatRespondError(c, "failed to find a chat container with that id") + return + } + chatContainerID = *input.Input.ChatContainerID + } + var lockedBy interface{} + var lockedAt interface{} + if channelType == databaseStructs.ChatChannelTypeAI && input.Input.Locked { + lockedBy = operatorOperation.CurrentOperator.ID + lockedAt = time.Now().UTC() + } + metadata := chatJSONText(input.Input.AIMetadata) + var channelID int + err = database.DB.Get(&channelID, `INSERT INTO chat_channel + (operation_id, name, slug, description, channel_type, created_by, locked, locked_by, locked_at, + chat_container_id, chat_model, ai_metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb) + RETURNING id`, + operatorOperation.CurrentOperation.ID, + name, + slug, + input.Input.Description, + channelType, + operatorOperation.CurrentOperator.ID, + channelType == databaseStructs.ChatChannelTypeAI && input.Input.Locked, + lockedBy, + lockedAt, + chatContainerID, + input.Input.ChatModel, + metadata.String(), + ) + if err != nil { + logging.LogError(err, "Failed to create chat channel") + chatRespondError(c, err.Error()) + return + } + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channelID, ChannelID: channelID}) +} + +func UpdateChatChannelWebhook(c *gin.Context) { + var input UpdateChatChannelInput + if !bindChatInput(c, &input) { + return + } + operatorOperation, ok := chatOperatorOperation(c) + if !ok { + return + } + channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID) + if err != nil { + logging.LogError(err, "Failed to get chat channel") + chatRespondError(c, "failed to find chat channel") + return + } + if !chatRequireScope(c, chatScopeForChannel(channel, true)) { + return + } + if !chatCanManageChannel(operatorOperation, channel) { + chatRespondError(c, "only the channel creator or an operation admin can update this channel") + return + } + + name := channel.Name + slug := channel.Slug + if input.Input.Name != nil { + name = strings.TrimSpace(*input.Input.Name) + if name == "" { + chatRespondError(c, "channel name is required") + return + } + if chatIsGeneralChannel(channel) { + if name != channel.Name { + chatRespondError(c, "the general channel name cannot be changed") + return + } + } else { + slug, err = uniqueChatSlug(operatorOperation.CurrentOperation.ID, slugifyChatChannelName(name), channel.ID) + if err != nil { + logging.LogError(err, "Failed to generate unique chat slug") + chatRespondError(c, err.Error()) + return + } + } + } + description := channel.Description + if input.Input.Description != nil { + description = *input.Input.Description + } + archived := channel.Archived + var archivedBy interface{} + var archivedAt interface{} + if channel.ArchivedBy.Valid { + archivedBy = channel.ArchivedBy.Int64 + } + if channel.ArchivedAt.Valid { + archivedAt = channel.ArchivedAt.Time + } + if input.Input.Archived != nil { + archived = *input.Input.Archived + if chatIsGeneralChannel(channel) && archived { + chatRespondError(c, "the general channel cannot be archived") + return + } + if archived { + archivedBy = operatorOperation.CurrentOperator.ID + archivedAt = time.Now().UTC() + } else { + archivedBy = nil + archivedAt = nil + } + } + locked := channel.Locked + var lockedBy interface{} + var lockedAt interface{} + if channel.LockedBy.Valid { + lockedBy = channel.LockedBy.Int64 + } + if channel.LockedAt.Valid { + lockedAt = channel.LockedAt.Time + } + if input.Input.Locked != nil { + if channel.ChannelType != databaseStructs.ChatChannelTypeAI { + chatRespondError(c, "only AI chat channels can be locked") + return + } + locked = *input.Input.Locked + if locked { + lockedBy = operatorOperation.CurrentOperator.ID + lockedAt = time.Now().UTC() + } else { + if !chatIsModerator(operatorOperation) && + (!channel.LockedBy.Valid || int(channel.LockedBy.Int64) != operatorOperation.CurrentOperator.ID) { + chatRespondError(c, "only the lock owner or an operation admin can unlock this AI chat") + return + } + lockedBy = nil + lockedAt = nil + } + } + chatModel := channel.ChatModel + if input.Input.ChatModel != nil { + chatModel = *input.Input.ChatModel + } + metadata := channel.AIMetadata + if input.Input.AIMetadata != nil { + metadata = chatJSONText(input.Input.AIMetadata) + } + _, err = database.DB.Exec(`UPDATE chat_channel + SET name=$3, slug=$4, description=$5, archived=$6, archived_by=$7, archived_at=$8, + locked=$9, locked_by=$10, locked_at=$11, chat_model=$12, ai_metadata=$13::jsonb + WHERE id=$1 AND operation_id=$2`, + channel.ID, + operatorOperation.CurrentOperation.ID, + name, + slug, + description, + archived, + archivedBy, + archivedAt, + locked, + lockedBy, + lockedAt, + chatModel, + metadata.String(), + ) + if err != nil { + logging.LogError(err, "Failed to update chat channel") + chatRespondError(c, err.Error()) + return + } + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channel.ID, ChannelID: channel.ID}) +} + +func CreateChatMessageWebhook(c *gin.Context) { + var input CreateChatMessageInput + if !bindChatInput(c, &input) { + return + } + operatorOperation, ok := chatOperatorOperation(c) + if !ok { + return + } + message := strings.TrimSpace(input.Input.Message) + if message == "" { + chatRespondError(c, "message is required") + return + } + channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID) + if err != nil { + logging.LogError(err, "Failed to get chat channel for message") + chatRespondError(c, "failed to find chat channel") + return + } + if !chatRequireScope(c, chatScopeForChannel(channel, true)) { + return + } + if input.Input.AllOperations { + if !input.Input.SystemMessage { + chatRespondError(c, "sending to all operations requires a system message") + return + } + if !operatorOperation.CurrentOperator.Admin { + chatRespondError(c, "only a Mythic admin can create system messages in all operations") + return + } + messageID, _, err := insertSystemChatMessageAllOperations(operatorOperation, message, authentication.RabbitMQAuthContextFromGin(c)) + if err != nil { + logging.LogError(err, "Failed to create system chat messages in all operations") + chatRespondError(c, err.Error()) + return + } + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID}) + return + } + if channel.Archived { + chatRespondError(c, "cannot post to an archived chat channel") + return + } + if input.Input.SystemMessage { + if !chatCanCreateSystemMessage(operatorOperation) { + chatRespondError(c, "only an admin can create system messages") + return + } + messageID, err := insertSystemChatMessage(operatorOperation, channel, message, authentication.RabbitMQAuthContextFromGin(c)) + if err != nil { + logging.LogError(err, "Failed to create system chat message") + chatRespondError(c, err.Error()) + return + } + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID}) + return + } + if channel.ChannelType == databaseStructs.ChatChannelTypeAI { + if !chatCanPostToAIChannel(operatorOperation, channel) { + chatRespondError(c, "this AI chat is locked to another operator") + return + } + createAIChatMessage(c, operatorOperation, channel, message, nil) + return + } + messageID, err := insertOperatorChatMessage(operatorOperation, channel, message, authentication.RabbitMQAuthContextFromGin(c)) + if err != nil { + logging.LogError(err, "Failed to create chat message") + chatRespondError(c, err.Error()) + return + } + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID}) +} + +func EditChatMessageWebhook(c *gin.Context) { + var input EditChatMessageInput + if !bindChatInput(c, &input) { + return + } + operatorOperation, ok := chatOperatorOperation(c) + if !ok { + return + } + message := strings.TrimSpace(input.Input.Message) + if message == "" { + chatRespondError(c, "message is required") + return + } + chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID) + if err != nil { + logging.LogError(err, "Failed to get chat message for edit") + chatRespondError(c, "failed to find chat message") + return + } + if !chatRequireScope(c, chatScopeForChannel(channel, true)) { + return + } + if chatMessage.Deleted { + chatRespondError(c, "cannot edit a deleted chat message") + return + } + if chatMessage.AuthorType != databaseStructs.ChatMessageAuthorOperator || + !chatMessage.OperatorID.Valid || + int(chatMessage.OperatorID.Int64) != operatorOperation.CurrentOperator.ID { + chatRespondError(c, "only the message author can edit this message") + return + } + _, err = database.DB.Exec(`UPDATE chat_message + SET message=$3, edited=true, edited_at=now() + WHERE id=$1 AND operation_id=$2`, chatMessage.ID, operatorOperation.CurrentOperation.ID, message) + if err != nil { + logging.LogError(err, "Failed to edit chat message") + chatRespondError(c, err.Error()) + return + } + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID}) +} + +func DeleteChatMessageWebhook(c *gin.Context) { + var input DeleteChatMessageInput + if !bindChatInput(c, &input) { + return + } + operatorOperation, ok := chatOperatorOperation(c) + if !ok { + return + } + chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID) + if err != nil { + logging.LogError(err, "Failed to get chat message for delete") + chatRespondError(c, "failed to find chat message") + return + } + if !chatRequireScope(c, chatScopeForChannel(channel, true)) { + return + } + if !chatIsModerator(operatorOperation) && + (!chatMessage.OperatorID.Valid || int(chatMessage.OperatorID.Int64) != operatorOperation.CurrentOperator.ID) { + chatRespondError(c, "only the message author or an operation admin can delete this message") + return + } + _, err = database.DB.Exec(`UPDATE chat_message + SET message='Message Deleted', + deleted=true, + deleted_by=$3, + deleted_at=now(), + status='complete' + WHERE id=$1 AND operation_id=$2`, chatMessage.ID, operatorOperation.CurrentOperation.ID, operatorOperation.CurrentOperator.ID) + if err != nil { + logging.LogError(err, "Failed to delete chat message") + chatRespondError(c, err.Error()) + return + } + _, _ = database.DB.Exec(`UPDATE chat_request + SET status='cancelled', cancelled_at=now(), error='Chat message was deleted' + WHERE operation_id=$1 AND (request_message_id=$2 OR response_message_id=$2) AND status IN ('pending', 'streaming')`, + operatorOperation.CurrentOperation.ID, chatMessage.ID) + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID}) +} + +func MarkChatReadWebhook(c *gin.Context) { + var input MarkChatReadInput + if !bindChatInput(c, &input) { + return + } + operatorOperation, ok := chatOperatorOperation(c) + if !ok { + return + } + channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID) + if err != nil { + logging.LogError(err, "Failed to get chat channel for read marker") + chatRespondError(c, "failed to find chat channel") + return + } + if !chatRequireScope(c, chatScopeForChannel(channel, false)) { + return + } + var lastReadID interface{} + if input.Input.LastReadMessageID != nil { + lastReadID = *input.Input.LastReadMessageID + } else { + var maxID sql.NullInt64 + if err = database.DB.Get(&maxID, `SELECT max(id) FROM chat_message + WHERE operation_id=$1 AND channel_id=$2`, operatorOperation.CurrentOperation.ID, channel.ID); err != nil { + logging.LogError(err, "Failed to get max chat message id") + chatRespondError(c, err.Error()) + return + } + if maxID.Valid { + lastReadID = maxID.Int64 + } + } + _, err = database.DB.Exec(`INSERT INTO chat_read_state + (operation_id, channel_id, operator_id, last_read_message_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (operator_id, channel_id) DO UPDATE + SET last_read_message_id=excluded.last_read_message_id`, + operatorOperation.CurrentOperation.ID, channel.ID, operatorOperation.CurrentOperator.ID, lastReadID) + if err != nil { + logging.LogError(err, "Failed to mark chat channel read") + chatRespondError(c, err.Error()) + return + } + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ChannelID: channel.ID}) +} + +func ChatSearchWebhook(c *gin.Context) { + var input ChatSearchInput + if !bindChatInput(c, &input) { + return + } + operatorOperation, ok := chatOperatorOperation(c) + if !ok { + return + } + query := strings.TrimSpace(input.Input.Query) + if query == "" { + chatRespondError(c, "query is required") + return + } + allowedTypes := chatSearchAllowedChannelTypes(c) + if len(allowedTypes) == 0 { + chatRespondError(c, "missing required chat read scope") + return + } + limit := input.Input.Limit + if limit <= 0 || limit > 100 { + limit = 50 + } + offset := input.Input.Offset + if offset < 0 { + offset = 0 + } + var channelID interface{} + if input.Input.ChannelID != nil { + channelID = *input.Input.ChannelID + } + results := []ChatSearchResult{} + err := database.DB.Select(&results, `SELECT + chat_message.id, + chat_message.channel_id, + chat_channel.name "channel_name", + chat_channel.slug "channel_slug", + chat_channel.channel_type, + chat_message.author_type, + chat_message.sender_display_name, + chat_message.message, + chat_message.edited, + chat_message.status, + chat_message.created_at, + ts_rank(chat_message.search_vector, plainto_tsquery('simple', $3)) "rank" + FROM chat_message + JOIN chat_channel ON chat_message.channel_id = chat_channel.id + WHERE chat_message.operation_id=$1 + AND chat_message.deleted=false + AND chat_channel.channel_type = ANY($2) + AND chat_message.search_vector @@ plainto_tsquery('simple', $3) + AND ($4::integer IS NULL OR chat_message.channel_id=$4) + ORDER BY rank DESC, chat_message.id DESC + LIMIT $5 OFFSET $6`, + operatorOperation.CurrentOperation.ID, + pq.Array(allowedTypes), + query, + channelID, + limit, + offset, + ) + if err != nil { + logging.LogError(err, "Failed to search chat") + chatRespondError(c, err.Error()) + return + } + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", Results: results}) +} + +func CancelChatRequestWebhook(c *gin.Context) { + var input ChatRequestActionInput + if !bindChatInput(c, &input) { + return + } + operatorOperation, ok := chatOperatorOperation(c) + if !ok { + return + } + request, channel, err := getChatRequestAndChannel(input.Input.RequestID, operatorOperation.CurrentOperation.ID) + if err != nil { + logging.LogError(err, "Failed to find chat request for cancel") + chatRespondError(c, "failed to find chat request") + return + } + if !chatRequireScope(c, chatScopeForChannel(channel, true)) { + return + } + if !chatIsModerator(operatorOperation) && request.CreatedBy != operatorOperation.CurrentOperator.ID { + chatRespondError(c, "only the request creator or an operation admin can cancel this request") + return + } + _, err = database.DB.Exec(`UPDATE chat_request + SET status='cancelled', cancelled_at=now(), error='Cancelled by operator' + WHERE id=$1 AND operation_id=$2 AND status IN ('pending', 'streaming')`, + request.ID, operatorOperation.CurrentOperation.ID) + if err != nil { + logging.LogError(err, "Failed to cancel chat request") + chatRespondError(c, err.Error()) + return + } + _, _ = database.DB.Exec(`UPDATE chat_message + SET status='cancelled' + WHERE id=$1 AND operation_id=$2 AND status IN ('pending', 'streaming')`, + request.ResponseMessageID, operatorOperation.CurrentOperation.ID) + c.JSON(http.StatusOK, ChatActionResponse{Status: "success", RequestID: request.ID, ResponseMessageID: request.ResponseMessageID, ChannelID: channel.ID}) +} + +func RetryChatRequestWebhook(c *gin.Context) { + var input ChatRequestActionInput + if !bindChatInput(c, &input) { + return + } + operatorOperation, ok := chatOperatorOperation(c) + if !ok { + return + } + request, channel, err := getChatRequestAndChannel(input.Input.RequestID, operatorOperation.CurrentOperation.ID) + if err != nil { + logging.LogError(err, "Failed to find chat request for retry") + chatRespondError(c, "failed to find chat request") + return + } + if !chatRequireScope(c, chatScopeForChannel(channel, true)) { + return + } + if channel.Archived { + chatRespondError(c, "cannot retry in an archived chat channel") + return + } + if !chatCanPostToAIChannel(operatorOperation, channel) { + chatRespondError(c, "this AI chat is locked to another operator") + return + } + var prompt string + if err = database.DB.Get(&prompt, `SELECT message FROM chat_message + WHERE id=$1 AND operation_id=$2 AND deleted=false`, + request.RequestMessageID, operatorOperation.CurrentOperation.ID); err != nil { + logging.LogError(err, "Failed to get original prompt for retry") + chatRespondError(c, "failed to find original prompt") + return + } + createAIChatMessage(c, operatorOperation, channel, prompt, &request.ID) +} + +func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, retryOfID *int) { + container, err := getChatContainer(int(channel.ChatContainerID.Int64)) + if err != nil { + logging.LogError(err, "Failed to find AI chat container") + chatRespondError(c, "failed to find AI chat container") + return + } + if !container.ContainerRunning { + chatRespondError(c, fmt.Sprintf("chat container %s is not running", container.Name)) + return + } + activeRequestID, err := getActiveAIChatRequest(operatorOperation.CurrentOperation.ID, channel.ID) + if err != nil { + logging.LogError(err, "Failed to check active AI chat request") + chatRespondError(c, err.Error()) + return + } + if activeRequestID > 0 { + chatRespondError(c, fmt.Sprintf("AI chat request %d is still in progress; wait for it to finish or cancel it before sending another prompt", activeRequestID)) + return + } + authContext := authentication.RabbitMQAuthContextFromGin(c) + requestMessageID, err := insertOperatorChatMessage(operatorOperation, channel, message, authContext) + if err != nil { + logging.LogError(err, "Failed to create AI prompt message") + chatRespondError(c, err.Error()) + return + } + var responseMessageID int + if err = database.DB.Get(&responseMessageID, `INSERT INTO chat_message + (operation_id, channel_id, author_type, chat_container_id, sender_display_name, status, metadata) + VALUES ($1, $2, 'ai', $3, $4, 'pending', $5::jsonb) + RETURNING id`, + operatorOperation.CurrentOperation.ID, + channel.ID, + container.ID, + container.Name, + rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{"model": channel.ChatModel}).String(), + ); err != nil { + logging.LogError(err, "Failed to create AI response placeholder") + chatRespondError(c, err.Error()) + return + } + var retryOf interface{} + if retryOfID != nil { + retryOf = *retryOfID + } + var requestID int + if err = database.DB.Get(&requestID, `INSERT INTO chat_request + (operation_id, channel_id, request_message_id, response_message_id, chat_container_id, + model, status, context_snapshot, retry_of_id, created_by) + VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7::jsonb, $8, $9) + RETURNING id`, + operatorOperation.CurrentOperation.ID, + channel.ID, + requestMessageID, + responseMessageID, + container.ID, + channel.ChatModel, + rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{}).String(), + retryOf, + operatorOperation.CurrentOperator.ID, + ); err != nil { + logging.LogError(err, "Failed to create AI chat request") + chatRespondError(c, err.Error()) + return + } + contextMessages, err := getChatContextMessages(operatorOperation.CurrentOperation.ID, channel.ID, requestMessageID) + if err != nil { + logging.LogError(err, "Failed to fetch AI chat context") + markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error()) + chatRespondError(c, err.Error()) + return + } + contextIDs := make([]int, len(contextMessages)) + for i := range contextMessages { + contextIDs[i] = contextMessages[i].ID + } + contextSnapshot := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{ + "context_message_ids": contextIDs, + "request_message_id": requestMessageID, + "response_message_id": responseMessageID, + "retry_of_id": retryOf, + "context_message_limit": chatContextMessageLimit, + }) + _, _ = database.DB.Exec(`UPDATE chat_request + SET context_snapshot=$3::jsonb + WHERE id=$1 AND operation_id=$2`, requestID, operatorOperation.CurrentOperation.ID, contextSnapshot.String()) + + err = rabbitmq.RabbitMQConnection.SendChatContainerRequest(container.Name, rabbitmq.ChatContainerRequestMessage{ + OperationID: operatorOperation.CurrentOperation.ID, + ChannelID: channel.ID, + ChannelName: channel.Name, + ChannelSlug: channel.Slug, + RequestID: requestID, + RequestMessageID: requestMessageID, + ResponseMessageID: responseMessageID, + Model: channel.ChatModel, + Prompt: message, + Context: contextMessages, + Secrets: rabbitmq.GetSecrets(operatorOperation.CurrentOperator.ID, 0), + }, authContext) + if err != nil { + markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error()) + chatRespondError(c, err.Error()) + return + } + c.JSON(http.StatusOK, ChatActionResponse{ + Status: "success", + ID: requestMessageID, + MessageID: requestMessageID, + ChannelID: channel.ID, + RequestID: requestID, + ResponseMessageID: responseMessageID, + }) +} + +func bindChatInput(c *gin.Context, input interface{}) bool { + if err := c.ShouldBindJSON(input); err != nil { + logging.LogError(err, "Failed to get JSON parameters for chat webhook") + c.JSON(http.StatusOK, ChatActionResponse{Status: "error", Error: err.Error()}) + return false + } + return true +} + +func chatOperatorOperation(c *gin.Context) (*databaseStructs.Operatoroperation, bool) { + ginOperatorOperation, ok := c.Get(authentication.ContextKeyOperatorOperationStruct) + if !ok { + chatRespondError(c, "failed to get current operation") + return nil, false + } + operatorOperation := ginOperatorOperation.(*databaseStructs.Operatoroperation) + return operatorOperation, true +} + +func chatRespondError(c *gin.Context, err string) { + c.JSON(http.StatusOK, ChatActionResponse{Status: "error", Error: err}) +} + +func chatRequireScope(c *gin.Context, requiredScope string) bool { + claims, err := authentication.GetClaims(c) + if err != nil { + logging.LogError(err, "Failed to get claims for chat scope check") + chatRespondError(c, "failed to get authentication claims") + return false + } + if !mythicjwt.AllowsScope(claims.Scopes, requiredScope) { + chatRespondError(c, fmt.Sprintf("missing required scope: %s", requiredScope)) + return false + } + return true +} + +func chatSearchAllowedChannelTypes(c *gin.Context) []string { + claims, err := authentication.GetClaims(c) + if err != nil { + logging.LogError(err, "Failed to get claims for chat search scope check") + return []string{} + } + channelTypes := []string{} + if mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_CHAT_READ) { + channelTypes = append(channelTypes, databaseStructs.ChatChannelTypeStandard) + } + if mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_CHAT_AI_READ) { + channelTypes = append(channelTypes, databaseStructs.ChatChannelTypeAI) + } + return channelTypes +} + +func chatScopeForChannelType(channelType string, write bool) string { + if channelType == databaseStructs.ChatChannelTypeAI { + if write { + return mythicjwt.SCOPE_CHAT_AI_WRITE + } + return mythicjwt.SCOPE_CHAT_AI_READ + } + if write { + return mythicjwt.SCOPE_CHAT_WRITE + } + return mythicjwt.SCOPE_CHAT_READ +} + +func chatScopeForChannel(channel databaseStructs.ChatChannel, write bool) string { + return chatScopeForChannelType(channel.ChannelType, write) +} + +func chatIsModerator(operatorOperation *databaseStructs.Operatoroperation) bool { + return operatorOperation.CurrentOperator.Admin || + operatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_LEAD +} + +func chatCanCreateSystemMessage(operatorOperation *databaseStructs.Operatoroperation) bool { + return chatIsModerator(operatorOperation) +} + +func chatIsGeneralChannel(channel databaseStructs.ChatChannel) bool { + return channel.ChannelType == databaseStructs.ChatChannelTypeStandard && + strings.EqualFold(channel.Slug, chatGeneralChannelName) +} + +func chatCanManageChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool { + return chatIsModerator(operatorOperation) || channel.CreatedBy == operatorOperation.CurrentOperator.ID +} + +func chatCanPostToAIChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool { + if channel.ChannelType != databaseStructs.ChatChannelTypeAI { + return true + } + if !channel.Locked { + return true + } + return chatIsModerator(operatorOperation) || + (channel.LockedBy.Valid && int(channel.LockedBy.Int64) == operatorOperation.CurrentOperator.ID) +} + +func slugifyChatChannelName(name string) string { + slug := strings.ToLower(strings.TrimSpace(name)) + slug = chatSlugInvalidCharacters.ReplaceAllString(slug, "-") + slug = strings.Trim(slug, "-") + if slug == "" { + return "channel" + } + return slug +} + +func uniqueChatSlug(operationID int, baseSlug string, excludeChannelID int) (string, error) { + for i := 0; i < 1000; i++ { + candidate := baseSlug + if i > 0 { + candidate = fmt.Sprintf("%s-%d", baseSlug, i+1) + } + var existingID int + err := database.DB.Get(&existingID, `SELECT id FROM chat_channel + WHERE operation_id=$1 AND lower(slug)=lower($2) AND id <> $3`, + operationID, candidate, excludeChannelID) + if err == sql.ErrNoRows { + return candidate, nil + } + if err != nil { + return "", err + } + } + return "", fmt.Errorf("failed to find an available slug for %s", baseSlug) +} + +func chatJSONText(input interface{}) databaseStructs.MythicJSONText { + if input == nil { + return rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{}) + } + if inputString, ok := input.(string); ok { + var raw json.RawMessage + if err := json.Unmarshal([]byte(inputString), &raw); err == nil { + return rabbitmq.GetMythicJSONTextFromStruct(raw) + } + } + return rabbitmq.GetMythicJSONTextFromStruct(input) +} + +func getChatContainer(containerID int) (databaseStructs.ConsumingContainer, error) { + container := databaseStructs.ConsumingContainer{} + err := database.DB.Get(&container, `SELECT * + FROM consuming_container + WHERE id=$1 AND type=$2 AND deleted=false`, containerID, string(rabbitmq.CONSUMING_SERVICES_TYPE_CHAT)) + return container, err +} + +func getChatChannel(channelID int, operationID int) (databaseStructs.ChatChannel, error) { + channel := databaseStructs.ChatChannel{} + err := database.DB.Get(&channel, `SELECT * + FROM chat_channel + WHERE id=$1 AND operation_id=$2`, channelID, operationID) + return channel, err +} + +func getChatMessageAndChannel(messageID int, operationID int) (databaseStructs.ChatMessage, databaseStructs.ChatChannel, error) { + chatMessage := databaseStructs.ChatMessage{} + if err := database.DB.Get(&chatMessage, `SELECT + id, + operation_id, + channel_id, + operator_id, + apitokens_id, + author_type, + chat_container_id, + sender_display_name, + message, + edited, + edited_at, + deleted, + deleted_by, + deleted_at, + status, + metadata, + created_at, + updated_at + FROM chat_message + WHERE id=$1 AND operation_id=$2`, messageID, operationID); err != nil { + return chatMessage, databaseStructs.ChatChannel{}, err + } + channel, err := getChatChannel(chatMessage.ChannelID, operationID) + return chatMessage, channel, err +} + +func getChatRequestAndChannel(requestID int, operationID int) (databaseStructs.ChatRequest, databaseStructs.ChatChannel, error) { + request := databaseStructs.ChatRequest{} + if err := database.DB.Get(&request, `SELECT * + FROM chat_request + WHERE id=$1 AND operation_id=$2`, requestID, operationID); err != nil { + return request, databaseStructs.ChatChannel{}, err + } + channel, err := getChatChannel(request.ChannelID, operationID) + return request, channel, err +} + +func insertOperatorChatMessage(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, authContext rabbitmq.RabbitMQAuthContext) (int, error) { + var apiTokenID interface{} + if authContext.APITokensID > 0 { + apiTokenID = authContext.APITokensID + } + var messageID int + err := database.DB.Get(&messageID, `INSERT INTO chat_message + (operation_id, channel_id, operator_id, apitokens_id, author_type, sender_display_name, message, status) + VALUES ($1, $2, $3, $4, 'operator', $5, $6, 'complete') + RETURNING id`, + operatorOperation.CurrentOperation.ID, + channel.ID, + operatorOperation.CurrentOperator.ID, + apiTokenID, + operatorOperation.CurrentOperator.Username, + message, + ) + return messageID, err +} + +func insertSystemChatMessage(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, authContext rabbitmq.RabbitMQAuthContext) (int, error) { + var apiTokenID interface{} + if authContext.APITokensID > 0 { + apiTokenID = authContext.APITokensID + } + metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{ + "submitted_by_operator_id": operatorOperation.CurrentOperator.ID, + "submitted_by": operatorOperation.CurrentOperator.Username, + }) + var messageID int + err := database.DB.Get(&messageID, `INSERT INTO chat_message + (operation_id, channel_id, apitokens_id, author_type, sender_display_name, message, status, metadata) + VALUES ($1, $2, $3, 'system', 'System', $4, 'complete', $5::jsonb) + RETURNING id`, + operatorOperation.CurrentOperation.ID, + channel.ID, + apiTokenID, + message, + metadata.String(), + ) + return messageID, err +} + +func insertSystemChatMessageAllOperations(operatorOperation *databaseStructs.Operatoroperation, message string, authContext rabbitmq.RabbitMQAuthContext) (int, int, error) { + if err := ensureGeneralChatChannels(); err != nil { + return 0, 0, err + } + var apiTokenID interface{} + if authContext.APITokensID > 0 { + apiTokenID = authContext.APITokensID + } + metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{ + "submitted_by_operator_id": operatorOperation.CurrentOperator.ID, + "submitted_by": operatorOperation.CurrentOperator.Username, + "all_operations": true, + }) + messageIDs := []int{} + err := database.DB.Select(&messageIDs, `INSERT INTO chat_message + (operation_id, channel_id, apitokens_id, author_type, sender_display_name, message, status, metadata) + SELECT chat_channel.operation_id, chat_channel.id, $1, 'system', 'System', $2, 'complete', $3::jsonb + FROM chat_channel + JOIN operation ON operation.id=chat_channel.operation_id + WHERE operation.deleted=false + AND chat_channel.channel_type=$4 + AND lower(chat_channel.slug)=$5 + RETURNING id`, + apiTokenID, + message, + metadata.String(), + databaseStructs.ChatChannelTypeStandard, + chatGeneralChannelName, + ) + if err != nil { + return 0, 0, err + } + if len(messageIDs) == 0 { + return 0, 0, fmt.Errorf("failed to find general channels for any operations") + } + return messageIDs[0], len(messageIDs), nil +} + +func ensureGeneralChatChannels() error { + _, err := database.DB.Exec(`INSERT INTO chat_channel + (operation_id, name, slug, description, channel_type, created_by) + SELECT id, $1, $1, 'Default operation chat channel', $2, admin_id + FROM operation + WHERE deleted=false + ON CONFLICT DO NOTHING`, + chatGeneralChannelName, + databaseStructs.ChatChannelTypeStandard, + ) + if err != nil { + return err + } + _, err = database.DB.Exec(`UPDATE chat_channel + SET name=$1, + archived=false, + archived_by=NULL, + archived_at=NULL + WHERE channel_type=$2 + AND lower(slug)=$1 + AND (name <> $1 OR archived=true OR archived_by IS NOT NULL OR archived_at IS NOT NULL)`, + chatGeneralChannelName, + databaseStructs.ChatChannelTypeStandard, + ) + return err +} + +func getActiveAIChatRequest(operationID int, channelID int) (int, error) { + var requestID int + err := database.DB.Get(&requestID, `SELECT id + FROM chat_request + WHERE operation_id=$1 + AND channel_id=$2 + AND status IN ('pending', 'streaming') + ORDER BY id DESC + LIMIT 1`, operationID, channelID) + if err == sql.ErrNoRows { + return 0, nil + } + return requestID, err +} + +func getChatContextMessages(operationID int, channelID int, lastMessageID int) ([]rabbitmq.ChatContainerContextMessage, error) { + contextMessages := []rabbitmq.ChatContainerContextMessage{} + if err := database.DB.Select(&contextMessages, `SELECT + id, + author_type, + sender_display_name, + message, + created_at + FROM chat_message + WHERE operation_id=$1 + AND channel_id=$2 + AND id <= $3 + AND deleted=false + AND (author_type <> 'ai' OR status='complete') + ORDER BY id DESC + LIMIT $4`, operationID, channelID, lastMessageID, chatContextMessageLimit); err != nil { + return contextMessages, err + } + for i, j := 0, len(contextMessages)-1; i < j; i, j = i+1, j-1 { + contextMessages[i], contextMessages[j] = contextMessages[j], contextMessages[i] + } + return contextMessages, nil +} + +func markChatRequestFailed(requestID int, responseMessageID int, operationID int, errorMessage string) { + metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{ + "error": errorMessage, + }) + _, _ = database.DB.Exec(`UPDATE chat_message + SET status='error', metadata=metadata || $3::jsonb + WHERE id=$1 AND operation_id=$2`, responseMessageID, operationID, metadata.String()) + _, _ = database.DB.Exec(`UPDATE chat_request + SET status='error', error=$3 + WHERE id=$1 AND operation_id=$2`, requestID, operationID, errorMessage) +} diff --git a/mythic-docker/src/webserver/controllers/login.go b/mythic-docker/src/webserver/controllers/login.go index 768be992..31ceff90 100644 --- a/mythic-docker/src/webserver/controllers/login.go +++ b/mythic-docker/src/webserver/controllers/login.go @@ -46,6 +46,8 @@ func Login(c *gin.Context) { "username": currentOperation.CurrentOperator.Username, "id": currentOperation.CurrentOperator.ID, "user_id": currentOperation.CurrentOperator.ID, + "admin": currentOperation.CurrentOperator.Admin, + "view_mode": currentOperation.ViewMode, "view_utc_time": currentOperation.CurrentOperator.ViewUtcTime, "current_utc_time": time.Now().UTC(), } @@ -75,8 +77,12 @@ func GetMeWebhook(c *gin.Context) { "current_operation_banner_color": currentOperation.CurrentOperation.BannerColor, "current_operation_complete": currentOperation.CurrentOperation.Complete, "current_operation_id": currentOperation.CurrentOperation.ID, + "username": currentOperation.CurrentOperator.Username, "user_id": currentOperation.CurrentOperator.ID, "id": currentOperation.CurrentOperator.ID, + "admin": currentOperation.CurrentOperator.Admin, + "view_mode": currentOperation.ViewMode, + "view_utc_time": currentOperation.CurrentOperator.ViewUtcTime, "current_utc_time": time.Now().UTC(), "scope_info": buildScopeIntrospectionResponse(c, currentClaimsOrNil(c)), }) @@ -162,6 +168,8 @@ func RefreshJWT(c *gin.Context) { "username": currentOperation.CurrentOperator.Username, "id": currentOperation.CurrentOperator.ID, "user_id": currentOperation.CurrentOperator.ID, + "admin": currentOperation.CurrentOperator.Admin, + "view_mode": currentOperation.ViewMode, "view_utc_time": currentOperation.CurrentOperator.ViewUtcTime, "current_utc_time": time.Now().UTC(), } diff --git a/mythic-docker/src/webserver/initialize.go b/mythic-docker/src/webserver/initialize.go index 275f5555..050945fd 100644 --- a/mythic-docker/src/webserver/initialize.go +++ b/mythic-docker/src/webserver/initialize.go @@ -313,6 +313,10 @@ func setRoutes(r *gin.Engine) { mythicjwt.SCOPE_FILE_READ, }), webcontroller.DownloadBulkFilesWebhook) + allOperationMembers.POST("chat_search_webhook", + webcontroller.ChatSearchWebhook) + allOperationMembers.POST("chat_mark_read_webhook", + webcontroller.MarkChatReadWebhook) allOperationMembers.POST("preview_file_webhook", authentication.TokenScopeMiddleware([]string{ mythicjwt.SCOPE_FILE_READ, @@ -354,6 +358,21 @@ func setRoutes(r *gin.Engine) { webcontroller.ConsumingServicesTestWebhook) noSpectators.POST("consuming_services_test_log", webcontroller.ConsumingServicesTestLog) + // chat + noSpectators.POST("chat_create_channel_webhook", + webcontroller.CreateChatChannelWebhook) + noSpectators.POST("chat_update_channel_webhook", + webcontroller.UpdateChatChannelWebhook) + noSpectators.POST("chat_create_message_webhook", + webcontroller.CreateChatMessageWebhook) + noSpectators.POST("chat_edit_message_webhook", + webcontroller.EditChatMessageWebhook) + noSpectators.POST("chat_delete_message_webhook", + webcontroller.DeleteChatMessageWebhook) + noSpectators.POST("chat_cancel_request_webhook", + webcontroller.CancelChatRequestWebhook) + noSpectators.POST("chat_retry_request_webhook", + webcontroller.RetryChatRequestWebhook) // creating a payload noSpectators.POST("createpayload_webhook", authentication.TokenScopeMiddleware([]string{