diff --git a/.gitignore b/.gitignore index 635e3160e8..e165a06432 100644 --- a/.gitignore +++ b/.gitignore @@ -1,93 +1,29 @@ +#### Modmail +# Plugins +plugins/* +!plugins/registry.json +!plugins/@local + +# Config files +config.json +.env + +# Data +temp/ + +### Python # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - # Installer logs pip-log.txt pip-delete-this-directory.txt -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - # pyenv .python-version -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - # Environments .env .venv @@ -98,43 +34,62 @@ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject +#### PyCharm +.idea/ -# mkdocs documentation -/site +#### VS Code +.vscode/ -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +### Windows +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db -# Pyre type checker -.pyre/ +# Dump file +*.stackdump -# PyCharm -.idea/ +# Folder config file +[Dd]esktop.ini -# MacOS -.DS_Store +# Recycle Bin used on file shares +$RECYCLE.BIN/ -# VS Code -.vscode/ +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp -# Node -package-lock.json -node_modules/ +# Windows shortcuts +*.lnk -# Modmail -plugins/* -!plugins/registry.json -!plugins/@local -config.json -temp/ -test.py -stack.yml -.github/workflows/build_docker.yml \ No newline at end of file +### macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/CHANGELOG.md b/CHANGELOG.md index 536680ad2a..f1c8d12311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,30 +6,73 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project mostly adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); however, insignificant breaking changes do not guarantee a major version bump, see the reasoning [here](https://github.com/modmail-dev/modmail/issues/319). If you're a plugin developer, note the "BREAKING" section. +# Unreleased + +### Fixed +* Confirm thread creation (react to contact) no longer leaves a thread stuck in a "not ready" cache state when the recipient has DMs disabled. The bot now catches `discord.Forbidden` when sending the confirmation prompt, cancels the thread, and clears the cache entry immediately instead of requiring a bot restart. (#3442) + # v4.2.1 ### Added + +**New Configuration Options:** * `unsnooze_history_limit`: Limits the number of messages replayed when unsnoozing (genesis message and notes are always shown). * `snooze_behavior`: Choose between `delete` (legacy) or `move` behavior for snoozing. * `snoozed_category_id`: Target category for `move` snoozing; required when `snooze_behavior` is `move`. -* Thread-creation menu: Adds an interactive select step before a thread channel is created. - * Commands: - * `threadmenu toggle`: Enable/disable the menu. - * `threadmenu show`: List current top-level options. - * `threadmenu option add`: Interactive wizard to create an option. - * `threadmenu option edit/remove/show`: Manage or inspect an existing option. - * `threadmenu submenu create/delete/list/show`: Manage submenus. - * `threadmenu submenu option add/edit/remove`: Manage options inside a submenu. - * Configuration / Behavior: - * Per-option `category` targeting when creating a thread; falls back to `main_category_id` if invalid/missing. - * Optional selection logging (`thread_creation_menu_selection_log`) posts the chosen option in the new thread. - * Anonymous prompt support (`thread_creation_menu_anonymous_menu`). +* `snooze_store_attachments`: When enabled, image attachments are stored as base64 when snoozing with delete behavior, allowing them to be re-uploaded on unsnooze. +* `snooze_attachment_max_bytes`: Maximum size per attachment to store as base64 (default: 4 MiB). +* `thread_creation_menu_timeout`: Timeout duration for user interaction with the menu (default: 30 seconds). +* `thread_creation_menu_close_on_timeout`: Silently abort thread creation if user doesn't select an option. +* `thread_creation_menu_anonymous_menu`: Anonymize the initial menu prompt relayed to staff. +* `thread_creation_menu_embed_text`: Text shown in the embed above the selection dropdown. +* `thread_creation_menu_dropdown_placeholder`: Placeholder text in the dropdown before selection. +* `thread_creation_menu_selection_log`: Log the chosen menu option in the newly created thread channel. +* `thread_creation_menu_precreate_channel`: Create thread channel immediately upon first DM even if menu is enabled. +* `thread_creation_menu_embed_title`: Optional title for the thread-creation menu embed. +* `thread_creation_menu_embed_footer`: Optional footer text for the menu embed. +* `thread_creation_menu_embed_footer_icon_url`: Optional URL for the footer icon. +* `thread_creation_menu_embed_thumbnail_url`: Optional thumbnail image URL. +* `thread_creation_menu_embed_image_url`: Optional large hero image URL for the menu embed. +* `thread_creation_menu_embed_large_image`: Promote thumbnail to large hero image if no separate image URL is set. +* `thread_creation_menu_embed_color`: Color for the menu embed's side strip. + +**Thread-Creation Menu Feature:** +* Full thread-creation menu system with interactive select menus: + * `?threadmenu toggle`: Enable/disable the menu globally. + * `?threadmenu show`: List current top-level options. + * `?threadmenu option add`: Interactive wizard to create an option. + * `?threadmenu option edit/remove/show`: Manage or inspect existing options. + * `?threadmenu submenu create/delete/list/show`: Manage submenus (nested menu levels). + * `?threadmenu submenu option add/edit/remove`: Manage options inside submenus. + * `?threadmenu dump_config`: Export current configuration to a file. + * `?threadmenu load_config`: Import configuration from a file. + * `?threadmenu reset`: Reset all thread-creation menu settings to defaults. +* Per-option category targeting: Each menu option can specify a target category where threads are created. +* Submenu support: Create up to 25 main-level options, each with up to 24 nested options. +* Optional selection logging: Log which menu option was chosen in the newly created thread channel. +* Anonymous menu support: Hide original prompt author context from staff when menu is anonymized. +* Category fallback: If an option's category is invalid/missing, creation falls back to `main_category_id`. + +**Snooze Enhancements:** +* Attachment persistence for delete-behavior snoozing: Image attachments can now be stored as base64 data. +* Enhanced unsnooze functionality with configurable message replay limits. +* Auto-unsnooze task continuously monitors and automatically unsnoozes threads when duration expires. ### Changed -- Renamed `max_snooze_time` to `snooze_default_duration`. The old config will be invalidated. +- Renamed `max_snooze_time` to `snooze_default_duration` (accepts seconds or human-readable time like "7 days"). - When `snooze_behavior` is set to `move`, the snoozed category now has a hard limit of 49 channels. New snoozes are blocked once it’s full until space is freed. - When switching `snooze_behavior` to `move` via `?config set`, the bot reminds admins to set `snoozed_category_id` if it’s missing. -- Thread-creation menu options & submenu options now support an optional per-option `category` target. The interactive wizards (`threadmenu option add` / `threadmenu submenu option add`) and edit commands allow specifying or updating a category. If the stored category is missing or invalid at selection time, channel creation automatically falls back to `main_category_id`. +- Thread-creation menu options and submenu options now support per-option `category` targeting. +- Category selection in menu option wizards allows specifying ID, name, or mention format. +- Snoozed thread restoration now respects `unsnooze_history_limit` (if set) to replay only the last N messages. +- Enhanced auto-unsnooze task monitors and automatically unsnoozes threads when their snooze duration expires. +- Snoozed threads can now be moved to a dedicated category instead of being deleted (via `snooze_behavior: move`). + +### Fixed + +- Corrected behavior when snooze channel count reaches the 49-channel limit in move-based snoozing. +- Improved category resolution in threadmenu wizards (handles ID, name, and mention formats reliably). +- Enhanced thread state restoration after unsnoozing to properly re-add all recipients. # v4.2.0 diff --git a/Pipfile b/Pipfile index daa0e60698..6feb42454e 100644 --- a/Pipfile +++ b/Pipfile @@ -7,28 +7,29 @@ verify_ssl = true bandit = ">=1.7.5" black = "==23.11.0" pylint = "==3.0.2" -tomli = "==2.2.1" # Needed for black on Python < 3.11 +tomli = "==2.2.1" [packages] aiohttp = "==3.13.2" -async-timeout = {version = "==5.0.1", markers = "python_version < '3.11'"} # Required by aiohttp -typing-extensions = ">=4.12.2" # Required by aiohttp +async-timeout = {version = "==5.0.1", markers = "python_version < '3.11'"} +typing-extensions = "==4.15.0" colorama = "==0.4.6" "discord.py" = {version = "==2.6.3", extras = ["speed"]} emoji = "==2.8.0" isodate = "==0.6.1" motor = "==3.7.1" -natural = "==0.2.0" # Why is this needed? +natural = "==0.2.0" packaging = "==23.2" parsedatetime = "==2.6" -dnspython = ">=2.8,<3" # Required by pymongo -pymongo = ">=4.9,<5" # Required by motor +dnspython = "==2.8.0" +pymongo = "==4.15.3" python-dateutil = "==2.8.2" python-dotenv = "==1.0.0" -uvloop = {version = ">=0.19.0", markers = "sys_platform != 'win32'"} +uvloop = {version = "==0.22.1", markers = "sys_platform != 'win32'"} lottie = {version = "==0.7.2", extras = ["pdf"]} -setuptools = "*" # Needed for lottie +setuptools = "==80.9.0" requests = "==2.31.0" +orjson = "==3.11.4" [scripts] bot = "python bot.py" diff --git a/Pipfile.lock b/Pipfile.lock index 39cd6c33e2..011514b29b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b9e47a4bb95c39f0d11eeffe03c9229ef1751eec0e412c1a9b4c1f6dc47ed754" + "sha256": "6dc9fd3ca0aa2c413384ee16afb30290a840f6755cbf0bf828d0661171604db4" }, "pipfile-spec": 6, "requires": {}, @@ -14,14 +14,6 @@ ] }, "default": { - "aiodns": { - "hashes": [ - "sha256:11264edbab51896ecf546c18eb0dd56dff0428c6aa6d2cd87e643e07300eb310", - "sha256:6d0404f7d5215849233f6ee44854f2bb2481adf71b336b2279016ea5990ca5c5" - ], - "markers": "python_version >= '3.9'", - "version": "==3.5.0" - }, "aiohappyeyeballs": { "hashes": [ "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", @@ -181,61 +173,6 @@ "markers": "python_version >= '3.9'", "version": "==25.4.0" }, - "audioop-lts": { - "hashes": [ - "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", - "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", - "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", - "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", - "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", - "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", - "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", - "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", - "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", - "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", - "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", - "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", - "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", - "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", - "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", - "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", - "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", - "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", - "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", - "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", - "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", - "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", - "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", - "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", - "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", - "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", - "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", - "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", - "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", - "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", - "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", - "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", - "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", - "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", - "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", - "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", - "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", - "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", - "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", - "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", - "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", - "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", - "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", - "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", - "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", - "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", - "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", - "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", - "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800" - ], - "markers": "python_version >= '3.13'", - "version": "==0.2.2" - }, "brotli": { "hashes": [ "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", @@ -359,11 +296,11 @@ }, "certifi": { "hashes": [ - "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", - "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43" + "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", + "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316" ], "markers": "python_version >= '3.7'", - "version": "==2025.10.5" + "version": "==2025.11.12" }, "cffi": { "hashes": [ @@ -1048,6 +985,7 @@ "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1", "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff" ], + "index": "pypi", "markers": "python_version >= '3.9'", "version": "==3.11.4" }, @@ -1293,104 +1231,6 @@ "markers": "python_version >= '3.9'", "version": "==0.4.1" }, - "pycares": { - "hashes": [ - "sha256:00538826d2eaf4a0e4becb0753b0ac8d652334603c445c9566c9eb273657eb4c", - "sha256:066f3caa07c85e1a094aebd9e7a7bb3f3b2d97cff2276665693dd5c0cc81cf84", - "sha256:0aed0974eab3131d832e7e84a73ddb0dddbc57393cd8c0788d68a759a78c4a7b", - "sha256:1571a7055c03a95d5270c914034eac7f8bfa1b432fc1de53d871b821752191a4", - "sha256:1732db81e348bfce19c9bf9448ba660aea03042eeeea282824da1604a5bd4dcf", - "sha256:1dbbf0cfb39be63598b4cdc2522960627bf2f523e49c4349fb64b0499902ec7c", - "sha256:218619b912cef7c64a339ab0e231daea10c994a05699740714dff8c428b9694a", - "sha256:23d50a0842e8dbdddf870a7218a7ab5053b68892706b3a391ecb3d657424d266", - "sha256:29daa36548c04cdcd1a78ae187a4b7b003f0b357a2f4f1f98f9863373eedc759", - "sha256:2c296ab94d1974f8d2f76c499755a9ce31ffd4986e8898ef19b90e32525f7d84", - "sha256:2d5cac829da91ade70ce1af97dad448c6cd4778b48facbce1b015e16ced93642", - "sha256:30ceed06f3bf5eff865a34d21562c25a7f3dad0ed336b9dd415330e03a6c50c4", - "sha256:30d197180af626bb56f17e1fa54640838d7d12ed0f74665a3014f7155435b199", - "sha256:30feeab492ac609f38a0d30fab3dc1789bd19c48f725b2955bcaaef516e32a21", - "sha256:3139ec1f4450a4b253386035c5ecd2722582ae3320a456df5021ffe3f174260a", - "sha256:31b85ad00422b38f426e5733a71dfb7ee7eb65a99ea328c508d4f552b1760dc8", - "sha256:35ff1ec260372c97ed688efd5b3c6e5481f2274dea08f6c4ea864c195a9673c6", - "sha256:3784b80d797bcc2ff2bf3d4b27f46d8516fe1707ff3b82c2580dc977537387f9", - "sha256:386da2581db4ea2832629e275c061103b0be32f9391c5dfaea7f6040951950ad", - "sha256:3b44e54cad31d3c3be5e8149ac36bc1c163ec86e0664293402f6f846fb22ad00", - "sha256:3bd81ad69f607803f531ff5cfa1262391fa06e78488c13495cee0f70d02e0287", - "sha256:3d5300a598ad48bbf169fba1f2b2e4cf7ab229e7c1a48d8c1166f9ccf1755cb3", - "sha256:3db6b6439e378115572fa317053f3ee6eecb39097baafe9292320ff1a9df73e3", - "sha256:3ef1ab7abbd238bb2dbbe871c3ea39f5a7fc63547c015820c1e24d0d494a1689", - "sha256:45d3254a694459fdb0640ef08724ca9d4b4f6ff6d7161c9b526d7d2e2111379e", - "sha256:4b6f7581793d8bb3014028b8397f6f80b99db8842da58f4409839c29b16397ad", - "sha256:4da2e805ed8c789b9444ef4053f6ef8040cd13b0c1ca6d3c4fe6f9369c458cb4", - "sha256:5344d52efa37df74728505a81dd52c15df639adffd166f7ddca7a6318ecdb605", - "sha256:5d69e2034160e1219665decb8140e439afc7a7afcfd4adff08eb0f6142405c3e", - "sha256:5d70324ca1d82c6c4b00aa678347f7560d1ef2ce1d181978903459a97751543a", - "sha256:5e1ab899bb0763dea5d6569300aab3a205572e6e2d0ef1a33b8cf2b86d1312a4", - "sha256:6195208b16cce1a7b121727710a6f78e8403878c1017ab5a3f92158b048cec34", - "sha256:66c310773abe42479302abf064832f4a37c8d7f788f4d5ee0d43cbad35cf5ff4", - "sha256:6f74b1d944a50fa12c5006fd10b45e1a45da0c5d15570919ce48be88e428264c", - "sha256:6f751f5a0e4913b2787f237c2c69c11a53f599269012feaa9fb86d7cef3aec26", - "sha256:702d21823996f139874aba5aa9bb786d69e93bde6e3915b99832eb4e335d31ae", - "sha256:719f7ddff024fdacde97b926b4b26d0cc25901d5ef68bb994a581c420069936d", - "sha256:742fbaa44b418237dbd6bf8cdab205c98b3edb334436a972ad341b0ea296fb47", - "sha256:7570e0b50db619b2ee370461c462617225dc3a3f63f975c6f117e2f0c94f82ca", - "sha256:775d99966e28c8abd9910ddef2de0f1e173afc5a11cea9f184613c747373ab80", - "sha256:77bf82dc0beb81262bf1c7f546e1c1fde4992e5c8a2343b867ca201b85f9e1aa", - "sha256:7830709c23bbc43fbaefbb3dde57bdd295dc86732504b9d2e65044df8fd5e9fb", - "sha256:7aba9a312a620052133437f2363aae90ae4695ee61cb2ee07cbb9951d4c69ddd", - "sha256:80752133442dc7e6dd9410cec227c49f69283c038c316a8585cca05ec32c2766", - "sha256:836725754c32363d2c5d15b931b3ebd46b20185c02e850672cb6c5f0452c1e80", - "sha256:83a7401d7520fa14b00d85d68bcca47a0676c69996e8515d53733972286f9739", - "sha256:84b0b402dd333403fdce0e204aef1ef834d839c439c0c1aa143dc7d1237bb197", - "sha256:84fde689557361764f052850a2d68916050adbfd9321f6105aca1d8f1a9bd49b", - "sha256:87dab618fe116f1936f8461df5970fcf0befeba7531a36b0a86321332ff9c20b", - "sha256:8a75a406432ce39ce0ca41edff7486df6c970eb0fe5cfbe292f195a6b8654461", - "sha256:910ce19a549f493fb55cfd1d7d70960706a03de6bfc896c1429fc5d6216df77e", - "sha256:9518514e3e85646bac798d94d34bf5b8741ee0cb580512e8450ce884f526b7cf", - "sha256:95bc81f83fadb67f7f87914f216a0e141555ee17fd7f56e25aa0cc165e99e53b", - "sha256:96e07d5a8b733d753e37d1f7138e7321d2316bb3f0f663ab4e3d500fabc82807", - "sha256:97d971b3a88a803bb95ff8a40ea4d68da59319eb8b59e924e318e2560af8c16d", - "sha256:9a00408105901ede92e318eecb46d0e661d7d093d0a9b1224c71b5dd94f79e83", - "sha256:9d0c543bdeefa4794582ef48f3c59e5e7a43d672a4bfad9cbbd531e897911690", - "sha256:a4060d8556c908660512d42df1f4a874e4e91b81f79e3a9090afedc7690ea5ba", - "sha256:a98fac4a3d4f780817016b6f00a8a2c2f41df5d25dfa8e5b1aa0d783645a6566", - "sha256:aa160dc9e785212c49c12bb891e242c949758b99542946cc8e2098ef391f93b0", - "sha256:aca981fc00c8af8d5b9254ea5c2f276df8ece089b081af1ef4856fbcfc7c698a", - "sha256:afc6503adf8b35c21183b9387be64ca6810644ef54c9ef6c99d1d5635c01601b", - "sha256:b50ca218a3e2e23cbda395fd002d030385202fbb8182aa87e11bea0a568bd0b8", - "sha256:b93d624560ba52287873bacff70b42c99943821ecbc810b959b0953560f53c36", - "sha256:bac55842047567ddae177fb8189b89a60633ac956d5d37260f7f71b517fd8b87", - "sha256:c0eec184df42fc82e43197e073f9cc8f93b25ad2f11f230c64c2dc1c80dbc078", - "sha256:c2971af3a4094280f7c24293ff4d361689c175c1ebcbea6b3c1560eaff7cb240", - "sha256:c2af7a9d3afb63da31df1456d38b91555a6c147710a116d5cc70ab1e9f457a4f", - "sha256:c863d9003ca0ce7df26429007859afd2a621d3276ed9fef154a9123db9252557", - "sha256:c9d839b5700542b27c1a0d359cbfad6496341e7c819c7fea63db9588857065ed", - "sha256:cb711a66246561f1cae51244deef700eef75481a70d99611fd3c8ab5bd69ab49", - "sha256:cdac992206756b024b371760c55719eb5cd9d6b2cb25a8d5a04ae1b0ff426232", - "sha256:cf306f3951740d7bed36149a6d8d656a7d5432dd4bbc6af3bb6554361fc87401", - "sha256:d2a3526dbf6cb01b355e8867079c9356a8df48706b4b099ac0bf59d4656e610d", - "sha256:d552fb2cb513ce910d1dc22dbba6420758a991a356f3cd1b7ec73a9e31f94d01", - "sha256:d5fe089be67bc5927f0c0bd60c082c79f22cf299635ee3ddd370ae2a6e8b4ae0", - "sha256:dc54a21586c096df73f06f9bdf594e8d86d7be84e5d4266358ce81c04c3cc88c", - "sha256:dcd4a7761fdfb5aaac88adad0a734dd065c038f5982a8c4b0dd28efa0bd9cc7c", - "sha256:dde02314eefb85dce3cfdd747e8b44c69a94d442c0d7221b7de151ee4c93f0f5", - "sha256:df0a17f4e677d57bca3624752bbb515316522ad1ce0de07ed9d920e6c4ee5d35", - "sha256:e0fcd3a8bac57a0987d9b09953ba0f8703eb9dca7c77f7051d8c2ed001185be8", - "sha256:e2f8d9cfe0eb3a2997fde5df99b1aaea5a46dabfcfcac97b2d05f027c2cd5e28", - "sha256:ea785d1f232b42b325578f0c8a2fa348192e182cc84a1e862896076a4a2ba2a7", - "sha256:eddf5e520bb88b23b04ac1f28f5e9a7c77c718b8b4af3a4a7a2cc4a600f34502", - "sha256:ee1ea367835eb441d246164c09d1f9703197af4425fc6865cefcde9e2ca81f85", - "sha256:ee751409322ff10709ee867d5aea1dc8431eec7f34835f0f67afd016178da134", - "sha256:f199702740f3b766ed8c70efb885538be76cb48cd0cb596b948626f0b825e07a", - "sha256:f4695153333607e63068580f2979b377b641a03bc36e02813659ffbea2b76fe2", - "sha256:f6c602c5e3615abbf43dbdf3c6c64c65e76e5aa23cb74e18466b55d4a2095468", - "sha256:faa8321bc2a366189dcf87b3823e030edf5ac97a6b9a7fc99f1926c4bf8ef28e", - "sha256:ff3d25883b7865ea34c00084dd22a7be7c58fd3131db6b25c35eafae84398f9d", - "sha256:ffb22cee640bc12ee0e654eba74ecfb59e2e0aebc5bccc3cc7ef92f487008af7" - ], - "markers": "python_version >= '3.9'", - "version": "==4.11.0" - }, "pycparser": { "hashes": [ "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", @@ -1523,11 +1363,11 @@ }, "tinycss2": { "hashes": [ - "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", - "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289" + "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", + "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957" ], - "markers": "python_version >= '3.8'", - "version": "==1.4.0" + "markers": "python_version >= '3.10'", + "version": "==1.5.1" }, "typing-extensions": { "hashes": [ @@ -1861,12 +1701,12 @@ }, "bandit": { "hashes": [ - "sha256:3348e934d736fcdb68b6aa4030487097e23a501adf3e7827b63658df464dddd0", - "sha256:dbfe9c25fc6961c2078593de55fd19f2559f9e45b99f1272341f5b95dea4e56b" + "sha256:32410415cd93bf9c8b91972159d5cf1e7f063a9146d70345641cd3877de348ce", + "sha256:bda8d68610fc33a6e10b7a8f1d61d92c8f6c004051d5e946406be1fb1b16a868" ], "index": "pypi", - "markers": "python_version >= '3.9'", - "version": "==1.8.6" + "markers": "python_version >= '3.10'", + "version": "==1.9.2" }, "black": { "hashes": [ @@ -1895,11 +1735,20 @@ }, "click": { "hashes": [ - "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", - "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4" + "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", + "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" ], "markers": "python_version >= '3.10'", - "version": "==8.3.0" + "version": "==8.3.1" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" }, "dill": { "hashes": [ @@ -2080,11 +1929,11 @@ }, "stevedore": { "hashes": [ - "sha256:18363d4d268181e8e8452e71a38cd77630f345b2ef6b4a8d5614dac5ee0d18cf", - "sha256:d31496a4f4df9825e1a1e4f1f74d19abb0154aff311c3b376fcc89dae8fccd73" + "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", + "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945" ], - "markers": "python_version >= '3.9'", - "version": "==5.5.0" + "markers": "python_version >= '3.10'", + "version": "==5.6.0" }, "tomli": { "hashes": [ @@ -2132,6 +1981,15 @@ ], "markers": "python_version >= '3.8'", "version": "==0.13.3" + }, + "typing-extensions": { + "hashes": [ + "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", + "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==4.15.0" } } } diff --git a/bot.py b/bot.py index 6176ac5824..7aa120fdad 100644 --- a/bot.py +++ b/bot.py @@ -794,6 +794,33 @@ def check_manual_blocked(self, author: discord.Member) -> bool: logger.debug("User blocked, user %s.", author.name) return False + def check_local_git(self) -> bool: + """ + Checks if the bot is installed via git. + """ + valid_local_git = False + git_folder_path = os.path.join(".git") + + # Check if the .git folder exists and is a directory + if os.path.exists(git_folder_path) and os.path.isdir(git_folder_path): + required_files = ["config", "HEAD"] + required_dirs = ["refs", "objects"] + + # Verify required files exist + for file in required_files: + if not os.path.isfile(os.path.join(git_folder_path, file)): + return valid_local_git + + # Verify required directories exist + for directory in required_dirs: + if not os.path.isdir(os.path.join(git_folder_path, directory)): + return valid_local_git + + # If all checks pass, set valid_local_git to True + valid_local_git = True + + return valid_local_git + async def _process_blocked(self, message): _, blocked_emoji = await self.retrieve_emoji() if await self.is_blocked(message.author, channel=message.channel, send_message=True): @@ -991,6 +1018,8 @@ def __init__(self, original_message, forwarded_content): self.author = original_message.author self.content = forwarded_content self.attachments = [] + for snap in getattr(original_message, "message_snapshots", []): + self.attachments.extend(getattr(snap, "attachments", [])) self.stickers = [] self.created_at = original_message.created_at self.embeds = [] @@ -1886,6 +1915,8 @@ async def on_message_delete(self, message): "DM message not found.", "Malformed thread message.", "Thread message not found.", + "Linked DM message not found.", + "Thread message is an internal message, not a note.", }: logger.debug("Failed to find linked message to delete: %s", e) embed = discord.Embed(description="Failed to delete message.", color=self.error_color) @@ -2160,6 +2191,12 @@ async def before_autoupdate(self): self.autoupdate.cancel() return + if not self.check_local_git(): + logger.warning("Bot not installed via git.") + logger.warning("Autoupdates disabled.") + self.autoupdate.cancel() + return + @tasks.loop(hours=1, reconnect=False) async def log_expiry(self): log_expire_after = self.config.get("log_expiration") diff --git a/cogs/modmail.py b/cogs/modmail.py index b0e38ed9e0..cbab46bcb0 100644 --- a/cogs/modmail.py +++ b/cogs/modmail.py @@ -480,6 +480,61 @@ async def snippet_edit(self, ctx, name: str.lower, *, value): embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") await ctx.send(embed=embed) + @snippet.command(name="rename") + @checks.has_permissions(PermissionLevel.SUPPORTER) + async def snippet_rename(self, ctx, name: str.lower, *, value): + """ + Rename a snippet. + + To rename a multi-word snippet name, use quotes: ``` + {prefix}snippet rename "two word" this is a new two word snippet. + ``` + """ + if name in self.bot.snippets: + if self.bot.get_command(value): + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A command with the same name already exists: `{value}`.", + ) + return await ctx.send(embed=embed) + elif value in self.bot.snippets: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Snippet `{value}` already exists.", + ) + return await ctx.send(embed=embed) + + if value in self.bot.aliases: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"An alias that shares the same name exists: `{value}`.", + ) + return await ctx.send(embed=embed) + + if len(value) > 120: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Snippet names cannot be longer than 120 characters.", + ) + return await ctx.send(embed=embed) + old_snippet_value = self.bot.snippets[name] + self.bot.snippets.pop(name) + self.bot.snippets[value] = old_snippet_value + await self.bot.config.update() + + embed = discord.Embed( + title="Renamed snippet", + color=self.bot.main_color, + description=f'`{name}` has been renamed to "{value}".', + ) + else: + embed = create_not_found_embed(name, self.bot.snippets.keys(), "Snippet") + await ctx.send(embed=embed) + @commands.command(usage=" [options]") @checks.has_permissions(PermissionLevel.MODERATOR) @checks.thread_only() @@ -1669,11 +1724,11 @@ async def edit(self, ctx, message_id: Optional[int] = None, *, message: str): try: await thread.edit_message(message_id, message) - except ValueError: + except ValueError as e: return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to edit. Plain messages are not supported.", + description=str(e), color=self.bot.error_color, ) ) @@ -2219,7 +2274,7 @@ async def delete(self, ctx, message_id: int = None): return await ctx.send( embed=discord.Embed( title="Failed", - description="Cannot find a message to delete. Plain messages are not supported.", + description=str(e), color=self.bot.error_color, ) ) diff --git a/cogs/plugins.py b/cogs/plugins.py index aa4ad5a65c..310b2f06c9 100644 --- a/cogs/plugins.py +++ b/cogs/plugins.py @@ -113,7 +113,7 @@ class Plugins(commands.Cog): These addons could have a range of features from moderation to simply making your life as a moderator easier! Learn how to create a plugin yourself here: - https://github.com/modmail-dev/modmail/wiki/Plugins + https://docs.modmail.dev/usage-guide/plugins """ def __init__(self, bot): @@ -153,6 +153,14 @@ async def initial_load_plugins(self): logger.info("Migrated legacy plugin name: %s, now %s.", plugin_name, str(plugin)) self.bot.config["plugins"].append(str(plugin)) + if self.bot.config.get("registry_plugins_only") and not plugin_name in self.registry: + self.bot.config["plugins"].remove(plugin_name) + logger.info( + "Loading of plugin %s was skipped and the plugin was removed because it is not present in the registry.", + plugin_name, + ) + continue + try: await self.download_plugin(plugin) await self.load_plugin(plugin) @@ -281,7 +289,7 @@ async def unload_plugin(self, plugin: Plugin) -> None: if module == ext_parent or module.startswith(ext_parent + "."): del sys.modules[module] - async def parse_user_input(self, ctx, plugin_name, check_version=False): + async def parse_user_input(self, ctx, plugin_name, check_version=False, check_registry=True): if not self.bot.config["enable_plugins"]: embed = discord.Embed( description="Plugins are disabled, enable them by setting `ENABLE_PLUGINS=true`", @@ -318,7 +326,7 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): plugin = Plugin(user, repo, plugin_name, branch) else: - if self.bot.config.get("registry_plugins_only"): + if check_registry and self.bot.config.get("registry_plugins_only"): embed = discord.Embed( description="This plugin is not in the registry. To install this plugin, " "you must set `REGISTRY_PLUGINS_ONLY=no` or remove this key in your .env file.", @@ -332,12 +340,12 @@ async def parse_user_input(self, ctx, plugin_name, check_version=False): embed = discord.Embed( description="Invalid plugin name, double check the plugin name " "or use one of the following formats: " - "username/repo/plugin-name, username/repo/plugin-name@branch, local/plugin-name.", + "username/repo/plugin-name, username/repo/plugin-name@branch, @local/plugin-name.", color=self.bot.error_color, ) await ctx.send(embed=embed) return - return plugin + return plugin @commands.group(aliases=["plugin"], invoke_without_command=True) @checks.has_permissions(PermissionLevel.OWNER) @@ -357,7 +365,7 @@ async def plugins_add(self, ctx, *, plugin_name: str): `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) - or `local/name` for local plugins. + or `@local/name` for local plugins. """ plugin = await self.parse_user_input(ctx, plugin_name, check_version=True) @@ -444,9 +452,9 @@ async def plugins_remove(self, ctx, *, plugin_name: str): Remove an installed plugin of the bot. `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference - to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `@local/name` for local plugins. """ - plugin = await self.parse_user_input(ctx, plugin_name) + plugin = await self.parse_user_input(ctx, plugin_name, check_registry=False) if plugin is None: return @@ -526,7 +534,7 @@ async def plugins_update(self, ctx, *, plugin_name: str = None): Update a plugin for the bot. `plugin_name` can be the name of the plugin found in `{prefix}plugin registry`, or a direct reference - to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `local/name` for local plugins. + to a GitHub hosted plugin (in the format `user/repo/name[@branch]`) or `@local/name` for local plugins. To update all plugins, do `{prefix}plugins update`. """ diff --git a/cogs/threadmenu.py b/cogs/threadmenu.py index 7f9e193844..4f4f1985ae 100644 --- a/cogs/threadmenu.py +++ b/cogs/threadmenu.py @@ -87,6 +87,19 @@ async def threadmenu_toggle(self, ctx): conf["enabled"] = not conf["enabled"] await self._save_conf(conf) await ctx.send(f"Thread-creation menu is now {'enabled' if conf['enabled'] else 'disabled'}.") + advancedmenu_plugin = self.bot.get_cog("AdvancedMenu") + if ( + advancedmenu_plugin + and hasattr(advancedmenu_plugin, "config") + and advancedmenu_plugin.config.get("enabled") + and advancedmenu_plugin.config["enabled"] is True + and conf["enabled"] + ): + await ctx.send( + "**Warning:** You are using both the core threadmenu feature and the advancedmenu plugin.\n" + "It is recommended to disable/uninstall the advancedmenu plugin to avoid interruption.\n" + "Migration guide can be found at: " + ) @checks.has_permissions(PermissionLevel.ADMINISTRATOR) @threadmenu.command(name="show") @@ -178,6 +191,9 @@ def typecheck(m): if label.lower() == "cancel": return await ctx.send("Cancelled.") + if label.lower() == "main menu": + return await ctx.send("You cannot use that label.") + if sanitized_label in conf["options"]: await ctx.send("That option already exists. Use `threadmenu edit` to edit it.") return diff --git a/cogs/utility.py b/cogs/utility.py index c420ee7979..bc863a6f45 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -873,14 +873,33 @@ async def config_remove(self, ctx, *, key: str.lower): color=self.bot.main_color, description=f"`{key}` had been reset to default.", ) + + # Cancel exsisting active closures from thread_auto_close due to being disabled. + if key == "thread_auto_close": + closures = self.bot.config["closures"] + for recipient_id, items in tuple(closures.items()): + if items.get("auto_close", False) is True: + self.bot.config["closures"].pop(recipient_id) + thread = await self.bot.threads.find(recipient_id=int(recipient_id)) + if thread: + await thread.cancel_closure(all=True) + else: + self.bot.config["closures"].pop(recipient_id) + # Only update config once after processing all closures + await self.bot.config.update() else: - embed = discord.Embed( - title="Error", - color=self.bot.error_color, - description=f"{key} is an invalid key.", - ) - valid_keys = [f"`{k}`" for k in sorted(keys)] - embed.add_field(name="Valid keys", value=", ".join(valid_keys)) + embeds = [] + for names in zip_longest(*(iter(sorted(keys)),) * 15): + description = "\n".join(f"`{name}`" for name in takewhile(lambda x: x is not None, names)) + embed = discord.Embed( + title="Error - Invalid Key", + color=self.bot.error_color, + description=f"`{key}` is an invalid key.\n\n**Valid configuration keys:**\n{description}", + ) + embeds.append(embed) + + session = EmbedPaginatorSession(ctx, *embeds) + return await session.run() return await ctx.send(embed=embed) @@ -1129,7 +1148,7 @@ async def alias_raw(self, ctx, *, name: str.lower): return await ctx.send(embed=embed) - async def make_alias(self, name, value, action): + async def make_alias(self, name, value, action, ctx): values = utils.parse_alias(value) if not values: embed = discord.Embed( @@ -1176,16 +1195,23 @@ async def make_alias(self, name, value, action): if multiple_alias: embed.description = ( "The command you are attempting to point " - f"to does not exist: `{linked_command}`." + f"to on step {i} does not exist: `{linked_command}`." ) else: embed.description = ( "The command you are attempting to point " - f"to on step {i} does not exist: `{linked_command}`." + f"to does not exist: `{linked_command}`." ) return embed else: + if linked_command == "eval" and not await checks.check_permissions(ctx, "eval"): + embed = discord.Embed( + title="Error", + description="You can only add the `eval` command to an alias if you have permissions for that command.", + color=self.bot.error_color, + ) + return embed save_aliases.append(val) if multiple_alias: embed.add_field(name=f"Step {i}:", value=utils.truncate(val, 1024)) @@ -1240,7 +1266,7 @@ async def alias_add(self, ctx, name: str.lower, *, value): ) if embed is None: - embed = await self.make_alias(name, value, "Added") + embed = await self.make_alias(name, value, "Added", ctx) return await ctx.send(embed=embed) @alias.command(name="remove", aliases=["del", "delete"]) @@ -1272,7 +1298,59 @@ async def alias_edit(self, ctx, name: str.lower, *, value): embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") return await ctx.send(embed=embed) - embed = await self.make_alias(name, value, "Edited") + embed = await self.make_alias(name, value, "Edited", ctx) + return await ctx.send(embed=embed) + + @alias.command(name="rename") + @checks.has_permissions(PermissionLevel.MODERATOR) + async def alias_rename(self, ctx, name: str.lower, *, value): + """ + Rename an alias. + """ + if name not in self.bot.aliases: + embed = utils.create_not_found_embed(name, self.bot.aliases.keys(), "Alias") + return await ctx.send(embed=embed) + + embed = None + if self.bot.get_command(value): + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A command with the same name already exists: `{value}`.", + ) + + elif value in self.bot.aliases: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"Another alias with the same name already exists: `{value}`.", + ) + + elif value in self.bot.snippets: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description=f"A snippet with the same name already exists: `{value}`.", + ) + + elif len(value) > 120: + embed = discord.Embed( + title="Error", + color=self.bot.error_color, + description="Alias names cannot be longer than 120 characters.", + ) + + if embed is None: + old_alias_value = self.bot.aliases[name] + self.bot.aliases.pop(name) + self.bot.aliases[value] = old_alias_value + await self.bot.config.update() + + embed = discord.Embed( + title="Alias renamed", + color=self.bot.main_color, + description=f'`{name}` has been renamed to "{value}".', + ) return await ctx.send(embed=embed) @commands.group(aliases=["perms"], invoke_without_command=True) @@ -1330,7 +1408,7 @@ def _parse_level(name): @permissions.command(name="override") @checks.has_permissions(PermissionLevel.OWNER) - async def permissions_override(self, ctx, command_name: str.lower, *, level_name: str): + async def permissions_override(self, ctx, command_name: str.lower, *, level_name: str = None): """ Change a permission level for a specific command. @@ -1344,8 +1422,16 @@ async def permissions_override(self, ctx, command_name: str.lower, *, level_name - `{prefix}perms remove override reply` - `{prefix}perms remove override plugin enabled` + You can also override multiple commands at once using: + - `{prefix}perms override bulk` + You can retrieve a single or all command level override(s), see`{prefix}help permissions get`. """ + if command_name == "bulk": + return await self._bulk_override_flow(ctx) + + if level_name is None: + raise commands.MissingRequiredArgument(DummyParam("level_name")) command = self.bot.get_command(command_name) if command is None: @@ -1380,6 +1466,221 @@ async def permissions_override(self, ctx, command_name: str.lower, *, level_name ) return await ctx.send(embed=embed) + async def _bulk_override_flow(self, ctx): + message = None + embed = discord.Embed( + title="Bulk Override", + description=( + "Please list the commands you want to override. " + "You can list multiple commands separated by spaces or newlines.\n" + "Example: `reply, block, unblock`.\n" + ), + color=self.bot.main_color, + ) + await ctx.send(embed=embed) + + try: + msg = await self.bot.wait_for( + "message", + check=lambda m: m.author == ctx.author and m.channel == ctx.channel, + timeout=120.0, + ) + + except asyncio.TimeoutError: + return await ctx.send( + embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color) + ) + + raw_commands = msg.content.replace(",", " ").replace("\n", " ").split(" ") + # Filter empty strings from split + raw_commands = [c for c in raw_commands if c.strip()] + + # Strip prefix from commands if present + prefixes = [self.bot.prefix, f"<@{self.bot.user.id}>", f"<@!{self.bot.user.id}>"] + if self.bot.prefix: + for i, cmd in enumerate(raw_commands): + for p in prefixes: + if cmd.startswith(p): + raw_commands[i] = cmd[len(p) :] + break + + # Filter empty strings again after stripping prefixes + raw_commands = [c for c in raw_commands if c.strip()] + + found_commands = [] + invalid_commands = [] + seen_commands = set() + duplicate_count = 0 + + # Commands that should not be bulk-updated for safety reasons + blocked_commands = {"eval"} + + for cmd_name in raw_commands: + cmd = self.bot.get_command(cmd_name) + if cmd: + if cmd.qualified_name in blocked_commands: + invalid_commands.append(cmd_name) + elif cmd.qualified_name in seen_commands: + duplicate_count += 1 + else: + seen_commands.add(cmd.qualified_name) + found_commands.append(cmd) + else: + invalid_commands.append(cmd_name) + + if invalid_commands or duplicate_count > 0: + description = "" + if invalid_commands: + description += f"The following commands were not found or are blocked:\n`{', '.join(invalid_commands)}`\n\n" + if duplicate_count > 0: + description += ( + f"Ignoring {duplicate_count} duplicate command{'s' if duplicate_count > 1 else ''}.\n\n" + ) + if found_commands: + found_list = ", ".join(c.qualified_name for c in found_commands) + found_list = utils.return_or_truncate(found_list, 1000) + description += f"The following commands **were** found:\n`{found_list}`\n\n" + + description += "Do you want to continue with the valid commands?" + + embed = discord.Embed( + title="Invalid Commands Found", + description=description, + color=self.bot.error_color, + ) + view = discord.ui.View() + view.add_item(utils.AcceptButton(custom_id="continue", emoji="✅")) + view.add_item(utils.DenyButton(custom_id="abort", emoji="❌")) + + message = await ctx.send(embed=embed, view=view) + timed_out = await view.wait() + + if timed_out or not view.value: + return await message.edit( + embed=discord.Embed( + title="Operation Aborted", + description="No changes have been applied.", + color=self.bot.error_color, + ), + view=None, + ) + + if not found_commands: + return await ctx.send( + embed=discord.Embed( + title="Error", + description="No valid commands provided. Aborting.", + color=self.bot.error_color, + ) + ) + + # Expand subcommands + final_commands = set() + + def add_command_recursive(cmd): + final_commands.add(cmd) + if hasattr(cmd, "commands"): + for sub in cmd.commands: + add_command_recursive(sub) + + for cmd in found_commands: + add_command_recursive(cmd) + + embed = discord.Embed( + title="Select Permission Level", + description=( + f"Found {len(final_commands)} commands (including subcommands).\n" + "What permission level should these commands be set to?" + ), + color=self.bot.main_color, + ) + + class LevelSelect(discord.ui.Select): + def __init__(self): + options = [ + discord.SelectOption(label="Owner", value="OWNER"), + discord.SelectOption(label="Administrator", value="ADMINISTRATOR"), + discord.SelectOption(label="Moderator", value="MODERATOR"), + discord.SelectOption(label="Supporter", value="SUPPORTER"), + discord.SelectOption(label="Regular", value="REGULAR"), + ] + super().__init__(placeholder="Select permission level...", options=options) + + async def callback(self, interaction: discord.Interaction): + self.view.value = self.values[0] + self.view.stop() + await interaction.response.defer() + + view = discord.ui.View() + view.add_item(LevelSelect()) + + if message: + await message.edit(embed=embed, view=view) + else: + message = await ctx.send(embed=embed, view=view) + timed_out = await view.wait() + + if timed_out or view.value is None: + return await message.edit( + embed=discord.Embed(title="Error", description="Timed out.", color=self.bot.error_color), + view=None, + ) + + level_name = view.value + level = self._parse_level(level_name) + + # Confirmation + command_list_str = ", ".join( + f"`{c.qualified_name}`" for c in sorted(final_commands, key=lambda x: x.qualified_name) + ) + + command_list_str = utils.return_or_truncate(command_list_str, 2048) + + embed = discord.Embed( + title="Confirm Bulk Override", + description=f"**Level:** {level.name}\n\n**Commands:**\n{command_list_str}", + color=self.bot.main_color, + ) + + view = discord.ui.View() + view.add_item(utils.AcceptButton(custom_id="confirm", emoji="✅")) + view.add_item(utils.DenyButton(custom_id="cancel", emoji="❌")) + + await message.edit(embed=embed, view=view) + timed_out = await view.wait() + + if timed_out or not view.value: + return await message.edit( + embed=discord.Embed( + title="Operation Aborted", + description="No changes have been applied.", + color=self.bot.error_color, + ), + view=None, + ) + + # Apply changes + for cmd in final_commands: + self.bot.config["override_command_level"][cmd.qualified_name] = level.name + + await self.bot.config.update() + + logger.info( + "Bulk override: set permission level %s for %d commands: %s", + level.name, + len(final_commands), + ", ".join(cmd.qualified_name for cmd in final_commands), + ) + + await message.edit( + embed=discord.Embed( + title="Success", + description=f"Successfully updated permissions for {len(final_commands)} commands.", + color=self.bot.main_color, + ), + view=None, + ) + @permissions.command(name="add", usage="[command/level] [name] [user/role]") @checks.has_permissions(PermissionLevel.OWNER) async def permissions_add( @@ -2082,11 +2383,7 @@ async def update(self, ctx, *, flag: str = ""): data = await self.bot.api.get_user_info() if data: user = data["user"] - embed.set_author( - name=user["username"], - icon_url=user["avatar_url"] if user["avatar_url"] else None, - url=user["url"], - ) + embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) await ctx.send(embed=embed) else: error = None @@ -2125,7 +2422,7 @@ async def update(self, ctx, *, flag: str = ""): embed.set_author( name=user["username"] + " - Updating bot", - icon_url=user["avatar_url"] if user["avatar_url"] else None, + icon_url=user["avatar_url"], url=user["url"], ) @@ -2143,13 +2440,18 @@ async def update(self, ctx, *, flag: str = ""): color=self.bot.main_color, ) embed.set_footer(text="Force update") - embed.set_author( - name=user["username"], - icon_url=user["avatar_url"] if user["avatar_url"] else None, - url=user["url"], - ) + embed.set_author(name=user["username"], icon_url=user["avatar_url"], url=user["url"]) await ctx.send(embed=embed) else: + if self.bot.check_local_git() is False: + embed = discord.Embed( + title="Update Command Unavailable", + description="The bot cannot be updated due to not being installed via git." + "You need to manually update the bot according to your hosting method." + "If you face any issues please don´t hesitate to contact modmail support.", + color=discord.Color.red(), + ) + return await ctx.send(embed=embed) command = "git pull" proc = await asyncio.create_subprocess_shell( command, @@ -2162,11 +2464,7 @@ async def update(self, ctx, *, flag: str = ""): res = res.decode("utf-8").rstrip() if err and not res: - embed = discord.Embed( - title="Update failed", - description=err, - color=self.bot.error_color, - ) + embed = discord.Embed(title="Update failed", description=err, color=self.bot.error_color) await ctx.send(embed=embed) elif res != "Already up to date.": diff --git a/core/clients.py b/core/clients.py index 90f09b3b48..ff0d3ff8cd 100644 --- a/core/clients.py +++ b/core/clients.py @@ -12,6 +12,7 @@ from pymongo.errors import ConfigurationError from core.models import InvalidConfigError, getLogger +from core.utils import extract_forwarded_content logger = getLogger(__name__) @@ -661,32 +662,59 @@ async def append_log( channel_id: str = "", type_: str = "thread_message", ) -> dict: - channel_id = str(channel_id) or str(message.channel.id) - message_id = str(message_id) or str(message.id) - - data = { - "timestamp": str(message.created_at), - "message_id": message_id, - "author": { - "id": str(message.author.id), - "name": message.author.name, - "discriminator": message.author.discriminator, - "avatar_url": message.author.display_avatar.url if message.author.display_avatar else None, - "mod": not isinstance(message.channel, DMChannel), - }, - "content": message.content, - "type": type_, - "attachments": [ - { - "id": a.id, - "filename": a.filename, - "is_image": a.width is not None, - "size": a.size, - "url": a.url, - } - for a in message.attachments - ], - } + channel_id = str(channel_id) or (str(message.channel.id) if message else "") + message_id = str(message_id) or (str(message.id) if message else "") + + if message: + content = message.content or "" + if forwarded := extract_forwarded_content(message): + if content: + content += "\n" + forwarded + else: + content = forwarded + + data = { + "timestamp": str(message.created_at), + "message_id": message_id, + "author": { + "id": str(message.author.id), + "name": message.author.name, + "discriminator": message.author.discriminator, + "avatar_url": ( + message.author.display_avatar.url if message.author.display_avatar else None + ), + "mod": not isinstance(message.channel, DMChannel), + }, + "content": content, + "type": type_, + "attachments": [ + { + "id": a.id, + "filename": a.filename, + "is_image": a.width is not None, + "size": a.size, + "url": a.url, + } + for a in message.attachments + ], + } + else: + # Fallback for when message is None but we still want to log something (e.g. system note) + # This requires at least some manual data to be useful. + data = { + "timestamp": str(discord.utils.utcnow()), + "message_id": message_id or "0", + "author": { + "id": "0", + "name": "System", + "discriminator": "0000", + "avatar_url": None, + "mod": True, + }, + "content": "System Message (No Content)", + "type": type_, + "attachments": [], + } return await self.logs.find_one_and_update( {"channel_id": channel_id}, diff --git a/core/config_help.json b/core/config_help.json index b5832935c7..fedf9279ed 100644 --- a/core/config_help.json +++ b/core/config_help.json @@ -533,7 +533,7 @@ "notes": [ "When `recipient_thread_close` is enabled and the recipient closed their own thread, `thread_self_close_response` is used instead of this configuration.", "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", - "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{logkey}}` for the unique key (ie. s3kf91a) of the log.", "Discord flavoured markdown is fully supported in `thread_close_response`.", "See also: `thread_close_title`, `thread_close_footer`, `thread_self_close_response`, `thread_creation_response`." ] @@ -547,7 +547,7 @@ "notes": [ "When `recipient_thread_close` is disabled or the thread wasn't closed by the recipient, `thread_close_response` is used instead of this configuration.", "You may use the `{{closer}}` variable for access to the [Member](https://discordpy.readthedocs.io/en/latest/api.html#discord.Member) that closed the thread.", - "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{loglink}}` for the unique key (ie. s3kf91a) of the log.", + "`{{loglink}}` can be used as a placeholder substitute for the full URL linked to the thread in the log viewer and `{{logkey}}` for the unique key (ie. s3kf91a) of the log.", "Discord flavoured markdown is fully supported in `thread_self_close_response`.", "See also: `thread_close_title`, `thread_close_footer`, `thread_close_response`." ] diff --git a/core/models.py b/core/models.py index 5f36f12181..c7d1e79599 100644 --- a/core/models.py +++ b/core/models.py @@ -438,6 +438,10 @@ def __init__(self, message): self._message = message def __getattr__(self, name: str): + if self._message is None: + # If we're wrapping None, we can't delegate attributes. + # This mimics behavior where the attribute doesn't exist. + raise AttributeError(f"'DummyMessage' object has no attribute '{name}' (wrapped message is None)") return getattr(self._message, name) def __bool__(self): diff --git a/core/thread.py b/core/thread.py index bf77180f8c..8bc83f7324 100644 --- a/core/thread.py +++ b/core/thread.py @@ -35,6 +35,7 @@ ConfirmThreadCreationView, DummyParam, extract_forwarded_content, + extract_forwarded_attachments, ) logger = getLogger(__name__) @@ -67,6 +68,7 @@ def __init__( self.wait_tasks = [] self.close_task = None self.auto_close_task = None + self.auto_close_cancelled = False # Track if auto-close was explicitly cancelled self._cancelled = False self._dm_menu_msg_id = None self._dm_menu_channel_id = None @@ -144,6 +146,7 @@ def cancelled(self) -> bool: def cancelled(self, flag: bool): self._cancelled = flag if flag: + self._ready_event.set() for i in self.wait_tasks: i.cancel() @@ -205,7 +208,10 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): "messages": [ { "author_id": m.author.id, - "content": m.content, + "content": ( + (m.content or "") + + (("\n" + extract_forwarded_content(m)) if extract_forwarded_content(m) else "") + ).strip(), "attachments": [a.url for a in m.attachments], "embeds": [e.to_dict() for e in m.embeds], "created_at": m.created_at.isoformat(), @@ -255,9 +261,10 @@ async def snooze(self, moderator=None, command_used=None, snooze_for=None): {"channel_id": str(self.channel.id)}, {"$set": {"snoozed": True, "snooze_data": self.snooze_data}}, ) - import logging + logger.info("[SNOOZE] DB update result: %s", result.modified_count) - logging.info(f"[SNOOZE] DB update result: {result.modified_count}") + # Dispatch thread_snoozed event for plugins + self.bot.dispatch("thread_snoozed", self, moderator, snooze_for) behavior = behavior_pre if behavior == "move": @@ -720,9 +727,7 @@ async def _ensure_genesis(force: bool = False): "$unset": {"snoozed": "", "snooze_data": ""}, }, ) - import logging - - logging.info(f"[UNSNOOZE] DB update result: {result.modified_count}") + logger.info("[UNSNOOZE] DB update result: %s", result.modified_count) # Notify in the configured channel notify_channel = self.bot.config.get("unsnooze_notify_channel") or "thread" notify_text = self.bot.config.get("unsnooze_text") or "This thread has been unsnoozed and restored." @@ -751,6 +756,9 @@ async def _ensure_genesis(force: bool = False): # Mark unsnooze as complete self._unsnoozing = False + # Dispatch thread_unsnoozed event for plugins + self.bot.dispatch("thread_unsnoozed", self) + # Process queued commands await self._process_command_queue() @@ -849,11 +857,9 @@ async def send_genesis_message(): if getattr(self, "_selected_thread_creation_menu_option", None) and self.bot.config.get( "thread_creation_menu_selection_log" ): - opt = self._selected_thread_creation_menu_option + path = self._selected_thread_creation_menu_option try: - log_txt = f"Selected menu option: {opt.get('label')} ({opt.get('type')})" - if opt.get("type") == "command": - log_txt += f" -> {opt.get('callback')}" + log_txt = f"Selected menu path: {' -> '.join(path)}" await channel.send(embed=discord.Embed(description=log_txt, color=self.bot.mod_color)) except Exception: logger.warning( @@ -1050,7 +1056,10 @@ async def close( """Close a thread now or after a set time in seconds""" # restarts the after timer - await self.cancel_closure(auto_close) + await self.cancel_closure( + auto_close, + mark_auto_close_cancelled=not auto_close, + ) if after > 0: # TODO: Add somewhere to clean up broken closures @@ -1074,6 +1083,7 @@ async def close( self.auto_close_task = task else: self.close_task = task + self.auto_close_cancelled = False # Reset flag when manually closing else: await self._close(closer, silent, delete_channel, message) @@ -1093,7 +1103,7 @@ async def _close(self, closer, silent=False, delete_channel=True, message=None, logger.error("Thread already closed: %s.", e) return - await self.cancel_closure(all=True) + await self.cancel_closure(all=True, mark_auto_close_cancelled=False) # Cancel auto closing the thread if closed by any means. @@ -1267,17 +1277,32 @@ async def _disable_dm_creation_menu(self) -> None: except Exception as inner_e: logger.debug("Failed removing view from DM menu message: %s", inner_e) - async def cancel_closure(self, auto_close: bool = False, all: bool = False) -> None: + async def cancel_closure( + self, + auto_close: bool = False, + all: bool = False, + *, + mark_auto_close_cancelled: bool = True, + ) -> None: if self.close_task is not None and (not auto_close or all): self.close_task.cancel() self.close_task = None if self.auto_close_task is not None and (auto_close or all): self.auto_close_task.cancel() self.auto_close_task = None - - to_update = self.bot.config["closures"].pop(str(self.id), None) - if to_update is not None: - await self.bot.config.update() + if mark_auto_close_cancelled: + self.auto_close_cancelled = True # Mark auto-close as explicitly cancelled + + closure_key = str(self.id) + existing = self.bot.config["closures"].get(closure_key) + if existing is not None: + existing_is_auto = bool(existing.get("auto_close", False)) + should_remove = ( + all or (auto_close and existing_is_auto) or ((not auto_close) and (not existing_is_auto)) + ) + if should_remove: + self.bot.config["closures"].pop(closure_key, None) + await self.bot.config.update() async def _restart_close_timer(self): """ @@ -1327,117 +1352,118 @@ async def find_linked_messages( message1: discord.Message = None, note: bool = True, ) -> typing.Tuple[discord.Message, typing.List[typing.Optional[discord.Message]]]: - if message1 is not None: - if note: - # For notes, don't require author.url; rely on footer/author.name markers - if not message1.embeds or message1.author != self.bot.user: - logger.warning( - f"Malformed note for deletion: embeds={bool(message1.embeds)}, author={message1.author}" - ) - raise ValueError("Malformed note message.") + if message1 is None: + if message_id is not None: + try: + message1 = await self.channel.fetch_message(message_id) + except discord.NotFound: + logger.warning(f"Message ID {message_id} not found in channel history.") + raise ValueError("Thread message not found.") else: - if ( - not message1.embeds - or not message1.embeds[0].author.url - or message1.author != self.bot.user - ): - logger.debug( - f"Malformed thread message for deletion: embeds={bool(message1.embeds)}, author_url={getattr(message1.embeds[0], 'author', None) and message1.embeds[0].author.url}, author={message1.author}" - ) - # Keep original error string to avoid extra failure embeds in on_message_delete - raise ValueError("Malformed thread message.") + # No ID provided - find last message sent by bot + async for msg in self.channel.history(): + if msg.author != self.bot.user: + continue + if not msg.embeds: + continue - elif message_id is not None: - try: - message1 = await self.channel.fetch_message(message_id) - except discord.NotFound: - logger.warning(f"Message ID {message_id} not found in channel history.") - raise ValueError("Thread message not found.") + is_valid_candidate = False + if ( + msg.embeds[0].footer + and msg.embeds[0].footer.text + and msg.embeds[0].footer.text.startswith("[PLAIN]") + ): + is_valid_candidate = True + elif msg.embeds[0].author.url and msg.embeds[0].author.url.split("#")[-1].isdigit(): + is_valid_candidate = True + + if is_valid_candidate: + message1 = msg + break - if note: - # Try to treat as note/persistent note first - if message1.embeds and message1.author == self.bot.user: - footer_text = (message1.embeds[0].footer and message1.embeds[0].footer.text) or "" - author_name = getattr(message1.embeds[0].author, "name", "") or "" - is_note = ( - "internal note" in footer_text.lower() - or "persistent internal note" in footer_text.lower() - or author_name.startswith("📝 Note") - or author_name.startswith("📝 Persistent Note") - ) - if is_note: - # Notes have no linked DM counterpart; keep None sentinel - return message1, None - # else: fall through to relay checks below - - # Non-note path (regular relayed messages): require author.url and colors - if not ( - message1.embeds - and message1.embeds[0].author.url - and message1.embeds[0].color - and message1.author == self.bot.user - ): - logger.warning( - f"Message {message_id} is not a valid modmail relay message. embeds={bool(message1.embeds)}, author_url={getattr(message1.embeds[0], 'author', None) and message1.embeds[0].author.url}, color={getattr(message1.embeds[0], 'color', None)}, author={message1.author}" - ) - raise ValueError("Thread message not found.") + if message1 is None: + raise ValueError("No editable thread message found.") + + is_note = False + if message1.embeds and message1.author == self.bot.user: + footer_text = (message1.embeds[0].footer and message1.embeds[0].footer.text) or "" + author_name = getattr(message1.embeds[0].author, "name", "") or "" + is_note = ( + "internal note" in footer_text.lower() + or "persistent internal note" in footer_text.lower() + or author_name.startswith("📝 Note") + or author_name.startswith("📝 Persistent Note") + ) - if message1.embeds[0].footer and "Internal Message" in message1.embeds[0].footer.text: - if not note: - logger.warning( - f"Message {message_id} is an internal message, but note deletion not requested." - ) - raise ValueError("Thread message is an internal message, not a note.") - # Internal bot-only message treated similarly; keep None sentinel - return message1, None + if note and is_note: + return message1, None - if message1.embeds[0].color.value != self.bot.mod_color and not ( - either_direction and message1.embeds[0].color.value == self.bot.recipient_color - ): - logger.warning("Message color does not match mod/recipient colors.") - raise ValueError("Thread message not found.") - else: - async for message1 in self.channel.history(): - if ( - message1.embeds - and message1.embeds[0].author.url - and message1.embeds[0].color - and ( - message1.embeds[0].color.value == self.bot.mod_color - or (either_direction and message1.embeds[0].color.value == self.bot.recipient_color) - ) - and message1.embeds[0].author.url.split("#")[-1].isdigit() - and message1.author == self.bot.user - ): - break - else: + if not note and is_note: + raise ValueError("Thread message is an internal message, not a note.") + + if is_note: + return message1, None + + is_plain = False + if message1.embeds and message1.embeds[0].footer and message1.embeds[0].footer.text: + if message1.embeds[0].footer.text.startswith("[PLAIN]"): + is_plain = True + + if not is_plain: + # Relaxed mod_color check: only ensure author is bot and has url (which implies it's a relay) + # We rely on author.url existing for Joint ID + if not (message1.embeds and message1.embeds[0].author.url and message1.author == self.bot.user): raise ValueError("Thread message not found.") - try: - joint_id = int(message1.embeds[0].author.url.split("#")[-1]) - except ValueError: - raise ValueError("Malformed thread message.") + try: + joint_id = int(message1.embeds[0].author.url.split("#")[-1]) + except (ValueError, AttributeError, IndexError): + raise ValueError("Malformed thread message.") + else: + joint_id = None + mod_tag = message1.embeds[0].footer.text.replace("[PLAIN]", "", 1).strip() + author_name = message1.embeds[0].author.name + desc = message1.embeds[0].description or "" + prefix = f"**{mod_tag} " if mod_tag else "**" + plain_content_expected = f"{prefix}{author_name}:** {desc}" + creation_time = message1.created_at messages = [message1] - for user in self.recipients: - async for msg in user.history(): - if either_direction: - if msg.id == joint_id: - return message1, msg - if not (msg.embeds and msg.embeds[0].author.url): - continue - try: - if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: + if is_plain: + for user in self.recipients: + async for msg in user.history(limit=50, around=creation_time): + if abs((msg.created_at - creation_time).total_seconds()) > 15: + continue + if msg.author != self.bot.user: + continue + if msg.embeds: + continue + + if msg.content == plain_content_expected: messages.append(msg) break - except ValueError: - continue + else: + for user in self.recipients: + async for msg in user.history(): + if either_direction: + if msg.id == joint_id: + messages.append(msg) + break + + if not (msg.embeds and msg.embeds[0].author.url): + continue + try: + if int(msg.embeds[0].author.url.split("#")[-1]) == joint_id: + messages.append(msg) + break + except (ValueError, IndexError, AttributeError): + continue if len(messages) > 1: return messages - raise ValueError("DM message not found.") + raise ValueError("Linked DM message not found.") async def edit_message(self, message_id: typing.Optional[int], message: str) -> None: try: @@ -1449,6 +1475,10 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> embed1 = message1.embeds[0] embed1.description = message + is_plain = False + if embed1.footer and embed1.footer.text and embed1.footer.text.startswith("[PLAIN]"): + is_plain = True + tasks = [ self.bot.api.edit_message(message1.id, message), message1.edit(embed=embed1), @@ -1458,9 +1488,17 @@ async def edit_message(self, message_id: typing.Optional[int], message: str) -> else: for m2 in message2: if m2 is not None: - embed2 = m2.embeds[0] - embed2.description = message - tasks += [m2.edit(embed=embed2)] + if is_plain: + # Reconstruct the plain message format to preserve matching capability + mod_tag = embed1.footer.text.replace("[PLAIN]", "", 1).strip() + author_name = embed1.author.name + prefix = f"**{mod_tag} " if mod_tag else "**" + new_content = f"{prefix}{author_name}:** {message}" + tasks += [m2.edit(content=new_content)] + else: + embed2 = m2.embeds[0] + embed2.description = message + tasks += [m2.edit(embed=embed2)] await asyncio.gather(*tasks) @@ -1774,6 +1812,13 @@ async def send( reply commands to avoid mutating the original message object. """ # Handle notes with Discord-like system message format - return early + if message is None: + # Safeguard against None messages (e.g. from menu interactions without a source message) + if not note and not from_mod and not thread_creation: + # If we're just trying to log/relay a user message and there is none, existing behavior + # suggests we might skip or error. Logging a warning and returning is safer than crashing. + return + if note: destination = destination or self.channel content = message.content or "[No content]" @@ -1806,7 +1851,14 @@ async def send( return await destination.send(embed=embed) if not note and from_mod: - self.bot.loop.create_task(self._restart_close_timer()) # Start or restart thread auto close + # Only restart auto-close if it wasn't explicitly cancelled. + # Auto-close is driven by the last moderator reply. + if not self.auto_close_cancelled: + self.bot.loop.create_task(self._restart_close_timer()) # Start or restart thread auto close + elif not note and not from_mod: + # If the user replied last, the thread should not auto-close. + # Cancel any pending auto-close without marking it as an explicit cancellation. + await self.cancel_closure(auto_close=True, mark_auto_close_cancelled=False) if self.close_task is not None: # cancel closing if a thread message is sent. @@ -1824,7 +1876,8 @@ async def send( await self.wait_until_ready() if not from_mod and not note: - self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id)) + if self.channel: + self.bot.loop.create_task(self.bot.api.append_log(message, channel_id=self.channel.id)) destination = destination or self.channel @@ -1944,6 +1997,11 @@ async def send( ext = [(a.url, a.filename, False) for a in message.attachments] + # Add forwarded message attachments + forwarded_attachments = extract_forwarded_attachments(message) + for url, filename in forwarded_attachments: + ext.append((url, filename, False)) + images = [] attachments = [] for attachment in ext: @@ -2547,6 +2605,10 @@ async def create( # checks for existing thread in cache thread = self.cache.get(recipient.id) if thread: + # If there's a pending menu, return the existing thread to avoid creating duplicates + if getattr(thread, "_pending_menu", False): + logger.debug("Thread for %s has pending menu, returning existing thread.", recipient) + return thread try: await thread.wait_until_ready() except asyncio.CancelledError: @@ -2555,8 +2617,8 @@ async def create( label = f"{recipient} ({recipient.id})" except Exception: label = f"User ({getattr(recipient, 'id', 'unknown')})" - logger.warning("Thread for %s cancelled, abort creating.", label) - return thread + self.cache.pop(recipient.id, None) + thread = None else: if thread.channel and self.bot.get_channel(thread.channel.id): logger.warning("Found an existing thread for %s, abort creating.", recipient) @@ -2602,14 +2664,27 @@ async def create( self.bot.config["confirm_thread_creation_deny"], ) ) - confirm = await destination.send( - embed=discord.Embed( - title=self.bot.config["confirm_thread_creation_title"], - description=self.bot.config["confirm_thread_response"], - color=self.bot.main_color, - ), - view=view, - ) + try: + confirm = await destination.send( + embed=discord.Embed( + title=self.bot.config["confirm_thread_creation_title"], + description=self.bot.config["confirm_thread_response"], + color=self.bot.main_color, + ), + view=view, + ) + except discord.Forbidden: + # Recipient has DMs disabled (or otherwise unreachable). Without this, + # the thread would stay stuck in cache in a "not ready" state until a + # bot restart, since view.wait() below would never run. + logger.warning( + "Could not send confirm-thread-creation message to %s, DMs are likely disabled.", + recipient, + ) + thread.cancelled = True + del self.cache[recipient.id] + return thread + await view.wait() if view.value is None: thread.cancelled = True @@ -2659,29 +2734,44 @@ async def create( placeholder = "Select an option to contact the staff team." timeout = 20 - options = self.bot.config.get("thread_creation_menu_options") or {} - submenus = self.bot.config.get("thread_creation_menu_submenus") or {} - # Minimal inline view implementation (avoid importing plugin code) thread.ready = False # not ready yet class _ThreadCreationMenuSelect(discord.ui.Select): - def __init__(self, outer_thread: Thread): + def __init__( + self, + bot, + outer_thread: Thread, + option_data: dict, + menu_msg: discord.Message, + path: list, + is_home: bool = True, + ): + self.bot = bot self.outer_thread = outer_thread - opts = [ + self.option_data = option_data + self.menu_msg = menu_msg + self.path = path + options = [ discord.SelectOption( label=o["label"], description=o["description"], emoji=o["emoji"], ) - for o in options.values() + for o in option_data.values() ] + if not is_home: + options.append( + discord.SelectOption( + label="main menu", description="Return to the main menu", emoji="🏠" + ) + ) super().__init__( placeholder=placeholder, min_values=1, max_values=1, - options=opts, + options=options, ) async def callback(self, interaction: discord.Interaction): @@ -2696,8 +2786,45 @@ async def callback(self, interaction: discord.Interaction): chosen_label = self.values[0] # Resolve option key key = chosen_label.lower().replace(" ", "_") - selected = options.get(key) - self.outer_thread._selected_thread_creation_menu_option = selected + if key == "main_menu": + option_data = self.bot.config.get("thread_creation_menu_options") or {} + new_view = _ThreadCreationMenuView( + self.bot, + self.outer_thread, + option_data, + self.menu_msg, + path=[], + is_home=True, + ) + return await self.menu_msg.edit(view=new_view) + selected: dict = self.option_data.get(key, {}) + next_path = [*self.path, chosen_label] + if selected.get("type", "command") == "submenu": + submenu_data = self.bot.config.get("thread_creation_menu_submenus") or {} + submenu_key = selected.get("callback", key) + option_data = submenu_data.get(submenu_key, {}) + if not option_data: + home_options = self.bot.config.get("thread_creation_menu_options") or {} + new_view = _ThreadCreationMenuView( + self.bot, + self.outer_thread, + home_options, + self.menu_msg, + path=[], + is_home=True, + ) + return await self.menu_msg.edit(view=new_view) + new_view = _ThreadCreationMenuView( + self.bot, + self.outer_thread, + option_data, + self.menu_msg, + path=next_path, + is_home=False, + ) + return await self.menu_msg.edit(view=new_view) + + self.outer_thread._selected_thread_creation_menu_option = next_path # Reflect the selection in the original DM by editing the embed/body try: msg = getattr(interaction, "message", None) @@ -2852,35 +2979,36 @@ async def callback(self, interaction: discord.Interaction): setattr(self.outer_thread, "_pending_menu", False) return # Forward the user's initial DM to the thread channel - try: - await self.outer_thread.send(message) - except Exception: - logger.error( - "Failed to relay initial message after menu selection", - exc_info=True, - ) - else: - # React to the user's DM with the 'sent' emoji + if message: try: - ( - sent_emoji, - _, - ) = await self.outer_thread.bot.retrieve_emoji() - await self.outer_thread.bot.add_reaction(message, sent_emoji) - except Exception as e: - logger.debug( - "Failed to add sent reaction to user's DM: %s", - e, + await self.outer_thread.send(message) + except Exception: + logger.error( + "Failed to relay initial message after menu selection", + exc_info=True, + ) + else: + # React to the user's DM with the 'sent' emoji + try: + ( + sent_emoji, + _, + ) = await self.outer_thread.bot.retrieve_emoji() + await self.outer_thread.bot.add_reaction(message, sent_emoji) + except Exception as e: + logger.debug( + "Failed to add sent reaction to user's DM: %s", + e, + ) + # Dispatch thread_reply event for parity + self.outer_thread.bot.dispatch( + "thread_reply", + self.outer_thread, + False, + message, + False, + False, ) - # Dispatch thread_reply event for parity - self.outer_thread.bot.dispatch( - "thread_reply", - self.outer_thread, - False, - message, - False, - False, - ) # Clear pending flag setattr(self.outer_thread, "_pending_menu", False) except Exception: @@ -2901,7 +3029,7 @@ async def callback(self, interaction: discord.Interaction): # Create a synthetic message object that makes the bot appear # as the author for menu-invoked command replies so the user # selecting the option is not shown as a "mod" sender. - synthetic = DummyMessage(copy.copy(message)) + synthetic = DummyMessage(copy.copy(self.outer_thread._genesis_message)) try: synthetic.author = ( self.outer_thread.bot.modmail_guild.me or self.outer_thread.bot.user @@ -2936,10 +3064,30 @@ async def callback(self, interaction: discord.Interaction): ctx_.command.checks = old_checks class _ThreadCreationMenuView(discord.ui.View): - def __init__(self, outer_thread: Thread): + def __init__( + self, + bot, + outer_thread: Thread, + option_data: dict, + menu_msg: discord.Message, + path: list, + is_home: bool = True, + ): super().__init__(timeout=timeout) self.outer_thread = outer_thread - self.add_item(_ThreadCreationMenuSelect(outer_thread)) + self.path = path + self.menu_msg = menu_msg + self.option_data = option_data + self.add_item( + _ThreadCreationMenuSelect( + bot, + outer_thread, + option_data=option_data, + menu_msg=menu_msg, + path=self.path, + is_home=is_home, + ) + ) async def on_timeout(self): # Timeout -> abort thread creation @@ -3061,8 +3209,12 @@ async def on_timeout(self): embed.set_thumbnail(url=embed_thumb) except Exception as e: logger.debug("Thumbnail set failed (ignored): %s", e) - menu_view = _ThreadCreationMenuView(thread) - menu_msg = await recipient.send(embed=embed, view=menu_view) + menu_msg = await recipient.send(embed=embed) + option_data = self.bot.config.get("thread_creation_menu_options") or {} + menu_view = _ThreadCreationMenuView( + self.bot, thread, option_data, menu_msg, path=[], is_home=True + ) + menu_msg = await menu_msg.edit(view=menu_view) # mark thread as pending menu selection thread._pending_menu = True # Explicitly attach the message to the view for safety in callbacks @@ -3229,7 +3381,7 @@ async def callback(self, interaction: discord.Interaction): ctxs = [] for al in normalize_alias(alias): view_ = StringView(self.outer_thread.bot.prefix + al) - synthetic = DummyMessage(copy.copy(message)) + synthetic = DummyMessage(copy.copy(self.outer_thread._genesis_message)) try: synthetic.author = ( self.outer_thread.bot.modmail_guild.me or self.outer_thread.bot.user diff --git a/core/utils.py b/core/utils.py index dc8737ead6..8ef56572cf 100644 --- a/core/utils.py +++ b/core/utils.py @@ -46,6 +46,7 @@ "ConfirmThreadCreationView", "DummyParam", "extract_forwarded_content", + "extract_forwarded_attachments", ] @@ -640,6 +641,35 @@ def __init__(self): self.value = None +def extract_forwarded_attachments(message) -> typing.List[typing.Tuple[str, str]]: + """ + Extract attachment URLs from forwarded messages. + + Parameters + ---------- + message : discord.Message + The message to extract attachments from. + + Returns + ------- + List[Tuple[str, str]] + List of (url, filename) tuples for attachments. + """ + attachments = [] + try: + if hasattr(message, "message_snapshots") and message.message_snapshots: + for snap in message.message_snapshots: + if hasattr(snap, "attachments") and snap.attachments: + for a in snap.attachments: + url = getattr(a, "url", None) + filename = getattr(a, "filename", "Unknown") + if url: + attachments.append((url.split("?")[0], filename)) + except Exception: + pass + return attachments + + def extract_forwarded_content(message) -> typing.Optional[str]: """ Extract forwarded message content from Discord forwarded messages. @@ -654,46 +684,54 @@ def extract_forwarded_content(message) -> typing.Optional[str]: Optional[str] The extracted forwarded content, or None if not a forwarded message. """ - import discord - try: # Handle multi-forward (message_snapshots) - if hasattr(message, "flags") and getattr(message.flags, "has_snapshot", False): - if hasattr(message, "message_snapshots") and message.message_snapshots: - forwarded_parts = [] - for snap in message.message_snapshots: - author = getattr(snap, "author", None) - author_name = getattr(author, "name", "Unknown") if author else "Unknown" - snap_content = getattr(snap, "content", "") - - if snap_content: - # Truncate very long messages to prevent spam - if len(snap_content) > 500: - snap_content = snap_content[:497] + "..." - forwarded_parts.append(f"**{author_name}:** {snap_content}") - elif getattr(snap, "embeds", None): - for embed in snap.embeds: - if hasattr(embed, "description") and embed.description: - embed_desc = embed.description - if len(embed_desc) > 300: - embed_desc = embed_desc[:297] + "..." - forwarded_parts.append(f"**{author_name}:** {embed_desc}") - break - elif getattr(snap, "attachments", None): - attachment_info = ", ".join( - [getattr(a, "filename", "Unknown") for a in snap.attachments[:3]] - ) - if len(snap.attachments) > 3: - attachment_info += f" (+{len(snap.attachments) - 3} more)" - forwarded_parts.append(f"**{author_name}:** [Attachments: {attachment_info}]") - else: - forwarded_parts.append(f"**{author_name}:** [No content]") - - if forwarded_parts: - return "\n".join(forwarded_parts) + # Check directly for snapshots as flags.has_snapshot can be unreliable in some versions + if hasattr(message, "message_snapshots") and message.message_snapshots: + forwarded_parts = [] + for snap in message.message_snapshots: + # If author is missing, we can try to rely on the container message context or just omit. + # Since we can't reliably get the original author from snapshot in this state, we focus on content. + + snap_content = getattr(snap, "content", "") + + formatted_part = "📨 **Forwarded Message**\n" + + if snap_content: + if len(snap_content) > 500: + snap_content = snap_content[:497] + "..." + formatted_part += "\n".join([f"{line}" for line in snap_content.splitlines()]) + "\n" + + if hasattr(snap, "embeds") and snap.embeds: + for embed in snap.embeds: + if hasattr(embed, "description") and embed.description: + embed_desc = embed.description + if len(embed_desc) > 300: + embed_desc = embed_desc[:297] + "..." + formatted_part += ( + "\n".join([f"> {line}" for line in embed_desc.splitlines()]) + "\n" + ) + break # One embed preview is usually enough + + if hasattr(snap, "attachments") and snap.attachments: + attachment_urls = [] + for a in snap.attachments[:3]: + url = getattr(a, "url", None) + if url: + # Use direct URL for proper embedding in logviewer + attachment_urls.append(url.split("?")[0]) + if len(snap.attachments) > 3: + formatted_part += f"📎 (+{len(snap.attachments) - 3} more attachments)\n" + # Add URLs on separate lines for proper embedding + for url in attachment_urls: + formatted_part += f"{url}\n" + forwarded_parts.append(formatted_part) + + if forwarded_parts: + return "\n".join(forwarded_parts) # Handle single-message forward - elif getattr(message, "type", None) == getattr(discord.MessageType, "forward", None): + elif hasattr(message, "type") and message.type == getattr(discord.MessageType, "forward", None): ref = getattr(message, "reference", None) if ( ref @@ -711,20 +749,27 @@ def extract_forwarded_content(message) -> typing.Optional[str]: if len(ref_content) > 500: ref_content = ref_content[:497] + "..." return f"**{ref_author_name}:** {ref_content}" - elif getattr(ref_msg, "embeds", None): + elif hasattr(ref_msg, "embeds") and ref_msg.embeds: for embed in ref_msg.embeds: if hasattr(embed, "description") and embed.description: embed_desc = embed.description if len(embed_desc) > 300: embed_desc = embed_desc[:297] + "..." return f"**{ref_author_name}:** {embed_desc}" - elif getattr(ref_msg, "attachments", None): - attachment_info = ", ".join( - [getattr(a, "filename", "Unknown") for a in ref_msg.attachments[:3]] - ) + elif hasattr(ref_msg, "attachments") and ref_msg.attachments: + attachment_urls = [] + for a in ref_msg.attachments[:3]: + url = getattr(a, "url", None) + if url: + # Use direct URL for proper embedding in logviewer + attachment_urls.append(url.split("?")[0]) + result = f"**{ref_author_name}:** 📎\n" if len(ref_msg.attachments) > 3: - attachment_info += f" (+{len(ref_msg.attachments) - 3} more)" - return f"**{ref_author_name}:** [Attachments: {attachment_info}]" + result += f"(+{len(ref_msg.attachments) - 3} more attachments)\n" + # Add URLs on separate lines for proper embedding + for url in attachment_urls: + result += f"{url}\n" + return result except Exception as e: # Log and continue; failing to extract a reference preview shouldn't break flow logger.debug("Failed to extract reference preview: %s", e) diff --git a/plugins/registry.json b/plugins/registry.json index 4079001a50..ee6b17b792 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1,13 +1,4 @@ { - "advanced-menu": { - "repository": "sebkuip/mm-plugins", - "branch": "master", - "description": "Advanced menu plugin using dropdown selectors. Supports submenus (and sub-submenus infinitely).", - "bot_version": "v4.0.0", - "title": "Advanced menu", - "icon_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png", - "thumbnail_url": "https://raw.githubusercontent.com/sebkuip/mm-plugins/master/advanced-menu/logo.png" - }, "announcement": { "repository": "Jerrie-Aries/modmail-plugins", "branch": "master", @@ -72,13 +63,13 @@ "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" }, "claim": { - "repository": "fourjr/modmail-plugins", - "branch": "v4", - "description": "Allows supporters to claim thread by sending ?claim in the thread channel", - "bot_version": "4.0.0", + "repository": "martinbndr/kyb3r-modmail-plugins", + "branch": "master", + "description": "Adds claim functionality to your modmail bot.", + "bot_version": "4.2.1", "title": "Claim Thread", - "icon_url": "https://i.imgur.com/Mo60CdK.png", - "thumbnail_url": "https://i.imgur.com/Mo60CdK.png" + "icon_url": "https://i.ibb.co/dsPjgKLj/87249157.png", + "thumbnail_url": "https://i.ibb.co/dsPjgKLj/87249157.png" }, "emote-manager": { "repository": "fourjr/modmail-plugins", @@ -134,4 +125,4 @@ "icon_url": "https://i.imgur.com/A1auJ95.png", "thumbnail_url": "https://i.imgur.com/A1auJ95.png" } -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9c07172039..1120657d07 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,13 @@ --i https://pypi.org/simple -aiodns==3.5.0; python_version >= '3.9' +-i https://pypi.org/simple aiohappyeyeballs==2.6.1; python_version >= '3.9' aiohttp==3.13.2; python_version >= '3.9' aiosignal==1.4.0; python_version >= '3.9' async-timeout==5.0.1; python_version < '3.11' attrs==25.4.0; python_version >= '3.9' -audioop-lts==0.2.2; python_version >= '3.13' brotli==1.2.0 cairocffi==1.7.1; python_version >= '3.8' cairosvg==2.8.2; python_version >= '3.9' -certifi==2025.10.5; python_version >= '3.7' +certifi==2025.11.12; python_version >= '3.7' cffi==2.0.0; python_version >= '3.9' charset-normalizer==3.4.4; python_version >= '3.7' colorama==0.4.6; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6' @@ -30,7 +28,6 @@ packaging==23.2; python_version >= '3.7' parsedatetime==2.6 pillow==12.0.0; python_version >= '3.10' propcache==0.4.1; python_version >= '3.9' -pycares==4.11.0; python_version >= '3.9' pycparser==2.23; python_version >= '3.8' pymongo==4.15.3; python_version >= '3.9' python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' @@ -38,7 +35,7 @@ python-dotenv==1.0.0; python_version >= '3.8' requests==2.31.0; python_version >= '3.7' setuptools==80.9.0; python_version >= '3.9' six==1.17.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2' -tinycss2==1.4.0; python_version >= '3.8' +tinycss2==1.5.1; python_version >= '3.10' typing-extensions==4.15.0; python_version >= '3.9' urllib3==2.5.0; python_version >= '3.9' uvloop==0.22.1; sys_platform != 'win32'