diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7a16ca8..1664f37c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,21 @@ jobs: - nightly steps: - uses: actions/checkout@v4 - - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo build --verbose - - run: cargo test --verbose + - name: Set up Rust + run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} + + - name: Install trunk and wasm target + run: | + rustup target add wasm32-unknown-unknown + cargo install trunk --locked + + - name: Build frontend + working-directory: rustmail_panel + run: trunk build --release --dist ../rustmail/static --config Trunk.toml + + - name: Build Rust backend + run: cargo build --verbose -p rustmail + + - name: Run tests + run: cargo test --verbose -p rustmail \ No newline at end of file diff --git a/.gitignore b/.gitignore index bd6c5741..785bb6e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ /target config.toml -/src/generated/prisma +config.toml.backup node_modules .env -db/db.sqlite +db/ .idea .vscode/ package-lock.json -bin \ No newline at end of file +bin +rustmail/static +rustmail_panel/dist \ No newline at end of file diff --git a/.sqlx/query-2440129afcae5c46a5e74767be8c3417377e5a2ba4cd203c12354634a606c3d1.json b/.sqlx/query-2440129afcae5c46a5e74767be8c3417377e5a2ba4cd203c12354634a606c3d1.json new file mode 100644 index 00000000..58bdcc17 --- /dev/null +++ b/.sqlx/query-2440129afcae5c46a5e74767be8c3417377e5a2ba4cd203c12354634a606c3d1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO sessions_panel (session_id, user_id, access_token, refresh_token, expires_at, avatar_hash)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(session_id) DO UPDATE SET\n access_token = excluded.access_token,\n refresh_token = excluded.refresh_token,\n expires_at = excluded.expires_at,\n avatar_hash = excluded.avatar_hash\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "2440129afcae5c46a5e74767be8c3417377e5a2ba4cd203c12354634a606c3d1" +} diff --git a/.sqlx/query-2eff567a5668138fed25817ebd77f268f5a140c04524e7dde35a3b6c9d05fa35.json b/.sqlx/query-2eff567a5668138fed25817ebd77f268f5a140c04524e7dde35a3b6c9d05fa35.json new file mode 100644 index 00000000..80f89afc --- /dev/null +++ b/.sqlx/query-2eff567a5668138fed25817ebd77f268f5a140c04524e7dde35a3b6c9d05fa35.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE threads\n SET\n status = 0,\n closed_at = ?,\n closed_by = ?,\n category_id = ?,\n category_name = ?,\n required_permissions = ?\n WHERE id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "2eff567a5668138fed25817ebd77f268f5a140c04524e7dde35a3b6c9d05fa35" +} diff --git a/.sqlx/query-3c9fbdf8549fa66a5ba863fad1e9aaee2f4e172a153fc44a20ff7a98604d16a3.json b/.sqlx/query-3c9fbdf8549fa66a5ba863fad1e9aaee2f4e172a153fc44a20ff7a98604d16a3.json new file mode 100644 index 00000000..fa4d2745 --- /dev/null +++ b/.sqlx/query-3c9fbdf8549fa66a5ba863fad1e9aaee2f4e172a153fc44a20ff7a98604d16a3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE thread_status\n SET\n taken_by = ?,\n last_message_by = ?,\n last_message_at = ?\n WHERE thread_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "3c9fbdf8549fa66a5ba863fad1e9aaee2f4e172a153fc44a20ff7a98604d16a3" +} diff --git a/.sqlx/query-437edc46a24b64d00cbbbce4481309d80ad250895d183ad2dc48f588bcac79c8.json b/.sqlx/query-437edc46a24b64d00cbbbce4481309d80ad250895d183ad2dc48f588bcac79c8.json new file mode 100644 index 00000000..b985de92 --- /dev/null +++ b/.sqlx/query-437edc46a24b64d00cbbbce4481309d80ad250895d183ad2dc48f588bcac79c8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM sessions_panel WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "437edc46a24b64d00cbbbce4481309d80ad250895d183ad2dc48f588bcac79c8" +} diff --git a/.sqlx/query-4d42153597bee6a9019e13cb2f1c873b32e6eb406e83c73c3838d05b0ff68576.json b/.sqlx/query-4d42153597bee6a9019e13cb2f1c873b32e6eb406e83c73c3838d05b0ff68576.json deleted file mode 100644 index de158757..00000000 --- a/.sqlx/query-4d42153597bee6a9019e13cb2f1c873b32e6eb406e83c73c3838d05b0ff68576.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "DELETE FROM staff_alerts WHERE staff_user_id = ? AND thread_user_id = ? AND used = FALSE", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "4d42153597bee6a9019e13cb2f1c873b32e6eb406e83c73c3838d05b0ff68576" -} diff --git a/.sqlx/query-4dad72c50271ccb618c9bc460a0646a09f34516482d72af781423bb1789b98b1.json b/.sqlx/query-4dad72c50271ccb618c9bc460a0646a09f34516482d72af781423bb1789b98b1.json deleted file mode 100644 index 7a85344e..00000000 --- a/.sqlx/query-4dad72c50271ccb618c9bc460a0646a09f34516482d72af781423bb1789b98b1.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE threads SET status = 0 WHERE id = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "4dad72c50271ccb618c9bc460a0646a09f34516482d72af781423bb1789b98b1" -} diff --git a/.sqlx/query-62f2f9b44d00169468b3596c352847582ff4fc54c9b6d26ff758cf04bba1d647.json b/.sqlx/query-62f2f9b44d00169468b3596c352847582ff4fc54c9b6d26ff758cf04bba1d647.json new file mode 100644 index 00000000..7eae63e8 --- /dev/null +++ b/.sqlx/query-62f2f9b44d00169468b3596c352847582ff4fc54c9b6d26ff758cf04bba1d647.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n (COUNT(*) OVER ())\n - ROW_NUMBER() OVER (ORDER BY created_at DESC) + 1 AS id,\n id AS ticket_id,\n user_id AS \"user_id: String\",\n created_at AS \"created_at: String\"\n FROM threads\n WHERE user_id = ? AND status = 0\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "ticket_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id: String", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "created_at: String", + "ordinal": 3, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "62f2f9b44d00169468b3596c352847582ff4fc54c9b6d26ff758cf04bba1d647" +} diff --git a/.sqlx/query-78851afb4bcef9737b5bb4a299320cce28a72d5755f57e0b874569311f569b51.json b/.sqlx/query-78851afb4bcef9737b5bb4a299320cce28a72d5755f57e0b874569311f569b51.json new file mode 100644 index 00000000..5e7dbcec --- /dev/null +++ b/.sqlx/query-78851afb4bcef9737b5bb4a299320cce28a72d5755f57e0b874569311f569b51.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id,\n thread_id,\n user_id,\n user_name,\n is_anonymous,\n dm_message_id,\n inbox_message_id,\n message_number,\n created_at as \"created_at: String\",\n content\n FROM thread_messages\n WHERE thread_id = ?\n ORDER BY created_at ASC\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "thread_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "user_name", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "is_anonymous", + "ordinal": 4, + "type_info": "Bool" + }, + { + "name": "dm_message_id", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "inbox_message_id", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "message_number", + "ordinal": 7, + "type_info": "Integer" + }, + { + "name": "created_at: String", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "content", + "ordinal": 9, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "78851afb4bcef9737b5bb4a299320cce28a72d5755f57e0b874569311f569b51" +} diff --git a/.sqlx/query-911cf434a65a1fb08918598f465f8a34facae0f1e0fe0f73d720a65b4c7ef6b6.json b/.sqlx/query-911cf434a65a1fb08918598f465f8a34facae0f1e0fe0f73d720a65b4c7ef6b6.json deleted file mode 100644 index ade45975..00000000 --- a/.sqlx/query-911cf434a65a1fb08918598f465f8a34facae0f1e0fe0f73d720a65b4c7ef6b6.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO thread_messages (\n thread_id, user_id, user_name, is_anonymous, dm_message_id, content, thread_status\n ) VALUES (\n ?, ?, ?, ?, ?, ?, ?\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "911cf434a65a1fb08918598f465f8a34facae0f1e0fe0f73d720a65b4c7ef6b6" -} diff --git a/.sqlx/query-a6bb2fb861c5e46306f8c370816639f55f82370b28eeb6272d5ee01c283611da.json b/.sqlx/query-a6bb2fb861c5e46306f8c370816639f55f82370b28eeb6272d5ee01c283611da.json new file mode 100644 index 00000000..f227c794 --- /dev/null +++ b/.sqlx/query-a6bb2fb861c5e46306f8c370816639f55f82370b28eeb6272d5ee01c283611da.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO thread_status (thread_id, channel_id, owner_id, taken_by, last_message_by, last_message_at) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "a6bb2fb861c5e46306f8c370816639f55f82370b28eeb6272d5ee01c283611da" +} diff --git a/.sqlx/query-ad9ce85578ae082fba6ee4378e0539f9a196910408b8fd2106de9587090ab9ff.json b/.sqlx/query-ad9ce85578ae082fba6ee4378e0539f9a196910408b8fd2106de9587090ab9ff.json new file mode 100644 index 00000000..24934e84 --- /dev/null +++ b/.sqlx/query-ad9ce85578ae082fba6ee4378e0539f9a196910408b8fd2106de9587090ab9ff.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id,\n user_id,\n user_name,\n channel_id,\n strftime('%s', created_at) as \"created_at: Option\",\n next_message_number as new_message_number,\n status,\n user_left,\n strftime('%s', closed_at) as \"closed_at: Option\",\n closed_by,\n category_id,\n category_name,\n required_permissions\n FROM threads\n WHERE id = ?\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "user_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "channel_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at: Option", + "ordinal": 4, + "type_info": "Null" + }, + { + "name": "new_message_number", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "status", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "user_left", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "closed_at: Option", + "ordinal": 8, + "type_info": "Null" + }, + { + "name": "closed_by", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "category_id", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "category_name", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "required_permissions", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + null, + true, + false, + false, + null, + true, + true, + true, + true + ] + }, + "hash": "ad9ce85578ae082fba6ee4378e0539f9a196910408b8fd2106de9587090ab9ff" +} diff --git a/.sqlx/query-b5e89fffcf439bc2e8c770ca2ded08dc95726471d1d8e9319b5e6943fee45171.json b/.sqlx/query-b5e89fffcf439bc2e8c770ca2ded08dc95726471d1d8e9319b5e6943fee45171.json new file mode 100644 index 00000000..47ba6701 --- /dev/null +++ b/.sqlx/query-b5e89fffcf439bc2e8c770ca2ded08dc95726471d1d8e9319b5e6943fee45171.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n channel_id,\n owner_id,\n taken_by,\n last_message_by,\n last_message_at\n FROM thread_status\n WHERE thread_id = ?\n ", + "describe": { + "columns": [ + { + "name": "channel_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "owner_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "taken_by", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_message_by", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "last_message_at", + "ordinal": 4, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "b5e89fffcf439bc2e8c770ca2ded08dc95726471d1d8e9319b5e6943fee45171" +} diff --git a/.sqlx/query-cf4c994a2e54bc6994e1b47ea303c1ba4f317a593414c40696633c8484ad278e.json b/.sqlx/query-cf4c994a2e54bc6994e1b47ea303c1ba4f317a593414c40696633c8484ad278e.json new file mode 100644 index 00000000..a8045f44 --- /dev/null +++ b/.sqlx/query-cf4c994a2e54bc6994e1b47ea303c1ba4f317a593414c40696633c8484ad278e.json @@ -0,0 +1,92 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id,\n user_id,\n user_name,\n channel_id,\n strftime('%s', created_at) as \"created_at: Option\",\n next_message_number as new_message_number,\n status,\n user_left,\n strftime('%s', closed_at) as \"closed_at: Option\",\n closed_by,\n category_id,\n category_name,\n required_permissions\n FROM threads\n WHERE status = 0\n ORDER BY closed_at DESC\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "user_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "channel_id", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at: Option", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "new_message_number", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "status", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "user_left", + "ordinal": 7, + "type_info": "Bool" + }, + { + "name": "closed_at: Option", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "closed_by", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "category_id", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "category_name", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "required_permissions", + "ordinal": 12, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "cf4c994a2e54bc6994e1b47ea303c1ba4f317a593414c40696633c8484ad278e" +} diff --git a/.sqlx/query-dee8be2076aabbf5c6895c2b0824dced4343f955a6361389fd36c932fe249b2d.json b/.sqlx/query-dee8be2076aabbf5c6895c2b0824dced4343f955a6361389fd36c932fe249b2d.json new file mode 100644 index 00000000..9b916e07 --- /dev/null +++ b/.sqlx/query-dee8be2076aabbf5c6895c2b0824dced4343f955a6361389fd36c932fe249b2d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id FROM reminders\n WHERE user_id = ? AND guild_id = ? AND trigger_time = ? AND completed = 0\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false + ] + }, + "hash": "dee8be2076aabbf5c6895c2b0824dced4343f955a6361389fd36c932fe249b2d" +} diff --git a/.sqlx/query-e8ad51199ceccfec9e6bbde94356a9e8feaa686c309ede399b64287fb77f2f69.json b/.sqlx/query-e8ad51199ceccfec9e6bbde94356a9e8feaa686c309ede399b64287fb77f2f69.json new file mode 100644 index 00000000..720d5ea5 --- /dev/null +++ b/.sqlx/query-e8ad51199ceccfec9e6bbde94356a9e8feaa686c309ede399b64287fb77f2f69.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id FROM staff_alerts\n WHERE staff_user_id = ? AND thread_user_id = ? AND used = FALSE\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "e8ad51199ceccfec9e6bbde94356a9e8feaa686c309ede399b64287fb77f2f69" +} diff --git a/.sqlx/query-ea1cdea86310018f27ee5b43603fd99f8386ea396839656999962e86a06f8019.json b/.sqlx/query-ea1cdea86310018f27ee5b43603fd99f8386ea396839656999962e86a06f8019.json new file mode 100644 index 00000000..eb2fd0df --- /dev/null +++ b/.sqlx/query-ea1cdea86310018f27ee5b43603fd99f8386ea396839656999962e86a06f8019.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT ts.channel_id,\n ts.owner_id,\n ts.taken_by,\n ts.last_message_by,\n ts.last_message_at\n FROM thread_status ts\n JOIN threads t ON ts.thread_id = t.id\n WHERE t.status = 1\n ", + "describe": { + "columns": [ + { + "name": "channel_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "owner_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "taken_by", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "last_message_by", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "last_message_at", + "ordinal": 4, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "ea1cdea86310018f27ee5b43603fd99f8386ea396839656999962e86a06f8019" +} diff --git a/Cargo.lock b/Cargo.lock index ce5fb261..985efcab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,19 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -32,6 +45,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + [[package]] name = "arrayvec" version = "0.7.6" @@ -73,6 +92,81 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -91,6 +185,15 @@ version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -115,6 +218,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "boolinator" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9" + [[package]] name = "bumpalo" version = "3.17.0" @@ -199,6 +308,17 @@ dependencies = [ "windows-link 0.2.0", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", + "serde", +] + [[package]] name = "command_attr" version = "0.5.3" @@ -219,12 +339,33 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -308,6 +449,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.101", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -378,6 +542,21 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "either" version = "1.15.0" @@ -506,6 +685,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.31" @@ -543,115 +732,497 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", - "futures-task", - "futures-util", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "gloo" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d" +dependencies = [ + "gloo-console 0.2.3", + "gloo-dialogs 0.1.1", + "gloo-events 0.1.2", + "gloo-file 0.2.3", + "gloo-history 0.1.5", + "gloo-net 0.3.1", + "gloo-render 0.1.1", + "gloo-storage 0.2.2", + "gloo-timers 0.2.6", + "gloo-utils 0.1.7", + "gloo-worker 0.2.1", +] + +[[package]] +name = "gloo" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249" +dependencies = [ + "gloo-console 0.3.0", + "gloo-dialogs 0.2.0", + "gloo-events 0.2.0", + "gloo-file 0.3.0", + "gloo-history 0.2.2", + "gloo-net 0.4.0", + "gloo-render 0.2.0", + "gloo-storage 0.3.0", + "gloo-timers 0.3.0", + "gloo-utils 0.2.0", + "gloo-worker 0.4.0", +] + +[[package]] +name = "gloo-console" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f" +dependencies = [ + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-console" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-dialogs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4748e10122b01435750ff530095b1217cf6546173459448b83913ebe7815df" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" +dependencies = [ + "gloo-events 0.1.2", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97563d71863fb2824b2e974e754a81d19c4a7ec47b09ced8a0e6656b6d54bd1f" +dependencies = [ + "futures-channel", + "gloo-events 0.2.0", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f" +dependencies = [ + "gloo-events 0.1.2", + "gloo-utils 0.1.7", + "serde", + "serde-wasm-bindgen 0.5.0", + "serde_urlencoded", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-history" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" +dependencies = [ + "getrandom 0.2.16", + "gloo-events 0.2.0", + "gloo-utils 0.2.0", + "serde", + "serde-wasm-bindgen 0.6.5", + "serde_urlencoded", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.1.7", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 0.2.12", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils 0.2.0", + "http 1.3.1", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "futures-intrusive" -version = "0.5.0" +name = "gloo-render" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764" dependencies = [ - "futures-core", - "lock_api", - "parking_lot", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "futures-io" -version = "0.3.31" +name = "gloo-render" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "56008b6744713a8e8d98ac3dcb7d06543d5662358c9c805b4ce2167ad4649833" +dependencies = [ + "wasm-bindgen", + "web-sys", +] [[package]] -name = "futures-macro" -version = "0.3.31" +name = "gloo-storage" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.101", + "gloo-utils 0.1.7", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "futures-sink" -version = "0.3.31" +name = "gloo-storage" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" +dependencies = [ + "gloo-utils 0.2.0", + "js-sys", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", +] [[package]] -name = "futures-task" -version = "0.3.31" +name = "gloo-timers" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] -name = "futures-util" -version = "0.3.31" +name = "gloo-timers" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" dependencies = [ "futures-channel", "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "fxhash" -version = "0.2.1" +name = "gloo-utils" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" dependencies = [ - "byteorder", + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "gloo-utils" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" dependencies = [ - "typenum", - "version_check", + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "getrandom" -version = "0.2.16" +name = "gloo-worker" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a" dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "anymap2", + "bincode", + "gloo-console 0.2.3", + "gloo-utils 0.1.7", + "js-sys", + "serde", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "getrandom" -version = "0.3.3" +name = "gloo-worker" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "bincode", + "futures", + "gloo-utils 0.2.0", + "gloo-worker-macros", + "js-sys", + "pinned", + "serde", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "glob" -version = "0.3.2" +name = "gloo-worker-macros" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "956caa58d4857bc9941749d55e4bd3000032d8212762586fa5705632967140e7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.101", +] [[package]] name = "h2" @@ -723,6 +1294,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -756,6 +1333,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "0.2.12" @@ -850,19 +1438,22 @@ dependencies = [ [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2 0.4.10", "http 1.3.1", "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -889,7 +1480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a01595e11bdcec50946522c32dde3fc6914743000a68b93000965f2f02406d" dependencies = [ "http 1.3.1", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "rustls 0.23.27", "rustls-pki-types", @@ -906,7 +1497,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-util", "native-tls", "tokio", @@ -927,7 +1518,7 @@ dependencies = [ "futures-util", "http 1.3.1", "http-body 1.0.1", - "hyper 1.6.0", + "hyper 1.7.0", "ipnet", "libc", "percent-encoding", @@ -940,6 +1531,17 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "i18nrs" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09d2f33c772de8e151f781d95ac0809fe01b4cf106446e11a9b7172c08a3d6e" +dependencies = [ + "serde_json", + "web-sys", + "yew", +] + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -1071,6 +1673,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "implicit-clone" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84" +dependencies = [ + "implicit-clone-derive", + "indexmap", +] + +[[package]] +name = "implicit-clone-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699c1b6d335e63d0ba5c1e1c7f647371ce989c3bcbe1f7ed2b85fa56e3bd1a21" +dependencies = [ + "quote", + "syn 2.0.101", +] + [[package]] name = "indexmap" version = "2.11.4" @@ -1179,6 +1801,46 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -1263,6 +1925,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1316,6 +1984,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1410,6 +2088,96 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1422,6 +2190,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pinned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a829027bd95e54cfe13e3e258a1ae7b645960553fb82b75ff852c29688ee595b" +dependencies = [ + "futures", + "rustversion", + "thiserror 1.0.69", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -1473,6 +2252,56 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +dependencies = [ + "proc-macro2", + "syn 2.0.101", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -1482,6 +2311,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prokio" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488" +dependencies = [ + "futures", + "gloo 0.8.1", + "num_cpus", + "once_cell", + "pin-project", + "pinned", + "tokio", + "tokio-stream", + "wasm-bindgen-futures", +] + [[package]] name = "pulldown-cmark" version = "0.9.6" @@ -1493,6 +2339,25 @@ dependencies = [ "unicase", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags 2.9.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quote" version = "1.0.40" @@ -1634,7 +2499,7 @@ dependencies = [ "http 1.3.1", "http-body 1.0.1", "http-body-util", - "hyper 1.6.0", + "hyper 1.7.0", "hyper-rustls 0.27.6", "hyper-tls", "hyper-util", @@ -1674,6 +2539,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "route-recognizer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" + [[package]] name = "rsa" version = "0.9.8" @@ -1694,6 +2565,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.101", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustix" version = "1.0.7" @@ -1798,20 +2703,61 @@ dependencies = [ [[package]] name = "rustmail" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", + "axum", + "axum-extra", + "base64 0.22.1", "chrono", + "hyper 1.7.0", + "mime_guess", + "rand", "regex", "reqwest 0.12.24", + "rust-embed", + "rustmail_types", "serde", + "serde_json", "serenity", "sqlx", + "subtle", "tokio", "toml", + "urlencoding", "uuid", ] +[[package]] +name = "rustmail_panel" +version = "0.1.0" +dependencies = [ + "ammonia", + "chrono-tz", + "gloo-net 0.6.0", + "gloo-utils 0.2.0", + "i18nrs", + "js-sys", + "pulldown-cmark 0.13.0", + "rustmail_types", + "serde", + "serde_json", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew", + "yew-router", +] + +[[package]] +name = "rustmail_types" +version = "0.1.1" +dependencies = [ + "chrono-tz", + "serde", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -1910,6 +2856,28 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1941,14 +2909,26 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", ] [[package]] @@ -2037,6 +3017,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -2047,6 +3036,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "skeptic" version = "0.13.7" @@ -2057,7 +3052,7 @@ dependencies = [ "cargo_metadata", "error-chain", "glob", - "pulldown-cmark", + "pulldown-cmark 0.9.6", "tempfile", "walkdir", ] @@ -2319,6 +3314,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -2445,6 +3465,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2551,6 +3582,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2 0.6.0", "tokio-macros", "windows-sys 0.61.1", @@ -2657,12 +3689,18 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.3", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.13", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + [[package]] name = "toml_datetime" version = "0.7.3" @@ -2672,13 +3710,24 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + [[package]] name = "toml_parser" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow", + "winnow 0.7.13", ] [[package]] @@ -2700,6 +3749,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2871,6 +3921,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -2889,6 +3945,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -3064,6 +4126,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "0.25.4" @@ -3340,6 +4414,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.13" @@ -3371,6 +4454,76 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "yew" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac" +dependencies = [ + "console_error_panic_hook", + "futures", + "gloo 0.10.0", + "implicit-clone", + "indexmap", + "js-sys", + "prokio", + "rustversion", + "serde", + "slab", + "thiserror 1.0.69", + "tokio", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yew-macro", +] + +[[package]] +name = "yew-macro" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2" +dependencies = [ + "boolinator", + "once_cell", + "prettyplease", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "yew-router" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca1d5052c96e6762b4d6209a8aded597758d442e6c479995faf0c7b5538e0c6" +dependencies = [ + "gloo 0.10.0", + "js-sys", + "route-recognizer", + "serde", + "serde_urlencoded", + "tracing", + "urlencoding", + "wasm-bindgen", + "web-sys", + "yew", + "yew-router-macro", +] + +[[package]] +name = "yew-router-macro" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bfd190a07ca8cfde7cd4c52b3ac463803dc07323db8c34daa697e86365978c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index d7a95ca8..2e3f2b0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,3 @@ -[package] -name = "rustmail" -version = "0.1.0" -edition = "2024" -license = "MIT" - -[dependencies] -serde = { version = "*", features = ["derive"] } -serenity = "0.12.4" -sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "macros", "migrate"] } -tokio = { version = "1.48.0", features = ["rt-multi-thread"] } -toml = "0.9.8" -reqwest = "0.12.24" -async-trait = "0.1.89" -chrono = { version = "0.4.42", features = ["serde"] } -regex = "1.12.2" - -[dependencies.uuid] -version = "1.18.1" -features = ["v4"] +[workspace] +members = ["rustmail", "rustmail_panel", "rustmail_types"] +resolver = "3" \ No newline at end of file diff --git a/migrations/20251017082144_sessions_panel.sql b/migrations/20251017082144_sessions_panel.sql new file mode 100644 index 00000000..c88e03be --- /dev/null +++ b/migrations/20251017082144_sessions_panel.sql @@ -0,0 +1,9 @@ +-- Add migration script here +CREATE TABLE IF NOT EXISTS sessions_panel ( + session_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + expires_at INTEGER NOT NULL, + avatar_hash TEXT NOT NULL +); \ No newline at end of file diff --git a/migrations/20251018171827_permissions.sql b/migrations/20251018171827_permissions.sql new file mode 100644 index 00000000..7b06014e --- /dev/null +++ b/migrations/20251018171827_permissions.sql @@ -0,0 +1,15 @@ +-- Add migration script here +ALTER TABLE "threads" + ADD COLUMN "closed_at" DATETIME NULL; + +ALTER TABLE "threads" + ADD COLUMN "closed_by" TEXT NULL; + +ALTER TABLE "threads" + ADD COLUMN "category_id" TEXT NULL; + +ALTER TABLE "threads" + ADD COLUMN "category_name" TEXT NULL; + +ALTER TABLE "threads" + ADD COLUMN "required_permissions" TEXT NULL; diff --git a/migrations/20251018174044_update_scheduled_closure_table.sql b/migrations/20251018174044_update_scheduled_closure_table.sql new file mode 100644 index 00000000..ec84fa84 --- /dev/null +++ b/migrations/20251018174044_update_scheduled_closure_table.sql @@ -0,0 +1,15 @@ +-- Add migration script here +ALTER TABLE "scheduled_closures" + ADD COLUMN "closed_at" DATETIME NULL; + +ALTER TABLE "scheduled_closures" + ADD COLUMN "closed_by" TEXT NULL; + +ALTER TABLE "scheduled_closures" + ADD COLUMN "category_id" TEXT NULL; + +ALTER TABLE "scheduled_closures" + ADD COLUMN "category_name" TEXT NULL; + +ALTER TABLE "scheduled_closures" + ADD COLUMN "required_permissions" TEXT NULL; \ No newline at end of file diff --git a/migrations/20251030075606_thread_status.sql b/migrations/20251030075606_thread_status.sql new file mode 100644 index 00000000..4e7c09a4 --- /dev/null +++ b/migrations/20251030075606_thread_status.sql @@ -0,0 +1,11 @@ +-- Add migration script here +CREATE TABLE IF NOT EXISTS "thread_status" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "thread_id" TEXT NOT NULL UNIQUE, + "channel_id" INTEGER NOT NULL UNIQUE, + "owner_id" TEXT NOT NULL, + "taken_by" TEXT DEFAULT NULL, + "last_message_by" TEXT NOT NULL, + "last_message_at" INTEGER NOT NULL, + CONSTRAINT "thread_status_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "threads" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index c664cd53..00000000 --- a/package-lock.json +++ /dev/null @@ -1,430 +0,0 @@ -{ - "name": "rustmail", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@prisma/client": "^6.18.0" - }, - "devDependencies": { - "prisma": "^6.18.0" - } - }, - "node_modules/@prisma/client": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.18.0.tgz", - "integrity": "sha512-jnL2I9gDnPnw4A+4h5SuNn8Gc+1mL1Z79U/3I9eE2gbxJG1oSA+62ByPW4xkeDgwE0fqMzzpAZ7IHxYnLZ4iQA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "peerDependencies": { - "prisma": "*", - "typescript": ">=5.1.0" - }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@prisma/config": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.18.0.tgz", - "integrity": "sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "c12": "3.1.0", - "deepmerge-ts": "7.1.5", - "effect": "3.18.4", - "empathic": "2.0.0" - } - }, - "node_modules/@prisma/debug": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz", - "integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/engines": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz", - "integrity": "sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.18.0", - "@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f", - "@prisma/fetch-engine": "6.18.0", - "@prisma/get-platform": "6.18.0" - } - }, - "node_modules/@prisma/engines-version": { - "version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f.tgz", - "integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==", - "devOptional": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/fetch-engine": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz", - "integrity": "sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.18.0", - "@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f", - "@prisma/get-platform": "6.18.0" - } - }, - "node_modules/@prisma/get-platform": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.18.0.tgz", - "integrity": "sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.18.0" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/c12": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", - "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.3", - "confbox": "^0.2.2", - "defu": "^6.1.4", - "dotenv": "^16.6.1", - "exsolve": "^1.0.7", - "giget": "^2.0.0", - "jiti": "^2.4.2", - "ohash": "^2.0.11", - "pathe": "^2.0.3", - "perfect-debounce": "^1.0.0", - "pkg-types": "^2.2.0", - "rc9": "^2.1.2" - }, - "peerDependencies": { - "magicast": "^0.3.5" - }, - "peerDependenciesMeta": { - "magicast": { - "optional": true - } - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/citty": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", - "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "consola": "^3.2.3" - } - }, - "node_modules/confbox": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/destr": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", - "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/effect": { - "version": "3.18.4", - "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", - "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "fast-check": "^3.23.1" - } - }, - "node_modules/empathic": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/fast-check": { - "version": "3.23.2", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", - "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^6.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/giget": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", - "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.0", - "defu": "^6.1.4", - "node-fetch-native": "^1.6.6", - "nypm": "^0.6.0", - "pathe": "^2.0.3" - }, - "bin": { - "giget": "dist/cli.mjs" - } - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/node-fetch-native": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", - "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/nypm": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", - "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "citty": "^0.1.6", - "consola": "^3.4.2", - "pathe": "^2.0.3", - "pkg-types": "^2.3.0", - "tinyexec": "^1.0.1" - }, - "bin": { - "nypm": "dist/cli.mjs" - }, - "engines": { - "node": "^14.16.0 || >=16.10.0" - } - }, - "node_modules/ohash": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", - "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" - } - }, - "node_modules/prisma": { - "version": "6.18.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.18.0.tgz", - "integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==", - "devOptional": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/config": "6.18.0", - "@prisma/engines": "6.18.0" - }, - "bin": { - "prisma": "build/index.js" - }, - "engines": { - "node": ">=18.18" - }, - "peerDependencies": { - "typescript": ">=5.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/rc9": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", - "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "defu": "^6.1.4", - "destr": "^2.0.3" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", - "devOptional": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index edc4055a..00000000 --- a/package.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "devDependencies": { - "prisma": "^6.18.0" - }, - "dependencies": { - "@prisma/client": "^6.18.0" - } -} diff --git a/prisma/schema.prisma b/prisma/schema.prisma deleted file mode 100644 index 33c45ec4..00000000 --- a/prisma/schema.prisma +++ /dev/null @@ -1,53 +0,0 @@ -datasource db { - provider = "sqlite" - url = "file:../db/db.sqlite" -} - -model blocked_users { - user_id String @id - user_name String - blocked_by String - blocked_at DateTime @default(now()) - expires_at DateTime @default(now()) -} - -model threads { - id String @id @unique - user_id Int - user_name String - channel_id String - created_at DateTime @default(now()) - next_message_number Int? @default(1) - status Int @default(1) - user_left Boolean @default(false) - thread_messages thread_messages[] -} - -model thread_messages { - id Int @id @unique() @default(autoincrement()) - thread_id String - thread threads @relation(fields: [thread_id], references: [id]) - user_id Int - user_name String - is_anonymous Boolean - dm_message_id String? - inbox_message_id String? - message_number Int? - created_at DateTime @default(now()) - content String - thread_status Int @default(1) -} - -model staff_alerts { - id Int @id @default(autoincrement()) - staff_user_id Int - thread_user_id Int - created_at DateTime @default(now()) - used Boolean @default(false) -} - -model system_metadata { - key String @id - value String - updated_at DateTime @default(now()) -} diff --git a/rustmail/Cargo.toml b/rustmail/Cargo.toml new file mode 100644 index 00000000..5bac39eb --- /dev/null +++ b/rustmail/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "rustmail" +version = "0.1.1" +edition = "2024" +license = "MIT" + +[dependencies] +rustmail_types = { path = "../rustmail_types" } +serde = { version = "*", features = ["derive"] } +serenity = "0.12.4" +sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "macros", "migrate"] } +tokio = { version = "1.47.1", features = ["rt-multi-thread", "signal"] } +toml = "0.9.7" +reqwest = { version = "0.12.23", features = ["json"] } +async-trait = "0.1.89" +chrono = { version = "0.4.42", features = ["serde"] } +regex = "1.11.3" +axum = "0.8.6" +axum-extra = { version = "0.10.3", features = ["cookie"] } +hyper = "1.7.0" +rust-embed = "8.7.2" +mime_guess = "2.0.5" +urlencoding = "2.1.3" +serde_json = "1.0.145" +rand = "0.8.5" +base64 = "0.22.1" +subtle = "2.6.1" + +[dependencies.uuid] +version = "1.18.1" +features = ["v4"] diff --git a/rustmail/src/api/handler/auth/callback.rs b/rustmail/src/api/handler/auth/callback.rs new file mode 100644 index 00000000..6249aaa3 --- /dev/null +++ b/rustmail/src/api/handler/auth/callback.rs @@ -0,0 +1,152 @@ +use crate::prelude::types::*; +use axum::extract::{Query, State}; +use axum::response::Redirect; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use chrono::{Duration, Utc}; +use reqwest::Client; +use std::sync::Arc; +use tokio::sync::Mutex; +use uuid::Uuid; + +#[derive(serde::Deserialize)] +pub struct AuthRequest { + pub code: String, + pub state: Option, +} + +#[derive(serde::Deserialize, Debug)] +struct DiscordUser { + id: String, + username: String, + discriminator: String, + avatar: Option, +} + +pub async fn handle_callback( + State(bot_state): State>>, + jar: CookieJar, + Query(params): Query, +) -> (CookieJar, Redirect) { + let state_lock = bot_state.lock().await; + let Some(config) = &state_lock.config else { + return (jar, Redirect::to("/error?message=Missing+configuration")); + }; + + let Some(db_pool) = &state_lock.db_pool else { + return (jar, Redirect::to("/error?message=Database+not+initialized")); + }; + + let client = Client::new(); + + let token_response = match client + .post("https://discord.com/api/oauth2/token") + .form(&[ + ("client_id", config.bot.client_id.to_string().as_str()), + ("client_secret", config.bot.client_secret.as_str()), + ("grant_type", "authorization_code"), + ("code", params.code.as_str()), + ("redirect_uri", config.bot.redirect_url.as_str()), + ]) + .send() + .await + { + Ok(resp) => { + if !resp.status().is_success() { + eprintln!("⚠ Token exchange failed with status: {}", resp.status()); + return (jar, Redirect::to("/error?message=Token+exchange+failed")); + } + resp + } + Err(e) => { + eprintln!( + "⚠ Failed to exchange code for token (maybe client_secret or client_id: {}", + e + ); + return ( + jar, + Redirect::to("/error?message=Failed+to+exchange+code+for+token"), + ); + } + }; + + let token_data: serde_json::Value = token_response.json().await.unwrap(); + + let access_token = token_data["access_token"].as_str().unwrap_or(""); + let refresh_token = token_data["refresh_token"].as_str().unwrap_or(""); + let expires_in = token_data["expires_in"].as_i64().unwrap_or(3600); + + let user_response = match client + .get("https://discord.com/api/users/@me") + .bearer_auth(access_token) + .send() + .await + { + Ok(resp) => resp, + Err(e) => { + eprintln!("⚠ Failed to fetch user info: {}", e); + return ( + jar, + Redirect::to("/error?message=Failed+to+fetch+user+info"), + ); + } + }; + + let user: DiscordUser = match user_response.json().await { + Ok(user) => user, + Err(e) => { + eprintln!("⚠ Failed to parse user info: {}", e); + return ( + jar, + Redirect::to("/error?message=Failed+to+parse+user+info"), + ); + } + }; + let user_id = user.id.clone(); + + let session_id = Uuid::new_v4().to_string(); + let expires_at = Utc::now() + Duration::seconds(expires_in); + let timestamp = expires_at.timestamp(); + + if let Err(e) = sqlx::query!(r#"DELETE FROM sessions_panel WHERE user_id = ?"#, user_id) + .execute(db_pool) + .await + { + eprintln!("⚠ Failed to delete old session: {}", e); + return (jar, Redirect::to("/error?message=Database+delete+failed")); + } + + if let Err(e) = sqlx::query!( + r#" + INSERT INTO sessions_panel (session_id, user_id, access_token, refresh_token, expires_at, avatar_hash) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = excluded.refresh_token, + expires_at = excluded.expires_at, + avatar_hash = excluded.avatar_hash + "#, + session_id, + user_id, + access_token, + refresh_token, + timestamp, + user.avatar + ) + .execute(db_pool) + .await + { + eprintln!("⚠ Failed to store session in database: {}", e); + return (jar, Redirect::to("/error?message=Database+write+failed")); + } + + let cookie_session = Cookie::build(("session_id", session_id)) + .path("/") + .http_only(true) + .same_site(SameSite::Lax) + .build(); + + let jar = jar.add(cookie_session); + + let target = params.state.unwrap_or_else(|| "/panel".to_string()); + (jar, Redirect::to(&target)) +} diff --git a/rustmail/src/api/handler/auth/login.rs b/rustmail/src/api/handler/auth/login.rs new file mode 100644 index 00000000..ca5e9267 --- /dev/null +++ b/rustmail/src/api/handler/auth/login.rs @@ -0,0 +1,71 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::extract::{Query, State}; +use axum::response::Redirect; +use axum_extra::extract::CookieJar; +use sqlx::{Row, query}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub async fn handle_login( + jar: CookieJar, + State(bot_state): State>>, + Query(params): Query>, +) -> Redirect { + let redirect_after = params + .get("redirect") + .cloned() + .unwrap_or_else(|| "/panel".to_string()); + + let db_pool = { + let state_lock = bot_state.lock().await; + + if let Some(pool) = &state_lock.db_pool { + pool.clone() + } else { + return Redirect::to("/error?message=Database+not+initialized"); + } + }; + + let bot_config = { + let state_lock = bot_state.lock().await; + + if let Some(config) = &state_lock.config { + config.clone() + } else { + return Redirect::to("/error?message=Bot+not+configured"); + } + }; + + let session_cookie = jar.get("session_id"); + + if let Some(session_cookie) = session_cookie { + let session_id = session_cookie.value(); + let user_id = get_user_id_from_session(session_id, &db_pool).await; + + if let Ok(row) = + query("SELECT expires_at FROM sessions_panel WHERE session_id = ? AND user_id = ?") + .bind(session_id) + .bind(user_id) + .fetch_one(&db_pool) + .await + { + let expires_at: i64 = row.get::("expires_at"); + let current_timestamp = chrono::Utc::now().timestamp(); + + if expires_at > current_timestamp { + return Redirect::to("/panel"); + } + } + } + + let url = format!( + "https://discord.com/oauth2/authorize?client_id={}&redirect_uri={}&response_type=code&scope=identify%20guilds&state={}", + bot_config.bot.client_id, + urlencoding::encode(bot_config.bot.redirect_url.as_str()), + urlencoding::encode(&redirect_after), + ); + + Redirect::to(&url) +} diff --git a/rustmail/src/api/handler/auth/logout.rs b/rustmail/src/api/handler/auth/logout.rs new file mode 100644 index 00000000..70496a26 --- /dev/null +++ b/rustmail/src/api/handler/auth/logout.rs @@ -0,0 +1,39 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::extract::State; +use axum::response::Redirect; +use axum_extra::extract::CookieJar; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub async fn handle_logout( + jar: CookieJar, + State(bot_state): State>>, +) -> Redirect { + let state_lock = bot_state.lock().await; + + let session_cookie = jar.get("session_id"); + + let db_pool = { + if let Some(pool) = &state_lock.db_pool { + pool.clone() + } else { + return Redirect::to("/error?message=Database+not+initialized"); + } + }; + + if let Some(session_cookie) = session_cookie { + let session_id = session_cookie.value(); + let user_id = get_user_id_from_session(&session_id, &db_pool).await; + + if let Some(db_pool) = &state_lock.db_pool { + let _ = sqlx::query("DELETE FROM sessions_panel WHERE session_id = ? AND user_id = ?") + .bind(session_id) + .bind(user_id) + .execute(db_pool) + .await; + } + } + + Redirect::to("/") +} diff --git a/rustmail/src/api/handler/auth/mod.rs b/rustmail/src/api/handler/auth/mod.rs new file mode 100644 index 00000000..e1da44e5 --- /dev/null +++ b/rustmail/src/api/handler/auth/mod.rs @@ -0,0 +1,7 @@ +pub mod callback; +pub mod login; +pub mod logout; + +pub use callback::*; +pub use login::*; +pub use logout::*; diff --git a/rustmail/src/api/handler/bot/config.rs b/rustmail/src/api/handler/bot/config.rs new file mode 100644 index 00000000..dd1f1917 --- /dev/null +++ b/rustmail/src/api/handler/bot/config.rs @@ -0,0 +1,164 @@ +use crate::config::{load_config, Config, LanguageConfigExt}; +use crate::prelude::types::*; +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use rustmail_types::ConfigResponse; +use std::fs; +use std::sync::Arc; +use tokio::sync::Mutex; + +fn mask_secret(secret: &str) -> String { + let len = secret.chars().count(); + if len <= 8 { + "*".repeat(len) + } else { + let chars: Vec = secret.chars().collect(); + let start: String = chars.iter().take(4).collect(); + let end: String = chars.iter().skip(len - 4).collect(); + format!("{}...{}", start, end) + } +} + +pub async fn handle_get_config( + State(bot_state): State>>, +) -> Result, StatusCode> { + let state = bot_state.lock().await; + + let config = match &state.config { + Some(c) => c, + None => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + + let mut masked_bot = config.bot.clone(); + masked_bot.token = mask_secret(&config.bot.token); + masked_bot.client_secret = mask_secret(&config.bot.client_secret); + + let response = ConfigResponse { + bot: masked_bot, + command: config.command.clone(), + thread: config.thread.clone(), + language: config.language.clone(), + error_handling: config.error_handling.clone(), + notifications: config.notifications.clone(), + reminders: config.reminders.clone(), + logs: config.logs.clone(), + }; + + Ok(Json(response)) +} + +pub async fn handle_update_config( + State(bot_state): State>>, + Json(update): Json, +) -> Result, (StatusCode, String)> { + let current_config = { + let state = bot_state.lock().await; + match &state.config { + Some(c) => c.clone(), + None => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Configuration not loaded".to_string(), + )) + } + } + }; + + let mut new_bot_config = update.bot.clone(); + + if new_bot_config.token.contains("...") { + new_bot_config.token = current_config.bot.token.clone(); + } + + if new_bot_config.client_secret.contains("...") { + new_bot_config.client_secret = current_config.bot.client_secret.clone(); + } + + new_bot_config.ip = current_config.bot.ip.clone(); + + let new_config = Config { + bot: new_bot_config, + command: update.command, + thread: update.thread, + language: update.language, + error_handling: update.error_handling, + notifications: update.notifications, + reminders: update.reminders, + logs: update.logs, + db_pool: None, + error_handler: None, + thread_locks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + }; + + if let Err(e) = validate_config(&new_config) { + return Err((StatusCode::BAD_REQUEST, e)); + } + + if let Err(e) = save_config_with_backup(&new_config, "config.toml").await { + return Err((StatusCode::INTERNAL_SERVER_ERROR, e)); + } + + let mut state = bot_state.lock().await; + state.config = load_config("config.toml"); + + Ok(Json(serde_json::json!({ + "success": true, + "message": "Configuration saved successfully. Restart the bot to apply changes." + }))) +} + +fn validate_config(config: &Config) -> Result<(), String> { + if u64::from_str_radix(&config.thread.user_message_color, 16).is_err() { + return Err("Invalid user message color format (must be hex)".to_string()); + } + + if u64::from_str_radix(&config.thread.staff_message_color, 16).is_err() { + return Err("Invalid staff message color format (must be hex)".to_string()); + } + + if u64::from_str_radix(&config.reminders.embed_color, 16).is_err() { + return Err("Invalid reminder embed color format (must be hex)".to_string()); + } + + config.bot.validate_logs_config()?; + config.bot.validate_features_config()?; + + if !config + .language + .is_language_supported(config.language.get_default_language()) + { + return Err(format!( + "Default language '{}' is not in supported languages list", + config.language.default_language + )); + } + + Ok(()) +} + +async fn save_config_with_backup(config: &Config, path: &str) -> Result<(), String> { + if std::path::Path::new(path).exists() { + let backup_path = format!("{}.backup", path); + fs::copy(path, &backup_path) + .map_err(|e| format!("Failed to create backup: {}", e))?; + } + + let config_response = ConfigResponse { + bot: config.bot.clone(), + command: config.command.clone(), + thread: config.thread.clone(), + language: config.language.clone(), + error_handling: config.error_handling.clone(), + notifications: config.notifications.clone(), + reminders: config.reminders.clone(), + logs: config.logs.clone(), + }; + + let toml_content = toml::to_string_pretty(&config_response) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + + fs::write(path, toml_content).map_err(|e| format!("Failed to write config file: {}", e))?; + + Ok(()) +} diff --git a/rustmail/src/api/handler/bot/mod.rs b/rustmail/src/api/handler/bot/mod.rs new file mode 100644 index 00000000..ff3c0ce0 --- /dev/null +++ b/rustmail/src/api/handler/bot/mod.rs @@ -0,0 +1,13 @@ +pub mod config; +pub mod restart; +pub mod start; +pub mod status; +pub mod stop; +pub mod tickets; + +pub use config::*; +pub use restart::*; +pub use start::*; +pub use status::*; +pub use stop::*; +pub use tickets::*; diff --git a/rustmail/src/api/handler/bot/restart.rs b/rustmail/src/api/handler/bot/restart.rs new file mode 100644 index 00000000..fc04199c --- /dev/null +++ b/rustmail/src/api/handler/bot/restart.rs @@ -0,0 +1,58 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use sqlx::__rt::sleep; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; + +pub async fn handle_restart_bot( + State(bot_state): State>>, +) -> (StatusCode, Json<&'static str>) { + let token = { + let state_lock = bot_state.lock().await; + state_lock.internal_token.clone() + }; + + let state_lock = bot_state.lock().await; + + match state_lock.status { + BotStatus::Stopped => { + drop(state_lock); + + let result = ping_internal("/api/bot/start", &token).await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Failed to restart bot"), + ) + }); + + println!("Restart result: {:?}", result); + + (StatusCode::OK, Json("Bot is starting")) + } + BotStatus::Running { .. } => { + drop(state_lock); + + let _ = ping_internal("/api/bot/stop", &token).await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Failed to stop bot"), + ) + }); + + sleep(Duration::from_secs(2)).await; + + let _ = ping_internal("/api/bot/start", &token).await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json("Failed to stop bot"), + ) + }); + + (StatusCode::OK, Json("Bot is restarting")) + } + } +} diff --git a/rustmail/src/api/handler/bot/start.rs b/rustmail/src/api/handler/bot/start.rs new file mode 100644 index 00000000..e01edd70 --- /dev/null +++ b/rustmail/src/api/handler/bot/start.rs @@ -0,0 +1,43 @@ +use crate::bot::run_bot; +use crate::prelude::config::*; +use crate::prelude::types::*; +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use std::sync::Arc; +use tokio::spawn; +use tokio::sync::Mutex; + +pub async fn handle_start_bot( + State(bot_state): State>>, +) -> (StatusCode, Json<&'static str>) { + let mut state_lock = bot_state.lock().await; + match state_lock.status { + BotStatus::Stopped => { + state_lock.config = load_config("config.toml"); + + if state_lock.config.is_none() { + return (StatusCode::BAD_REQUEST, Json("Missing configuration.")); + } + + let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false); + let (command_tx, command_rx) = tokio::sync::mpsc::channel(32); + let bot_state_clone = bot_state.clone(); + + let handle = spawn(async move { + run_bot(bot_state_clone.clone(), &mut shutdown_rx, command_rx).await; + let mut s = bot_state_clone.lock().await; + s.status = BotStatus::Stopped; + }); + state_lock.status = BotStatus::Running { + handle, + shutdown: shutdown_tx, + }; + state_lock.command_tx = command_tx; + + drop(state_lock); + (StatusCode::OK, Json("Bot is starting")) + } + BotStatus::Running { .. } => (StatusCode::CONFLICT, Json("Bot is already running")), + } +} diff --git a/rustmail/src/api/handler/bot/status.rs b/rustmail/src/api/handler/bot/status.rs new file mode 100644 index 00000000..d2b3063e --- /dev/null +++ b/rustmail/src/api/handler/bot/status.rs @@ -0,0 +1,22 @@ +use crate::prelude::types::*; +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub async fn handle_status_bot(State(bot_state): State>>) -> impl IntoResponse { + let state_lock = bot_state.lock().await; + + match state_lock.status { + BotStatus::Running { .. } => ( + StatusCode::OK, + Json(serde_json::json!({"status": "running"})), + ), + BotStatus::Stopped => ( + StatusCode::OK, + Json(serde_json::json!({"status": "stopped"})), + ), + } +} diff --git a/rustmail/src/api/handler/bot/stop.rs b/rustmail/src/api/handler/bot/stop.rs new file mode 100644 index 00000000..f58f56c4 --- /dev/null +++ b/rustmail/src/api/handler/bot/stop.rs @@ -0,0 +1,28 @@ +use crate::prelude::types::*; +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub async fn handle_stop_bot( + State(bot_state): State>>, +) -> (StatusCode, Json<&'static str>) { + let handle_and_shutdown = { + let mut state_lock = bot_state.lock().await; + + match std::mem::replace(&mut state_lock.status, BotStatus::Stopped) { + BotStatus::Running { handle, shutdown } => Some((handle, shutdown)), + BotStatus::Stopped => None, + } + }; + + if let Some((handle, shutdown_tx)) = handle_and_shutdown { + let _ = shutdown_tx.send(true); + handle.await.unwrap(); + (StatusCode::OK, Json("Bot stopped")) + } else { + println!("Not Starting bot stop"); + (StatusCode::CONFLICT, Json("Bot is not running")) + } +} diff --git a/rustmail/src/api/handler/bot/tickets.rs b/rustmail/src/api/handler/bot/tickets.rs new file mode 100644 index 00000000..be88070d --- /dev/null +++ b/rustmail/src/api/handler/bot/tickets.rs @@ -0,0 +1,391 @@ +use crate::prelude::types::*; +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, Serialize)] +pub struct ThreadMessage { + pub id: i64, + pub thread_id: String, + pub user_id: i64, + pub user_name: String, + pub is_anonymous: bool, + pub dm_message_id: Option, + pub inbox_message_id: Option, + pub message_number: Option, + pub created_at: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompleteThread { + pub id: String, + pub user_id: i64, + pub user_name: String, + pub channel_id: String, + pub created_at: i64, + pub new_message_number: i64, + pub status: i64, + pub user_left: bool, + pub closed_at: Option, + pub closed_by: Option, + pub category_id: Option, + pub category_name: Option, + pub required_permissions: Option, + pub messages: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct TicketQuery { + pub id: Option, + pub page: Option, + pub page_size: Option, + pub status: Option, + pub category_id: Option, + pub sort_by: Option, + pub sort_order: Option, +} + +#[derive(Debug, Serialize)] +pub struct PaginatedThreadsResponse { + pub threads: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, + pub total_pages: i64, +} + +pub async fn handle_tickets_bot( + State(bot_state): State>>, + Query(params): Query, +) -> (StatusCode, Json) { + let db_pool = { + let state_lock = bot_state.lock().await; + match &state_lock.db_pool { + Some(pool) => pool.clone(), + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Database pool not initialized" + })), + ); + } + } + }; + + if let Some(id) = params.id { + let thread = match sqlx::query!( + r#" + SELECT + id, + user_id, + user_name, + channel_id, + strftime('%s', created_at) as "created_at: Option", + next_message_number as new_message_number, + status, + user_left, + strftime('%s', closed_at) as "closed_at: Option", + closed_by, + category_id, + category_name, + required_permissions + FROM threads + WHERE id = ? + "#, + id + ) + .fetch_optional(&db_pool) + .await + { + Ok(Some(row)) => row, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Thread not found" + })), + ); + } + Err(err) => { + eprintln!("Erreur SQL thread {}: {:?}", id, err); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to fetch thread" + })), + ); + } + }; + + let messages_query = match sqlx::query!( + r#" + SELECT + id, + thread_id, + user_id, + user_name, + is_anonymous, + dm_message_id, + inbox_message_id, + message_number, + created_at as "created_at: String", + content + FROM thread_messages + WHERE thread_id = ? + ORDER BY created_at ASC + "#, + thread.id + ) + .fetch_all(&db_pool) + .await + { + Ok(rows) => rows, + Err(err) => { + eprintln!("Erreur SQL messages pour {}: {:?}", thread.id, err); + Vec::new() + } + }; + + let messages: Vec = messages_query + .into_iter() + .map(|m| ThreadMessage { + id: m.id, + thread_id: m.thread_id, + user_id: m.user_id, + user_name: m.user_name, + is_anonymous: m.is_anonymous, + dm_message_id: m.dm_message_id, + inbox_message_id: m.inbox_message_id, + message_number: m.message_number, + created_at: m.created_at, + content: m.content, + }) + .collect(); + + let complete = CompleteThread { + id: thread.id, + user_id: thread.user_id, + user_name: thread.user_name, + channel_id: thread.channel_id, + created_at: thread.created_at + .flatten() + .and_then(|ts: String| ts.parse::().ok()) + .unwrap_or_default(), + new_message_number: thread.new_message_number.unwrap_or_default(), + status: thread.status, + user_left: thread.user_left, + closed_at: thread.closed_at.flatten().and_then(|ts: String| ts.parse::().ok()), + closed_by: thread.closed_by, + category_id: thread.category_id, + category_name: thread.category_name, + required_permissions: thread.required_permissions, + messages, + }; + + return (StatusCode::OK, Json(serde_json::json!(complete))); + } + + let page = params.page.unwrap_or(1).max(1); + let page_size = params.page_size.unwrap_or(50).min(200).max(1); + let offset = (page - 1) * page_size; + + let status_filter = params.status.unwrap_or(0); + + let mut where_conditions = vec![format!("status = {}", status_filter)]; + + if let Some(ref cat_id) = params.category_id { + where_conditions.push(format!("category_id = '{}'", cat_id.replace("'", "''"))); + } + + let where_clause = where_conditions.join(" AND "); + + let sort_column = match params.sort_by.as_deref() { + Some("user_name") => "user_name", + Some("closed_at") => "closed_at", + Some("created_at") => "created_at", + _ => "created_at", + }; + + let sort_order = match params.sort_order.as_deref() { + Some("asc") => "ASC", + _ => "DESC", + }; + + let count_query = format!("SELECT COUNT(*) as count FROM threads WHERE {}", where_clause); + let total: i64 = match sqlx::query_scalar(&count_query) + .fetch_one(&db_pool) + .await + { + Ok(count) => count, + Err(err) => { + eprintln!("Erreur SQL count: {:?}", err); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to count threads" + })), + ); + } + }; + + let total_pages = (total as f64 / page_size as f64).ceil() as i64; + + let query_str = format!( + r#" + SELECT + id, + user_id, + user_name, + channel_id, + strftime('%s', created_at) as created_at, + next_message_number as new_message_number, + status, + user_left, + strftime('%s', closed_at) as closed_at, + closed_by, + category_id, + category_name, + required_permissions + FROM threads + WHERE {} + ORDER BY {} {} + LIMIT {} OFFSET {} + "#, + where_clause, sort_column, sort_order, page_size, offset + ); + + let threads_query = match sqlx::query_as::<_, ( + String, + i64, + String, + String, + Option, + Option, + i64, + bool, + Option, + Option, + Option, + Option, + Option, + )>(&query_str) + .fetch_all(&db_pool) + .await + { + Ok(rows) => rows, + Err(err) => { + eprintln!("Erreur SQL threads: {:?}", err); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "Failed to fetch threads" + })), + ); + } + }; + + let mut threads: Vec = Vec::new(); + + let thread_ids: Vec = threads_query.iter().map(|t| t.0.clone()).collect(); + + let placeholders = thread_ids.iter().map(|_| "?").collect::>().join(","); + let messages_query_str = format!( + r#" + SELECT + id, + thread_id, + user_id, + user_name, + is_anonymous, + dm_message_id, + inbox_message_id, + message_number, + created_at, + content + FROM thread_messages + WHERE thread_id IN ({}) + ORDER BY thread_id, created_at ASC + "#, + placeholders + ); + + let mut messages_query = sqlx::query_as::<_, ( + i64, + String, + i64, + String, + bool, + Option, + Option, + Option, + String, + String, + )>(&messages_query_str); + + for thread_id in &thread_ids { + messages_query = messages_query.bind(thread_id); + } + + let all_messages = messages_query.fetch_all(&db_pool).await.unwrap_or_else(|err| { + eprintln!("Erreur SQL messages batch: {:?}", err); + Vec::new() + }); + + let mut messages_by_thread: std::collections::HashMap> = + std::collections::HashMap::new(); + + for msg in all_messages { + messages_by_thread.entry(msg.1.clone()).or_insert_with(Vec::new).push(ThreadMessage { + id: msg.0, + thread_id: msg.1.clone(), + user_id: msg.2, + user_name: msg.3, + is_anonymous: msg.4, + dm_message_id: msg.5, + inbox_message_id: msg.6, + message_number: msg.7, + created_at: msg.8, + content: msg.9, + }); + } + + for thread in threads_query { + let messages = messages_by_thread.get(&thread.0).cloned().unwrap_or_default(); + + threads.push(CompleteThread { + id: thread.0.clone(), + user_id: thread.1, + user_name: thread.2, + channel_id: thread.3, + created_at: thread.4 + .and_then(|ts: String| ts.parse::().ok()) + .unwrap_or_default(), + new_message_number: thread.5.unwrap_or_default(), + status: thread.6, + user_left: thread.7, + closed_at: thread.8.and_then(|ts: String| ts.parse::().ok()), + closed_by: thread.9, + category_id: thread.10, + category_name: thread.11, + required_permissions: thread.12, + messages, + }); + } + + let response = PaginatedThreadsResponse { + threads, + total, + page, + page_size, + total_pages, + }; + + (StatusCode::OK, Json(serde_json::json!(response))) +} diff --git a/rustmail/src/api/handler/mod.rs b/rustmail/src/api/handler/mod.rs new file mode 100644 index 00000000..ca20cbde --- /dev/null +++ b/rustmail/src/api/handler/mod.rs @@ -0,0 +1,9 @@ +pub mod auth; +pub mod bot; +pub mod panel; +pub mod user; + +pub use auth::*; +pub use bot::*; +pub use panel::*; +pub use user::*; diff --git a/rustmail/src/api/handler/panel/mod.rs b/rustmail/src/api/handler/panel/mod.rs new file mode 100644 index 00000000..0e1b226a --- /dev/null +++ b/rustmail/src/api/handler/panel/mod.rs @@ -0,0 +1,3 @@ +pub mod panel; + +pub use panel::*; diff --git a/rustmail/src/api/handler/panel/panel.rs b/rustmail/src/api/handler/panel/panel.rs new file mode 100644 index 00000000..bfef7079 --- /dev/null +++ b/rustmail/src/api/handler/panel/panel.rs @@ -0,0 +1,11 @@ +use crate::prelude::types::*; +use axum::extract::State; +use axum::response::IntoResponse; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub async fn handle_panel_check( + State(bot_state): State>>, +) -> impl IntoResponse { + axum::response::Json(serde_json::json!({ "authorized": true })) +} diff --git a/rustmail/src/api/handler/user/avatar.rs b/rustmail/src/api/handler/user/avatar.rs new file mode 100644 index 00000000..1ff659e3 --- /dev/null +++ b/rustmail/src/api/handler/user/avatar.rs @@ -0,0 +1,77 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::extract::State; +use axum::response::IntoResponse; +use axum_extra::extract::CookieJar; +use sqlx::Row; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(serde::Deserialize, Debug, serde::Serialize)] +pub struct UserAvatar { + pub avatar_url: Option, +} + +pub async fn handle_get_user_avatar( + State(bot_state): State>>, + jar: CookieJar, +) -> impl IntoResponse { + let pool = { + let state_lock = bot_state.lock().await; + if let Some(pool) = &state_lock.db_pool { + pool.clone() + } else { + return axum::response::Json(serde_json::json!(UserAvatar { avatar_url: None })); + } + }; + + let session_id = match jar.get("session_id") { + Some(cookie) => cookie.value().to_string(), + None => { + return axum::response::Json(serde_json::json!(UserAvatar { avatar_url: None })); + } + }; + + let user_id_str: String = get_user_id_from_session(&session_id, &pool).await; + let user_id: u64 = match user_id_str.parse() { + Ok(id) => id, + Err(e) => { + eprintln!("Error parsing user ID: {}", e); + return axum::response::Json(serde_json::json!(UserAvatar { avatar_url: None })); + } + }; + + let user_avatar = match sqlx::query("SELECT avatar_hash FROM sessions_panel WHERE user_id = ?") + .bind(user_id_str.clone()) + .fetch_one(&pool) + .await + { + Ok(record) => { + let avatar_hash: String = record.get::("avatar_hash"); + + if !avatar_hash.is_empty() { + let avatar_url = format!( + "https://cdn.discordapp.com/avatars/{}/{}.png", + user_id_str, avatar_hash + ); + UserAvatar { + avatar_url: Some(avatar_url), + } + } else { + let avatar_url = format!( + "https://cdn.discordapp.com/embed/avatars/{}.png", + (user_id >> 22) % 6 + ); + UserAvatar { + avatar_url: Some(avatar_url), + } + } + } + Err(e) => { + eprintln!("Error fetching user avatar: {}", e); + UserAvatar { avatar_url: None } + } + }; + + axum::response::Json(serde_json::json!(user_avatar)) +} diff --git a/rustmail/src/api/handler/user/mod.rs b/rustmail/src/api/handler/user/mod.rs new file mode 100644 index 00000000..1e966d18 --- /dev/null +++ b/rustmail/src/api/handler/user/mod.rs @@ -0,0 +1,3 @@ +pub mod avatar; + +pub use avatar::*; diff --git a/rustmail/src/api/middleware/auth.rs b/rustmail/src/api/middleware/auth.rs new file mode 100644 index 00000000..d977ab2c --- /dev/null +++ b/rustmail/src/api/middleware/auth.rs @@ -0,0 +1,175 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::extract::State; +use axum::extract::{ConnectInfo, Request}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum_extra::extract::CookieJar; +use chrono::Utc; +use hyper::StatusCode; +use serenity::all::{GuildId, UserId}; +use sqlx::{Row, query}; +use std::net::SocketAddr; +use std::sync::Arc; +use subtle::ConstantTimeEq; +use tokio::sync::Mutex; + +async fn check_user_with_bot(bot_state: Arc>, user_id: &str) -> bool { + let user_id_num = match user_id.parse::() { + Ok(id) => id, + Err(_) => return false, + }; + + let cmd_tx = { + let state_lock = bot_state.lock().await; + state_lock.command_tx.clone() + }; + + let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); + + if cmd_tx + .send(BotCommand::CheckUserIsMember { + user_id: user_id_num, + resp: resp_tx, + }) + .await + .is_err() + { + return false; + } + + resp_rx.await.unwrap() +} + +async fn check_user_with_api( + user_id: &str, + guild_id: u64, + bot_http: Arc, +) -> bool { + let guild_id = GuildId::new(guild_id); + let user_id = match user_id.parse::() { + Ok(id) => UserId::new(id), + Err(_) => return false, + }; + + match guild_id.member(bot_http, user_id).await { + Ok(_) => true, + Err(_) => false, + } +} + +async fn verify_user(user_id: &str, guild_id: u64, bot_state: Arc>) -> bool { + let state_lock = bot_state.lock().await; + + let is_bot_on = match state_lock.status { + BotStatus::Running { .. } => true, + BotStatus::Stopped => false, + }; + + drop(state_lock); + + let http = { + let state_lock = bot_state.lock().await; + match &state_lock.bot_http { + Some(bot_http) => bot_http.clone(), + None => return false, + } + }; + + if is_bot_on { + return check_user_with_bot(bot_state, user_id).await; + } + check_user_with_api(user_id, guild_id, http).await +} + +pub async fn auth_middleware( + State(bot_state): State>>, + ConnectInfo(addr): ConnectInfo, + jar: CookieJar, + req: Request, + next: Next, +) -> Response { + if addr.ip().is_loopback() { + if let Some(h) = req.headers().get("x-internal-call") { + if let Ok(s) = h.to_str() { + let state_lock = bot_state.lock().await; + let expected = state_lock.internal_token.as_bytes(); + + if expected.ct_eq(s.as_bytes()).unwrap_u8() == 1 { + drop(state_lock); + return next.run(req).await; + } + } + } + } + + let session_cookie = jar.get("session_id"); + + if session_cookie.is_none() { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + } + + let db_pool = { + let state_lock = bot_state.lock().await; + match &state_lock.db_pool { + Some(pool) => pool.clone(), + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Database not initialized", + ) + .into_response(); + } + } + }; + + let session_id = session_cookie.unwrap().value().to_string(); + let user_id = get_user_id_from_session(&session_id, &db_pool).await; + + let guild_id = { + let state_lock = bot_state.lock().await; + match &state_lock.config { + Some(config) => config.bot.get_staff_guild_id(), + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Database not initialized", + ) + .into_response(); + } + } + }; + + let result = + query("SELECT expires_at FROM sessions_panel WHERE session_id = ? AND user_id = ?") + .bind(&session_id) + .bind(&user_id) + .fetch_one(&db_pool) + .await; + + match result { + Ok(row) => { + let expires_at = row.get::("expires_at"); + let now = Utc::now().timestamp(); + + if expires_at < now { + let _ = query("DELETE FROM sessions_panel WHERE session_id = ?") + .bind(&session_id) + .execute(&db_pool) + .await; + return (StatusCode::UNAUTHORIZED, "Session expired").into_response(); + } + + if !verify_user(&user_id, guild_id, bot_state.clone()).await { + let _ = query("DELETE FROM sessions_panel WHERE session_id = ?") + .bind(&session_id) + .execute(&db_pool) + .await; + return (StatusCode::UNAUTHORIZED, "Invalid session").into_response(); + } + + next.run(req).await + } + Err(_) => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + } +} diff --git a/rustmail/src/api/middleware/mod.rs b/rustmail/src/api/middleware/mod.rs new file mode 100644 index 00000000..efadf860 --- /dev/null +++ b/rustmail/src/api/middleware/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; + +pub use auth::*; diff --git a/rustmail/src/api/mod.rs b/rustmail/src/api/mod.rs new file mode 100644 index 00000000..af728132 --- /dev/null +++ b/rustmail/src/api/mod.rs @@ -0,0 +1,11 @@ +pub mod handler; +pub mod middleware; +pub mod router; +pub mod routes; +pub mod utils; + +pub use handler::*; +pub use middleware::*; +pub use router::*; +pub use routes::*; +pub use utils::*; diff --git a/rustmail/src/api/router.rs b/rustmail/src/api/router.rs new file mode 100644 index 00000000..272993c9 --- /dev/null +++ b/rustmail/src/api/router.rs @@ -0,0 +1,21 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::Router; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub fn create_api_router(bot_state: Arc>) -> Router { + let bot_router = create_bot_router(bot_state.clone()); + let auth_router = create_auth_router(); + let panel_router = create_panel_router(bot_state.clone()); + let user_router = create_user_router(bot_state.clone()); + + let app = Router::new() + .nest("/api/bot", bot_router) + .nest("/api/auth", auth_router) + .nest("/api/panel", panel_router) + .nest("/api/user", user_router) + .with_state(bot_state.clone()); + + app +} diff --git a/rustmail/src/api/routes/auth.rs b/rustmail/src/api/routes/auth.rs new file mode 100644 index 00000000..2debcafd --- /dev/null +++ b/rustmail/src/api/routes/auth.rs @@ -0,0 +1,15 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::Router; +use axum::routing::get; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub fn create_auth_router() -> Router>> { + let auth_router = Router::new() + .route("/login", get(handle_login)) + .route("/callback", get(handle_callback)) + .route("/logout", get(handle_logout)); + + auth_router +} diff --git a/rustmail/src/api/routes/bot.rs b/rustmail/src/api/routes/bot.rs new file mode 100644 index 00000000..f974af2e --- /dev/null +++ b/rustmail/src/api/routes/bot.rs @@ -0,0 +1,23 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::Router; +use axum::routing::{get, post}; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub fn create_bot_router(bot_state: Arc>) -> Router>> { + let bot_router = Router::new() + .route("/start", post(handle_start_bot)) + .route("/stop", post(handle_stop_bot)) + .route("/restart", post(handle_restart_bot)) + .route("/status", get(handle_status_bot)) + .route("/tickets", get(handle_tickets_bot)) + .route("/config", get(handle_get_config)) + .route("/config", axum::routing::put(handle_update_config)) + .layer(axum::middleware::from_fn_with_state( + bot_state, + auth_middleware, + )); + + bot_router +} diff --git a/rustmail/src/api/routes/mod.rs b/rustmail/src/api/routes/mod.rs new file mode 100644 index 00000000..ca20cbde --- /dev/null +++ b/rustmail/src/api/routes/mod.rs @@ -0,0 +1,9 @@ +pub mod auth; +pub mod bot; +pub mod panel; +pub mod user; + +pub use auth::*; +pub use bot::*; +pub use panel::*; +pub use user::*; diff --git a/rustmail/src/api/routes/panel.rs b/rustmail/src/api/routes/panel.rs new file mode 100644 index 00000000..ae3a7abf --- /dev/null +++ b/rustmail/src/api/routes/panel.rs @@ -0,0 +1,17 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::Router; +use axum::routing::get; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub fn create_panel_router(bot_state: Arc>) -> Router>> { + let panel_router = Router::new() + .route("/check", get(handle_panel_check)) + .layer(axum::middleware::from_fn_with_state( + bot_state, + auth_middleware, + )); + + panel_router +} diff --git a/rustmail/src/api/routes/user.rs b/rustmail/src/api/routes/user.rs new file mode 100644 index 00000000..43aef119 --- /dev/null +++ b/rustmail/src/api/routes/user.rs @@ -0,0 +1,17 @@ +use crate::prelude::api::*; +use crate::prelude::types::*; +use axum::Router; +use axum::routing::get; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub fn create_user_router(bot_state: Arc>) -> Router>> { + let user_router = Router::new() + .route("/avatar", get(handle_get_user_avatar)) + .layer(axum::middleware::from_fn_with_state( + bot_state, + auth_middleware, + )); + + user_router +} diff --git a/rustmail/src/api/utils/get_user_id_from_session.rs b/rustmail/src/api/utils/get_user_id_from_session.rs new file mode 100644 index 00000000..4b4af401 --- /dev/null +++ b/rustmail/src/api/utils/get_user_id_from_session.rs @@ -0,0 +1,16 @@ +use sqlx::{Row, SqlitePool, query}; + +pub async fn get_user_id_from_session(session_id: &str, db_pool: &SqlitePool) -> String { + let result = query("SELECT user_id FROM sessions_panel WHERE session_id = ?") + .bind(session_id) + .fetch_one(db_pool) + .await; + + match result { + Ok(row) => { + let user_id: String = row.get::("user_id"); + user_id + } + Err(_) => "".to_string(), + } +} diff --git a/rustmail/src/api/utils/mod.rs b/rustmail/src/api/utils/mod.rs new file mode 100644 index 00000000..f4d433bd --- /dev/null +++ b/rustmail/src/api/utils/mod.rs @@ -0,0 +1,5 @@ +pub mod get_user_id_from_session; +pub mod ping_internal; + +pub use get_user_id_from_session::*; +pub use ping_internal::*; diff --git a/rustmail/src/api/utils/ping_internal.rs b/rustmail/src/api/utils/ping_internal.rs new file mode 100644 index 00000000..6fd5c6f4 --- /dev/null +++ b/rustmail/src/api/utils/ping_internal.rs @@ -0,0 +1,14 @@ +use reqwest::Client; + +pub async fn ping_internal( + endpoint: &str, + token: &str, +) -> Result { + let client = Client::new(); + let url = format!("http://127.0.0.1:3002{}", endpoint); + client + .post(&url) + .header("x-internal-call", token) + .send() + .await +} diff --git a/rustmail/src/bot.rs b/rustmail/src/bot.rs new file mode 100644 index 00000000..ce6f18b9 --- /dev/null +++ b/rustmail/src/bot.rs @@ -0,0 +1,237 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::panel_commands::*; +use crate::prelude::types::*; +use base64::Engine; +use rand::RngCore; +use serenity::all::{ClientBuilder, GatewayIntents}; +use serenity::cache::Settings as CacheSettings; +use std::collections::HashMap; +use std::process; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::{select, spawn}; + +pub async fn init_bot_state() -> Arc> { + let pool = init_database().await.expect("An error occured!"); + println!("Database connected!"); + + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + let token = base64::engine::general_purpose::STANDARD.encode(&bytes); + + let config = load_config("config.toml"); + + let (command_tx, _command_rx) = tokio::sync::mpsc::channel(32); + + let bot_state = BotState { + config, + status: BotStatus::Stopped, + db_pool: Some(pool), + command_tx: command_tx.clone(), + bot_http: None, + internal_token: token, + }; + + Arc::new(Mutex::new(bot_state)) +} + +pub async fn start_bot_if_config_valid( + bot_state: Arc>, +) -> Result<(), ModmailError> { + let mut state_lock = bot_state.lock().await; + + match state_lock.status { + BotStatus::Stopped => { + if state_lock.config.is_none() { + return Err(ModmailError::Config(ConfigError::FileNotFound)); + } + + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let (command_tx, command_rx) = tokio::sync::mpsc::channel(32); + let bot_state_clone = bot_state.clone(); + + let handle = spawn(async move { + let mut shutdown_rx_owned = shutdown_rx.clone(); + run_bot(bot_state_clone.clone(), &mut shutdown_rx_owned, command_rx).await; + let mut s = bot_state_clone.lock().await; + s.status = BotStatus::Stopped; + }); + state_lock.status = BotStatus::Running { + handle, + shutdown: shutdown_tx, + }; + state_lock.command_tx = command_tx; + + drop(state_lock); + Ok(()) + } + BotStatus::Running { .. } => Ok(()), + } +} + +pub async fn run_bot( + bot_state: Arc>, + shutdown: &mut tokio::sync::watch::Receiver, + mut command_rx: tokio::sync::mpsc::Receiver, +) { + let shutdown_rx_command = shutdown.clone(); + let shutdown_rx = shutdown.clone(); + + println!("Starting rustmail..."); + + let pool = { + let state_lock = bot_state.lock().await; + state_lock.db_pool.clone().expect("Database pool not set") + }; + + let mut config = { + let state_lock = bot_state.lock().await; + if state_lock.config.is_none() { + panic!("Config not set before starting rustmail!"); + } + state_lock.config.clone().expect("Config not set") + }; + + let pagination = Arc::new(Mutex::new(HashMap::::new())); + + config.db_pool = Some(pool.clone()); + + let intents = GatewayIntents::GUILDS + | GatewayIntents::GUILD_MESSAGES + | GatewayIntents::MESSAGE_CONTENT + | GatewayIntents::DIRECT_MESSAGES + | GatewayIntents::GUILD_MESSAGE_TYPING + | GatewayIntents::DIRECT_MESSAGE_TYPING + | GatewayIntents::GUILD_MEMBERS + | GatewayIntents::GUILD_PRESENCES + | GatewayIntents::GUILD_MESSAGE_REACTIONS + | GatewayIntents::DIRECT_MESSAGE_REACTIONS + | GatewayIntents::GUILD_MODERATION; + + let mut cache_settings = CacheSettings::default(); + cache_settings.max_messages = 10_000; + cache_settings.time_to_live = Duration::from_secs(6 * 60 * 60); + + let mut registry = CommandRegistry::new(shutdown_rx_command, pagination.clone()); + registry.register_command(AddStaffCommand); + registry.register_command(AlertCommand); + registry.register_command(CloseCommand); + registry.register_command(DeleteCommand); + registry.register_command(EditCommand); + registry.register_command(ForceCloseCommand); + registry.register_command(HelpCommand); + registry.register_command(IdCommand); + registry.register_command(MoveCommand); + registry.register_command(NewThreadCommand); + registry.register_command(RecoverCommand); + registry.register_command(RemoveStaffCommand); + registry.register_command(ReplyCommand); + registry.register_command(AddReminderCommand); + registry.register_command(RemoveReminderCommand); + registry.register_command(LogsCommand); + registry.register_command(TakeCommand); + registry.register_command(ReleaseCommand); + + let registry = Arc::new(registry); + + let mut client: serenity::Client = ClientBuilder::new(config.bot.token.clone(), intents) + .cache_settings(cache_settings) + .event_handler(ReadyHandler::new( + &config, + registry.clone(), + shutdown_rx.clone(), + )) + .event_handler( + GuildMessagesHandler::new( + &config, + registry.clone(), + shutdown_rx.clone(), + pagination.clone(), + ) + .await, + ) + .event_handler(TypingProxyHandler::new(&config)) + .event_handler(GuildMembersHandler::new(&config)) + .event_handler(GuildMessageReactionsHandler::new(&config)) + .event_handler(GuildModerationHandler::new(&config)) + .event_handler(InteractionHandler::new( + &config, + registry.clone(), + shutdown_rx, + pagination, + )) + .event_handler(GuildHandler::new(&config)) + .await + .expect("Failed to create client."); + + { + let mut state_lock = bot_state.lock().await; + state_lock.bot_http = Some(client.http.clone()); + } + + if let Err(e) = config.validate_servers(&client.http).await { + eprintln!("Configuration validation error: {}", e); + eprintln!( + "Check that the server IDs are correct and that the rustmail has access to the servers." + ); + process::exit(1); + } + + println!("Configuration successfully validated!!"); + if config.bot.is_dual_mode() { + println!( + "Mode: Dual server (Community: {}, Staff: {})", + config.bot.get_community_guild_id(), + config.bot.get_staff_guild_id() + ); + } else { + println!( + "Mode: Mono server (ID: {})", + config.bot.get_community_guild_id() + ); + } + + let shard_manager = client.shard_manager.clone(); + + let mut discord_task = spawn(async move { + if let Err(e) = client.start().await { + println!("Failed to initialize client: {e}"); + } + }); + + loop { + select! { + _ = shutdown.changed() => { + println!("Shutdown signal received, shutting down..."); + shard_manager.shutdown_all().await; + break; + } + Some(cmd) = command_rx.recv() => { + match cmd { + BotCommand::CheckUserIsMember { user_id, resp} => { + let http = { + let state_lock = bot_state.lock().await; + state_lock.bot_http.clone().expect("Failed to get bot http") + }; + let is_member = is_member(http, config.bot.get_staff_guild_id(), user_id).await; + + let _ = resp.send(is_member); + } + _ => {} + } + } + + _ = &mut discord_task => { + println!("Discord task ended."); + break; + } + } + } + + println!("Bot has been shut down."); +} diff --git a/src/commands/add_reminder/common.rs b/rustmail/src/commands/add_reminder/common.rs similarity index 91% rename from src/commands/add_reminder/common.rs rename to rustmail/src/commands/add_reminder/common.rs index c3365100..aaba806d 100644 --- a/src/commands/add_reminder/common.rs +++ b/rustmail/src/commands/add_reminder/common.rs @@ -1,12 +1,14 @@ -use crate::config::Config; -use crate::db::reminders::{Reminder, is_reminder_active, update_reminder_status}; -use crate::utils::conversion::hex_string_to_int::hex_string_to_int; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::utils::*; use chrono::Local; use serenity::all::{ChannelId, CommandInteraction, Context, Message, UserId}; use sqlx::SqlitePool; use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; +use tokio::select; +use tokio::sync::watch::Receiver; use tokio::time::sleep; pub async fn send_register_confirmation_from_message( @@ -39,7 +41,7 @@ pub async fn send_register_confirmation_from_message( .await .to_channel(msg.channel_id) .footer(format!("{}: {}", "ID", reminder_id)) - .send() + .send(true) .await; } else { let _ = MessageBuilder::system_message(&ctx, &config) @@ -52,7 +54,7 @@ pub async fn send_register_confirmation_from_message( .await .to_channel(msg.channel_id) .footer(format!("{}: {}", "ID", reminder_id)) - .send() + .send(true) .await; } } @@ -115,11 +117,13 @@ pub fn spawn_reminder( ctx: &Context, config: &Config, pool: &SqlitePool, + shutdown: Arc>, ) { let pool = pool.clone(); let config = config.clone(); let ctx = ctx.clone(); let reminder = reminder.clone(); + let mut shutdown_rx = shutdown.as_ref().clone(); tokio::spawn(async move { let now = Local::now().timestamp(); @@ -128,7 +132,12 @@ pub fn spawn_reminder( } else { 0 }; - sleep(Duration::from_secs(delay_duration as u64)).await; + select! { + _ = sleep(Duration::from_secs(delay_duration as u64)) => {} + _ = shutdown_rx.changed() => { + return; + } + } if let Some(reminder_id) = reminder_id { match is_reminder_active(reminder_id, &pool).await { @@ -167,7 +176,7 @@ pub fn spawn_reminder( .to_channel(ChannelId::new(reminder.channel_id as u64)) .color(hex_string_to_int(&config.reminders.embed_color) as u32) .mention(mentions) - .send() + .send(true) .await; } else { let _ = MessageBuilder::system_message(&ctx, &config) @@ -176,7 +185,7 @@ pub fn spawn_reminder( .to_channel(ChannelId::new(reminder.channel_id as u64)) .color(hex_string_to_int(&config.reminders.embed_color) as u32) .mention(mentions) - .send() + .send(true) .await; } diff --git a/rustmail/src/commands/add_reminder/mod.rs b/rustmail/src/commands/add_reminder/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/add_reminder/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/src/commands/add_reminder/slash_command/add_reminder.rs b/rustmail/src/commands/add_reminder/slash_command/add_reminder.rs similarity index 75% rename from src/commands/add_reminder/slash_command/add_reminder.rs rename to rustmail/src/commands/add_reminder/slash_command/add_reminder.rs index e3d0730c..92cd587b 100644 --- a/src/commands/add_reminder/slash_command/add_reminder.rs +++ b/rustmail/src/commands/add_reminder/slash_command/add_reminder.rs @@ -1,31 +1,34 @@ -use crate::commands::add_reminder::common::{ - send_register_confirmation_from_command, spawn_reminder, -}; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::reminders::{Reminder, insert_reminder}; -use crate::db::threads::get_thread_by_user_id; -use crate::errors::{ - CommandError, DatabaseError, ModmailError, ModmailResult, ThreadError, common, -}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use chrono::{Local, NaiveTime}; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use chrono::{Local, NaiveTime, TimeZone}; use regex::Regex; +use serenity::FutureExt; use serenity::all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, ResolvedOption, }; +use std::sync::Arc; pub struct AddReminderCommand; #[async_trait::async_trait] impl RegistrableCommand for AddReminderCommand { fn name(&self) -> &'static str { - "add_reminder" + "remind" + } + + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { + get_translated_message(config, "help.add_reminder", None, None, None, None).await + }.boxed() } - fn register(&self, config: &Config) -> BoxFuture> { + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); let name = self.name(); @@ -81,9 +84,10 @@ impl RegistrableCommand for AddReminderCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -92,7 +96,7 @@ impl RegistrableCommand for AddReminderCommand { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; let _ = defer_response(&ctx, &command).await; @@ -118,28 +122,22 @@ impl RegistrableCommand for AddReminderCommand { let time = match time { Some(t) => t.clone(), None => { - return Err(ModmailError::Command(CommandError::InvalidArguments( - "Missing required arguments".to_string(), - ))); + return Err(ModmailError::Command(CommandError::InvalidReminderFormat)); } }; let content = match content { Some(c) => c, None => { - return Err(ModmailError::Command(CommandError::InvalidArguments( - "Missing required arguments".to_string(), - ))); + return Err(ModmailError::Command(CommandError::InvalidReminderFormat)); } }; let time_str = time.to_string(); let re = Regex::new(r"^(?P[01]?\d|2[0-3]):(?P[0-5]\d)$").unwrap(); - let captures = re.captures(&time_str).ok_or_else(|| { - return ModmailError::Command(CommandError::InvalidArguments( - "duration".to_string(), - )); - })?; + let captures = re + .captures(&time_str) + .ok_or_else(|| ModmailError::Command(CommandError::InvalidReminderFormat))?; let hours: u32 = captures .name("hour") @@ -152,14 +150,19 @@ impl RegistrableCommand for AddReminderCommand { .unwrap_or(0); let time = NaiveTime::from_hms_opt(hours, minutes, 0).unwrap(); - let now = Local::now(); - let mut trigger_dt = now.date_naive().and_time(time); + let now = Local::now().with_timezone(&config.bot.timezone); - if trigger_dt < now.date_naive().and_time(time) { + let mut trigger_dt = config + .bot + .timezone + .from_local_datetime(&now.date_naive().and_time(time)) + .unwrap(); + + if trigger_dt < now { trigger_dt += chrono::Duration::days(1); } - let trigger_timestamp = trigger_dt.and_local_timezone(Local).unwrap().timestamp(); + let trigger_timestamp = trigger_dt.with_timezone(&config.bot.timezone).timestamp(); let thread = match get_thread_by_user_id(command.user.id, pool).await { Some(t) => t, @@ -183,9 +186,7 @@ impl RegistrableCommand for AddReminderCommand { Ok(id) => id, Err(e) => { eprintln!("Failed to insert reminder: {}", e); - return Err(ModmailError::Database(DatabaseError::InsertFailed( - e.to_string(), - ))); + return Err(e); } }; @@ -199,7 +200,14 @@ impl RegistrableCommand for AddReminderCommand { ) .await; - spawn_reminder(&reminder, Some(reminder_id), &ctx, &config, &pool); + spawn_reminder( + &reminder, + Some(reminder_id), + &ctx, + &config, + &pool, + handler.shutdown.clone(), + ); Ok(()) }) diff --git a/rustmail/src/commands/add_reminder/slash_command/mod.rs b/rustmail/src/commands/add_reminder/slash_command/mod.rs new file mode 100644 index 00000000..7f4f3fa0 --- /dev/null +++ b/rustmail/src/commands/add_reminder/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod add_reminder; + +pub use add_reminder::*; diff --git a/src/commands/add_reminder/text_command/add_reminder.rs b/rustmail/src/commands/add_reminder/text_command/add_reminder.rs similarity index 53% rename from src/commands/add_reminder/text_command/add_reminder.rs rename to rustmail/src/commands/add_reminder/text_command/add_reminder.rs index 78aa3979..6a267a79 100644 --- a/src/commands/add_reminder/text_command/add_reminder.rs +++ b/rustmail/src/commands/add_reminder/text_command/add_reminder.rs @@ -1,44 +1,41 @@ -use crate::commands::add_reminder::common::{ - send_register_confirmation_from_message, spawn_reminder, -}; -use crate::config::Config; -use crate::db::reminders::{Reminder, insert_reminder}; -use crate::db::threads::get_thread_by_user_id; -use crate::errors::{ - CommandError, DatabaseError, ModmailError, ModmailResult, ThreadError, common, -}; -use crate::utils::command::extract_reply_content::extract_reply_content; -use chrono::{Local, NaiveTime}; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; +use chrono::{Local, NaiveTime, TimeZone}; use regex::Regex; use serenity::all::{Context, Message}; - -pub async fn add_reminder(ctx: &Context, msg: &Message, config: &Config) -> ModmailResult<()> { +use std::sync::Arc; + +pub async fn add_reminder( + ctx: Context, + msg: Message, + config: &Config, + handler: Arc, +) -> ModmailResult<()> { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; - - let content = match extract_reply_content( - &msg.content, - &config.command.prefix, - &["add_reminder", "add_rap"], - ) { - Some(c) => c, - None => { - return Err(ModmailError::Command(CommandError::InvalidArguments( - "No content provided".to_string(), - ))); - } - }; + .ok_or_else(database_connection_failed)?; + + let content = + match extract_reply_content(&msg.content, &config.command.prefix, &["remind", "rem"]) { + Some(c) => c, + None => { + return Err(ModmailError::Command(CommandError::InvalidReminderFormat)); + } + }; let mut parts = content.splitn(2, ' '); let duration_str = parts.next().unwrap_or(""); let reminder_content = parts.next().unwrap_or(""); let re = Regex::new(r"^(?P[01]?\d|2[0-3]):(?P[0-5]\d)$").unwrap(); - let captures = re.captures(duration_str).ok_or_else(|| { - ModmailError::Command(CommandError::InvalidArguments("duration".to_string())) - })?; + let captures = re + .captures(&duration_str) + .ok_or_else(|| ModmailError::Command(CommandError::InvalidReminderFormat))?; let hours: u32 = captures .name("hour") @@ -51,14 +48,19 @@ pub async fn add_reminder(ctx: &Context, msg: &Message, config: &Config) -> Modm .unwrap_or(0); let time = NaiveTime::from_hms_opt(hours, minutes, 0).unwrap(); - let now = Local::now(); - let mut trigger_dt = now.date_naive().and_time(time); + let now = Local::now().with_timezone(&config.bot.timezone); + + let mut trigger_dt = config + .bot + .timezone + .from_local_datetime(&now.date_naive().and_time(time)) + .unwrap(); - if trigger_dt < now.date_naive().and_time(time) { + if trigger_dt < now { trigger_dt += chrono::Duration::days(1); } - let trigger_timestamp = trigger_dt.and_local_timezone(Local).unwrap().timestamp(); + let trigger_timestamp = trigger_dt.with_timezone(&config.bot.timezone).timestamp(); let thread = match get_thread_by_user_id(msg.author.id, pool).await { Some(t) => t, @@ -82,16 +84,14 @@ pub async fn add_reminder(ctx: &Context, msg: &Message, config: &Config) -> Modm Ok(id) => id, Err(e) => { eprintln!("Failed to insert reminder: {}", e); - return Err(ModmailError::Database(DatabaseError::InsertFailed( - e.to_string(), - ))); + return Err(e); } }; send_register_confirmation_from_message( reminder_id, reminder_content, - ctx, + &ctx, &msg, config, trigger_timestamp, @@ -100,7 +100,14 @@ pub async fn add_reminder(ctx: &Context, msg: &Message, config: &Config) -> Modm let _ = msg.delete(&ctx.http).await; - spawn_reminder(&reminder, Some(reminder_id), &ctx, &config, &pool); + spawn_reminder( + &reminder, + Some(reminder_id), + &ctx, + &config, + &pool, + handler.shutdown.clone(), + ); Ok(()) } diff --git a/rustmail/src/commands/add_reminder/text_command/mod.rs b/rustmail/src/commands/add_reminder/text_command/mod.rs new file mode 100644 index 00000000..7f4f3fa0 --- /dev/null +++ b/rustmail/src/commands/add_reminder/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod add_reminder; + +pub use add_reminder::*; diff --git a/src/commands/add_staff/common.rs b/rustmail/src/commands/add_staff/common.rs similarity index 69% rename from src/commands/add_staff/common.rs rename to rustmail/src/commands/add_staff/common.rs index 2bdda8b9..e2989a6b 100644 --- a/src/commands/add_staff/common.rs +++ b/rustmail/src/commands/add_staff/common.rs @@ -1,5 +1,5 @@ -use crate::config::Config; -use crate::errors::ModmailResult; +use crate::prelude::config::*; +use crate::prelude::errors::*; use serenity::all::{ ChannelId, Context, Message, PermissionOverwrite, PermissionOverwriteType, UserId, }; @@ -26,16 +26,16 @@ pub async fn add_user_to_channel( Ok(()) } -pub async fn extract_user_id(msg: &Message, config: &Config) -> String { +pub async fn extract_staff_id(msg: &Message, config: &Config) -> String { let content = msg.content.trim(); let prefix = &config.command.prefix; - let command_names = ["add_staff", "as"]; + let command_names = ["addmod", "am"]; - if command_names + if let Some(matched_name) = command_names .iter() - .any(|&name| content.starts_with(&format!("{}{}", prefix, name))) + .find(|&name| content.starts_with(&format!("{}{}", prefix, name))) { - let start = prefix.len() + command_names[0].len(); + let start = prefix.len() + matched_name.len(); content[start..].trim().to_string() } else { String::new() diff --git a/rustmail/src/commands/add_staff/mod.rs b/rustmail/src/commands/add_staff/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/add_staff/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/src/commands/add_staff/slash_command/add_staff.rs b/rustmail/src/commands/add_staff/slash_command/add_staff.rs similarity index 71% rename from src/commands/add_staff/slash_command/add_staff.rs rename to rustmail/src/commands/add_staff/slash_command/add_staff.rs index 7bc5f0c2..025ac372 100644 --- a/src/commands/add_staff/slash_command/add_staff.rs +++ b/rustmail/src/commands/add_staff/slash_command/add_staff.rs @@ -1,28 +1,34 @@ -use crate::commands::add_staff::common::add_user_to_channel; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::thread_exists; -use crate::errors::CommandError::InvalidFormat; -use crate::errors::ThreadError::NotAThreadChannel; -use crate::errors::{CommandError, ModmailError, ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{ - CommandDataOptionValue, CommandInteraction, CommandOptionType, CommandType, Context, - CreateCommand, CreateCommandOption, ResolvedOption, + CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, + CreateCommandOption, ResolvedOption, }; use std::collections::HashMap; +use std::sync::Arc; pub struct AddStaffCommand; impl RegistrableCommand for AddStaffCommand { fn name(&self) -> &'static str { - "add_staff" + "addmod" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { + get_translated_message(config, "help.add_staff", None, None, None, None).await + }.boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); + let name = self.name(); Box::pin(async move { let cmd_desc = get_translated_message( @@ -46,13 +52,10 @@ impl RegistrableCommand for AddStaffCommand { .await; vec![ - CreateCommand::new("add_staff") - .description(cmd_desc) - .add_option( - CreateCommandOption::new(CommandOptionType::User, "user_id", user_id_desc) - .required(true), - ), - CreateCommand::new("add_staff").kind(CommandType::User), + CreateCommand::new(name).description(cmd_desc).add_option( + CreateCommandOption::new(CommandOptionType::User, "user_id", user_id_desc) + .required(true), + ), ] }) } @@ -63,7 +66,8 @@ impl RegistrableCommand for AddStaffCommand { command: &CommandInteraction, _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -72,7 +76,7 @@ impl RegistrableCommand for AddStaffCommand { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; defer_response(&ctx, &command).await?; @@ -118,10 +122,10 @@ impl RegistrableCommand for AddStaffCommand { Ok(()) } - Err(..) => Err(ModmailError::Command(InvalidFormat)), + Err(..) => Err(ModmailError::Command(CommandError::InvalidFormat)), } } else { - Err(ModmailError::Thread(NotAThreadChannel)) + Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) } }) } diff --git a/rustmail/src/commands/add_staff/slash_command/mod.rs b/rustmail/src/commands/add_staff/slash_command/mod.rs new file mode 100644 index 00000000..b8b7f744 --- /dev/null +++ b/rustmail/src/commands/add_staff/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod add_staff; + +pub use add_staff::*; diff --git a/rustmail/src/commands/add_staff/text_command/add_staff.rs b/rustmail/src/commands/add_staff/text_command/add_staff.rs new file mode 100644 index 00000000..bff1ea41 --- /dev/null +++ b/rustmail/src/commands/add_staff/text_command/add_staff.rs @@ -0,0 +1,53 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; +use serenity::all::{Context, Message, UserId}; +use std::collections::HashMap; +use std::sync::Arc; + +pub async fn add_staff( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + let user_id_str = extract_staff_id(&msg, config).await; + + if user_id_str.is_empty() { + return Err(ModmailError::Command(CommandError::InvalidFormat)); + } + + let user_id = match user_id_str.parse::() { + Ok(id) => UserId::new(id), + Err(_) => return Err(ModmailError::Command(CommandError::InvalidFormat)), + }; + + if thread_exists(msg.author.id, pool).await { + match add_user_to_channel(&ctx, msg.channel_id, user_id).await { + Ok(_) => { + let mut params = HashMap::new(); + params.insert("user".to_string(), format!("<@{}>", user_id)); + + let _ = MessageBuilder::system_message(&ctx, config) + .translated_content("add_staff.add_success", Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + + Ok(()) + } + Err(..) => Err(ModmailError::Command(CommandError::InvalidFormat)), + } + } else { + Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + } +} diff --git a/rustmail/src/commands/add_staff/text_command/mod.rs b/rustmail/src/commands/add_staff/text_command/mod.rs new file mode 100644 index 00000000..b8b7f744 --- /dev/null +++ b/rustmail/src/commands/add_staff/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod add_staff; + +pub use add_staff::*; diff --git a/src/commands/alert/common.rs b/rustmail/src/commands/alert/common.rs similarity index 82% rename from src/commands/alert/common.rs rename to rustmail/src/commands/alert/common.rs index 768479e4..ee4d61c0 100644 --- a/src/commands/alert/common.rs +++ b/rustmail/src/commands/alert/common.rs @@ -1,9 +1,7 @@ -use crate::config::Config; -use crate::db::{cancel_alert_for_staff, get_user_id_from_channel_id, set_alert_for_staff}; -use crate::errors::DatabaseError::QueryFailed; -use crate::errors::DiscordError::ApiError; -use crate::errors::{ModmailError, ModmailResult, common}; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::utils::*; use serenity::all::colours::branding::GREEN; use serenity::all::{CommandInteraction, Context, CreateInteractionResponse, Message}; use std::collections::HashMap; @@ -38,7 +36,7 @@ pub async fn get_thread_user_id_from_command( None => { let bot_user = match ctx.http.get_current_user().await { Ok(user) => user, - Err(e) => return Err(ModmailError::Discord(ApiError(e.to_string()))), + Err(e) => return Err(ModmailError::Discord(DiscordError::ApiError(e.to_string()))), }; let bot_user_id = ctx.cache.current_user().id; @@ -74,9 +72,7 @@ pub async fn handle_cancel_alert_from_msg( pool: &sqlx::SqlitePool, ) -> ModmailResult<()> { if let Err(e) = cancel_alert_for_staff(msg.author.id, user_id, pool).await { - eprintln!("Failed to cancel alert: {}", e); - send_alert_message(ctx, msg, config, "alert.cancel_failed", None).await; - return Ok(()); + return Err(e); } let mut params = HashMap::new(); @@ -93,8 +89,8 @@ pub async fn handle_cancel_alert_from_command( user_id: i64, pool: &sqlx::SqlitePool, ) -> ModmailResult<()> { - if let Err(e) = cancel_alert_for_staff(command.user.id, user_id, pool).await { - Err(ModmailError::Database(QueryFailed(e.to_string()))) + if let Err(_) = cancel_alert_for_staff(command.user.id, user_id, pool).await { + Err(ModmailError::Command(CommandError::AlertDoesNotExist)) } else { let mut params = HashMap::new(); params.insert("user".to_string(), format!("<@{}>", user_id)); @@ -125,7 +121,6 @@ pub async fn handle_set_alert_from_msg( pool: &sqlx::SqlitePool, ) -> ModmailResult<()> { if let Err(e) = set_alert_for_staff(msg.author.id, user_id, pool).await { - eprintln!("Failed to set alert: {}", e); send_alert_message(ctx, msg, config, "alert.set_failed", None).await; return Ok(()); } @@ -145,7 +140,9 @@ pub async fn handle_set_alert_from_command( pool: &sqlx::SqlitePool, ) -> ModmailResult<()> { if let Err(e) = set_alert_for_staff(command.user.id, user_id, pool).await { - Err(ModmailError::Database(QueryFailed(e.to_string()))) + Err(ModmailError::Database(DatabaseError::QueryFailed( + e.to_string(), + ))) } else { let mut params = HashMap::new(); params.insert("user".to_string(), format!("<@{}>", user_id)); @@ -175,14 +172,7 @@ pub async fn send_alert_message( message_key: &str, params: Option<&HashMap>, ) { - let bot_user = match ctx.http.get_current_user().await { - Ok(user) => user, - Err(_) => return, - }; - - let bot_user_id = ctx.cache.current_user().id; - - let _ = MessageBuilder::staff_message(ctx, config, bot_user_id, bot_user.name.clone()) + let _ = MessageBuilder::system_message(ctx, config) .translated_content( message_key, params, @@ -191,8 +181,7 @@ pub async fn send_alert_message( ) .await .to_channel(msg.channel_id) - .color(GREEN.0) - .send() + .send(true) .await; } @@ -205,7 +194,7 @@ pub async fn extract_alert_action(msg: &Message, config: &Config) -> bool { let start = prefix.len() + command_name.len(); let args = content[start..].trim(); - args.to_lowercase() == "cancel" + args.contains("cancel") || args.contains("c") } else { false } diff --git a/rustmail/src/commands/alert/mod.rs b/rustmail/src/commands/alert/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/alert/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/src/commands/alert/slash_command/alert.rs b/rustmail/src/commands/alert/slash_command/alert.rs similarity index 78% rename from src/commands/alert/slash_command/alert.rs rename to rustmail/src/commands/alert/slash_command/alert.rs index da5f3c92..ac0eb4e4 100644 --- a/src/commands/alert/slash_command/alert.rs +++ b/rustmail/src/commands/alert/slash_command/alert.rs @@ -1,16 +1,15 @@ -use crate::commands::alert::common::{ - get_thread_user_id_from_command, handle_cancel_alert_from_command, - handle_set_alert_from_command, -}; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::errors::{CommandError, ModmailError, ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, ResolvedOption, }; +use std::sync::Arc; pub struct AlertCommand; @@ -20,7 +19,12 @@ impl RegistrableCommand for AlertCommand { "alert" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.alert", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -58,9 +62,10 @@ impl RegistrableCommand for AlertCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -69,7 +74,7 @@ impl RegistrableCommand for AlertCommand { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; defer_response(&ctx, &command).await?; diff --git a/rustmail/src/commands/alert/slash_command/mod.rs b/rustmail/src/commands/alert/slash_command/mod.rs new file mode 100644 index 00000000..18e98477 --- /dev/null +++ b/rustmail/src/commands/alert/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod alert; + +pub use alert::*; diff --git a/rustmail/src/commands/alert/text_command/alert.rs b/rustmail/src/commands/alert/text_command/alert.rs new file mode 100644 index 00000000..e13710f3 --- /dev/null +++ b/rustmail/src/commands/alert/text_command/alert.rs @@ -0,0 +1,27 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use serenity::all::{Context, Message}; +use std::sync::Arc; + +pub async fn alert( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + let user_id = get_thread_user_id_from_msg(&ctx, &msg, config, pool).await?; + let is_cancel = extract_alert_action(&msg, config).await; + + if is_cancel { + handle_cancel_alert_from_msg(&ctx, &msg, config, user_id, pool).await + } else { + handle_set_alert_from_msg(&ctx, &msg, config, user_id, pool).await + } +} diff --git a/rustmail/src/commands/alert/text_command/mod.rs b/rustmail/src/commands/alert/text_command/mod.rs new file mode 100644 index 00000000..18e98477 --- /dev/null +++ b/rustmail/src/commands/alert/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod alert; + +pub use alert::*; diff --git a/rustmail/src/commands/anonreply/mod.rs b/rustmail/src/commands/anonreply/mod.rs new file mode 100644 index 00000000..51e29f44 --- /dev/null +++ b/rustmail/src/commands/anonreply/mod.rs @@ -0,0 +1,3 @@ +pub mod text_command; + +pub use text_command::*; diff --git a/src/commands/anonreply/text_command/anonreply.rs b/rustmail/src/commands/anonreply/text_command/anonreply.rs similarity index 69% rename from src/commands/anonreply/text_command/anonreply.rs rename to rustmail/src/commands/anonreply/text_command/anonreply.rs index fd9b6445..1173b063 100644 --- a/src/commands/anonreply/text_command/anonreply.rs +++ b/rustmail/src/commands/anonreply/text_command/anonreply.rs @@ -1,24 +1,31 @@ -use crate::config::Config; -use crate::db::operations::allocate_next_message_number; -use crate::errors::{ModmailResult, common}; -use crate::utils::command::extract_reply_content::extract_reply_content; -use crate::utils::message::message_builder::MessageBuilder; -use crate::utils::message::reply_intent::{ReplyIntent, extract_intent}; -use crate::utils::thread::fetch_thread::fetch_thread; +use crate::modules::update_thread_status_ui; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; +use crate::types::TicketAuthor; +use chrono::Utc; use serenity::all::{Context, GuildId, Message, UserId}; use std::collections::HashMap; - -pub async fn anonreply(ctx: &Context, msg: &Message, config: &Config) -> ModmailResult<()> { +use std::sync::Arc; + +pub async fn anonreply( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { let db_pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; let content = extract_reply_content(&msg.content, &config.command.prefix, &["anonreply", "ar"]); let intent = extract_intent(content, &msg.attachments).await; let Some(intent) = intent else { - MessageBuilder::system_message(ctx, config) + MessageBuilder::system_message(&ctx, config) .translated_content( "reply.missing_content", None, @@ -31,7 +38,7 @@ pub async fn anonreply(ctx: &Context, msg: &Message, config: &Config) -> Modmail .send_and_forget() .await; - return Err(common::validation_failed("Missing content")); + return Err(validation_failed("Missing content")); }; let thread = fetch_thread(db_pool, &msg.channel_id.to_string()).await?; @@ -43,7 +50,7 @@ pub async fn anonreply(ctx: &Context, msg: &Message, config: &Config) -> Modmail let mut params = HashMap::new(); params.insert("username".to_string(), thread.user_name.clone()); - MessageBuilder::user_message(ctx, config, msg.author.id, msg.author.name.clone()) + MessageBuilder::user_message(&ctx, config, msg.author.id, msg.author.name.clone()) .translated_content( "user.left_server", Some(¶ms), @@ -60,12 +67,30 @@ pub async fn anonreply(ctx: &Context, msg: &Message, config: &Config) -> Modmail let next_message_number = allocate_next_message_number(&thread.id, db_pool) .await - .map_err(|_| common::validation_failed("Failed to allocate message number"))?; + .map_err(|_| validation_failed("Failed to allocate message number"))?; + + let mut ticket_status = match get_thread_status(&thread.id, db_pool).await { + Some(status) => status, + None => { + return Err(validation_failed("Failed to get thread status")); + } + }; + + ticket_status.last_message_by = TicketAuthor::Staff; + ticket_status.last_message_at = Utc::now().timestamp(); + update_thread_status_db(&thread.id, &ticket_status, db_pool).await?; + + tokio::spawn({ + let ctx = ctx.clone(); + async move { + let _ = update_thread_status_ui(&ctx, &ticket_status).await; + } + }); let _ = msg.delete(&ctx.http).await; let mut sr = MessageBuilder::begin_staff_reply( - ctx, + &ctx, config, thread.id.clone(), msg.author.id, @@ -91,7 +116,7 @@ pub async fn anonreply(ctx: &Context, msg: &Message, config: &Config) -> Modmail let (thread_msg, dm_msg_opt) = match sr.send_msg_and_record(db_pool).await { Ok(tuple) => tuple, Err(_) => { - MessageBuilder::system_message(ctx, config) + MessageBuilder::system_message(&ctx, config) .translated_content( "reply.send_failed_thread", None, @@ -102,12 +127,12 @@ pub async fn anonreply(ctx: &Context, msg: &Message, config: &Config) -> Modmail .to_channel(msg.channel_id) .send_and_forget() .await; - return Err(common::validation_failed("Failed to send to thread")); + return Err(validation_failed("Failed to send to thread")); } }; if dm_msg_opt.is_none() { - MessageBuilder::system_message(ctx, config) + MessageBuilder::system_message(&ctx, config) .translated_content( "reply.send_failed_dm", None, @@ -127,7 +152,7 @@ pub async fn anonreply(ctx: &Context, msg: &Message, config: &Config) -> Modmail params.insert("number".to_string(), next_message_number.to_string()); let _ = error_handler .send_success_message( - ctx, + &ctx, msg.channel_id, "success.message_sent", Some(params), diff --git a/rustmail/src/commands/anonreply/text_command/mod.rs b/rustmail/src/commands/anonreply/text_command/mod.rs new file mode 100644 index 00000000..248f1fa3 --- /dev/null +++ b/rustmail/src/commands/anonreply/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod anonreply; + +pub use anonreply::*; diff --git a/src/commands/close/common.rs b/rustmail/src/commands/close/common.rs similarity index 100% rename from src/commands/close/common.rs rename to rustmail/src/commands/close/common.rs diff --git a/rustmail/src/commands/close/mod.rs b/rustmail/src/commands/close/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/close/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/src/commands/close/slash_command/close.rs b/rustmail/src/commands/close/slash_command/close.rs similarity index 79% rename from src/commands/close/slash_command/close.rs rename to rustmail/src/commands/close/slash_command/close.rs index d93da4b1..1e78bf23 100644 --- a/src/commands/close/slash_command/close.rs +++ b/rustmail/src/commands/close/slash_command/close.rs @@ -1,20 +1,18 @@ -use crate::commands::close::common::parse_duration_spec; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::{ - close_thread, delete_scheduled_closure, get_scheduled_closure, upsert_scheduled_closure, -}; -use crate::errors::{CommandError, ModmailError, ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use crate::utils::message::message_builder::MessageBuilder; -use crate::utils::thread::fetch_thread::fetch_thread; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; use chrono::Utc; +use serenity::FutureExt; use serenity::all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, GuildId, ResolvedOption, UserId, }; use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use tokio::time::sleep; @@ -26,7 +24,12 @@ impl RegistrableCommand for CloseCommand { "close" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.close", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -94,9 +97,10 @@ impl RegistrableCommand for CloseCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -105,12 +109,12 @@ impl RegistrableCommand for CloseCommand { let db_pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; defer_response(&ctx, &command).await?; let mut time_before_close: Option = None; - let mut silent: Option = None; + let mut silent: bool = false; let mut cancel: Option = None; let mut duration: Option = None; @@ -123,7 +127,7 @@ impl RegistrableCommand for CloseCommand { } "silent" => { if let CommandDataOptionValue::Boolean(val) = &option.value { - silent.replace(*val); + silent = *val; } } "cancel" => { @@ -200,9 +204,7 @@ impl RegistrableCommand for CloseCommand { let mut params = HashMap::new(); params.insert("time".to_string(), human); - let _ = if let Some(silent) = silent - && silent - { + let _ = if silent { let response = MessageBuilder::system_message(&ctx, &config) .translated_content( "close.silent_closing", @@ -234,9 +236,24 @@ impl RegistrableCommand for CloseCommand { let thread_id = thread.id.clone(); let close_at = Utc::now().timestamp() + delay.as_secs() as i64; - let silent = silent.unwrap_or(false); - if let Err(e) = - upsert_scheduled_closure(&thread_id, close_at, silent, db_pool).await + + let closed_by = command.user.id.to_string(); + let category_id = get_category_id_from_command(&ctx, &command).await; + let category_name = get_category_name_from_command(&ctx, &command).await; + let required_permissions = + get_required_permissions_channel_from_command(&ctx, &command).await; + + if let Err(e) = upsert_scheduled_closure( + &thread_id, + close_at, + silent, + &closed_by, + &category_id, + &category_name, + &required_permissions.to_string(), + db_pool, + ) + .await { eprintln!("Failed to persist scheduled closure: {e:?}"); } @@ -253,7 +270,15 @@ impl RegistrableCommand for CloseCommand { get_scheduled_closure(&thread_id_for_task, pool).await { if record.close_at <= Utc::now().timestamp() { - let _ = close_thread(&thread_id_for_task, pool).await; + let _ = close_thread( + &thread_id_for_task, + &record.closed_by, + &record.category_id, + &record.category_name, + record.required_permissions.parse::().unwrap_or(0), + pool, + ) + .await; let _ = delete_scheduled_closure(&thread_id_for_task, pool).await; let community_guild_id = @@ -269,7 +294,7 @@ impl RegistrableCommand for CloseCommand { MessageBuilder::system_message(&ctx_clone, &config_clone) .content(&config_clone.bot.close_message) .to_user(user_id_clone) - .send() + .send(true) .await; } let _ = channel_id.delete(&ctx_clone.http).await; @@ -287,7 +312,17 @@ impl RegistrableCommand for CloseCommand { get_scheduled_closure(&thread_id_again, pool2).await { if r2.close_at <= Utc::now().timestamp() { - let _ = close_thread(&thread_id_again, pool2).await; + let _ = close_thread( + &thread_id_again, + &r2.closed_by, + &r2.category_id, + &r2.category_name, + r2.required_permissions + .parse::() + .unwrap_or(0), + pool2, + ) + .await; let _ = delete_scheduled_closure( &thread_id_again, pool2, @@ -307,7 +342,7 @@ impl RegistrableCommand for CloseCommand { ) .content(&config_clone2.bot.close_message) .to_user(user_id_clone) - .send() + .send(true) .await; } let _ = channel_id.delete(&ctx_clone2.http).await; @@ -324,14 +359,17 @@ impl RegistrableCommand for CloseCommand { let user_still_member = community_guild_id.member(&ctx.http, user_id).await.is_ok(); - if user_still_member - && let Some(silent) = silent - && !silent - { + let closed_by = command.user.id.to_string(); + let category_id = get_category_id_from_command(&ctx, &command).await; + let category_name = get_category_name_from_command(&ctx, &command).await; + let required_permissions = + get_required_permissions_channel_from_command(&ctx, &command).await; + + if user_still_member && !silent { let _ = MessageBuilder::system_message(&ctx, &config) .content(&config.bot.close_message) .to_user(user_id) - .send() + .send(true) .await; } else if !user_still_member { let mut params = HashMap::new(); @@ -352,7 +390,15 @@ impl RegistrableCommand for CloseCommand { let _ = command.create_followup(&ctx.http, response).await; } - close_thread(&thread.id, db_pool).await?; + close_thread( + &thread.id, + &closed_by, + &category_id, + &category_name, + required_permissions, + db_pool, + ) + .await?; let _ = delete_scheduled_closure(&thread.id, db_pool).await; let _ = command.channel_id.delete(&ctx.http).await?; diff --git a/rustmail/src/commands/close/slash_command/mod.rs b/rustmail/src/commands/close/slash_command/mod.rs new file mode 100644 index 00000000..fc1f9eb1 --- /dev/null +++ b/rustmail/src/commands/close/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod close; + +pub use close::*; diff --git a/src/commands/close/text_command/close.rs b/rustmail/src/commands/close/text_command/close.rs similarity index 67% rename from src/commands/close/text_command/close.rs rename to rustmail/src/commands/close/text_command/close.rs index dad8631f..7cd515f4 100644 --- a/src/commands/close/text_command/close.rs +++ b/rustmail/src/commands/close/text_command/close.rs @@ -1,22 +1,26 @@ -use crate::commands::close::common::parse_duration_spec; -use crate::config::Config; -use crate::db::{ - close_thread, delete_scheduled_closure, get_scheduled_closure, upsert_scheduled_closure, -}; -use crate::errors::{CommandError, ModmailError, ModmailResult, common}; -use crate::utils::message::message_builder::MessageBuilder; -use crate::utils::thread::fetch_thread::fetch_thread; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; use chrono::Utc; -use serenity::all::{Context, GuildId, Message, UserId}; +use serenity::all::{Channel, Context, GuildId, Message, PermissionOverwriteType, RoleId, UserId}; use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use tokio::time::sleep; -pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResult<()> { +pub async fn close( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { let db_pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; let content = msg.content.trim(); let prefix = &config.command.prefix; @@ -71,7 +75,7 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu .await .unwrap_or(false); if existed { - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content( "close.closure_canceled", None, @@ -80,10 +84,10 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } else { - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content( "close.no_scheduled_closures_to_cancel", None, @@ -92,7 +96,7 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } return Ok(()); @@ -106,7 +110,7 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu params.insert("seconds".to_string(), remaining.to_string()); if remaining > 0 { - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content( "close.closure_already_scheduled", Some(¶ms), @@ -115,7 +119,7 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; return Ok(()); } @@ -137,7 +141,7 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu params.insert("time".to_string(), human); let _ = if silent { - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content( "close.silent_closing", Some(¶ms), @@ -146,10 +150,10 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } else { - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content( "close.closing", Some(¶ms), @@ -158,13 +162,64 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; }; + let closed_by = msg.author.id.to_string(); + let category_id = match msg.channel_id.to_channel(&ctx.http).await { + Ok(channel) => match channel.category() { + Some(category) => category.id.to_string(), + None => String::new(), + }, + _ => String::new(), + }; + let category_name = match msg.channel_id.to_channel(&ctx.http).await { + Ok(channel) => match channel.category() { + Some(category) => category.name.clone(), + None => String::new(), + }, + _ => String::new(), + }; + + let required_permissions = match msg.channel_id.to_channel(&ctx.http).await { + Ok(Channel::Guild(guild_channel)) => { + let guild_id = guild_channel.guild_id; + let guild = guild_id.to_partial_guild(&ctx.http).await.ok(); + + let everyone_role_id = RoleId::new(guild_id.get()); + + let mut perms = guild + .and_then(|g| g.roles.get(&everyone_role_id).map(|r| r.permissions.bits())) + .unwrap_or(0u64); + + for overwrite in &guild_channel.permission_overwrites { + if let PermissionOverwriteType::Role(_) = overwrite.kind { + let allow = overwrite.allow.bits(); + let deny = overwrite.deny.bits(); + perms = (perms & !deny) | allow; + } + } + + perms + } + _ => 0u64, + }; + let thread_id = thread.id.clone(); let close_at = Utc::now().timestamp() + delay.as_secs() as i64; - if let Err(e) = upsert_scheduled_closure(&thread_id, close_at, silent, db_pool).await { + if let Err(e) = upsert_scheduled_closure( + &thread_id, + close_at, + silent, + &closed_by, + &category_id, + &category_name, + &required_permissions.to_string(), + db_pool, + ) + .await + { eprintln!("Failed to persist scheduled closure: {e:?}"); } let channel_id = msg.channel_id; @@ -178,7 +233,15 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu if let Some(pool) = config_clone.db_pool.as_ref() { if let Ok(Some(record)) = get_scheduled_closure(&thread_id_for_task, pool).await { if record.close_at <= Utc::now().timestamp() { - let _ = close_thread(&thread_id_for_task, pool).await; + let _ = close_thread( + &thread_id_for_task, + &record.closed_by, + &record.category_id, + &category_name, + record.required_permissions.parse::().unwrap_or(0), + pool, + ) + .await; let _ = delete_scheduled_closure(&thread_id_for_task, pool).await; let community_guild_id = @@ -193,7 +256,7 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu let _ = MessageBuilder::system_message(&ctx_clone, &config_clone) .content(&config_clone.bot.close_message) .to_user(user_id_clone) - .send() + .send(true) .await; } let _ = channel_id.delete(&ctx_clone.http).await; @@ -210,7 +273,15 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu get_scheduled_closure(&thread_id_again, pool2).await { if r2.close_at <= Utc::now().timestamp() { - let _ = close_thread(&thread_id_again, pool2).await; + let _ = close_thread( + &thread_id_again, + &r2.closed_by, + &r2.category_id, + &r2.category_id, + r2.required_permissions.parse::().unwrap_or(0), + pool2, + ) + .await; let _ = delete_scheduled_closure(&thread_id_again, pool2).await; let community_guild_id = GuildId::new( @@ -227,7 +298,7 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu ) .content(&config_clone2.bot.close_message) .to_user(user_id_clone) - .send() + .send(true) .await; } let _ = channel_id.delete(&ctx_clone2.http).await; @@ -244,17 +315,22 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu let user_still_member = community_guild_id.member(&ctx.http, user_id).await.is_ok(); + let closed_by = msg.author.id.to_string(); + let category_id = get_category_id_from_message(&ctx, &msg).await; + let category_name = get_category_name_from_message(&ctx, &msg).await; + let required_permissions = get_required_permissions_channel_from_message(&ctx, &msg).await; + if user_still_member && !silent { - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .content(&config.bot.close_message) .to_user(user_id) - .send() + .send(true) .await; } else if !user_still_member { let mut params = HashMap::new(); params.insert("username".to_string(), thread.user_name.clone()); - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content( "user.left_server_close", Some(¶ms), @@ -263,11 +339,19 @@ pub async fn close(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } - close_thread(&thread.id, db_pool).await?; + close_thread( + &thread.id, + &closed_by, + &category_id, + &category_name, + required_permissions, + db_pool, + ) + .await?; let _ = delete_scheduled_closure(&thread.id, db_pool).await; let _ = msg.channel_id.delete(&ctx.http).await?; diff --git a/rustmail/src/commands/close/text_command/mod.rs b/rustmail/src/commands/close/text_command/mod.rs new file mode 100644 index 00000000..fc1f9eb1 --- /dev/null +++ b/rustmail/src/commands/close/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod close; + +pub use close::*; diff --git a/src/commands/delete/common.rs b/rustmail/src/commands/delete/common.rs similarity index 86% rename from src/commands/delete/common.rs rename to rustmail/src/commands/delete/common.rs index 3b07a28a..e362db8a 100644 --- a/src/commands/delete/common.rs +++ b/rustmail/src/commands/delete/common.rs @@ -1,12 +1,8 @@ -use crate::config::Config; -use crate::db::messages::MessageIds; -use crate::db::repr::Thread; -use crate::db::{ - delete_message, get_message_ids_by_number, get_thread_by_channel_id, - get_user_id_from_channel_id, update_message_numbers_after_deletion, -}; -use crate::errors::{CommandError, ModmailError, ModmailResult, common}; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::utils::*; use serenity::all::{ChannelId, Context, Message, MessageId, UserId}; use std::collections::HashMap; @@ -17,21 +13,21 @@ pub async fn get_thread_info( let user_id = match get_user_id_from_channel_id(&channel_id, pool).await { Some(uid) => uid, None => { - return Err(common::validation_failed("Not in a thread")); + return Err(validation_failed("Not in a thread")); } }; let thread = match get_thread_by_channel_id(&channel_id, pool).await { Some(thread) => thread, None => { - return Err(common::validation_failed("Thread not found")); + return Err(validation_failed("Thread not found")); } }; Ok((user_id, thread)) } -pub async fn get_message_ids( +pub async fn get_message_ids_for_delete( user_id: i64, thread: &Thread, message_number: i64, @@ -49,7 +45,7 @@ pub async fn get_message_ids( None => { let mut params = HashMap::new(); params.insert("number".to_string(), message_number.to_string()); - Err(common::message_not_found("Try an other message number.")) + Err(message_not_found("Try an other message number.")) } } } @@ -116,7 +112,7 @@ pub async fn delete_database_message( && let Err(e) = delete_message(dm_msg_id, pool).await { eprintln!("Failed to delete message from database: {}", e); - return Err(common::database_connection_failed()); + return Err(database_connection_failed()); } Ok(()) } @@ -166,6 +162,6 @@ pub async fn send_delete_message( ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } diff --git a/rustmail/src/commands/delete/mod.rs b/rustmail/src/commands/delete/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/delete/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/src/commands/delete/slash_command/delete.rs b/rustmail/src/commands/delete/slash_command/delete.rs similarity index 83% rename from src/commands/delete/slash_command/delete.rs rename to rustmail/src/commands/delete/slash_command/delete.rs index 7ba9216d..8914f045 100644 --- a/src/commands/delete/slash_command/delete.rs +++ b/rustmail/src/commands/delete/slash_command/delete.rs @@ -1,19 +1,17 @@ -use crate::commands::delete::common::{ - delete_database_message, delete_discord_messages, get_message_ids, get_thread_info, - update_message_numbers, -}; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::messages::get_thread_message_by_message_id; -use crate::errors::{MessageError, ModmailError, ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response_ephemeral; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, CommandType, Context, CreateCommand, CreateCommandOption, ResolvedOption, }; use std::collections::HashMap; +use std::sync::Arc; pub struct DeleteCommand; @@ -23,7 +21,12 @@ impl RegistrableCommand for DeleteCommand { "delete" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.delete", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -66,9 +69,10 @@ impl RegistrableCommand for DeleteCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -77,7 +81,7 @@ impl RegistrableCommand for DeleteCommand { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; defer_response_ephemeral(&ctx, &command).await?; @@ -125,7 +129,8 @@ impl RegistrableCommand for DeleteCommand { } } - let message_ids = get_message_ids(user_id, &thread, message_number, pool).await?; + let message_ids = + get_message_ids_for_delete(user_id, &thread, message_number, pool).await?; delete_discord_messages(&ctx, &command.channel_id, user_id, &message_ids).await?; delete_database_message(&message_ids, pool).await?; diff --git a/rustmail/src/commands/delete/slash_command/mod.rs b/rustmail/src/commands/delete/slash_command/mod.rs new file mode 100644 index 00000000..cbc0911d --- /dev/null +++ b/rustmail/src/commands/delete/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod delete; + +pub use delete::*; diff --git a/rustmail/src/commands/delete/text_command/delete.rs b/rustmail/src/commands/delete/text_command/delete.rs new file mode 100644 index 00000000..9a6c5e61 --- /dev/null +++ b/rustmail/src/commands/delete/text_command/delete.rs @@ -0,0 +1,37 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use serenity::all::{Context, Message}; +use std::sync::Arc; + +pub async fn delete( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + let (user_id, thread) = get_thread_info(&msg.channel_id.to_string(), pool).await?; + let message_number = extract_message_number(&msg, config).await; + + if message_number.is_none() { + send_delete_message(&ctx, &msg, config, "delete.missing_number", None).await; + return Ok(()); + } + + let message_number = message_number.unwrap(); + let message_ids = get_message_ids_for_delete(user_id, &thread, message_number, pool).await?; + + delete_discord_messages(&ctx, &msg.channel_id, user_id, &message_ids).await?; + delete_database_message(&message_ids, pool).await?; + update_message_numbers(&thread.channel_id, message_number, pool).await; + + let _ = msg.delete(&ctx.http).await; + + Ok(()) +} diff --git a/rustmail/src/commands/delete/text_command/mod.rs b/rustmail/src/commands/delete/text_command/mod.rs new file mode 100644 index 00000000..cbc0911d --- /dev/null +++ b/rustmail/src/commands/delete/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod delete; + +pub use delete::*; diff --git a/src/commands/edit/common.rs b/rustmail/src/commands/edit/common.rs similarity index 84% rename from src/commands/edit/common.rs rename to rustmail/src/commands/edit/common.rs index a8db46b7..7a622f9b 100644 --- a/src/commands/edit/common.rs +++ b/rustmail/src/commands/edit/common.rs @@ -1,7 +1,6 @@ -use crate::config::Config; -use crate::errors::ModmailResult; -use crate::errors::common::invalid_command; -use crate::utils::command::extract_reply_content::extract_reply_content; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::utils::*; use serenity::all::Message; pub fn extract_command_content(msg: &Message, config: &Config) -> ModmailResult { @@ -12,17 +11,13 @@ pub fn extract_command_content(msg: &Message, config: &Config) -> ModmailResult< #[cfg(test)] mod tests { use super::*; - use crate::config::{ - BotConfig, CommandConfig, Config, ErrorHandlingConfig, LanguageConfig, LogsConfig, - NotificationsConfig, ReminderConfig, ThreadConfig, - }; use std::sync::{Arc, Mutex}; fn create_test_config() -> Config { Config { bot: BotConfig { token: "test".to_string(), - mode: crate::config::ServerMode::Dual { + mode: ServerMode::Dual { community_guild_id: 184848, staff_guild_id: 64456, }, @@ -33,8 +28,14 @@ mod tests { typing_proxy_from_staff: false, enable_logs: true, enable_features: true, + enable_panel: false, features_channel_id: Some(12345), logs_channel_id: Some(15456), + client_id: 123456789012345678, + client_secret: "secret".to_string(), + redirect_url: "http://localhost:3002/api/auth/callback".to_string(), + ip: Option::from("0.0.0.0".to_string()), + timezone: "Europe/Paris".to_string().parse().unwrap(), }, command: CommandConfig { prefix: "!".to_string(), @@ -48,6 +49,8 @@ mod tests { block_quote: false, time_to_close_thread: 5, create_ticket_by_create_channel: false, + close_on_leave: false, + auto_archive_duration: 0, }, notifications: NotificationsConfig::default(), logs: LogsConfig::default(), diff --git a/src/commands/edit/message_ops.rs b/rustmail/src/commands/edit/message_ops.rs similarity index 87% rename from src/commands/edit/message_ops.rs rename to rustmail/src/commands/edit/message_ops.rs index d3456b4f..5e8e4b0e 100644 --- a/src/commands/edit/message_ops.rs +++ b/rustmail/src/commands/edit/message_ops.rs @@ -1,13 +1,8 @@ -use crate::config::Config; -use crate::db::messages::{MessageIds, get_thread_message_by_inbox_message_id}; -use crate::db::operations::{ - get_message_ids_by_number, get_thread_by_channel_id, get_user_id_from_channel_id, -}; -use crate::errors::MessageError::{DmAccessFailed, EditFailed}; -use crate::errors::common::{incorrect_message_id, not_found, permission_denied, thread_not_found}; -use crate::errors::{ModmailError, ModmailResult}; -use crate::utils::conversion::hex_string_to_int::hex_string_to_int; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::utils::*; use serenity::all::{ ChannelId, CommandInteraction, Context, EditMessage, GuildId, Message, MessageId, User, UserId, }; @@ -137,7 +132,9 @@ pub async fn edit_inbox_message( .await { Ok(_) => Ok(()), - Err(e) => Err(ModmailError::Message(EditFailed(e.to_string()))), + Err(e) => Err(ModmailError::Message(MessageError::EditFailed( + e.to_string(), + ))), } } @@ -173,10 +170,12 @@ pub async fn edit_dm_message<'a>( let dm_channel = match user_id.create_dm_channel(&ctx.http).await { Ok(channel) => channel, Err(e) => { - return Err(ModmailError::Message(DmAccessFailed(format!( - "Unable to access user DM (Maybe the user doesn't allow private messages from bots) : {}", - e - )))); + return Err(ModmailError::Message(MessageError::DmAccessFailed( + format!( + "Unable to access user DM (Maybe the user doesn't allow private messages from bots) : {}", + e + ), + ))); } }; @@ -185,7 +184,11 @@ pub async fn edit_dm_message<'a>( .await { Ok(_) => Ok(()), - Err(e) => return Err(ModmailError::Message(EditFailed(e.to_string()))), + Err(e) => { + return Err(ModmailError::Message(MessageError::EditFailed( + e.to_string(), + ))); + } }; edit_result diff --git a/rustmail/src/commands/edit/mod.rs b/rustmail/src/commands/edit/mod.rs new file mode 100644 index 00000000..98d13bdc --- /dev/null +++ b/rustmail/src/commands/edit/mod.rs @@ -0,0 +1,11 @@ +pub mod common; +pub mod message_ops; +pub mod slash_command; +pub mod text_command; +pub mod validation; + +pub use common::*; +pub use message_ops::*; +pub use slash_command::*; +pub use text_command::*; +pub use validation::*; diff --git a/src/commands/edit/slash_command/edit.rs b/rustmail/src/commands/edit/slash_command/edit.rs similarity index 89% rename from src/commands/edit/slash_command/edit.rs rename to rustmail/src/commands/edit/slash_command/edit.rs index fa51a008..e8ec69dc 100644 --- a/src/commands/edit/slash_command/edit.rs +++ b/rustmail/src/commands/edit/slash_command/edit.rs @@ -1,19 +1,17 @@ -use crate::commands::edit::message_ops::{edit_messages, format_new_message, get_message_ids}; -use crate::commands::edit::validation::validate_edit_permissions; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::{get_thread_message_by_inbox_message_id, update_message_content}; -use crate::errors::common::message_not_found; -use crate::errors::{ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use crate::utils::conversion::hex_string_to_int::hex_string_to_int; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, ResolvedOption, }; use std::collections::HashMap; +use std::sync::Arc; pub struct EditCommand; @@ -23,7 +21,12 @@ impl RegistrableCommand for EditCommand { "edit" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.edit", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -82,9 +85,10 @@ impl RegistrableCommand for EditCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -93,7 +97,7 @@ impl RegistrableCommand for EditCommand { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; defer_response(&ctx, &command).await?; diff --git a/rustmail/src/commands/edit/slash_command/mod.rs b/rustmail/src/commands/edit/slash_command/mod.rs new file mode 100644 index 00000000..810909e0 --- /dev/null +++ b/rustmail/src/commands/edit/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod edit; + +pub use edit::*; diff --git a/src/commands/edit/text_command/edit.rs b/rustmail/src/commands/edit/text_command/edit.rs similarity index 77% rename from src/commands/edit/text_command/edit.rs rename to rustmail/src/commands/edit/text_command/edit.rs index 71eb9174..95a9e8e4 100644 --- a/src/commands/edit/text_command/edit.rs +++ b/rustmail/src/commands/edit/text_command/edit.rs @@ -1,27 +1,25 @@ -use crate::commands::edit::common::extract_command_content; -use crate::commands::edit::message_ops::{ - cleanup_command_message, edit_messages, format_new_message, get_message_ids, -}; -use crate::commands::edit::validation::{ - EditCommandInput, parse_edit_command, validate_edit_permissions, -}; -use crate::config::Config; -use crate::db::get_thread_message_by_inbox_message_id; -use crate::db::update_message_content; -use crate::errors::common::message_not_found; -use crate::errors::{ModmailResult, common}; -use crate::utils::conversion::hex_string_to_int::hex_string_to_int; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; use serenity::all::{Context, Message}; use std::collections::HashMap; - -pub async fn edit(ctx: &Context, msg: &Message, config: &Config) -> ModmailResult<()> { +use std::sync::Arc; + +pub async fn edit( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; - let raw_content: String = match extract_command_content(msg, config) { + let raw_content: String = match extract_command_content(&msg, config) { Ok(content) => content, Err(e) => return Err(e), }; @@ -47,7 +45,7 @@ pub async fn edit(ctx: &Context, msg: &Message, config: &Config) -> ModmailResul command_input.message_number, msg.author.id, pool, - ctx, + &ctx, msg.channel_id, ) .await @@ -67,8 +65,8 @@ pub async fn edit(ctx: &Context, msg: &Message, config: &Config) -> ModmailResul }; let edited_messages_builder = match format_new_message( - ctx, - (Some(msg), None), + &ctx, + (Some(&msg), None), &command_input.new_content, &inbox_message_id, command_input.message_number as u64, @@ -88,7 +86,7 @@ pub async fn edit(ctx: &Context, msg: &Message, config: &Config) -> ModmailResul }; let edit_result = edit_messages( - ctx, + &ctx, msg.channel_id, dm_msg_id.clone(), inbox_message_id.clone(), @@ -101,7 +99,7 @@ pub async fn edit(ctx: &Context, msg: &Message, config: &Config) -> ModmailResul match edit_result { Ok(()) => { if config.notifications.show_success_on_edit { - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content( "success.message_edited", None, @@ -111,7 +109,7 @@ pub async fn edit(ctx: &Context, msg: &Message, config: &Config) -> ModmailResul .await .color(hex_string_to_int(&config.thread.system_message_color) as u32) .to_channel(msg.channel_id) - .send() + .send(true) .await; }; @@ -138,7 +136,7 @@ pub async fn edit(ctx: &Context, msg: &Message, config: &Config) -> ModmailResul ); params.insert("link".to_string(), message_link); - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content( "edit.modification_from_staff", Some(¶ms), @@ -147,11 +145,11 @@ pub async fn edit(ctx: &Context, msg: &Message, config: &Config) -> ModmailResul ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } - cleanup_command_message(ctx, msg).await; + cleanup_command_message(&ctx, &msg).await; match update_message_content(&dm_msg_id, &command_input.new_content, pool).await { Ok(()) => (), diff --git a/rustmail/src/commands/edit/text_command/mod.rs b/rustmail/src/commands/edit/text_command/mod.rs new file mode 100644 index 00000000..810909e0 --- /dev/null +++ b/rustmail/src/commands/edit/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod edit; + +pub use edit::*; diff --git a/src/commands/edit/validation.rs b/rustmail/src/commands/edit/validation.rs similarity index 91% rename from src/commands/edit/validation.rs rename to rustmail/src/commands/edit/validation.rs index e9ed2b90..27647ae1 100644 --- a/src/commands/edit/validation.rs +++ b/rustmail/src/commands/edit/validation.rs @@ -1,11 +1,9 @@ -use crate::db::messages::get_thread_message_by_inbox_message_id; -use crate::db::{get_message_ids_by_number, get_thread_by_channel_id}; -use crate::errors::common::{not_found, permission_denied, thread_not_found}; -use crate::errors::{ - CommandError, ModmailError, ModmailResult, ValidationError as ErrorValidationError, - command_error, -}; -use crate::i18n::get_translated_message; +use crate::command_error; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::ValidationError as ErrorValidationError; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; use serenity::all::{ChannelId, Context, Message, UserId}; #[derive(Debug)] @@ -40,7 +38,7 @@ impl From for ModmailError { } impl ValidationError { - pub async fn _error_message(&self, config: &crate::config::Config, msg: &Message) -> String { + pub async fn _error_message(&self, config: &Config, msg: &Message) -> String { let key = match self { ValidationError::InvalidFormat => "edit.validation.invalid_format", ValidationError::MissingMessageNumber => "edit.validation.missing_number", @@ -59,7 +57,7 @@ impl ValidationError { .await } - pub async fn _send_error(&self, ctx: &Context, msg: &Message, config: &crate::config::Config) { + pub async fn _send_error(&self, ctx: &Context, msg: &Message, config: &Config) { let error_msg = self._error_message(config, msg).await; let _ = msg.reply(ctx, error_msg).await; } diff --git a/src/commands/force_close/common.rs b/rustmail/src/commands/force_close/common.rs similarity index 64% rename from src/commands/force_close/common.rs rename to rustmail/src/commands/force_close/common.rs index 03081445..0d47913b 100644 --- a/src/commands/force_close/common.rs +++ b/rustmail/src/commands/force_close/common.rs @@ -1,5 +1,4 @@ -use crate::errors::DiscordError::ApiError; -use crate::errors::{ModmailError, ModmailResult}; +use crate::prelude::errors::*; use serenity::all::{ChannelId, Context}; pub async fn delete_channel(ctx: &Context, channel_id: ChannelId) -> ModmailResult<()> { @@ -8,6 +7,6 @@ pub async fn delete_channel(ctx: &Context, channel_id: ChannelId) -> ModmailResu println!("Channel {} deleted successfully", channel_id); Ok(()) } - Err(e) => Err(ModmailError::Discord(ApiError(e.to_string()))), + Err(e) => Err(ModmailError::Discord(DiscordError::ApiError(e.to_string()))), } } diff --git a/rustmail/src/commands/force_close/mod.rs b/rustmail/src/commands/force_close/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/force_close/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/src/commands/force_close/slash_command/force_close.rs b/rustmail/src/commands/force_close/slash_command/force_close.rs similarity index 69% rename from src/commands/force_close/slash_command/force_close.rs rename to rustmail/src/commands/force_close/slash_command/force_close.rs index 086d2420..22d8a43a 100644 --- a/src/commands/force_close/slash_command/force_close.rs +++ b/rustmail/src/commands/force_close/slash_command/force_close.rs @@ -1,12 +1,12 @@ -use crate::commands::force_close::common::delete_channel; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::threads::{is_a_ticket_channel, is_orphaned_thread_channel}; -use crate::errors::DatabaseError::QueryFailed; -use crate::errors::ThreadError::{NotAThreadChannel, UserStillInServer}; -use crate::errors::{ModmailError, ModmailResult, common}; -use crate::i18n::get_translated_message; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use serenity::FutureExt; use serenity::all::{CommandInteraction, Context, CreateCommand, ResolvedOption}; +use std::sync::Arc; pub struct ForceCloseCommand; @@ -16,7 +16,13 @@ impl RegistrableCommand for ForceCloseCommand { "force_close" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { + get_translated_message(config, "help.force_close", None, None, None, None).await + }.boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -38,9 +44,10 @@ impl RegistrableCommand for ForceCloseCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -49,7 +56,7 @@ impl RegistrableCommand for ForceCloseCommand { let db_pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; if !is_a_ticket_channel(command.channel_id, db_pool).await { match command.channel_id.to_channel(&ctx.http).await { @@ -57,7 +64,7 @@ impl RegistrableCommand for ForceCloseCommand { let guild_channel = match channel.guild() { Some(guild_channel) => guild_channel, None => { - return Err(ModmailError::Thread(NotAThreadChannel)); + return Err(ModmailError::Thread(ThreadError::NotAThreadChannel)); } }; @@ -65,12 +72,12 @@ impl RegistrableCommand for ForceCloseCommand { if category_id == config.thread.inbox_category_id { delete_channel(&ctx, command.channel_id).await?; } else { - return Err(ModmailError::Thread(NotAThreadChannel)); + return Err(ModmailError::Thread(ThreadError::NotAThreadChannel)); } } } Err(_) => { - return Err(ModmailError::Thread(NotAThreadChannel)); + return Err(ModmailError::Thread(ThreadError::NotAThreadChannel)); } } } @@ -78,11 +85,11 @@ impl RegistrableCommand for ForceCloseCommand { match is_orphaned_thread_channel(command.channel_id, db_pool).await { Ok(res) => { if !res { - return Err(ModmailError::Thread(UserStillInServer)); + return Err(ModmailError::Thread(ThreadError::UserStillInServer)); } delete_channel(&ctx, command.channel_id).await } - Err(..) => Err(ModmailError::Database(QueryFailed( + Err(..) => Err(ModmailError::Database(DatabaseError::QueryFailed( "Failed to check if thread channel is orphaned".to_string(), ))), } diff --git a/rustmail/src/commands/force_close/slash_command/mod.rs b/rustmail/src/commands/force_close/slash_command/mod.rs new file mode 100644 index 00000000..8eb0f4c2 --- /dev/null +++ b/rustmail/src/commands/force_close/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod force_close; + +pub use force_close::*; diff --git a/rustmail/src/commands/force_close/text_command/force_close.rs b/rustmail/src/commands/force_close/text_command/force_close.rs new file mode 100644 index 00000000..47e7624d --- /dev/null +++ b/rustmail/src/commands/force_close/text_command/force_close.rs @@ -0,0 +1,40 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use serenity::all::{Context, Message}; +use std::sync::Arc; + +pub async fn force_close( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let db_pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + if !is_a_ticket_channel(msg.channel_id, db_pool).await { + return match msg.category_id(&ctx.http).await { + Some(category_id) if category_id == config.thread.inbox_category_id => { + delete_channel(&ctx, msg.channel_id).await + } + _ => Err(ModmailError::Thread(ThreadError::NotAThreadChannel)), + }; + } + + match is_orphaned_thread_channel(msg.channel_id, db_pool).await { + Ok(res) => { + if !res { + return Err(ModmailError::Thread(ThreadError::UserStillInServer)); + } + delete_channel(&ctx, msg.channel_id).await + } + Err(..) => Err(ModmailError::Database(DatabaseError::QueryFailed( + "Failed to check if thread channel is orphaned".to_string(), + ))), + } +} diff --git a/rustmail/src/commands/force_close/text_command/mod.rs b/rustmail/src/commands/force_close/text_command/mod.rs new file mode 100644 index 00000000..8eb0f4c2 --- /dev/null +++ b/rustmail/src/commands/force_close/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod force_close; + +pub use force_close::*; diff --git a/rustmail/src/commands/help/common.rs b/rustmail/src/commands/help/common.rs new file mode 100644 index 00000000..ecb70048 --- /dev/null +++ b/rustmail/src/commands/help/common.rs @@ -0,0 +1,93 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::all::{CommandInteraction, Context, Message}; +use std::sync::Arc; + +pub async fn display_commands_list( + ctx: &Context, + config: &Config, + registry: Arc, + msg: Option<&Message>, + command: Option<&CommandInteraction>, +) -> ModmailResult<()> { + let mut docs_message = String::new(); + + let welcome_msg = get_translated_message(&config, "help.message", None, None, None, None).await; + docs_message.push_str(&welcome_msg); + + for (name, _) in ®istry.commands { + docs_message.push_str(&format!("- **{}**\n", name)) + } + + if let Some(msg) = msg { + let _ = MessageBuilder::system_message(&ctx, config) + .content(docs_message) + .to_channel(msg.channel_id) + .send(true) + .await; + + return Ok(()); + } + + if let Some(command) = command { + let response = MessageBuilder::system_message(&ctx, config) + .content(docs_message) + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + command.create_followup(&ctx.http, response).await?; + + return Ok(()); + } + + println!("No valid message or command interaction provided."); + Ok(()) +} + +pub async fn display_command_help( + ctx: &Context, + config: &Config, + registry: Arc, + msg: Option<&Message>, + command: Option<&CommandInteraction>, + command_name: &str, +) -> ModmailResult<()> { + if let Some(cmd) = registry.commands.get(command_name) { + let command_doc = cmd.doc(config).await; + let mut docs_message = String::new(); + docs_message.push_str(&format!("**{}**\n\n", command_name)); + docs_message.push_str(&command_doc); + + if let Some(msg) = msg { + let _ = MessageBuilder::system_message(&ctx, config) + .content(docs_message) + .to_channel(msg.channel_id) + .send(true) + .await; + + return Ok(()); + } + + if let Some(command) = command { + let response = MessageBuilder::system_message(&ctx, config) + .content(docs_message) + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + command.create_followup(&ctx.http, response).await?; + return Ok(()); + } + + println!("No valid message or command interaction provided."); + Ok(()) + } else { + Err(ModmailError::Command(CommandError::UnknownCommand( + format!("{}", command_name), + ))) + } +} diff --git a/rustmail/src/commands/help/mod.rs b/rustmail/src/commands/help/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/help/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/help/slash_command/help.rs b/rustmail/src/commands/help/slash_command/help.rs new file mode 100644 index 00000000..4b48b10e --- /dev/null +++ b/rustmail/src/commands/help/slash_command/help.rs @@ -0,0 +1,111 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; +use serenity::all::{ + CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, + CreateCommandOption, ResolvedOption, +}; +use std::sync::Arc; + +pub struct HelpCommand; + +#[async_trait::async_trait] +impl RegistrableCommand for HelpCommand { + fn name(&self) -> &'static str { + "help" + } + + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.help", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { + let config = config.clone(); + + Box::pin(async move { + let cmd_desc = get_translated_message( + &config, + "slash_command.help_command_description", + None, + None, + None, + None, + ) + .await; + let cmd_arg_desc = get_translated_message( + &config, + "slash_command.help_command_argument_desc", + None, + None, + None, + None, + ) + .await; + + vec![ + CreateCommand::new("help").description(cmd_desc).add_option( + CreateCommandOption::new(CommandOptionType::String, "command", cmd_arg_desc) + .required(false), + ), + ] + }) + } + + fn run( + &self, + ctx: &Context, + command: &CommandInteraction, + _options: &[ResolvedOption<'_>], + config: &Config, + handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { + let ctx = ctx.clone(); + let command = command.clone(); + let config = config.clone(); + + Box::pin(async move { + defer_response(&ctx, &command).await?; + + let mut command_name: Option = None; + + for option in &command.data.options { + match option.name.as_str() { + "command" => { + if let CommandDataOptionValue::String(val) = &option.value { + command_name.replace(val.clone()); + } + } + _ => {} + } + } + + if let Some(cmd_name) = command_name { + display_command_help( + &ctx, + &config, + handler.registry.clone(), + None, + Some(&command), + &cmd_name, + ) + .await?; + } else { + display_commands_list( + &ctx, + &config, + handler.registry.clone(), + None, + Some(&command), + ) + .await?; + } + + Ok(()) + }) + } +} diff --git a/rustmail/src/commands/help/slash_command/mod.rs b/rustmail/src/commands/help/slash_command/mod.rs new file mode 100644 index 00000000..5249255a --- /dev/null +++ b/rustmail/src/commands/help/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod help; + +pub use help::*; diff --git a/rustmail/src/commands/help/text_command/help.rs b/rustmail/src/commands/help/text_command/help.rs new file mode 100644 index 00000000..3dd72df4 --- /dev/null +++ b/rustmail/src/commands/help/text_command/help.rs @@ -0,0 +1,36 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use serenity::all::{Context, Message}; +use std::sync::Arc; + +fn extract_request_command_name(command: &str) -> &str { + let parts: Vec<&str> = command.trim().split_whitespace().collect(); + if parts.len() > 1 { parts[1] } else { "" } +} + +pub async fn help( + ctx: Context, + msg: Message, + config: &Config, + handler: Arc, +) -> ModmailResult<()> { + let command_name = extract_request_command_name(&msg.content); + + if command_name.is_empty() { + display_commands_list(&ctx, config, handler.registry.clone(), Some(&msg), None).await?; + } else { + display_command_help( + &ctx, + config, + handler.registry.clone(), + Some(&msg), + None, + command_name, + ) + .await?; + } + + Ok(()) +} diff --git a/rustmail/src/commands/help/text_command/mod.rs b/rustmail/src/commands/help/text_command/mod.rs new file mode 100644 index 00000000..5249255a --- /dev/null +++ b/rustmail/src/commands/help/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod help; + +pub use help::*; diff --git a/rustmail/src/commands/id/mod.rs b/rustmail/src/commands/id/mod.rs new file mode 100644 index 00000000..b8524198 --- /dev/null +++ b/rustmail/src/commands/id/mod.rs @@ -0,0 +1,5 @@ +pub mod slash_command; +pub mod text_command; + +pub use slash_command::*; +pub use text_command::*; diff --git a/src/commands/id/slash_command/id.rs b/rustmail/src/commands/id/slash_command/id.rs similarity index 72% rename from src/commands/id/slash_command/id.rs rename to rustmail/src/commands/id/slash_command/id.rs index 906ea387..b3c43446 100644 --- a/src/commands/id/slash_command/id.rs +++ b/rustmail/src/commands/id/slash_command/id.rs @@ -1,14 +1,14 @@ -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::get_thread_by_channel_id; -use crate::db::threads::is_a_ticket_channel; -use crate::errors::ThreadError::{NotAThreadChannel, ThreadNotFound}; -use crate::errors::{DatabaseError, ModmailError, ModmailResult}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{CommandInteraction, Context, ResolvedOption}; use serenity::builder::CreateCommand; +use std::sync::Arc; pub struct IdCommand; @@ -18,7 +18,12 @@ impl RegistrableCommand for IdCommand { "id" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.id", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -40,9 +45,10 @@ impl RegistrableCommand for IdCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -58,14 +64,14 @@ impl RegistrableCommand for IdCommand { defer_response(&ctx, &command).await?; if !is_a_ticket_channel(command.channel_id, pool).await { - return Err(ModmailError::Thread(NotAThreadChannel)); + return Err(ModmailError::Thread(ThreadError::NotAThreadChannel)); } let thread = match get_thread_by_channel_id(&command.channel_id.to_string(), pool).await { Some(thread) => thread, None => { - return Err(ModmailError::Thread(ThreadNotFound)); + return Err(ModmailError::Thread(ThreadError::ThreadNotFound)); } }; diff --git a/rustmail/src/commands/id/slash_command/mod.rs b/rustmail/src/commands/id/slash_command/mod.rs new file mode 100644 index 00000000..4d3f0204 --- /dev/null +++ b/rustmail/src/commands/id/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod id; + +pub use id::*; diff --git a/src/commands/id/text_command/id.rs b/rustmail/src/commands/id/text_command/id.rs similarity index 60% rename from src/commands/id/text_command/id.rs rename to rustmail/src/commands/id/text_command/id.rs index 119f9c01..a1d100e1 100644 --- a/src/commands/id/text_command/id.rs +++ b/rustmail/src/commands/id/text_command/id.rs @@ -1,13 +1,17 @@ -use crate::config::Config; -use crate::db::get_thread_by_channel_id; -use crate::db::threads::is_a_ticket_channel; -use crate::errors::ThreadError::NotAThreadChannel; -use crate::errors::common::{database_connection_failed, thread_not_found}; -use crate::errors::{ModmailError, ModmailResult}; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; use serenity::all::{Context, Message}; +use std::sync::Arc; -pub async fn id(ctx: &Context, msg: &Message, config: &Config) -> ModmailResult<()> { +pub async fn id( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { let db_pool = config .db_pool .as_ref() @@ -26,15 +30,15 @@ pub async fn id(ctx: &Context, msg: &Message, config: &Config) -> ModmailResult< format!("||{}||", thread.user_id.to_string()), ); - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content("id.show_id", Some(¶ms), None, None) .await .to_channel(msg.channel_id) - .send() + .send(true) .await?; Ok(()) } else { - Err(ModmailError::Thread(NotAThreadChannel)) + Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) } } diff --git a/rustmail/src/commands/id/text_command/mod.rs b/rustmail/src/commands/id/text_command/mod.rs new file mode 100644 index 00000000..4d3f0204 --- /dev/null +++ b/rustmail/src/commands/id/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod id; + +pub use id::*; diff --git a/rustmail/src/commands/logs/common.rs b/rustmail/src/commands/logs/common.rs new file mode 100644 index 00000000..e86681ad --- /dev/null +++ b/rustmail/src/commands/logs/common.rs @@ -0,0 +1,107 @@ +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; +use crate::prelude::types::*; +use crate::prelude::utils::*; +use serenity::all::{ChannelId, CommandInteraction, Message}; +use serenity::builder::CreateActionRow; +use serenity::client::Context; + +pub fn extract_user_id_for_logs(msg: &Message, config: &Config) -> String { + let content = msg.content.trim(); + let prefix = &config.command.prefix; + let command_name = "logs"; + + if content.starts_with(&format!("{}{}", prefix, command_name)) { + let start = prefix.len() + command_name.len(); + content[start..].trim().to_string() + } else { + String::new() + } +} + +pub async fn render_logs_page( + config: &Config, + logs: &[TicketLog], + page: usize, + per_page: usize, +) -> String { + let total_pages = (logs.len() + per_page - 1) / per_page; + let start = page * per_page; + let end = usize::min(start + per_page, logs.len()); + let no_logs = get_translated_message( + config, + "slash_commands.no_logs_found", + None, + None, + None, + None, + ) + .await; + + let mut desc = String::new(); + + if start >= logs.len() { + return no_logs; + } + + for (_, log) in logs[start..end].iter().enumerate() { + use std::fmt::Write; + let _ = writeln!( + desc, + "**#{}** | [`đŸŽ« {}`]({}) | 🔒 {} {}", + log.id, + log.ticket_id, + format!( + "http://{}:3002/panel/tickets/{}", + &config.bot.ip.clone().unwrap(), + log.ticket_id + ), + log.created_at, + "\n".to_string(), + ); + } + + if desc.is_empty() { + desc = "_Aucun log trouvĂ© pour cet utilisateur._".into(); + } + + format!( + "{}\n_Page {}/{} ( đŸ§Ÿ {} )_", + desc, + page + 1, + total_pages.max(1), + logs.len() + ) +} + +pub async fn get_response( + ctx: Context, + config: Config, + content: &str, + components: Vec, + channel_id: ChannelId, + command: Option, +) -> Result { + if !command.is_none() { + let command = command.unwrap(); + + let response = MessageBuilder::system_message(&ctx.clone(), &config.clone()) + .content(content) + .components(components) + .to_channel(channel_id) + .build_interaction_message_followup() + .await; + + let tkt = command.create_followup(&ctx.http, response).await; + + Ok(tkt?) + } else { + MessageBuilder::system_message(&ctx, &config) + .content(content) + .components(components) + .to_channel(channel_id) + .send(true) + .await + } +} diff --git a/rustmail/src/commands/logs/mod.rs b/rustmail/src/commands/logs/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/logs/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/logs/slash_command/logs.rs b/rustmail/src/commands/logs/slash_command/logs.rs new file mode 100644 index 00000000..efe23e4e --- /dev/null +++ b/rustmail/src/commands/logs/slash_command/logs.rs @@ -0,0 +1,112 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; +use serenity::all::{ + CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, + CreateCommandOption, ResolvedOption, UserId, +}; +use std::sync::Arc; + +pub struct LogsCommand; + +impl RegistrableCommand for LogsCommand { + fn name(&self) -> &'static str { + "logs" + } + + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.logs", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { + let config = config.clone(); + + Box::pin(async move { + let cmd_desc = get_translated_message( + &config, + "slash_commands.logs_command_description", + None, + None, + None, + None, + ) + .await; + let id_desc = get_translated_message( + &config, + "slash_commands.logs_id_argument_description", + None, + None, + None, + None, + ) + .await; + + vec![CreateCommand::new("logs").description(cmd_desc).add_option( + CreateCommandOption::new(CommandOptionType::User, "id", id_desc).required(false), + )] + }) + } + + fn run( + &self, + ctx: &Context, + command: &CommandInteraction, + _options: &[ResolvedOption<'_>], + config: &Config, + handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { + let ctx = ctx.clone(); + let command = command.clone(); + let config = config.clone(); + + Box::pin(async move { + let pool = match config.db_pool.clone() { + Some(pool) => pool.clone(), + None => return Err(ModmailError::Database(DatabaseError::ConnectionFailed)), + }; + + defer_response(&ctx, &command).await?; + + let mut user_id: Option = None; + + for option in &command.data.options { + match option.name.as_str() { + "id" => { + if let CommandDataOptionValue::User(val) = &option.value { + user_id.replace(val.clone()); + } + } + _ => {} + } + } + + if !user_id.is_some() { + handle_logs_in_thread( + &ctx, + &command.clone().channel_id, + Some(command.clone()), + &config, + &pool, + handler.pagination.clone(), + ) + .await + } else { + handle_logs_from_user_id( + &ctx, + &command.clone().channel_id, + Some(command), + &config, + &pool, + &user_id.unwrap().to_string(), + handler.pagination.clone(), + ) + .await + } + }) + } +} diff --git a/rustmail/src/commands/logs/slash_command/mod.rs b/rustmail/src/commands/logs/slash_command/mod.rs new file mode 100644 index 00000000..da94856a --- /dev/null +++ b/rustmail/src/commands/logs/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod logs; + +pub use logs::*; diff --git a/rustmail/src/commands/logs/text_command/logs.rs b/rustmail/src/commands/logs/text_command/logs.rs new file mode 100644 index 00000000..be912d58 --- /dev/null +++ b/rustmail/src/commands/logs/text_command/logs.rs @@ -0,0 +1,142 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::features::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::modules::*; +use crate::prelude::types::*; +use serenity::all::{ButtonStyle, ChannelId, CommandInteraction, Context, Message}; +use sqlx::SqlitePool; +use std::sync::Arc; +use uuid::Uuid; + +pub async fn handle_logs_in_thread( + ctx: &Context, + channel_id: &ChannelId, + command: Option, + config: &Config, + pool: &SqlitePool, + pagination: PaginationStore, +) -> ModmailResult<()> { + let thread = match get_thread_by_channel_id(&channel_id.to_string(), &pool).await { + Some(thread) => thread, + None => return Err(ModmailError::Thread(ThreadError::ThreadNotFound)), + }; + + handle_logs_from_user_id( + &ctx, + channel_id, + command, + &config, + &pool, + &thread.user_id.to_string(), + pagination, + ) + .await +} + +pub async fn handle_logs_from_user_id( + ctx: &Context, + channel_id: &ChannelId, + command: Option, + config: &Config, + pool: &SqlitePool, + user_id: &str, + pagination_store: PaginationStore, +) -> ModmailResult<()> { + let logs = match get_logs_from_user_id(&user_id, &pool).await { + Ok(logs) => logs, + Err(e) => { + eprintln!("Error retrieving logs for user ID {}: {:?}", user_id, e); + return Err(ModmailError::Database(DatabaseError::QueryFailed( + "Failed to retrieve logs.".to_string(), + ))); + } + }; + + let page = 0; + let content = render_logs_page(config, &logs, page, LOGS_PAGE_SIZE).await; + let session_id = Uuid::new_v4().to_string(); + + let next_button = + get_translated_message(&config, "logs_command.next", None, None, None, None).await; + let prev_button = + get_translated_message(&config, "logs_command.prev", None, None, None, None).await; + + let components = make_buttons(&[ + ( + &prev_button.to_string(), + &format!("command:logs_prev:{}", session_id), + ButtonStyle::Primary, + page == 0, + ), + ( + &next_button.to_string(), + &format!("command:logs_next:{}", session_id), + ButtonStyle::Primary, + (page + 1) * 10 >= logs.len(), + ), + ]); + + let response = get_response( + ctx.clone(), + config.clone(), + &content, + components, + *channel_id, + command, + ) + .await?; + + pagination_store.lock().await.insert( + session_id.clone(), + PaginationContext { + user_id: user_id.to_string(), + logs, + current_page: page, + message_id: response.id, + channel_id: response.channel_id, + }, + ); + + Ok(()) +} + +pub async fn logs( + ctx: Context, + msg: Message, + config: &Config, + handler: Arc, +) -> ModmailResult<()> { + let pool = match config.db_pool.clone() { + Some(pool) => pool.clone(), + None => return Err(ModmailError::Database(DatabaseError::ConnectionFailed)), + }; + + let user_id = extract_user_id_for_logs(&msg, &config); + + if user_id.is_empty() { + handle_logs_in_thread( + &ctx, + &msg.channel_id, + None, + &config, + &pool, + handler.pagination.clone(), + ) + .await + } else { + handle_logs_from_user_id( + &ctx, + &msg.channel_id, + None, + config, + &pool, + &user_id.to_string(), + handler.pagination.clone(), + ) + .await + } +} diff --git a/rustmail/src/commands/logs/text_command/mod.rs b/rustmail/src/commands/logs/text_command/mod.rs new file mode 100644 index 00000000..da94856a --- /dev/null +++ b/rustmail/src/commands/logs/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod logs; + +pub use logs::*; diff --git a/rustmail/src/commands/mod.rs b/rustmail/src/commands/mod.rs new file mode 100644 index 00000000..273a77e3 --- /dev/null +++ b/rustmail/src/commands/mod.rs @@ -0,0 +1,109 @@ +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::types::*; +use serenity::all::{CommandInteraction, Context, CreateCommand, ResolvedOption}; +use std::any::Any; +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::Arc; +use tokio::sync::watch::Receiver; + +pub mod add_reminder; +pub mod add_staff; +pub mod alert; +pub mod anonreply; +pub mod close; +pub mod delete; +pub mod edit; +pub mod force_close; +pub mod help; +pub mod id; +pub mod logs; +pub mod move_thread; +pub mod new_thread; +pub mod recover; +pub mod release; +pub mod remove_reminder; +pub mod remove_staff; +pub mod reply; +pub mod take; + +pub use add_reminder::*; +pub use add_staff::*; +pub use alert::*; +pub use anonreply::*; +pub use close::*; +pub use delete::*; +pub use edit::*; +pub use force_close::*; +pub use help::*; +pub use id::*; +pub use logs::*; +pub use move_thread::*; +pub use new_thread::*; +pub use recover::*; +pub use release::*; +pub use remove_reminder::*; +pub use remove_staff::*; +pub use reply::*; +pub use take::*; + +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +pub trait RegistrableCommand: Any + Send + Sync { + fn as_community(&self) -> Option<&dyn CommunityRegistrable> { + None + } + + fn name(&self) -> &'static str; + + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String>; + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec>; + + fn run( + &self, + ctx: &Context, + command: &CommandInteraction, + options: &[ResolvedOption<'_>], + config: &Config, + handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>>; +} + +pub trait CommunityRegistrable: RegistrableCommand { + fn register_community(&self, config: &Config) -> BoxFuture<'_, Vec>; +} + +pub struct CommandRegistry { + commands: HashMap<&'static str, Arc>, + _shutdown: Arc>, + _pagination: PaginationStore, +} + +impl CommandRegistry { + pub fn new(shutdown: Receiver, pagination: PaginationStore) -> Self { + Self { + commands: HashMap::new(), + _shutdown: Arc::new(shutdown), + _pagination: pagination, + } + } + + pub fn _shutdown(&self) -> Arc> { + self._shutdown.clone() + } + + pub fn register_command(&mut self, command: C) { + self.commands.insert(command.name(), Arc::new(command)); + } + + pub fn get(&self, name: &str) -> Option> { + self.commands.get(name).cloned() + } + + pub fn all(&self) -> Vec> { + self.commands.values().cloned().collect() + } +} diff --git a/src/commands/move_thread/common.rs b/rustmail/src/commands/move_thread/common.rs similarity index 72% rename from src/commands/move_thread/common.rs rename to rustmail/src/commands/move_thread/common.rs index 4e7d50dd..683ec46b 100644 --- a/src/commands/move_thread/common.rs +++ b/rustmail/src/commands/move_thread/common.rs @@ -1,7 +1,7 @@ -use crate::config::Config; -use crate::db::get_user_id_from_channel_id; -use crate::i18n::get_translated_message; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; use serenity::all::{ChannelId, CommandInteraction, Context, EditChannel, GuildId, Message}; use std::collections::HashMap; @@ -66,59 +66,6 @@ pub async fn move_channel_to_category_by_command_option( .await } -pub async fn send_error_message( - ctx: &Context, - msg: &Message, - config: &Config, - error_key: &str, - params: Option<&HashMap>, -) { - let error_msg = get_translated_message( - config, - error_key, - params, - Some(msg.author.id), - msg.guild_id.map(|g| g.get()), - None, - ) - .await; - - let _ = MessageBuilder::system_message(ctx, config) - .content(error_msg) - .to_channel(msg.channel_id) - .send() - .await; -} - -pub async fn send_success_message( - ctx: &Context, - msg: &Message, - config: &Config, - category_name: &str, -) { - let mut params = HashMap::new(); - params.insert("category".to_string(), category_name.to_string()); - params.insert("staff".to_string(), msg.author.name.clone()); - - let confirmation_msg = get_translated_message( - config, - "move_thread.success", - Some(¶ms), - Some(msg.author.id), - msg.guild_id.map(|g| g.get()), - None, - ) - .await; - - let _ = MessageBuilder::system_message(ctx, config) - .content(confirmation_msg) - .to_channel(msg.channel_id) - .send() - .await; - - let _ = msg.delete(&ctx.http).await; -} - pub fn find_best_match_category( target_name: &str, categories: &[(ChannelId, String)], @@ -144,7 +91,7 @@ pub fn find_best_match_category( } if let Some((id, name)) = best_match { - let max_distance = (target_name.len().max(name.len()) as f64 * 0.5) as usize; + let max_distance = (target_name.len().max(name.len()) as f64 * 0.7) as usize; if best_distance <= max_distance { return Some((id, name)); } diff --git a/rustmail/src/commands/move_thread/mod.rs b/rustmail/src/commands/move_thread/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/move_thread/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/move_thread/slash_command/mod.rs b/rustmail/src/commands/move_thread/slash_command/mod.rs new file mode 100644 index 00000000..7a8a59e9 --- /dev/null +++ b/rustmail/src/commands/move_thread/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod move_thread; + +pub use move_thread::*; diff --git a/src/commands/move_thread/slash_command/move_thread.rs b/rustmail/src/commands/move_thread/slash_command/move_thread.rs similarity index 74% rename from src/commands/move_thread/slash_command/move_thread.rs rename to rustmail/src/commands/move_thread/slash_command/move_thread.rs index 9cf5a77a..6e090447 100644 --- a/src/commands/move_thread/slash_command/move_thread.rs +++ b/rustmail/src/commands/move_thread/slash_command/move_thread.rs @@ -1,20 +1,17 @@ -use crate::commands::move_thread::common::{ - fetch_server_categories, find_best_match_category, move_channel_to_category_by_command_option, -}; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::get_user_id_from_channel_id; -use crate::errors::{ - CommandError, DatabaseError, DiscordError, ModmailError, ModmailResult, ThreadError, -}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{ - CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, - ResolvedOption, + CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, + CreateCommandOption, ResolvedOption, }; use std::collections::HashMap; +use std::sync::Arc; pub struct MoveCommand; @@ -24,7 +21,12 @@ impl RegistrableCommand for MoveCommand { "move" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.move", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -50,7 +52,7 @@ impl RegistrableCommand for MoveCommand { vec![ CreateCommand::new("move").description(cmd_desc).add_option( CreateCommandOption::new( - CommandOptionType::String, + CommandOptionType::Channel, "category", catagory_field_desc, ) @@ -64,9 +66,10 @@ impl RegistrableCommand for MoveCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -88,19 +91,23 @@ impl RegistrableCommand for MoveCommand { return Err(ModmailError::Command(CommandError::NotInThread())); } - let category_name = match command + let category_option = command .data .options .iter() .find(|opt| opt.name == "category") - { - Some(opt) => match &opt.value { - serenity::all::CommandDataOptionValue::String(name) => name.trim().to_string(), - _ => String::new(), - }, - None => String::new(), + .ok_or(ModmailError::Command(CommandError::MissingArguments))?; + + let category_channel = match &category_option.value { + CommandDataOptionValue::Channel(category) => category, + _ => return Err(ModmailError::Command(CommandError::MissingArguments)), }; + let category_name = category_channel + .name(&ctx.http) + .await + .map_err(|_| ModmailError::Command(CommandError::MissingArguments))?; + if category_name.is_empty() { return Err(ModmailError::Thread(ThreadError::CategoryNotFound)); } diff --git a/rustmail/src/commands/move_thread/text_command/mod.rs b/rustmail/src/commands/move_thread/text_command/mod.rs new file mode 100644 index 00000000..7a8a59e9 --- /dev/null +++ b/rustmail/src/commands/move_thread/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod move_thread; + +pub use move_thread::*; diff --git a/rustmail/src/commands/move_thread/text_command/move_thread.rs b/rustmail/src/commands/move_thread/text_command/move_thread.rs new file mode 100644 index 00000000..3369960f --- /dev/null +++ b/rustmail/src/commands/move_thread/text_command/move_thread.rs @@ -0,0 +1,74 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::all::{Context, Message}; +use std::collections::HashMap; +use std::sync::Arc; + +pub async fn move_thread( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + if !is_in_thread(&msg, pool).await { + return Err(ModmailError::Command(CommandError::NotInThread())); + } + + let category_name = extract_category_name(&msg, config).await; + if category_name.is_empty() { + return Err(ModmailError::Thread(ThreadError::CategoryNotFound)); + } + + let categories = fetch_server_categories(&ctx, config).await; + if categories.is_empty() { + return Err(ModmailError::Discord(DiscordError::FailedToFetchCategories)); + } + + let target_category = find_best_match_category(&category_name, &categories); + + match target_category { + Some((category_id, category_name)) => { + if let Err(e) = move_channel_to_category_by_msg(&ctx, &msg, category_id).await { + return Err(ModmailError::Command(CommandError::CommandFailed( + e.to_string(), + ))); + } + + let mut params = HashMap::new(); + params.insert("category".to_string(), category_name.to_string()); + params.insert("staff".to_string(), msg.author.name.clone()); + + let confirmation_msg = get_translated_message( + config, + "move_thread.success", + Some(¶ms), + Some(msg.author.id), + msg.guild_id.map(|g| g.get()), + None, + ) + .await; + + let _ = MessageBuilder::system_message(&ctx.clone(), config) + .content(confirmation_msg) + .to_channel(msg.channel_id) + .send(true) + .await; + + let _ = msg.delete(&ctx.http).await; + } + None => { + return Err(ModmailError::Thread(ThreadError::CategoryNotFound)); + } + } + + Ok(()) +} diff --git a/src/commands/new_thread/common.rs b/rustmail/src/commands/new_thread/common.rs similarity index 87% rename from src/commands/new_thread/common.rs rename to rustmail/src/commands/new_thread/common.rs index 6fe0dba5..d0f5978b 100644 --- a/src/commands/new_thread/common.rs +++ b/rustmail/src/commands/new_thread/common.rs @@ -1,6 +1,6 @@ -use crate::config::Config; -use crate::errors::ModmailResult; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::utils::*; use serenity::all::{Context, GuildChannel, Message, User, UserId}; use std::collections::HashMap; @@ -60,7 +60,19 @@ pub async fn send_welcome_message( ) .await .to_channel(channel.id) - .send() + .send(true) + .await; + + let _ = MessageBuilder::system_message(ctx, config) + .translated_content( + "new_thread.welcome_message", + Some(¶ms), + None, + Some(channel.guild_id.get()), + ) + .await + .to_user(user.id) + .send(true) .await; } @@ -69,7 +81,7 @@ pub async fn send_dm_to_user(ctx: &Context, user: &User, config: &Config) -> Mod .translated_content("new_thread.dm_notification", None, Some(user.id), None) .await .to_user(user.id) - .send() + .send(true) .await; Ok(()) @@ -91,7 +103,7 @@ pub async fn send_error_message( ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } @@ -123,6 +135,6 @@ pub async fn send_success_message( ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } diff --git a/rustmail/src/commands/new_thread/mod.rs b/rustmail/src/commands/new_thread/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/new_thread/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/new_thread/slash_command/mod.rs b/rustmail/src/commands/new_thread/slash_command/mod.rs new file mode 100644 index 00000000..e92a3fcd --- /dev/null +++ b/rustmail/src/commands/new_thread/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod new_thread; + +pub use new_thread::*; diff --git a/src/commands/new_thread/slash_command/new_thread.rs b/rustmail/src/commands/new_thread/slash_command/new_thread.rs similarity index 71% rename from src/commands/new_thread/slash_command/new_thread.rs rename to rustmail/src/commands/new_thread/slash_command/new_thread.rs index 9da45526..0a54df1a 100644 --- a/src/commands/new_thread/slash_command/new_thread.rs +++ b/rustmail/src/commands/new_thread/slash_command/new_thread.rs @@ -1,28 +1,37 @@ -use crate::commands::new_thread::common::send_welcome_message; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::{create_thread_for_user, get_thread_channel_by_user_id, thread_exists}; -use crate::errors::{ - CommandError, DatabaseError, DiscordError, ModmailError, ModmailResult, common, -}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{ ChannelId, CommandDataOptionValue, CommandInteraction, CommandOptionType, CommandType, Context, CreateCommand, CreateCommandOption, CreateInteractionResponseFollowup, GuildId, ResolvedOption, }; use std::collections::HashMap; +use std::sync::Arc; pub struct NewThreadCommand; #[async_trait::async_trait] impl RegistrableCommand for NewThreadCommand { + fn as_community(&self) -> Option<&dyn CommunityRegistrable> { + Some(self) + } + fn name(&self) -> &'static str { "new_thread" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { + get_translated_message(config, "help.new_thread", None, None, None, None).await + }.boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -62,9 +71,10 @@ impl RegistrableCommand for NewThreadCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -73,7 +83,7 @@ impl RegistrableCommand for NewThreadCommand { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; defer_response(&ctx, &command).await?; @@ -127,7 +137,7 @@ impl RegistrableCommand for NewThreadCommand { } let inbox_category_id = ChannelId::new(config.thread.inbox_category_id); - let channel_name = user.name.to_lowercase().replace(" ", "-").to_string(); + let channel_name = format!("đŸ”Žăƒ»{}・0m", user.name); let mut channel_builder = serenity::all::CreateChannel::new(&channel_name); channel_builder = channel_builder .kind(serenity::model::channel::ChannelType::Text) @@ -163,6 +173,42 @@ impl RegistrableCommand for NewThreadCommand { } }; + let community_guild_id = GuildId::new(config.bot.get_community_guild_id()); + + let member_join_date = get_member_join_date_for_user(&ctx, user_id, community_guild_id) + .await + .unwrap_or_else(|| "Unknown".to_string()); + + let logs_count = match get_logs_from_user_id(&user_id.clone().to_string(), pool).await { + Ok(logs) => logs.len(), + Err(_) => 0, + }; + + let params = { + let mut p = HashMap::new(); + p.insert("logs_count".to_string(), logs_count.to_string()); + p.insert("prefix".to_string(), config.command.prefix.clone()); + p + }; + + let logs_info = get_translated_message( + &config, + "new_thread.show_logs", + Some(¶ms), + None, + None, + None, + ) + .await; + + let recap = get_user_recap(user_id, &user.name, &member_join_date, &logs_info); + + let _ = MessageBuilder::system_message(&ctx, &config) + .content(recap) + .to_channel(guild_channel.id) + .send(true) + .await; + send_welcome_message(&ctx, &guild_channel, &config, &user).await; let mut params = HashMap::new(); @@ -191,3 +237,9 @@ impl RegistrableCommand for NewThreadCommand { }) } } + +impl CommunityRegistrable for NewThreadCommand { + fn register_community(&self, _config: &Config) -> BoxFuture<'_, Vec> { + Box::pin(async move { vec![CreateCommand::new("new_thread").kind(CommandType::User)] }) + } +} diff --git a/rustmail/src/commands/new_thread/text_command/mod.rs b/rustmail/src/commands/new_thread/text_command/mod.rs new file mode 100644 index 00000000..e92a3fcd --- /dev/null +++ b/rustmail/src/commands/new_thread/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod new_thread; + +pub use new_thread::*; diff --git a/rustmail/src/commands/new_thread/text_command/new_thread.rs b/rustmail/src/commands/new_thread/text_command/new_thread.rs new file mode 100644 index 00000000..afc91aad --- /dev/null +++ b/rustmail/src/commands/new_thread/text_command/new_thread.rs @@ -0,0 +1,148 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::all::{ChannelId, Context, GuildId, Message}; +use std::collections::HashMap; +use std::sync::Arc; + +pub async fn new_thread( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + let user_id = extract_user_id(&msg, config).await; + if user_id.is_none() { + send_error_message(&ctx, &msg, config, "new_thread.missing_user", None).await; + return Ok(()); + } + + let user_id = user_id.unwrap(); + + let user = match ctx.http.get_user(user_id).await { + Ok(user) => user, + Err(_) => return Err(ModmailError::Discord(DiscordError::UserNotFound)), + }; + + if user.bot { + return Err(ModmailError::Discord(DiscordError::UserIsABot)); + } + + if thread_exists(user_id, pool).await { + if let Some(channel_id_str) = get_thread_channel_by_user_id(user_id, pool).await { + let mut params = HashMap::new(); + params.insert("user".to_string(), user.name.clone()); + params.insert("channel_id".to_string(), channel_id_str.clone()); + + send_error_message( + &ctx, + &msg, + config, + "new_thread.user_has_thread_with_link", + Some(¶ms), + ) + .await; + } else { + send_error_message(&ctx, &msg, config, "new_thread.user_has_thread", None).await; + } + return Ok(()); + } + + let inbox_category_id = ChannelId::new(config.thread.inbox_category_id); + let channel_name = format!("đŸ”Žăƒ»{}・0m", user.name); + let mut channel_builder = serenity::all::CreateChannel::new(&channel_name); + channel_builder = channel_builder + .kind(serenity::model::channel::ChannelType::Text) + .category(inbox_category_id); + + let staff_guild_id = GuildId::new(config.bot.get_staff_guild_id()); + let guild_channel = match staff_guild_id + .create_channel(&ctx.http, channel_builder) + .await + { + Ok(channel) => channel, + Err(e) => { + eprintln!("Failed to create channel: {}", e); + send_error_message( + &ctx, + &msg, + config, + "new_thread.channel_creation_failed", + None, + ) + .await; + return Ok(()); + } + }; + + let community_guild_id = GuildId::new(config.bot.get_community_guild_id()); + + let member_join_date = get_member_join_date_for_user(&ctx, user_id, community_guild_id) + .await + .unwrap_or_else(|| "Unknown".to_string()); + + let logs_count = match get_logs_from_user_id(&user_id.clone().to_string(), pool).await { + Ok(logs) => logs.len(), + Err(_) => 0, + }; + + let params = { + let mut p = HashMap::new(); + p.insert("logs_count".to_string(), logs_count.to_string()); + p.insert("prefix".to_string(), config.command.prefix.clone()); + p + }; + + let logs_info = get_translated_message( + &config, + "new_thread.show_logs", + Some(¶ms), + None, + None, + None, + ) + .await; + + let recap = get_user_recap(user_id, &user.name, &member_join_date, &logs_info); + + let _ = MessageBuilder::system_message(&ctx, &config) + .content(recap) + .to_channel(guild_channel.id) + .send(true) + .await; + + let _ = match create_thread_for_user(&guild_channel, user_id.get() as i64, &user.name, pool) + .await + { + Ok(thread_id) => thread_id, + Err(e) => { + eprintln!("Failed to create thread in database: {}", e); + let _ = guild_channel.delete(&ctx.http).await; + send_error_message(&ctx, &msg, config, "new_thread.database_error", None).await; + return Ok(()); + } + }; + + send_welcome_message(&ctx, &guild_channel, config, &user).await; + + match send_dm_to_user(&ctx, &user, config).await { + Ok(_) => { + send_success_message(&ctx, &msg, config, &user, &guild_channel, true).await; + } + Err(dm_error) => { + eprintln!("Failed to send DM to user: {}", dm_error); + send_success_message(&ctx, &msg, config, &user, &guild_channel, false).await; + } + } + + Ok(()) +} diff --git a/rustmail/src/commands/recover/mod.rs b/rustmail/src/commands/recover/mod.rs new file mode 100644 index 00000000..b8524198 --- /dev/null +++ b/rustmail/src/commands/recover/mod.rs @@ -0,0 +1,5 @@ +pub mod slash_command; +pub mod text_command; + +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/recover/slash_command/mod.rs b/rustmail/src/commands/recover/slash_command/mod.rs new file mode 100644 index 00000000..c5032b79 --- /dev/null +++ b/rustmail/src/commands/recover/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod recover; + +pub use recover::*; diff --git a/src/commands/recover/slash_command/recover.rs b/rustmail/src/commands/recover/slash_command/recover.rs similarity index 81% rename from src/commands/recover/slash_command/recover.rs rename to rustmail/src/commands/recover/slash_command/recover.rs index 95e662a2..08e9abd2 100644 --- a/src/commands/recover/slash_command/recover.rs +++ b/rustmail/src/commands/recover/slash_command/recover.rs @@ -1,12 +1,14 @@ -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::errors::{ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::modules::message_recovery::recover_missing_messages; -use crate::utils::command::defer_response::defer_response; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::modules::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{CommandInteraction, Context, CreateCommand, ResolvedOption}; use std::collections::HashMap; +use std::sync::Arc; pub struct RecoverCommand; @@ -16,7 +18,12 @@ impl RegistrableCommand for RecoverCommand { "recover" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.recover", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -38,9 +45,10 @@ impl RegistrableCommand for RecoverCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -49,7 +57,7 @@ impl RegistrableCommand for RecoverCommand { let _ = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; defer_response(&ctx, &command).await?; diff --git a/rustmail/src/commands/recover/text_command/mod.rs b/rustmail/src/commands/recover/text_command/mod.rs new file mode 100644 index 00000000..c5032b79 --- /dev/null +++ b/rustmail/src/commands/recover/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod recover; + +pub use recover::*; diff --git a/src/commands/recover/text_command/recover.rs b/rustmail/src/commands/recover/text_command/recover.rs similarity index 77% rename from src/commands/recover/text_command/recover.rs rename to rustmail/src/commands/recover/text_command/recover.rs index 06626b89..12f7fbce 100644 --- a/src/commands/recover/text_command/recover.rs +++ b/rustmail/src/commands/recover/text_command/recover.rs @@ -1,16 +1,23 @@ -use crate::config::Config; -use crate::errors::{ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::modules::message_recovery::recover_missing_messages; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::modules::*; +use crate::prelude::utils::*; use serenity::all::{Context, Message}; use std::collections::HashMap; +use std::sync::Arc; -pub async fn recover(ctx: &Context, msg: &Message, config: &Config) -> ModmailResult<()> { +pub async fn recover( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { let _ = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; let mut params = HashMap::new(); params.insert("user".to_string(), msg.author.name.clone()); @@ -25,10 +32,10 @@ pub async fn recover(ctx: &Context, msg: &Message, config: &Config) -> ModmailRe ) .await; - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .content(confirmation_message) .to_channel(msg.channel_id) - .send() + .send(true) .await; let ctx_clone = ctx.clone(); @@ -60,7 +67,7 @@ pub async fn recover(ctx: &Context, msg: &Message, config: &Config) -> ModmailRe let _ = MessageBuilder::system_message(&ctx_clone, &config_clone) .content(summary_message) .to_channel(channel_id) - .send() + .send(true) .await; }); diff --git a/rustmail/src/commands/release/mod.rs b/rustmail/src/commands/release/mod.rs new file mode 100644 index 00000000..b8524198 --- /dev/null +++ b/rustmail/src/commands/release/mod.rs @@ -0,0 +1,5 @@ +pub mod slash_command; +pub mod text_command; + +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/release/slash_command/mod.rs b/rustmail/src/commands/release/slash_command/mod.rs new file mode 100644 index 00000000..84bceb56 --- /dev/null +++ b/rustmail/src/commands/release/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod release; + +pub use release::*; diff --git a/rustmail/src/commands/release/slash_command/release.rs b/rustmail/src/commands/release/slash_command/release.rs new file mode 100644 index 00000000..7b00013e --- /dev/null +++ b/rustmail/src/commands/release/slash_command/release.rs @@ -0,0 +1,134 @@ +use crate::modules::update_thread_status_ui; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; +use serenity::all::{ChannelId, CommandInteraction, Context, CreateCommand, ResolvedOption}; +use std::sync::Arc; + +pub struct ReleaseCommand; + +#[async_trait::async_trait] +impl RegistrableCommand for ReleaseCommand { + fn name(&self) -> &'static str { + "release" + } + + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.release", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { + let config = config.clone(); + + Box::pin(async move { + let cmd_desc = get_translated_message( + &config, + "slash_command.release_command_description", + None, + None, + None, + None, + ) + .await; + + vec![CreateCommand::new(self.name()).description(cmd_desc)] + }) + } + + fn run( + &self, + ctx: &Context, + command: &CommandInteraction, + _options: &[ResolvedOption<'_>], + config: &Config, + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { + let ctx = ctx.clone(); + let command = command.clone(); + let config = config.clone(); + + Box::pin(async move { + let db_pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + defer_response(&ctx, &command).await?; + + if is_a_ticket_channel(command.channel_id, &db_pool).await { + let thread = match get_thread_by_channel_id( + &command.channel_id.to_string(), + db_pool, + ) + .await + { + Some(thread) => thread, + None => return Err(thread_not_found()), + }; + + let parse_thread_id = thread.channel_id.parse::().unwrap_or(0); + + let thread_id = ChannelId::new(parse_thread_id); + + let thread_name = thread_id + .name(&ctx) + .await + .unwrap_or_else(|_| "Unknown".to_string()); + + if thread_name == thread.user_name { + return Err(ModmailError::Command(CommandError::TicketAlreadyReleased)); + } + + tokio::spawn({ + let db_pool = db_pool.clone(); + + async move { + let mut ticket_status = match get_thread_status(&thread.id, &db_pool).await + { + Some(status) => status, + None => { + return; + } + }; + ticket_status.taken_by = None; + let _ = update_thread_status_db( + &thread.id.to_string(), + &ticket_status, + &db_pool, + ) + .await; + + tokio::spawn({ + let ctx = ctx.clone(); + async move { + let _ = update_thread_status_ui(&ctx, &ticket_status).await; + } + }); + + let mut params = std::collections::HashMap::new(); + params.insert("staff".to_string(), format!("<@{}>", command.user.id)); + + let response = MessageBuilder::system_message(&ctx, &config) + .translated_content("release.confirmation", Some(¶ms), None, None) + .await + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + let _ = command.create_followup(ctx.clone(), response).await; + } + }); + + Ok(()) + } else { + Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + } + }) + } +} diff --git a/rustmail/src/commands/release/text_command/mod.rs b/rustmail/src/commands/release/text_command/mod.rs new file mode 100644 index 00000000..84bceb56 --- /dev/null +++ b/rustmail/src/commands/release/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod release; + +pub use release::*; diff --git a/rustmail/src/commands/release/text_command/release.rs b/rustmail/src/commands/release/text_command/release.rs new file mode 100644 index 00000000..5f81a408 --- /dev/null +++ b/rustmail/src/commands/release/text_command/release.rs @@ -0,0 +1,79 @@ +use crate::modules::update_thread_status_ui; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; +use serenity::all::{ChannelId, Context, Message}; +use std::sync::Arc; + +pub async fn release( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let db_pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + if is_a_ticket_channel(msg.channel_id, &db_pool).await { + let thread = match get_thread_by_channel_id(&msg.channel_id.to_string(), db_pool).await { + Some(thread) => thread, + None => return Err(thread_not_found()), + }; + + let parse_thread_id = thread.channel_id.parse::().unwrap_or(0); + + let thread_id = ChannelId::new(parse_thread_id); + + let thread_name = thread_id + .name(&ctx) + .await + .unwrap_or_else(|_| "Unknown".to_string()); + + if thread_name == thread.user_name { + return Err(ModmailError::Command(CommandError::TicketAlreadyReleased)); + } + + let config_clone = config.clone(); + + tokio::spawn({ + let db_pool = db_pool.clone(); + + async move { + let mut ticket_status = match get_thread_status(&thread.id, &db_pool).await { + Some(status) => status, + None => { + return; + } + }; + ticket_status.taken_by = None; + let _ = update_thread_status_db(&thread.id, &ticket_status, &db_pool).await; + + tokio::spawn({ + let ctx = ctx.clone(); + async move { + let _ = update_thread_status_ui(&ctx, &ticket_status).await; + } + }); + + let mut params = std::collections::HashMap::new(); + params.insert("staff".to_string(), format!("<@{}>", msg.author.id)); + + let _ = MessageBuilder::system_message(&ctx, &config_clone) + .translated_content("release.confirmation", Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + } + }); + + Ok(()) + } else { + Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + } +} diff --git a/rustmail/src/commands/remove_reminder/mod.rs b/rustmail/src/commands/remove_reminder/mod.rs new file mode 100644 index 00000000..b8524198 --- /dev/null +++ b/rustmail/src/commands/remove_reminder/mod.rs @@ -0,0 +1,5 @@ +pub mod slash_command; +pub mod text_command; + +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/remove_reminder/slash_command/mod.rs b/rustmail/src/commands/remove_reminder/slash_command/mod.rs new file mode 100644 index 00000000..a2db9f4e --- /dev/null +++ b/rustmail/src/commands/remove_reminder/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod remove_reminder; + +pub use remove_reminder::*; diff --git a/src/commands/remove_reminder/slash_command/remove_reminder.rs b/rustmail/src/commands/remove_reminder/slash_command/remove_reminder.rs similarity index 83% rename from src/commands/remove_reminder/slash_command/remove_reminder.rs rename to rustmail/src/commands/remove_reminder/slash_command/remove_reminder.rs index c63d43e5..681c4ff1 100644 --- a/src/commands/remove_reminder/slash_command/remove_reminder.rs +++ b/rustmail/src/commands/remove_reminder/slash_command/remove_reminder.rs @@ -1,24 +1,33 @@ -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::reminders::{get_reminder_by_id, update_reminder_status}; -use crate::errors::{CommandError, DatabaseError, ModmailError, ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, ResolvedOption, }; use std::collections::HashMap; +use std::sync::Arc; pub struct RemoveReminderCommand; impl RegistrableCommand for RemoveReminderCommand { fn name(&self) -> &'static str { - "remove_reminder" + "unremind" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { + get_translated_message(config, "help.remove_reminder", None, None, None, None).await + } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); let name = self.name(); @@ -52,9 +61,10 @@ impl RegistrableCommand for RemoveReminderCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -63,7 +73,7 @@ impl RegistrableCommand for RemoveReminderCommand { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; let _ = defer_response(&ctx, &command).await; diff --git a/rustmail/src/commands/remove_reminder/text_command/mod.rs b/rustmail/src/commands/remove_reminder/text_command/mod.rs new file mode 100644 index 00000000..a2db9f4e --- /dev/null +++ b/rustmail/src/commands/remove_reminder/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod remove_reminder; + +pub use remove_reminder::*; diff --git a/src/commands/remove_reminder/text_command/remove_reminder.rs b/rustmail/src/commands/remove_reminder/text_command/remove_reminder.rs similarity index 63% rename from src/commands/remove_reminder/text_command/remove_reminder.rs rename to rustmail/src/commands/remove_reminder/text_command/remove_reminder.rs index 58b969e7..26a0c0fa 100644 --- a/src/commands/remove_reminder/text_command/remove_reminder.rs +++ b/rustmail/src/commands/remove_reminder/text_command/remove_reminder.rs @@ -1,29 +1,32 @@ -use crate::config::Config; -use crate::db::reminders::{get_reminder_by_id, update_reminder_status}; -use crate::errors::{CommandError, DatabaseError, ModmailError, ModmailResult, common}; -use crate::utils::command::extract_reply_content::extract_reply_content; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; use serenity::all::{Context, Message}; use std::collections::HashMap; +use std::sync::Arc; -pub async fn remove_reminder(ctx: &Context, msg: &Message, config: &Config) -> ModmailResult<()> { +pub async fn remove_reminder( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; - let content = match extract_reply_content( - &msg.content, - &config.command.prefix, - &["remove_reminder", "rr"], - ) { - Some(c) => c, - None => { - return Err(ModmailError::Command(CommandError::InvalidArguments( - "".to_string(), - ))); - } - }; + let content = + match extract_reply_content(&msg.content, &config.command.prefix, &["unremind", "urem"]) { + Some(c) => c, + None => { + return Err(ModmailError::Command(CommandError::InvalidArguments( + "".to_string(), + ))); + } + }; let reminder_id = match content.parse::() { Ok(id) => id, @@ -57,7 +60,7 @@ pub async fn remove_reminder(ctx: &Context, msg: &Message, config: &Config) -> M .translated_content("remove_reminder.confirmation", Some(¶ms), None, None) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } Err(e) => { diff --git a/src/commands/remove_staff/common.rs b/rustmail/src/commands/remove_staff/common.rs similarity index 69% rename from src/commands/remove_staff/common.rs rename to rustmail/src/commands/remove_staff/common.rs index d89937ef..c7bdbdc6 100644 --- a/src/commands/remove_staff/common.rs +++ b/rustmail/src/commands/remove_staff/common.rs @@ -1,5 +1,5 @@ -use crate::config::Config; -use crate::errors::ModmailResult; +use crate::prelude::config::*; +use crate::prelude::errors::*; use serenity::all::{ ChannelId, Context, Message, PermissionOverwrite, PermissionOverwriteType, Permissions, UserId, }; @@ -25,16 +25,16 @@ pub async fn remove_user_from_channel( Ok(()) } -pub async fn extract_user_id(msg: &Message, config: &Config) -> String { +pub async fn extract_remove_staff_id(msg: &Message, config: &Config) -> String { let content = msg.content.trim(); let prefix = &config.command.prefix; - let command_names = ["remove_staff", "rs"]; + let command_names = ["delmod", "dm"]; - if command_names + if let Some(matched_name) = command_names .iter() - .any(|&name| content.starts_with(&format!("{}{}", prefix, name))) + .find(|&name| content.starts_with(&format!("{}{}", prefix, name))) { - let start = prefix.len() + command_names[0].len(); + let start = prefix.len() + matched_name.len(); content[start..].trim().to_string() } else { String::new() diff --git a/rustmail/src/commands/remove_staff/mod.rs b/rustmail/src/commands/remove_staff/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/remove_staff/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/remove_staff/slash_command/mod.rs b/rustmail/src/commands/remove_staff/slash_command/mod.rs new file mode 100644 index 00000000..d94ee3b8 --- /dev/null +++ b/rustmail/src/commands/remove_staff/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod remove_staff; + +pub use remove_staff::*; diff --git a/src/commands/remove_staff/slash_command/remove_staff.rs b/rustmail/src/commands/remove_staff/slash_command/remove_staff.rs similarity index 74% rename from src/commands/remove_staff/slash_command/remove_staff.rs rename to rustmail/src/commands/remove_staff/slash_command/remove_staff.rs index f387a75b..4b5fcdc1 100644 --- a/src/commands/remove_staff/slash_command/remove_staff.rs +++ b/rustmail/src/commands/remove_staff/slash_command/remove_staff.rs @@ -1,29 +1,35 @@ -use crate::commands::remove_staff::common::remove_user_from_channel; -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::thread_exists; -use crate::errors::CommandError::InvalidFormat; -use crate::errors::ThreadError::NotAThreadChannel; -use crate::errors::{CommandError, ModmailError, ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; use serenity::all::{ CommandDataOptionValue, CommandInteraction, CommandOptionType, CommandType, Context, CreateCommand, CreateCommandOption, CreateInteractionResponseFollowup, ResolvedOption, }; use std::collections::HashMap; +use std::sync::Arc; pub struct RemoveStaffCommand; #[async_trait::async_trait] impl RegistrableCommand for RemoveStaffCommand { fn name(&self) -> &'static str { - "remove_staff" + "delmod" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { + get_translated_message(config, "help.remove_staff", None, None, None, None).await + }.boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); + let name = self.name(); Box::pin(async move { let cmd_desc = get_translated_message( @@ -47,13 +53,11 @@ impl RegistrableCommand for RemoveStaffCommand { .await; vec![ - CreateCommand::new("remove_staff") - .description(cmd_desc) - .add_option( - CreateCommandOption::new(CommandOptionType::User, "user_id", user_id_desc) - .required(true), - ), - CreateCommand::new("remove_staff").kind(CommandType::User), + CreateCommand::new(name).description(cmd_desc).add_option( + CreateCommandOption::new(CommandOptionType::User, "user_id", user_id_desc) + .required(true), + ), + CreateCommand::new(name).kind(CommandType::User), ] }) } @@ -62,9 +66,10 @@ impl RegistrableCommand for RemoveStaffCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -73,7 +78,7 @@ impl RegistrableCommand for RemoveStaffCommand { let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; defer_response(&ctx, &command).await?; @@ -129,10 +134,10 @@ impl RegistrableCommand for RemoveStaffCommand { Ok(()) } - Err(..) => Err(ModmailError::Command(InvalidFormat)), + Err(..) => Err(ModmailError::Command(CommandError::InvalidFormat)), } } else { - Err(ModmailError::Thread(NotAThreadChannel)) + Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) } }) } diff --git a/rustmail/src/commands/remove_staff/text_command/mod.rs b/rustmail/src/commands/remove_staff/text_command/mod.rs new file mode 100644 index 00000000..d94ee3b8 --- /dev/null +++ b/rustmail/src/commands/remove_staff/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod remove_staff; + +pub use remove_staff::*; diff --git a/rustmail/src/commands/remove_staff/text_command/remove_staff.rs b/rustmail/src/commands/remove_staff/text_command/remove_staff.rs new file mode 100644 index 00000000..2a2f146e --- /dev/null +++ b/rustmail/src/commands/remove_staff/text_command/remove_staff.rs @@ -0,0 +1,53 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; +use serenity::all::{Context, Message, UserId}; +use std::collections::HashMap; +use std::sync::Arc; + +pub async fn remove_staff( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + let user_id_str = extract_remove_staff_id(&msg, config).await; + + if user_id_str.is_empty() { + return Err(ModmailError::Command(CommandError::InvalidFormat)); + } + + let user_id = match user_id_str.parse::() { + Ok(id) => UserId::new(id), + Err(_) => return Err(ModmailError::Command(CommandError::InvalidFormat)), + }; + + if thread_exists(msg.author.id, pool).await { + match remove_user_from_channel(&ctx, msg.channel_id, user_id).await { + Ok(_) => { + let mut params = HashMap::new(); + params.insert("user".to_string(), format!("<@{}>", user_id)); + + let _ = MessageBuilder::system_message(&ctx, config) + .translated_content("add_staff.remove_success", Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + + Ok(()) + } + Err(..) => Err(ModmailError::Command(CommandError::InvalidFormat)), + } + } else { + Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + } +} diff --git a/rustmail/src/commands/reply/mod.rs b/rustmail/src/commands/reply/mod.rs new file mode 100644 index 00000000..b8524198 --- /dev/null +++ b/rustmail/src/commands/reply/mod.rs @@ -0,0 +1,5 @@ +pub mod slash_command; +pub mod text_command; + +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/reply/slash_command/mod.rs b/rustmail/src/commands/reply/slash_command/mod.rs new file mode 100644 index 00000000..b0525521 --- /dev/null +++ b/rustmail/src/commands/reply/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod reply; + +pub use reply::*; diff --git a/src/commands/reply/slash_command/reply.rs b/rustmail/src/commands/reply/slash_command/reply.rs similarity index 78% rename from src/commands/reply/slash_command/reply.rs rename to rustmail/src/commands/reply/slash_command/reply.rs index af9659c7..1c9bbee7 100644 --- a/src/commands/reply/slash_command/reply.rs +++ b/rustmail/src/commands/reply/slash_command/reply.rs @@ -1,18 +1,20 @@ -use crate::commands::{BoxFuture, RegistrableCommand}; -use crate::config::Config; -use crate::db::allocate_next_message_number; -use crate::errors::MessageError::MessageEmpty; -use crate::errors::{CommandError, ModmailError, ModmailResult, ThreadError, common}; -use crate::i18n::get_translated_message; -use crate::utils::command::defer_response::defer_response; -use crate::utils::message::message_builder::MessageBuilder; -use crate::utils::message::reply_intent::{ReplyIntent, extract_intent}; -use crate::utils::thread::fetch_thread::fetch_thread; +use crate::modules::update_thread_status_ui; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::types::*; +use crate::prelude::utils::*; +use chrono::Utc; +use serenity::FutureExt; use serenity::all::{ Attachment, CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption, GuildId, ResolvedOption, UserId, }; use std::collections::HashMap; +use std::sync::Arc; pub struct ReplyCommand; @@ -22,7 +24,12 @@ impl RegistrableCommand for ReplyCommand { "reply" } - fn register(&self, config: &Config) -> BoxFuture> { + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.reply", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { let config = config.clone(); Box::pin(async move { @@ -95,9 +102,10 @@ impl RegistrableCommand for ReplyCommand { &self, ctx: &Context, command: &CommandInteraction, - options: &[ResolvedOption<'_>], + _options: &[ResolvedOption<'_>], config: &Config, - ) -> BoxFuture> { + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { let ctx = ctx.clone(); let command = command.clone(); let config = config.clone(); @@ -106,7 +114,7 @@ impl RegistrableCommand for ReplyCommand { let db_pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; defer_response(&ctx, &command).await?; @@ -134,7 +142,7 @@ impl RegistrableCommand for ReplyCommand { let intent = extract_intent(content, &attachments).await; let Some(intent) = intent else { - return Err(ModmailError::Message(MessageEmpty)); + return Err(ModmailError::Message(MessageError::MessageEmpty)); }; let thread = fetch_thread(db_pool, &command.channel_id.to_string()).await?; @@ -148,7 +156,25 @@ impl RegistrableCommand for ReplyCommand { let next_message_number = allocate_next_message_number(&thread.id, db_pool) .await - .map_err(|_| common::validation_failed("Failed to allocate message number"))?; + .map_err(|_| validation_failed("Failed to allocate message number"))?; + + let mut ticket_status = match get_thread_status(&thread.id, db_pool).await { + Some(status) => status, + None => { + return Err(validation_failed("Failed to get thread status")); + } + }; + + ticket_status.last_message_by = TicketAuthor::Staff; + ticket_status.last_message_at = Utc::now().timestamp(); + update_thread_status_db(&thread.id, &ticket_status, db_pool).await?; + + tokio::spawn({ + let ctx = ctx.clone(); + async move { + let _ = update_thread_status_ui(&ctx, &ticket_status).await; + } + }); let mut sr = MessageBuilder::begin_staff_reply( &ctx, @@ -177,7 +203,7 @@ impl RegistrableCommand for ReplyCommand { let (_, dm_msg_opt) = match sr.send_command_and_record(&command, db_pool).await { Ok(tuple) => tuple, Err(_) => { - return Err(common::validation_failed("Failed to send to thread")); + return Err(validation_failed("Failed to send to thread")); } }; diff --git a/rustmail/src/commands/reply/text_command/mod.rs b/rustmail/src/commands/reply/text_command/mod.rs new file mode 100644 index 00000000..b0525521 --- /dev/null +++ b/rustmail/src/commands/reply/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod reply; + +pub use reply::*; diff --git a/src/commands/reply/text_command/reply.rs b/rustmail/src/commands/reply/text_command/reply.rs similarity index 62% rename from src/commands/reply/text_command/reply.rs rename to rustmail/src/commands/reply/text_command/reply.rs index ab377b79..20b94369 100644 --- a/src/commands/reply/text_command/reply.rs +++ b/rustmail/src/commands/reply/text_command/reply.rs @@ -1,25 +1,31 @@ -use crate::config::Config; -use crate::db::allocate_next_message_number; -use crate::errors::MessageError::MessageEmpty; -use crate::errors::{CommandError, ModmailError, ModmailResult, ThreadError, common}; -use crate::utils::command::extract_reply_content::extract_reply_content; -use crate::utils::message::message_builder::MessageBuilder; -use crate::utils::message::reply_intent::{ReplyIntent, extract_intent}; -use crate::utils::thread::fetch_thread::fetch_thread; +use crate::modules::update_thread_status_ui; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::types::*; +use crate::prelude::utils::*; +use chrono::Utc; use serenity::all::{Context, GuildId, Message, UserId}; use std::collections::HashMap; +use std::sync::Arc; -pub async fn reply(ctx: &Context, msg: &Message, config: &Config) -> ModmailResult<()> { +pub async fn reply( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { let db_pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; let content = extract_reply_content(&msg.content, &config.command.prefix, &["reply", "r"]); let intent = extract_intent(content, &msg.attachments).await; let Some(intent) = intent else { - return Err(ModmailError::Message(MessageEmpty)); + return Err(ModmailError::Message(MessageError::MessageEmpty)); }; let thread = fetch_thread(db_pool, &msg.channel_id.to_string()).await?; @@ -33,12 +39,30 @@ pub async fn reply(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu let next_message_number = allocate_next_message_number(&thread.id, db_pool) .await - .map_err(|_| common::validation_failed("Failed to allocate message number"))?; + .map_err(|_| validation_failed("Failed to allocate message number"))?; + + let mut ticket_status = match get_thread_status(&thread.id, db_pool).await { + Some(status) => status, + None => { + return Err(validation_failed("Failed to get thread status")); + } + }; + + ticket_status.last_message_by = TicketAuthor::Staff; + ticket_status.last_message_at = Utc::now().timestamp(); + update_thread_status_db(&thread.id, &ticket_status, db_pool).await?; + + tokio::spawn({ + let ctx = ctx.clone(); + async move { + let _ = update_thread_status_ui(&ctx, &ticket_status).await; + } + }); let _ = msg.delete(&ctx.http).await; let mut sr = MessageBuilder::begin_staff_reply( - ctx, + &ctx, config, thread.id.clone(), msg.author.id, @@ -63,7 +87,7 @@ pub async fn reply(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu let (_, dm_msg_opt) = match sr.send_msg_and_record(db_pool).await { Ok(tuple) => tuple, Err(_) => { - return Err(common::validation_failed("Failed to send to thread")); + return Err(validation_failed("Failed to send to thread")); } }; @@ -75,7 +99,7 @@ pub async fn reply(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu let mut params = HashMap::new(); params.insert("number".to_string(), next_message_number.to_string()); - let _ = MessageBuilder::system_message(ctx, config) + let _ = MessageBuilder::system_message(&ctx, config) .translated_content( "success.message_sent", Some(¶ms), @@ -84,7 +108,7 @@ pub async fn reply(ctx: &Context, msg: &Message, config: &Config) -> ModmailResu ) .await .to_channel(msg.channel_id) - .send() + .send(true) .await; } diff --git a/rustmail/src/commands/take/common.rs b/rustmail/src/commands/take/common.rs new file mode 100644 index 00000000..9ca93a99 --- /dev/null +++ b/rustmail/src/commands/take/common.rs @@ -0,0 +1,72 @@ +use crate::prelude::config::*; +use crate::prelude::errors::*; +use crate::prelude::utils::*; +use serenity::all::{ChannelId, CommandInteraction, Context, EditChannel, Message}; +use std::time::Duration; +use tokio::time::sleep; + +pub async fn rename_channel_with_timeout( + ctx: &Context, + config: &Config, + channel_id: ChannelId, + new_name: String, + msg: Option<&Message>, + command: Option<&CommandInteraction>, +) -> ModmailResult<()> { + let rename_future = channel_id.edit( + &ctx.http, + EditChannel::new().name(new_name.clone()), + ); + let timeout = sleep(Duration::from_secs(2)); + + tokio::select! { + res = rename_future => { + if let Err(e) = res { + return Err(ModmailError::Discord(DiscordError::ApiError(e.to_string()))); + } + return Ok(()); + } + _ = timeout => { + let message_response: Option = if let Some(message) = msg { + let response = MessageBuilder::system_message(ctx, config) + .translated_content("take.timeout", None, None, None).await + .to_channel(message.channel_id) + .send(true) + .await; + + match response { + Ok(msg) => Some(msg), + Err(_) => None, + } + } else { + None + }; + + let command_response: Option = if let Some(command) = command { + let message = MessageBuilder::system_message(ctx, config) + .translated_content("take.timeout", None, None, None).await + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + match command.create_followup(&ctx.http, message).await { + Ok(msg) => Some(msg), + Err(_) => None, + } + } else { + None + }; + + let _ = channel_id.edit(&ctx.http, EditChannel::new().name(new_name)).await; + + if let Some(m) = message_response { + let _ = m.delete(&ctx.http).await; + } + if let Some(m) = command_response { + let _ = m.delete(&ctx.http).await; + } + + return Ok(()); + } + } +} diff --git a/rustmail/src/commands/take/mod.rs b/rustmail/src/commands/take/mod.rs new file mode 100644 index 00000000..e08ac479 --- /dev/null +++ b/rustmail/src/commands/take/mod.rs @@ -0,0 +1,7 @@ +pub mod common; +pub mod slash_command; +pub mod text_command; + +pub use common::*; +pub use slash_command::*; +pub use text_command::*; diff --git a/rustmail/src/commands/take/slash_command/mod.rs b/rustmail/src/commands/take/slash_command/mod.rs new file mode 100644 index 00000000..f7094af2 --- /dev/null +++ b/rustmail/src/commands/take/slash_command/mod.rs @@ -0,0 +1,3 @@ +pub mod take; + +pub use take::*; diff --git a/rustmail/src/commands/take/slash_command/take.rs b/rustmail/src/commands/take/slash_command/take.rs new file mode 100644 index 00000000..e6095ee2 --- /dev/null +++ b/rustmail/src/commands/take/slash_command/take.rs @@ -0,0 +1,130 @@ +use crate::modules::update_thread_status_ui; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::FutureExt; +use serenity::all::{ChannelId, CommandInteraction, Context, CreateCommand, ResolvedOption}; +use std::sync::Arc; + +pub struct TakeCommand; + +#[async_trait::async_trait] +impl RegistrableCommand for TakeCommand { + fn name(&self) -> &'static str { + "take" + } + + fn doc<'a>(&self, config: &'a Config) -> BoxFuture<'a, String> { + async move { get_translated_message(config, "help.take", None, None, None, None).await } + .boxed() + } + + fn register(&self, config: &Config) -> BoxFuture<'_, Vec> { + let config = config.clone(); + + Box::pin(async move { + let cmd_desc = get_translated_message( + &config, + "slash_command.help_command_description", + None, + None, + None, + None, + ) + .await; + + vec![CreateCommand::new(self.name()).description(cmd_desc)] + }) + } + + fn run( + &self, + ctx: &Context, + command: &CommandInteraction, + _options: &[ResolvedOption<'_>], + config: &Config, + _handler: Arc, + ) -> BoxFuture<'_, ModmailResult<()>> { + let ctx = ctx.clone(); + let command = command.clone(); + let config = config.clone(); + + Box::pin(async move { + let db_pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + defer_response(&ctx, &command).await?; + + if is_a_ticket_channel(command.channel_id, &db_pool).await { + let thread = match get_thread_by_channel_id( + &command.channel_id.to_string(), + db_pool, + ) + .await + { + Some(thread) => thread, + None => return Err(thread_not_found()), + }; + + let parse_thread_id = thread.channel_id.parse::().unwrap_or(0); + + let thread_id = ChannelId::new(parse_thread_id); + + let thread_name = thread_id + .name(&ctx) + .await + .unwrap_or_else(|_| "Unknown".to_string()); + + if thread_name == format!("đŸ””-{}", command.user.name) { + return Err(ModmailError::Command(CommandError::TicketAlreadyTaken)); + } + + tokio::spawn({ + let config = config.clone(); + let db_pool = db_pool.clone(); + + async move { + let mut ticket_status = match get_thread_status(&thread.id, &db_pool).await + { + Some(status) => status, + None => { + return; + } + }; + ticket_status.taken_by = Some(command.user.id.to_string()); + let _ = update_thread_status_db(&thread.id, &ticket_status, &db_pool).await; + + tokio::spawn({ + let ctx = ctx.clone(); + async move { + let _ = update_thread_status_ui(&ctx, &ticket_status).await; + } + }); + + let mut params = std::collections::HashMap::new(); + params.insert("staff".to_string(), format!("<@{}>", command.user.id)); + + let response = MessageBuilder::system_message(&ctx, &config) + .translated_content("take.confirmation", Some(¶ms), None, None) + .await + .to_channel(command.channel_id) + .build_interaction_message_followup() + .await; + + let _ = command.create_followup(ctx.clone(), response).await; + } + }); + + Ok(()) + } else { + Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + } + }) + } +} diff --git a/rustmail/src/commands/take/text_command/mod.rs b/rustmail/src/commands/take/text_command/mod.rs new file mode 100644 index 00000000..f7094af2 --- /dev/null +++ b/rustmail/src/commands/take/text_command/mod.rs @@ -0,0 +1,3 @@ +pub mod take; + +pub use take::*; diff --git a/rustmail/src/commands/take/text_command/take.rs b/rustmail/src/commands/take/text_command/take.rs new file mode 100644 index 00000000..4d62d2b2 --- /dev/null +++ b/rustmail/src/commands/take/text_command/take.rs @@ -0,0 +1,78 @@ +use crate::modules::update_thread_status_ui; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::handlers::*; +use crate::prelude::utils::*; +use serenity::all::{ChannelId, Context, Message}; +use std::sync::Arc; + +pub async fn take( + ctx: Context, + msg: Message, + config: &Config, + _handler: Arc, +) -> ModmailResult<()> { + let db_pool = config + .db_pool + .as_ref() + .ok_or_else(database_connection_failed)?; + + if is_a_ticket_channel(msg.channel_id, &db_pool).await { + let thread = match get_thread_by_channel_id(&msg.channel_id.to_string(), db_pool).await { + Some(thread) => thread, + None => return Err(thread_not_found()), + }; + + let parse_thread_id = thread.channel_id.parse::().unwrap_or(0); + + let thread_id = ChannelId::new(parse_thread_id); + + let thread_name = thread_id + .name(&ctx) + .await + .unwrap_or_else(|_| "Unknown".to_string()); + + if thread_name == format!("đŸ””-{}", msg.author.name) { + return Err(ModmailError::Command(CommandError::TicketAlreadyTaken)); + } + + let config_clone = config.clone(); + + tokio::spawn({ + let db_pool = db_pool.clone(); + async move { + let mut ticket_status = match get_thread_status(&thread.id, &db_pool).await { + Some(status) => status, + None => { + return; + } + }; + ticket_status.taken_by = Some(msg.author.id.to_string()); + let _ = update_thread_status_db(&thread.id, &ticket_status, &db_pool).await; + + tokio::spawn({ + let ctx = ctx.clone(); + async move { + let _ = update_thread_status_ui(&ctx, &ticket_status).await; + } + }); + + let mut params = std::collections::HashMap::new(); + params.insert("staff".to_string(), format!("<@{}>", msg.author.id)); + + let _ = MessageBuilder::system_message(&ctx, &config_clone) + .translated_content("take.confirmation", Some(¶ms), None, None) + .await + .to_channel(msg.channel_id) + .send(true) + .await; + } + }); + + Ok(()) + } else { + Err(ModmailError::Thread(ThreadError::NotAThreadChannel)) + } +} diff --git a/rustmail/src/config.rs b/rustmail/src/config.rs new file mode 100644 index 00000000..d630a8be --- /dev/null +++ b/rustmail/src/config.rs @@ -0,0 +1,169 @@ +use crate::prelude::errors::*; +use crate::prelude::i18n::*; +use serenity::all::GuildId; +use serenity::http::Http; +use sqlx::SqlitePool; +use std::fs; +use std::net::UdpSocket; +use std::sync::Arc; + +pub use rustmail_types::*; +#[derive(Debug, Clone)] +pub struct Config { + pub bot: BotConfig, + pub command: CommandConfig, + pub thread: ThreadConfig, + pub language: LanguageConfig, + pub error_handling: ErrorHandlingConfig, + pub notifications: NotificationsConfig, + pub reminders: ReminderConfig, + pub logs: LogsConfig, + + pub db_pool: Option, + pub error_handler: Option>, + pub thread_locks: Arc>>>>, +} + +fn get_local_ip() -> Option { + let socket = UdpSocket::bind("0.0.0.0:0").ok()?; + socket.connect("1.1.1.1:80").ok()?; + Some(socket.local_addr().ok()?.ip().to_string()) +} + +pub fn load_config(path: &str) -> Option { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return None, + }; + + let config_response: ConfigResponse = match toml::from_str(&content) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to parse config.toml: {}", e); + return None; + } + }; + + let mut bot = config_response.bot; + if bot.ip.is_none() { + bot.ip = get_local_ip(); + } + + if u64::from_str_radix(&config_response.thread.user_message_color, 16).is_err() { + eprintln!("Incorrect user message color in the config.toml! Please put a color in hex format!"); + return None; + } + + if u64::from_str_radix(&config_response.thread.staff_message_color, 16).is_err() { + eprintln!("Incorrect staff message color in the config.toml! Please put a color in hex format!"); + return None; + } + + if u64::from_str_radix(&config_response.reminders.embed_color, 16).is_err() { + eprintln!("Incorrect reminder embed color in the config.toml! Please put a color in hex format!"); + return None; + } + + if !config_response.language.is_language_supported(config_response.language.get_default_language()) { + eprintln!( + "Warning: Default language '{}' is not in supported languages list", + config_response.language.default_language + ); + } + + if let Err(e) = bot.validate_logs_config() { + eprintln!("Invalid logs configuration: {}", e); + return None; + } + + if let Err(e) = bot.validate_features_config() { + eprintln!("Invalid features configuration: {}", e); + return None; + } + + let default_lang = config_response.language.get_default_language(); + let fallback_lang = config_response.language.get_fallback_language(); + let error_handler = Arc::new(ErrorHandler::with_languages(default_lang, fallback_lang)); + + Some(Config { + bot, + command: config_response.command, + thread: config_response.thread, + language: config_response.language, + error_handling: config_response.error_handling, + notifications: config_response.notifications, + reminders: config_response.reminders, + logs: config_response.logs, + db_pool: None, + error_handler: Some(error_handler), + thread_locks: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + }) +} + +pub trait LanguageConfigExt { + fn get_default_language(&self) -> Language; + fn get_fallback_language(&self) -> Language; + fn get_supported_languages(&self) -> Vec; + fn is_language_supported(&self, language: Language) -> bool; +} + +impl LanguageConfigExt for LanguageConfig { + fn get_default_language(&self) -> Language { + Language::from_str(&self.default_language).unwrap_or(Language::English) + } + + fn get_fallback_language(&self) -> Language { + Language::from_str(&self.fallback_language).unwrap_or(Language::English) + } + + fn get_supported_languages(&self) -> Vec { + self.supported_languages + .iter() + .filter_map(|s| Language::from_str(s)) + .collect() + } + + fn is_language_supported(&self, language: Language) -> bool { + self.get_supported_languages().contains(&language) + } +} + +impl Config { + pub async fn validate_servers(&self, http: &Http) -> Result<(), String> { + match &self.bot.mode { + ServerMode::Single { guild_id } => { + let guild_id = GuildId::new(*guild_id); + if guild_id.to_partial_guild(http).await.is_err() { + return Err(format!("Serveur principal introuvable: {}", guild_id)); + } + } + ServerMode::Dual { + community_guild_id, + staff_guild_id, + } => { + let community_guild_id = GuildId::new(*community_guild_id); + let staff_guild_id = GuildId::new(*staff_guild_id); + + if let Err(e) = community_guild_id.to_partial_guild(http).await { + eprintln!("Error fetching community guild: {}", e); + return Err(format!( + "Serveur communautaire introuvable: {}", + community_guild_id + )); + } + + if staff_guild_id.to_partial_guild(http).await.is_err() { + return Err(format!("Serveur staff introuvable: {}", staff_guild_id)); + } + + if community_guild_id == staff_guild_id { + return Err( + "Les serveurs communautaire et staff doivent ĂȘtre diffĂ©rents".to_string(), + ); + } + } + } + + Ok(()) + } +} diff --git a/src/db/mod.rs b/rustmail/src/db/mod.rs similarity index 77% rename from src/db/mod.rs rename to rustmail/src/db/mod.rs index af2d8add..7c14f81e 100644 --- a/src/db/mod.rs +++ b/rustmail/src/db/mod.rs @@ -2,3 +2,4 @@ pub mod operations; pub mod repr; pub use operations::*; +pub use repr::*; diff --git a/src/db/operations/features.rs b/rustmail/src/db/operations/features.rs similarity index 100% rename from src/db/operations/features.rs rename to rustmail/src/db/operations/features.rs diff --git a/src/db/operations/init.rs b/rustmail/src/db/operations/init.rs similarity index 92% rename from src/db/operations/init.rs rename to rustmail/src/db/operations/init.rs index 7dafed5b..38a26177 100644 --- a/src/db/operations/init.rs +++ b/rustmail/src/db/operations/init.rs @@ -3,9 +3,9 @@ use std::fs; use std::path::Path; pub async fn init_database() -> Result { - let db_path = "./db/db.sqlite"; + let db_path = "db/db.sqlite"; - fs::create_dir_all("./db")?; + fs::create_dir_all("db")?; if !Path::new(db_path).exists() { fs::File::create(db_path)?; @@ -18,7 +18,7 @@ pub async fn init_database() -> Result { .connect(&db_url) .await?; - sqlx::migrate!("./migrations").run(&pool).await?; + sqlx::migrate!("../migrations").run(&pool).await?; println!("Database connection pool established"); Ok(pool) diff --git a/rustmail/src/db/operations/logs.rs b/rustmail/src/db/operations/logs.rs new file mode 100644 index 00000000..6bba2955 --- /dev/null +++ b/rustmail/src/db/operations/logs.rs @@ -0,0 +1,35 @@ +use crate::prelude::errors::*; +use crate::prelude::types::*; +use sqlx::SqlitePool; + +pub async fn get_logs_from_user_id( + user_id: &str, + pool: &SqlitePool, +) -> ModmailResult> { + let logs = sqlx::query_as!( + TicketLog, + r#" + SELECT + (COUNT(*) OVER ()) + - ROW_NUMBER() OVER (ORDER BY created_at DESC) + 1 AS id, + id AS ticket_id, + user_id AS "user_id: String", + created_at AS "created_at: String" + FROM threads + WHERE user_id = ? AND status = 0 + ORDER BY created_at DESC + "#, + user_id + ) + .fetch_all(pool) + .await + .map_err(|e| { + eprintln!( + "Database error getting logs for user ID {}: {:?}", + user_id, e + ); + ModmailError::Database(DatabaseError::QueryFailed(e.to_string())) + })?; + + Ok(logs) +} diff --git a/src/db/operations/messages.rs b/rustmail/src/db/operations/messages.rs similarity index 83% rename from src/db/operations/messages.rs rename to rustmail/src/db/operations/messages.rs index 2896d788..c6fe5b48 100644 --- a/src/db/operations/messages.rs +++ b/rustmail/src/db/operations/messages.rs @@ -1,8 +1,8 @@ -use crate::config::Config; -use crate::db::operations::threads::get_user_name_from_thread_id; -use crate::errors::ModmailResult; -use crate::errors::common::message_not_found; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; use serenity::all::{Message, MessageId, UserId}; +use serenity::client::Context; use sqlx::{Error, SqlitePool}; #[derive(Debug, Clone)] @@ -12,6 +12,7 @@ pub struct MessageIds { } pub async fn insert_staff_message( + ctx: &Context, inbox_msg: &Message, dm_msg_id: Option, thread_id: &str, @@ -19,17 +20,18 @@ pub async fn insert_staff_message( is_anonymous: bool, pool: &SqlitePool, config: &Config, - message_number: u64, + message_number: Option, ) -> Result<(), Error> { let inbox_message_id = inbox_msg.id.to_string(); let user_id = staff_user_id.get() as i64; - let user_name = get_user_name_from_thread_id(thread_id, pool) + let user_name = staff_user_id + .to_user(&ctx.http) .await - .unwrap_or_else(|| "Unknown".to_string()); + .map(|user| user.name) + .unwrap_or_else(|_| "Unknown".to_string()); let content = extract_message_content(inbox_msg, config); - let message_number_i64 = message_number as i64; sqlx::query!( r#" @@ -45,7 +47,7 @@ pub async fn insert_staff_message( is_anonymous, dm_msg_id, inbox_message_id, - message_number_i64, + message_number, content, 1 ) @@ -55,49 +57,6 @@ pub async fn insert_staff_message( Ok(()) } -pub async fn insert_staff_message_from_command( - inbox_msg_id: String, - inbox_msg_content: &str, - dm_msg_id: Option, - thread_id: &str, - staff_user_id: UserId, - is_anonymous: bool, - pool: &SqlitePool, - config: &Config, - message_number: u64, -) -> Result<(), Error> { - let user_id = staff_user_id.get() as i64; - - let user_name = get_user_name_from_thread_id(thread_id, pool) - .await - .unwrap_or_else(|| "Unknown".to_string()); - - let message_number_i64 = message_number as i64; - - sqlx::query!( - r#" - INSERT INTO thread_messages ( - thread_id, user_id, user_name, is_anonymous, dm_message_id, inbox_message_id, message_number, content, thread_status - ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ? - ) - "#, - thread_id, - user_id, - user_name, - is_anonymous, - dm_msg_id, - inbox_msg_id, - message_number_i64, - inbox_msg_content, - 1 - ) - .execute(pool) - .await?; - - Ok(()) -} - pub async fn get_message_ids_by_number( message_number: i64, _user_id: UserId, @@ -234,36 +193,6 @@ pub async fn get_latest_thread_message( Ok(latest) } -pub async fn insert_recovered_message( - thread_id: &str, - user_id: i64, - user_name: &str, - dm_message_id: &str, - content: &str, - pool: &SqlitePool, -) -> Result<(), Error> { - sqlx::query!( - r#" - INSERT INTO thread_messages ( - thread_id, user_id, user_name, is_anonymous, dm_message_id, content, thread_status - ) VALUES ( - ?, ?, ?, ?, ?, ?, ? - ) - "#, - thread_id, - user_id, - user_name, - false, - dm_message_id, - content, - 1 - ) - .execute(pool) - .await?; - - Ok(()) -} - pub async fn get_message_ids_by_message_id( message_id: &str, pool: &SqlitePool, diff --git a/rustmail/src/db/operations/mod.rs b/rustmail/src/db/operations/mod.rs new file mode 100644 index 00000000..bd4d2d8c --- /dev/null +++ b/rustmail/src/db/operations/mod.rs @@ -0,0 +1,15 @@ +pub mod features; +pub mod init; +pub mod logs; +pub mod messages; +pub mod reminders; +pub mod scheduled; +pub mod threads; + +pub use features::*; +pub use init::*; +pub use logs::*; +pub use messages::*; +pub use reminders::*; +pub use scheduled::*; +pub use threads::*; diff --git a/src/db/operations/reminders.rs b/rustmail/src/db/operations/reminders.rs similarity index 81% rename from src/db/operations/reminders.rs rename to rustmail/src/db/operations/reminders.rs index 12d427d8..b6ee4722 100644 --- a/src/db/operations/reminders.rs +++ b/rustmail/src/db/operations/reminders.rs @@ -1,3 +1,5 @@ +use crate::prelude::errors::*; + #[derive(Debug, Clone)] pub struct Reminder { pub thread_id: String, @@ -10,14 +12,27 @@ pub struct Reminder { pub completed: bool, } -pub async fn insert_reminder( - reminder: &Reminder, - pool: &sqlx::SqlitePool, -) -> Result { +pub async fn insert_reminder(reminder: &Reminder, pool: &sqlx::SqlitePool) -> ModmailResult { let user_id = reminder.user_id; let channel_id = &reminder.channel_id; let guild_id = &reminder.guild_id; + let existing = sqlx::query_scalar!( + r#" + SELECT id FROM reminders + WHERE user_id = ? AND guild_id = ? AND trigger_time = ? AND completed = 0 + "#, + reminder.user_id, + reminder.guild_id, + reminder.trigger_time + ) + .fetch_optional(pool) + .await?; + + if let Some(_existging_id) = existing { + return Err(ModmailError::Command(CommandError::ReminderAlreadyExists)); + } + let result = sqlx::query!( r#" INSERT INTO reminders (thread_id, user_id, channel_id, guild_id, reminder_content, trigger_time, created_at, completed) diff --git a/src/db/operations/scheduled.rs b/rustmail/src/db/operations/scheduled.rs similarity index 55% rename from src/db/operations/scheduled.rs rename to rustmail/src/db/operations/scheduled.rs index e00b39ca..057202d9 100644 --- a/src/db/operations/scheduled.rs +++ b/rustmail/src/db/operations/scheduled.rs @@ -1,4 +1,4 @@ -use crate::errors::{ModmailResult, common}; +use crate::prelude::errors::*; use sqlx::{Row, SqlitePool}; #[derive(Debug, Clone)] @@ -6,24 +6,57 @@ pub struct ScheduledClosure { pub thread_id: String, pub close_at: i64, pub silent: bool, + pub closed_by: String, + pub category_id: String, + pub category_name: String, + pub required_permissions: String, } pub async fn upsert_scheduled_closure( thread_id: &str, close_at: i64, silent: bool, + closed_by: &str, + category_id: &str, + category_name: &str, + required_permissions: &str, pool: &SqlitePool, ) -> ModmailResult<()> { sqlx::query( - r#"INSERT INTO scheduled_closures(thread_id, close_at, silent) VALUES(?, ?, ?) - ON CONFLICT(thread_id) DO UPDATE SET close_at=excluded.close_at, silent=excluded.silent"#, + r#" + INSERT INTO scheduled_closures ( + thread_id, + close_at, + silent, + closed_by, + category_id, + category_name, + required_permissions + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(thread_id) + DO UPDATE SET + close_at = excluded.close_at, + silent = excluded.silent, + closed_by = excluded.closed_by, + category_id = excluded.category_id, + category_name = excluded.category_name, + required_permissions = excluded.required_permissions + "#, ) .bind(thread_id) .bind(close_at) .bind(silent) + .bind(closed_by) + .bind(category_id) + .bind(category_name) + .bind(required_permissions) .execute(pool) .await - .map_err(|_| common::validation_failed("Failed to upsert scheduled closure"))?; + .map_err(|e| { + eprintln!("Failed to upsert scheduled closure: {e:?}"); + common::validation_failed("Failed to upsert scheduled closure") + })?; + Ok(()) } @@ -52,6 +85,10 @@ pub async fn get_scheduled_closure( thread_id: row.get::(0), close_at: row.get::(1), silent: row.get::(2) != 0, + closed_by: row.get::(3), + category_id: row.get::(4), + category_name: row.get::(5), + required_permissions: row.get::(6), })) } @@ -67,6 +104,10 @@ pub async fn get_all_scheduled_closures(pool: &SqlitePool) -> ModmailResult(0), close_at: row.get::(1), silent: row.get::(2) != 0, + closed_by: row.get::(3), + category_id: row.get::(4), + category_name: row.get::(5), + required_permissions: row.get::(6), }) .collect()) } diff --git a/src/db/operations/threads.rs b/rustmail/src/db/operations/threads.rs similarity index 61% rename from src/db/operations/threads.rs rename to rustmail/src/db/operations/threads.rs index d7217ff8..78d5ed8d 100644 --- a/src/db/operations/threads.rs +++ b/rustmail/src/db/operations/threads.rs @@ -1,5 +1,7 @@ -use crate::db::repr::Thread; -use crate::errors::ModmailResult; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::types::*; +use chrono::Utc; use serenity::all::{ChannelId, GuildChannel, UserId}; use sqlx::{Error, SqlitePool}; use uuid::Uuid; @@ -115,17 +117,17 @@ pub async fn create_thread_for_user( let channel_id = channel.id.to_string(); let thread_id = Uuid::new_v4().to_string(); - match sqlx::query!( + let res = match sqlx::query!( "INSERT INTO threads (id, user_id, user_name, channel_id) VALUES (?, ?, ?, ?)", thread_id, user_id, user_name, channel_id ) - .execute(pool) + .execute(&pool.clone()) .await { - Ok(_) => Ok(thread_id), + Ok(_) => Ok(thread_id.clone()), Err(Error::Database(db_err)) if db_err.code() == Some(std::borrow::Cow::Borrowed("2067")) => { @@ -141,13 +143,77 @@ pub async fn create_thread_for_user( } } Err(e) => Err(e), - } + }; + + let channel_id = channel.id.get() as i64; + let user_id_str = user_id.to_string(); + let timestamp = Utc::now().timestamp(); + + let _ = match sqlx::query!( + "INSERT INTO thread_status (thread_id, channel_id, owner_id, taken_by, last_message_by, last_message_at) VALUES (?, ?, ?, ?, ?, ?)", + thread_id, + channel_id, + user_id_str, + None::, + "user", + timestamp + ) + .execute(&pool.clone()) + .await + { + Ok(_) => Ok(thread_id), + Err(Error::Database(db_err)) + if db_err.code() == Some(std::borrow::Cow::Borrowed("2067")) => + { + if let Some(existing_thread_id) = + sqlx::query_scalar("SELECT id FROM threads WHERE user_id = ? AND status = 1") + .bind(user_id) + .fetch_optional(pool) + .await? + { + Ok(existing_thread_id) + } else { + Err(Error::Database(db_err)) + } + } + Err(e) => Err(e), + }; + + res } -pub async fn close_thread(thread_id: &str, pool: &SqlitePool) -> ModmailResult<()> { - sqlx::query!("UPDATE threads SET status = 0 WHERE id = ?", thread_id) - .execute(pool) - .await?; +pub async fn close_thread( + thread_id: &str, + closed_by: &str, + category_id: &str, + category_name: &str, + required_permissions: u64, + pool: &SqlitePool, +) -> ModmailResult<()> { + let closed_at = Utc::now().timestamp(); + let required_permissions = required_permissions.clone().to_string(); + + sqlx::query!( + r#" + UPDATE threads + SET + status = 0, + closed_at = ?, + closed_by = ?, + category_id = ?, + category_name = ?, + required_permissions = ? + WHERE id = ? + "#, + closed_at, + closed_by, + category_id, + category_name, + required_permissions, + thread_id + ) + .execute(pool) + .await?; Ok(()) } @@ -219,8 +285,24 @@ pub async fn cancel_alert_for_staff( staff_user_id: serenity::all::UserId, thread_user_id: i64, pool: &SqlitePool, -) -> Result<(), Error> { +) -> ModmailResult<()> { let staff_user_id_i64 = staff_user_id.get() as i64; + + let existing = sqlx::query_scalar!( + r#" + SELECT id FROM staff_alerts + WHERE staff_user_id = ? AND thread_user_id = ? AND used = FALSE + "#, + staff_user_id_i64, + thread_user_id + ) + .fetch_optional(pool) + .await?; + + if existing.is_none() { + return Err(ModmailError::Command(CommandError::AlertDoesNotExist)); + } + sqlx::query!( "DELETE FROM staff_alerts WHERE staff_user_id = ? AND thread_user_id = ?", staff_user_id_i64, @@ -233,19 +315,29 @@ pub async fn cancel_alert_for_staff( } pub async fn set_alert_for_staff( - staff_user_id: serenity::all::UserId, + staff_user_id: UserId, thread_user_id: i64, pool: &SqlitePool, -) -> Result<(), Error> { +) -> ModmailResult<()> { let staff_user_id_i64 = staff_user_id.get() as i64; - sqlx::query!( - "DELETE FROM staff_alerts WHERE staff_user_id = ? AND thread_user_id = ? AND used = FALSE", + + let existing = sqlx::query_scalar!( + r#" + SELECT id FROM staff_alerts + WHERE staff_user_id = ? AND thread_user_id = ? AND used = FALSE + "#, staff_user_id_i64, thread_user_id ) - .execute(pool) + .fetch_optional(pool) .await?; + if existing.is_some() { + return Err(ModmailError::Database(DatabaseError::InsertFailed( + "".to_string(), + ))); + } + sqlx::query!( "INSERT INTO staff_alerts (staff_user_id, thread_user_id) VALUES (?, ?)", staff_user_id_i64, @@ -324,3 +416,103 @@ pub async fn is_orphaned_thread_channel( Ok(exists) } + +pub async fn get_all_thread_status(pool: &SqlitePool) -> Vec { + match sqlx::query!( + r#" + SELECT ts.channel_id, + ts.owner_id, + ts.taken_by, + ts.last_message_by, + ts.last_message_at + FROM thread_status ts + JOIN threads t ON ts.thread_id = t.id + WHERE t.status = 1 + "# + ) + .fetch_all(pool) + .await + { + Ok(rows) => rows + .into_iter() + .map(|r| TicketState { + channel_id: r.channel_id, + owner_id: r.owner_id, + taken_by: r.taken_by, + last_message_by: TicketAuthor::from_str(&r.last_message_by), + last_message_at: r.last_message_at, + }) + .collect(), + Err(e) => { + eprintln!("Database error getting thread statuses: {:?}", e); + vec![] + } + } +} + +pub async fn get_thread_status(thread_id: &str, pool: &SqlitePool) -> Option { + match sqlx::query!( + r#" + SELECT + channel_id, + owner_id, + taken_by, + last_message_by, + last_message_at + FROM thread_status + WHERE thread_id = ? + "#, + thread_id + ) + .fetch_optional(pool) + .await + { + Ok(Some(row)) => Some(TicketState { + channel_id: row.channel_id, + owner_id: row.owner_id, + taken_by: row.taken_by, + last_message_by: TicketAuthor::from_str(&row.last_message_by), + last_message_at: row.last_message_at, + }), + Ok(None) => None, + Err(e) => { + eprintln!( + "⚠ Database error getting thread status for id {}: {:?}", + thread_id, e + ); + None + } + } +} + +pub async fn update_thread_status_db( + thread_id: &str, + ticket: &TicketState, + pool: &SqlitePool, +) -> ModmailResult<()> { + let last_message_by = format!("{:?}", ticket.last_message_by).to_lowercase(); + + println!( + "Updating thread status for thread_id {}: taken_by={:?}, last_message_by={}, last_message_at={}", + thread_id, ticket.taken_by, last_message_by, ticket.last_message_at + ); + + sqlx::query!( + r#" + UPDATE thread_status + SET + taken_by = ?, + last_message_by = ?, + last_message_at = ? + WHERE thread_id = ? + "#, + ticket.taken_by, + last_message_by, + ticket.last_message_at, + thread_id + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/src/db/repr.rs b/rustmail/src/db/repr.rs similarity index 100% rename from src/db/repr.rs rename to rustmail/src/db/repr.rs diff --git a/src/errors/dictionary.rs b/rustmail/src/errors/dictionary.rs similarity index 94% rename from src/errors/dictionary.rs rename to rustmail/src/errors/dictionary.rs index cbe21ddf..54e42c5b 100644 --- a/src/errors/dictionary.rs +++ b/rustmail/src/errors/dictionary.rs @@ -1,16 +1,5 @@ -use crate::errors::types::*; -use crate::i18n::language::cn::load_chinese_messages; -use crate::i18n::language::dt::load_dutch_messages; -use crate::i18n::language::en::load_english_messages; -use crate::i18n::language::fr::load_french_messages; -use crate::i18n::language::gr::load_german_messages; -use crate::i18n::language::it::load_italian_messages; -use crate::i18n::language::jp::load_japanese_messages; -use crate::i18n::language::kr::load_korean_messages; -use crate::i18n::language::pr::load_portuguese_messages; -use crate::i18n::language::ru::load_russian_messages; -use crate::i18n::language::sp::load_spanish_messages; -use crate::i18n::languages::Language; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -241,6 +230,16 @@ impl DictionaryManager { CommandError::DiscordDeleteFailed => { ("command.discord_delete_failed".to_string(), None) } + CommandError::ReminderAlreadyExists => { + ("reminder.reminder_already_exists".to_string(), None) + } + CommandError::NotInThread() => ("command.not_in_thread".to_string(), None), + CommandError::AlertDoesNotExist => ("alert.alert_not_found".to_string(), None), + CommandError::InvalidReminderFormat => ("add_reminder.helper".to_string(), None), + CommandError::TicketAlreadyTaken => ("take.ticket_already_taken".to_string(), None), + CommandError::TicketAlreadyReleased => { + ("release.ticket_already_taken".to_string(), None) + } _ => ("command.invalid_format".to_string(), None), }, ModmailError::Thread(thread_err) => match thread_err { diff --git a/src/errors/handler.rs b/rustmail/src/errors/handler.rs similarity index 98% rename from src/errors/handler.rs rename to rustmail/src/errors/handler.rs index 4f3c71b7..c2d46943 100644 --- a/src/errors/handler.rs +++ b/rustmail/src/errors/handler.rs @@ -1,7 +1,5 @@ -use crate::errors::DiscordError; -use crate::errors::dictionary::DictionaryManager; -use crate::errors::types::{ModmailError, ModmailResult}; -use crate::i18n::languages::{Language, LanguageDetector, LanguagePreferences}; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; use serenity::all::{ ChannelId, Colour, CommandInteraction, Context, CreateEmbed, CreateInteractionResponseFollowup, CreateMessage, Message, UserId, diff --git a/src/errors/mod.rs b/rustmail/src/errors/mod.rs similarity index 88% rename from src/errors/mod.rs rename to rustmail/src/errors/mod.rs index d6171898..d2ccd587 100644 --- a/src/errors/mod.rs +++ b/rustmail/src/errors/mod.rs @@ -2,20 +2,16 @@ pub mod dictionary; pub mod handler; pub mod types; -pub use types::{ - CommandError, DatabaseError, DiscordError, MessageError, ModmailError, ModmailResult, - PermissionError, ThreadError, ValidationError, -}; - -pub use dictionary::{DictionaryMessage, ErrorDictionary}; - -pub use crate::{ - command_error, database_error, discord_error, message_error, permission_error, thread_error, - validation_error, -}; +pub use dictionary::*; +pub use handler::*; +pub use types::*; pub mod common { use super::*; + use crate::{ + command_error, database_error, discord_error, message_error, permission_error, + thread_error, validation_error, + }; pub fn not_found(entity: &str) -> ModmailError { database_error!(NotFound, entity) @@ -72,7 +68,7 @@ pub mod common { pub mod results { use super::*; - use crate::db::repr::Thread; + use crate::prelude::db::*; use serenity::all::{Channel, Message}; pub type DatabaseResult = Result; @@ -92,6 +88,7 @@ pub mod results { pub mod conversions { use super::*; + use crate::{database_error, discord_error}; pub fn from_serenity_with_context(err: serenity::Error, context: &str) -> ModmailError { match err { @@ -117,3 +114,7 @@ pub mod conversions { } } } + +pub use common::*; +pub use conversions::*; +pub use results::*; diff --git a/src/errors/types.rs b/rustmail/src/errors/types.rs similarity index 94% rename from src/errors/types.rs rename to rustmail/src/errors/types.rs index 802d321a..2ccca1d4 100644 --- a/src/errors/types.rs +++ b/rustmail/src/errors/types.rs @@ -76,6 +76,11 @@ pub enum CommandError { NoSchedulableClosureToCancel, SendDmFailed, DiscordDeleteFailed, + ReminderAlreadyExists, + AlertDoesNotExist, + InvalidReminderFormat, + TicketAlreadyTaken, + TicketAlreadyReleased, } #[derive(Debug, Clone)] @@ -127,7 +132,7 @@ pub enum ValidationError { OutOfRange(String), TooShort(String), TooLong(String), - InvalidFormat(String), + InvalidValidationFormat(String), RequiredFieldMissing(String), InvalidCharacters(String), } @@ -180,12 +185,12 @@ impl fmt::Display for DiscordError { DiscordError::ApiError(msg) => write!(f, "Discord API error: {}", msg), DiscordError::ChannelNotFound => write!(f, "Channel not found"), DiscordError::UserNotFound => write!(f, "User not found"), - DiscordError::UserIsABot => write!(f, "User is a bot"), + DiscordError::UserIsABot => write!(f, "User is a rustmail"), DiscordError::GuildNotFound => write!(f, "Guild not found"), DiscordError::MessageNotFound => write!(f, "Message not found"), DiscordError::PermissionDenied => write!(f, "Permission denied"), DiscordError::RateLimited => write!(f, "Rate limited"), - DiscordError::InvalidToken => write!(f, "Invalid bot token"), + DiscordError::InvalidToken => write!(f, "Invalid rustmail token"), DiscordError::WebhookError(msg) => write!(f, "Webhook error: {}", msg), DiscordError::EmbedTooLarge => write!(f, "Embed too large"), DiscordError::MessageTooLong => write!(f, "Message too long"), @@ -228,6 +233,11 @@ impl fmt::Display for CommandError { } CommandError::SendDmFailed => write!(f, "Failed to send DM to user"), CommandError::DiscordDeleteFailed => write!(f, "Failed to delete message on Discord"), + CommandError::ReminderAlreadyExists => write!(f, "A reminder already exists"), + CommandError::AlertDoesNotExist => write!(f, "Alert does not exist"), + CommandError::InvalidReminderFormat => write!(f, "Invalid reminder format"), + CommandError::TicketAlreadyTaken => write!(f, "Ticket already taken"), + CommandError::TicketAlreadyReleased => write!(f, "Ticket already released"), } } } @@ -296,7 +306,9 @@ impl fmt::Display for ValidationError { ValidationError::OutOfRange(range) => write!(f, "Out of range: {}", range), ValidationError::TooShort(field) => write!(f, "Too short: {}", field), ValidationError::TooLong(field) => write!(f, "Too long: {}", field), - ValidationError::InvalidFormat(format) => write!(f, "Invalid format: {}", format), + ValidationError::InvalidValidationFormat(format) => { + write!(f, "Invalid format: {}", format) + } ValidationError::RequiredFieldMissing(field) => { write!(f, "Required field missing: {}", field) } diff --git a/src/features/mod.rs b/rustmail/src/features/mod.rs similarity index 92% rename from src/features/mod.rs rename to rustmail/src/features/mod.rs index 774cc532..2d032201 100644 --- a/src/features/mod.rs +++ b/rustmail/src/features/mod.rs @@ -1,14 +1,14 @@ -use crate::config::Config; -use crate::db::operations::{get_feature_message, upsert_feature_message}; +use crate::prelude::config::*; +use crate::prelude::db::*; use async_trait::async_trait; use serenity::all::ButtonStyle; use serenity::all::{ChannelId, ComponentInteraction, Context, CreateMessage}; use serenity::builder::{CreateActionRow, CreateButton}; use std::sync::Arc; -mod poll; +pub mod poll; -pub use poll::PollFeature; +pub use poll::*; #[async_trait] pub trait Feature<'a>: Send + Sync { @@ -27,13 +27,14 @@ pub fn registry<'a>() -> Vec>> { vec![Arc::new(PollFeature)] } -pub fn make_buttons(pairs: &[(&str, &str, ButtonStyle)]) -> Vec { +pub fn make_buttons(pairs: &[(&str, &str, ButtonStyle, bool)]) -> Vec { let mut row = CreateActionRow::Buttons(vec![]); let mut buttons: Vec = Vec::new(); - for (label, custom_id, style) in pairs { + for (label, custom_id, style, disable) in pairs { let b = CreateButton::new(custom_id.to_string()) .label(label.to_string()) - .style(*style); + .style(*style) + .disabled(*disable); buttons.push(b); } diff --git a/src/features/poll.rs b/rustmail/src/features/poll.rs similarity index 91% rename from src/features/poll.rs rename to rustmail/src/features/poll.rs index 7b14872e..151dd20e 100644 --- a/src/features/poll.rs +++ b/rustmail/src/features/poll.rs @@ -1,6 +1,6 @@ -use super::{Feature, make_buttons}; -use crate::config::Config; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::features::*; +use crate::prelude::utils::*; use async_trait::async_trait; use serenity::all::{ ButtonStyle, ComponentInteraction, Context, CreateInteractionResponse, CreateMessage, @@ -23,8 +23,9 @@ impl<'a> Feature<'a> for PollFeature { "CrĂ©er un sondage", "feature:poll:create", ButtonStyle::Success, + false, ), - ("Test", "feature:poll:delete", ButtonStyle::Danger), + ("Test", "feature:poll:delete", ButtonStyle::Danger, false), ]); MessageBuilder::system_message(ctx, config) diff --git a/src/handlers/guild_handler.rs b/rustmail/src/handlers/guild_handler.rs similarity index 64% rename from src/handlers/guild_handler.rs rename to rustmail/src/handlers/guild_handler.rs index aeda4dc9..3f735f8a 100644 --- a/src/handlers/guild_handler.rs +++ b/rustmail/src/handlers/guild_handler.rs @@ -1,7 +1,6 @@ -use crate::config::Config; -use crate::db::close_thread; -use crate::db::operations::get_thread_by_channel_id; -use crate::db::threads::is_an_opened_ticket_channel; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::utils::*; use async_trait::async_trait; use serenity::all::{Context, GuildChannel, Message}; use serenity::client::EventHandler; @@ -22,7 +21,7 @@ impl GuildHandler { impl EventHandler for GuildHandler { async fn channel_delete( &self, - _ctx: Context, + ctx: Context, channel: GuildChannel, _messages: Option>, ) { @@ -42,7 +41,22 @@ impl EventHandler for GuildHandler { } }; - match close_thread(&thread.id, pool).await { + let closed_by = "deleted_by_client".to_string(); + let category_id = get_category_id_from_guild_channel(&ctx, &channel).await; + let category_name = get_category_name_from_guild_channel(&ctx, &channel).await; + let required_permissions = + get_required_permissions_channel_from_guild_channel(&ctx, &channel).await; + + match close_thread( + &thread.id, + &closed_by, + &category_id, + &category_name, + required_permissions, + pool, + ) + .await + { Ok(_) => { println!("Close thread successfully by deleted channel!"); } diff --git a/src/handlers/guild_interaction_handler.rs b/rustmail/src/handlers/guild_interaction_handler.rs similarity index 64% rename from src/handlers/guild_interaction_handler.rs rename to rustmail/src/handlers/guild_interaction_handler.rs index 8eae40c3..7f183e57 100644 --- a/src/handlers/guild_interaction_handler.rs +++ b/rustmail/src/handlers/guild_interaction_handler.rs @@ -1,23 +1,32 @@ -use crate::commands::CommandRegistry; -use crate::config::Config; -use crate::features::handle_feature_component_interaction; -use crate::modules::threads::{ - handle_thread_component_interaction, handle_thread_modal_interaction, -}; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::features::*; +use crate::prelude::modules::*; +use crate::prelude::types::*; use serenity::all::{Context, EventHandler, Interaction}; use std::sync::Arc; +use tokio::sync::watch::Receiver; #[derive(Clone)] pub struct InteractionHandler { - pub config: Config, + pub config: Arc, pub registry: Arc, + pub shutdown: Arc>, + pub pagination: PaginationStore, } impl InteractionHandler { - pub fn new(config: &Config, register: Arc) -> Self { + pub fn new( + config: &Config, + registry: Arc, + shutdown: Receiver, + pagination: PaginationStore, + ) -> Self { Self { - config: config.clone(), - registry: register, + config: Arc::new(config.clone()), + registry, + shutdown: Arc::new(shutdown), + pagination, } } } @@ -37,6 +46,16 @@ impl EventHandler for InteractionHandler { { return; } + if let Err(..) = handle_command_component_interaction( + &ctx, + &self.config, + &mut comp, + self.pagination.clone(), + ) + .await + { + return; + } } Interaction::Modal(mut modal) => { if let Err(..) = @@ -52,7 +71,9 @@ impl EventHandler for InteractionHandler { let config = self.config.clone(); if let Some(handler) = self.registry.get(command.data.name.as_str()) { - let result = handler.run(&ctx, &command, &options, &config).await; + let result = handler + .run(&ctx, &command, &options, &config, Arc::new(self.clone())) + .await; if let Err(e) = result { if let Some(error_handler) = &self.config.error_handler { diff --git a/src/handlers/guild_members_handler.rs b/rustmail/src/handlers/guild_members_handler.rs similarity index 57% rename from src/handlers/guild_members_handler.rs rename to rustmail/src/handlers/guild_members_handler.rs index 9a7e066a..e4538dd2 100644 --- a/src/handlers/guild_members_handler.rs +++ b/rustmail/src/handlers/guild_members_handler.rs @@ -1,11 +1,9 @@ -use crate::config::Config; -use crate::db::close_thread; -use crate::db::operations::update_thread_user_left; -use crate::db::threads::get_thread_by_user_id; -use crate::features::make_buttons; -use crate::i18n::get_translated_message; -use crate::utils::message::message_builder::MessageBuilder; -use serenity::all::{ButtonStyle, Member}; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::features::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use serenity::all::{ButtonStyle, Channel, Member, PermissionOverwriteType, RoleId}; use serenity::{ all::{Context, EventHandler, GuildId, User}, async_trait, @@ -74,6 +72,7 @@ impl EventHandler for GuildMembersHandler { .as_ref(), "ticket:keep", ButtonStyle::Success, + false, ), ( get_translated_message(&self.config, "thread.ask_to_close", None, None, None, None) @@ -81,6 +80,7 @@ impl EventHandler for GuildMembersHandler { .as_ref(), "ticket:delete", ButtonStyle::Danger, + false, ), ]); @@ -94,13 +94,61 @@ impl EventHandler for GuildMembersHandler { .await .to_channel(channel_id) .components(close_buttons) - .send() + .send(true) .await; if let Err(e) = update_thread_user_left(&thread.channel_id, pool).await { eprintln!("Erreur lors de la mise Ă  jour du statut du thread: {:?}", e); } - let _ = close_thread(&thread.id, pool).await; + let closed_by = "user_left_server".to_string(); + let category_id = match channel_id.to_channel(&ctx.http).await { + Ok(channel) => match channel.category() { + Some(category) => category.id.to_string(), + None => String::new(), + }, + _ => String::new(), + }; + let category_name = match channel_id.to_channel(&ctx.http).await { + Ok(channel) => match channel.category() { + Some(category) => category.name.clone(), + None => String::new(), + }, + _ => String::new(), + }; + + let required_permissions = match channel_id.to_channel(&ctx.http).await { + Ok(Channel::Guild(guild_channel)) => { + let guild_id = guild_channel.guild_id; + let guild = guild_id.to_partial_guild(&ctx.http).await.ok(); + + let everyone_role_id = RoleId::new(guild_id.get()); + + let mut perms = guild + .and_then(|g| g.roles.get(&everyone_role_id).map(|r| r.permissions.bits())) + .unwrap_or(0u64); + + for overwrite in &guild_channel.permission_overwrites { + if let PermissionOverwriteType::Role(_) = overwrite.kind { + let allow = overwrite.allow.bits(); + let deny = overwrite.deny.bits(); + perms = (perms & !deny) | allow; + } + } + + perms + } + _ => 0u64, + }; + + let _ = close_thread( + &thread.id, + &closed_by, + &category_id, + &category_name, + required_permissions, + pool, + ) + .await; } } diff --git a/src/handlers/guild_message_reactions_handler.rs b/rustmail/src/handlers/guild_message_reactions_handler.rs similarity index 92% rename from src/handlers/guild_message_reactions_handler.rs rename to rustmail/src/handlers/guild_message_reactions_handler.rs index 1711d419..969d229b 100644 --- a/src/handlers/guild_message_reactions_handler.rs +++ b/rustmail/src/handlers/guild_message_reactions_handler.rs @@ -1,10 +1,6 @@ -use crate::config::Config; -use crate::db::operations::{ - get_message_ids_by_message_id, get_thread_channel_by_user_id, get_user_id_from_channel_id, -}; -use crate::errors::MessageError::{DmAccessFailed, MessageEmpty, MessageNotFound}; -use crate::errors::types::ConfigError::ParseError; -use crate::errors::{ModmailError, ModmailResult}; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; use serenity::all::{ChannelId, Context, EventHandler, MessageId, Reaction, UserId}; use serenity::async_trait; @@ -81,19 +77,20 @@ async fn handle_all_reaction_remove( channel_id: ChannelId, config: &Config, ) -> ModmailResult<()> { - let pool = config - .db_pool - .clone() - .ok_or_else(|| ModmailError::Config(ParseError("Database pool not available".into())))?; + let pool = config.db_pool.clone().ok_or_else(|| { + ModmailError::Config(ConfigError::ParseError( + "Database pool not available".into(), + )) + })?; let dm_message_id_str = match get_message_ids_by_message_id(&removed_from_message_id.to_string(), &pool).await { Some(ids) => match ids.dm_message_id { Some(id) => id, - None => return Err(ModmailError::Message(MessageEmpty)), + None => return Err(ModmailError::Message(MessageError::MessageEmpty)), }, None => { - return Err(ModmailError::Message(MessageNotFound( + return Err(ModmailError::Message(MessageError::MessageNotFound( removed_from_message_id.to_string(), ))); } @@ -101,7 +98,7 @@ async fn handle_all_reaction_remove( let dm_message_id_parsed = dm_message_id_str .parse::() - .map_err(|e| ModmailError::Message(MessageNotFound(e.to_string())))?; + .map_err(|e| ModmailError::Message(MessageError::MessageNotFound(e.to_string())))?; let user_id = match get_user_id_from_channel_id(&channel_id.to_string(), &pool).await { Some(id) if id > 0 => id as u64, @@ -111,12 +108,12 @@ async fn handle_all_reaction_remove( let dm_channel = UserId::new(user_id) .create_dm_channel(&ctx.http) .await - .map_err(|e| ModmailError::Message(DmAccessFailed(e.to_string())))?; + .map_err(|e| ModmailError::Message(MessageError::DmAccessFailed(e.to_string())))?; let dm_message = dm_channel .message(&ctx.http, dm_message_id_parsed) .await - .map_err(|e| ModmailError::Message(DmAccessFailed(e.to_string())))?; + .map_err(|e| ModmailError::Message(MessageError::DmAccessFailed(e.to_string())))?; let bot_user_id = Some(ctx.cache.current_user().id); for reaction in &dm_message.reactions { diff --git a/src/handlers/guild_messages_handler.rs b/rustmail/src/handlers/guild_messages_handler.rs similarity index 73% rename from src/handlers/guild_messages_handler.rs rename to rustmail/src/handlers/guild_messages_handler.rs index 57b7a68a..67faabc2 100644 --- a/src/handlers/guild_messages_handler.rs +++ b/rustmail/src/handlers/guild_messages_handler.rs @@ -1,34 +1,12 @@ -use crate::commands::add_reminder::text_command::add_reminder::add_reminder; -use crate::commands::add_staff::text_command::add_staff::add_staff; -use crate::commands::alert::text_command::alert::alert; -use crate::commands::anonreply::text_command::anonreply::anonreply; -use crate::commands::close::text_command::close::close; -use crate::commands::delete::text_command::delete::delete; -use crate::commands::edit::message_ops::edit_inbox_message; -use crate::commands::edit::text_command::edit::edit; -use crate::commands::force_close::text_command::force_close::force_close; -use crate::commands::help::text_command::help::help; -use crate::commands::id::text_command::id::id; -use crate::commands::move_thread::text_command::move_thread::move_thread; -use crate::commands::new_thread::text_command::new_thread::new_thread; -use crate::commands::recover::text_command::recover::recover; -use crate::commands::remove_reminder::text_command::remove_reminder::remove_reminder; -use crate::commands::remove_staff::text_command::remove_staff::remove_staff; -use crate::commands::reply::text_command::reply::reply; -use crate::config::Config; -use crate::db::messages::get_thread_message_by_dm_message_id; -use crate::db::operations::messages::get_thread_message_by_message_id; -use crate::db::operations::{ - delete_message as db_delete_message, update_message_numbers_after_deletion, -}; -use crate::db::operations::{get_thread_channel_by_user_id, thread_exists, update_message_content}; -use crate::db::threads::get_thread_by_user_id; -use crate::errors::{ModmailResult, common}; -use crate::i18n::get_translated_message; -use crate::utils::message::message_builder::MessageBuilder; -use crate::utils::thread::get_thread_lock::get_thread_lock; -use crate::utils::thread::send_to_thread::send_to_thread; -use crate::{modules::threads::create_channel, utils::wrap_command}; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; +use crate::prelude::modules::*; +use crate::prelude::types::*; +use crate::prelude::utils::*; +use crate::wrap_command; use serenity::all::{GuildId, MessageId, UserId}; use serenity::{ all::{ChannelId, Context, EventHandler, Message, MessageUpdateEvent}, @@ -37,43 +15,70 @@ use serenity::{ use std::collections::HashSet; use std::sync::{LazyLock, Mutex}; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; +use tokio::sync::Mutex as AsyncMutex; +use tokio::sync::watch::Receiver; static SUPPRESSED_DELETES: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); type CommandFunc = Arc; -type StaticCommandFunc = dyn Fn(Context, Message, Config) -> Pin> + Send>> +type StaticCommandFunc = dyn for<'a> Fn( + Context, + Message, + &'a Config, + Arc, + ) -> Pin> + Send + 'a>> + Send + Sync + 'static; +#[derive(Clone)] pub struct GuildMessagesHandler { - pub config: Config, - pub commands: HashMap, + pub config: Arc, + pub commands: Arc>>, + pub registry: Arc, + pub shutdown: Arc>, + pub pagination: PaginationStore, } impl GuildMessagesHandler { - pub fn new(config: &Config) -> Self { - let mut h = Self { - config: config.clone(), - commands: HashMap::new(), + pub async fn new( + config: &Config, + registry: Arc, + shutdown: Receiver, + pagination: PaginationStore, + ) -> Self { + let h = Self { + config: Arc::new(config.clone()), + commands: Arc::new(AsyncMutex::new(HashMap::new())), + registry, + shutdown: Arc::new(shutdown), + pagination, }; - wrap_command!(h.commands, ["reply", "r"], reply); - wrap_command!(h.commands, ["edit", "e"], edit); - wrap_command!(h.commands, ["close", "c"], close); - wrap_command!(h.commands, "recover", recover); - wrap_command!(h.commands, "alert", alert); - wrap_command!(h.commands, ["move", "mv"], move_thread); - wrap_command!(h.commands, ["nt", "new_thread"], new_thread); - wrap_command!(h.commands, "delete", delete); - wrap_command!(h.commands, ["anonreply", "ar"], anonreply); - wrap_command!(h.commands, ["force_close", "fc"], force_close); - wrap_command!(h.commands, ["add_staff", "as"], add_staff); - wrap_command!(h.commands, ["remove_staff", "rs"], remove_staff); - wrap_command!(h.commands, "id", id); - wrap_command!(h.commands, "help", help); - wrap_command!(h.commands, ["add_reminder", "add_rap"], add_reminder); - wrap_command!(h.commands, ["remove_reminder", "rr"], remove_reminder); + + let mut lock = h.commands.lock().await; + + wrap_command!(lock, ["reply", "r"], reply); + wrap_command!(lock, ["edit", "e"], edit); + wrap_command!(lock, ["close", "c"], close); + wrap_command!(lock, "recover", recover); + wrap_command!(lock, "alert", alert); + wrap_command!(lock, ["move", "mv"], move_thread); + wrap_command!(lock, ["nt", "new_thread"], new_thread); + wrap_command!(lock, "delete", delete); + wrap_command!(lock, ["anonreply", "ar"], anonreply); + wrap_command!(lock, ["force_close", "fc"], force_close); + wrap_command!(lock, ["addmod", "am"], add_staff); + wrap_command!(lock, ["delmod", "dm"], remove_staff); + wrap_command!(lock, "id", id); + wrap_command!(lock, "help", help); + wrap_command!(lock, ["remind", "rem"], add_reminder); + wrap_command!(lock, ["unremind", "urem"], remove_reminder); + wrap_command!(lock, "logs", logs); + wrap_command!(lock, "take", take); + wrap_command!(lock, "release", release); + + drop(lock); h } } @@ -90,12 +95,12 @@ async fn manage_incoming_message( let pool = config .db_pool .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; let error_handler = config .error_handler .as_ref() - .ok_or_else(common::database_connection_failed)?; + .ok_or_else(database_connection_failed)?; if let Some(guild_id) = msg.guild_id { let community_guild_id = config.bot.get_community_guild_id(); @@ -116,7 +121,7 @@ async fn manage_incoming_message( ) .await; - let error = common::validation_failed(&error_msg); + let error = validation_failed(&error_msg); let _ = error_handler .reply_to_msg_with_error(ctx, msg, &error) .await; @@ -132,12 +137,12 @@ async fn manage_incoming_message( if let Some(channel_id_str) = get_thread_channel_by_user_id(msg.author.id, pool).await { let channel_id_num = channel_id_str .parse::() - .map_err(|_| common::validation_failed("Invalid channel ID format"))?; + .map_err(|_| validation_failed("Invalid channel ID format"))?; let channel_id = ChannelId::new(channel_id_num); if let Err(e) = send_to_thread(ctx, channel_id, msg, config, false).await { - let error = common::validation_failed(&format!("Failed to forward message: {}", e)); + let error = validation_failed(&format!("Failed to forward message: {}", e)); let _ = error_handler .reply_to_msg_with_error(ctx, msg, &error) .await; @@ -176,9 +181,16 @@ impl EventHandler for GuildMessagesHandler { command_name = &message_content[self.config.command.prefix.len()..i]; } - if let Some(command_func) = self.commands.get(command_name) - && let Err(error) = - command_func(ctx.clone(), msg.clone(), self.config.clone()).await + let commands_lock = self.commands.lock().await; + + if let Some(command_func) = commands_lock.get(command_name) + && let Err(error) = command_func( + ctx.clone(), + msg.clone(), + &self.config.clone(), + Arc::new(self.clone()), + ) + .await { if let Some(error_handler) = &self.config.error_handler { let _ = error_handler @@ -255,7 +267,7 @@ impl EventHandler for GuildMessagesHandler { .to_channel(ChannelId::new( thread.channel_id.parse::().unwrap_or(0), )) - .send() + .send(true) .await; } @@ -263,7 +275,7 @@ impl EventHandler for GuildMessagesHandler { let _ = update_message_numbers_after_deletion(&thread.channel_id, num, pool).await; } - let _ = db_delete_message(&deleted_message_id.to_string(), pool).await; + let _ = delete_message(&deleted_message_id.to_string(), pool).await; } async fn message_delete_bulk( @@ -383,7 +395,7 @@ impl EventHandler for GuildMessagesHandler { ) .await .to_channel(channel_id_parse) - .send() + .send(true) .await; } diff --git a/src/handlers/guild_moderation_handler.rs b/rustmail/src/handlers/guild_moderation_handler.rs similarity index 96% rename from src/handlers/guild_moderation_handler.rs rename to rustmail/src/handlers/guild_moderation_handler.rs index 2bcdfb38..9bc73cc1 100644 --- a/src/handlers/guild_moderation_handler.rs +++ b/rustmail/src/handlers/guild_moderation_handler.rs @@ -1,8 +1,8 @@ -use crate::config::Config; -use crate::db::get_thread_by_channel_id; -use crate::features::make_buttons; -use crate::i18n::get_translated_message; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::features::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; use serenity::all::audit_log::Action; use serenity::all::{ AutoModAction, ButtonStyle, ChannelAction, ChannelOverwriteAction, CreatorMonetizationAction, @@ -237,8 +237,18 @@ async fn manage_creating_ticket_via_opening_thread( let yes = get_translated_message(&config, "general.yes", None, None, None, None).await; let no = get_translated_message(&config, "general.no", None, None, None, None).await; let res_button = make_buttons(&[ - (yes.as_ref(), "ticket:wants_to_create", ButtonStyle::Success), - (no.as_ref(), "ticket:dont_create", ButtonStyle::Danger), + ( + yes.as_ref(), + "ticket:wants_to_create", + ButtonStyle::Success, + false, + ), + ( + no.as_ref(), + "ticket:dont_create", + ButtonStyle::Danger, + false, + ), ]); let _ = MessageBuilder::system_message(&ctx, &config) @@ -246,7 +256,7 @@ async fn manage_creating_ticket_via_opening_thread( .await .components(res_button) .to_channel(channel_id_inner) - .send() + .send(true) .await; } @@ -289,7 +299,7 @@ impl EventHandler for GuildModerationHandler { let _ = MessageBuilder::user_message(&ctx, &self.config, user.id, user.name) .to_channel(ChannelId::new(logs_channel_id)) .content(format_audit_log(&entry)) - .send() + .send(true) .await; } } diff --git a/rustmail/src/handlers/mod.rs b/rustmail/src/handlers/mod.rs new file mode 100644 index 00000000..dcddde79 --- /dev/null +++ b/rustmail/src/handlers/mod.rs @@ -0,0 +1,17 @@ +pub mod guild_handler; +pub mod guild_interaction_handler; +pub mod guild_members_handler; +pub mod guild_message_reactions_handler; +pub mod guild_messages_handler; +pub mod guild_moderation_handler; +pub mod ready_handler; +pub mod typing_proxy_handler; + +pub use guild_handler::*; +pub use guild_interaction_handler::*; +pub use guild_members_handler::*; +pub use guild_message_reactions_handler::*; +pub use guild_messages_handler::*; +pub use guild_moderation_handler::*; +pub use ready_handler::*; +pub use typing_proxy_handler::*; diff --git a/rustmail/src/handlers/ready_handler.rs b/rustmail/src/handlers/ready_handler.rs new file mode 100644 index 00000000..d7d2906a --- /dev/null +++ b/rustmail/src/handlers/ready_handler.rs @@ -0,0 +1,132 @@ +use crate::db::get_all_thread_status; +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::features::*; +use crate::prelude::modules::*; +use serenity::all::{ActivityData, CreateCommand, GuildId}; +use serenity::futures::future::join_all; +use serenity::{ + all::{Context, EventHandler, Ready}, + async_trait, +}; +use sqlx::SqlitePool; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::watch::Receiver; +use tokio::time::interval; + +#[derive(Clone)] +pub struct ReadyHandler { + pub config: Config, + pub registry: Arc, + pub shutdown: Arc>, +} + +impl ReadyHandler { + pub fn new(config: &Config, registry: Arc, shutdown: Receiver) -> Self { + Self { + config: config.clone(), + registry, + shutdown: Arc::new(shutdown), + } + } +} + +#[async_trait] +impl EventHandler for ReadyHandler { + async fn ready(&self, ctx: Context, ready: Ready) { + println!("{} is online !", ready.user.name); + let pool = match &self.config.db_pool { + Some(pool) => pool, + None => { + eprintln!("Database pool is not set in config."); + return; + } + }; + + let config = self.config.clone(); + + ctx.set_activity(Option::from(ActivityData::playing(&self.config.bot.status))); + + tokio::spawn({ + let ctx = ctx.clone(); + let config = config.clone(); + + async move { + let recovery_results = recover_missing_messages(&ctx, &config).await; + send_recovery_summary(&ctx, &config, &recovery_results).await; + sync_features(&ctx, &config).await; + hydrate_scheduled_closures(&ctx, &config).await; + } + }); + + load_reminders(&ctx, &self.config, &pool.clone(), self.shutdown.clone()).await; + + update_threads_status(&ctx, &pool.clone()); + + let guild_id = GuildId::new(self.config.bot.get_staff_guild_id()); + let guild_id2 = GuildId::new(self.config.bot.get_community_guild_id()); + + let mut guild_commands: Vec = Vec::new(); + let mut community_commands: Vec = Vec::new(); + + for command in self.registry.all() { + let mut cmds = command.register(&self.config).await; + guild_commands.append(&mut cmds); + + if let Some(commu) = command.as_community() { + let mut commu_cmds = commu.register_community(&self.config).await; + community_commands.append(&mut commu_cmds); + } + } + + if let Err(e) = guild_id + .set_commands(&ctx.http, guild_commands.clone()) + .await + { + eprintln!("set_commands() failed: {:?}", e); + } + + if let Err(e) = guild_id2.set_commands(&ctx.http, community_commands).await { + eprintln!("set_commands() failed: {:?}", e); + } + } +} + +fn update_threads_status(ctx: &Context, pool: &SqlitePool) { + tokio::spawn({ + let ctx = ctx.clone(); + let pool = pool.clone(); + + async move { + let mut interval = interval(Duration::from_secs(60 * 10)); + + interval.tick().await; + + loop { + let tickets_status = get_all_thread_status(&pool).await; + + let mut handles = Vec::new(); + for ticket in tickets_status.iter() { + let ctx = ctx.clone(); + let ticket = ticket.clone(); + let handle = tokio::spawn(async move { + if let Err(e) = update_thread_status_ui(&ctx, &ticket).await { + eprintln!( + "Failed to update thread status for channel {}: {:?}", + ticket.channel_id, e + ); + } + }); + handles.push(handle); + } + + join_all(handles).await; + + println!("Updated {} ticket statuses", tickets_status.len()); + + interval.tick().await; + } + } + }); +} diff --git a/src/handlers/typing_proxy_handler.rs b/rustmail/src/handlers/typing_proxy_handler.rs similarity index 95% rename from src/handlers/typing_proxy_handler.rs rename to rustmail/src/handlers/typing_proxy_handler.rs index 3070914f..8ae3ebcf 100644 --- a/src/handlers/typing_proxy_handler.rs +++ b/rustmail/src/handlers/typing_proxy_handler.rs @@ -1,5 +1,5 @@ -use crate::config::Config; -use crate::db::{get_thread_channel_by_user_id, get_user_id_from_channel_id}; +use crate::prelude::config::*; +use crate::prelude::db::*; use serenity::all::{ChannelId, Context, EventHandler, TypingStartEvent, UserId}; use serenity::async_trait; use sqlx::SqlitePool; diff --git a/src/i18n/language/cn.rs b/rustmail/src/i18n/language/cn.rs similarity index 89% rename from src/i18n/language/cn.rs rename to rustmail/src/i18n/language/cn.rs index a7a577d9..124dd4da 100644 --- a/src/i18n/language/cn.rs +++ b/rustmail/src/i18n/language/cn.rs @@ -1,4 +1,4 @@ -use crate::errors::{DictionaryMessage, ErrorDictionary}; +use crate::prelude::errors::*; pub fn load_chinese_messages(dict: &mut ErrorDictionary) { dict.messages.insert( diff --git a/src/i18n/language/dt.rs b/rustmail/src/i18n/language/dt.rs similarity index 89% rename from src/i18n/language/dt.rs rename to rustmail/src/i18n/language/dt.rs index fa84d3aa..ec1f7d5b 100644 --- a/src/i18n/language/dt.rs +++ b/rustmail/src/i18n/language/dt.rs @@ -1,4 +1,4 @@ -use crate::errors::{DictionaryMessage, ErrorDictionary}; +use crate::prelude::errors::*; pub fn load_dutch_messages(dict: &mut ErrorDictionary) { dict.messages.insert( diff --git a/src/i18n/language/en.rs b/rustmail/src/i18n/language/en.rs similarity index 77% rename from src/i18n/language/en.rs rename to rustmail/src/i18n/language/en.rs index a5f033b1..6f664dfd 100644 --- a/src/i18n/language/en.rs +++ b/rustmail/src/i18n/language/en.rs @@ -1,11 +1,13 @@ -use crate::errors::dictionary::DictionaryMessage; -use crate::errors::dictionary::ErrorDictionary; +use crate::commands::help; +use crate::errors::ModmailError::Discord; +use crate::prelude::errors::*; +use std::thread::current; pub fn load_english_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "database.connection_failed".to_string(), DictionaryMessage::new("Failed to connect to the database") - .with_description("The bot couldn't establish a connection to the database"), + .with_description("The rustmail couldn't establish a connection to the database"), ); dict.messages.insert( "database.query_failed".to_string(), @@ -20,7 +22,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "discord.channel_not_found".to_string(), DictionaryMessage::new("Channel not found").with_description( - "The specified channel doesn't exist or the bot doesn't have access to it", + "The specified channel doesn't exist or the rustmail doesn't have access to it", ), ); dict.messages.insert( @@ -31,7 +33,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "discord.permission_denied".to_string(), DictionaryMessage::new("Permission denied").with_description( - "The bot doesn't have the required permissions to perform this action", + "The rustmail doesn't have the required permissions to perform this action", ), ); dict.messages.insert( @@ -46,7 +48,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "discord.user_is_a_bot".to_string(), - DictionaryMessage::new("The specified user is a bot."), + DictionaryMessage::new("The specified user is a rustmail."), ); dict.messages.insert( "command.invalid_format".to_string(), @@ -211,7 +213,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "thread.modal_bot_user".to_string(), - DictionaryMessage::new("The specified user is a bot, please choose another one."), + DictionaryMessage::new("The specified user is a rustmail, please choose another one."), ); dict.messages.insert( "thread.thread_closing".to_string(), @@ -257,12 +259,12 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "reply.send_failed_thread".to_string(), DictionaryMessage::new("Failed to send the message to the channel.") - .with_description("The bot could not send the message to the thread channel."), + .with_description("The rustmail could not send the message to the thread channel."), ); dict.messages.insert( "reply.send_failed_dm".to_string(), DictionaryMessage::new("Failed to send the message to the user in DM.") - .with_description("The bot could not send the message to the user's DM."), + .with_description("The rustmail could not send the message to the user's DM."), ); dict.messages.insert( "edit.validation.invalid_format".to_string(), @@ -372,7 +374,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "config.invalid_configuration".to_string(), DictionaryMessage::new("Invalid configuration") - .with_description("The bot configuration is incorrect"), + .with_description("The rustmail configuration is incorrect"), ); dict.messages.insert( "general.unknown_error".to_string(), @@ -382,7 +384,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "recovery.messages_recovered".to_string(), - DictionaryMessage::new("đŸ“„ **{count} message(s) recovered** during bot downtime") + DictionaryMessage::new("đŸ“„ **{count} message(s) recovered** during rustmail downtime") .with_description("Notification of recovered missing messages"), ); dict.messages.insert( @@ -405,10 +407,17 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { DictionaryMessage::new("❌ This command can only be used in a support thread") .with_description("The alert command must be used in a thread channel"), ); + dict.messages.insert( + "alert.alert_not_found".to_string(), + DictionaryMessage::new("No alert set for this thread"), + ); + dict.messages.insert( + "command.not_in_thread".to_string(), + DictionaryMessage::new("This command can only be used in a support thread"), + ); dict.messages.insert( "alert.set_failed".to_string(), - DictionaryMessage::new("❌ Failed to set alert") - .with_description("An error occurred while setting the alert"), + DictionaryMessage::new("You already have an alert for this thread!"), ); dict.messages.insert( "alert.confirmation".to_string(), @@ -448,8 +457,9 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "move_thread.failed_to_fetch_categories".to_string(), - DictionaryMessage::new("❌ Failed to fetch server categories") - .with_description("The bot couldn't retrieve the list of categories from the server"), + DictionaryMessage::new("❌ Failed to fetch server categories").with_description( + "The rustmail couldn't retrieve the list of categories from the server", + ), ); dict.messages.insert( "move_thread.category_not_found".to_string(), @@ -497,7 +507,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "new_thread.user_is_a_bot".to_string(), - DictionaryMessage::new("❌ You cannot create a thread for a bot user."), + DictionaryMessage::new("❌ You cannot create a thread for a rustmail user."), ); dict.messages.insert( "new_thread.channel_creation_failed".to_string(), @@ -720,7 +730,7 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "slash_command.recover_command_description".to_string(), DictionaryMessage::new( - "Retrieve messages missed during the bot's downtime (This process is automatic).", + "Retrieve messages missed during the rustmail's downtime (This process is automatic).", ), ); dict.messages.insert( @@ -767,4 +777,155 @@ pub fn load_english_messages(dict: &mut ErrorDictionary) { "slash_command.remove_reminder_id_argument".to_string(), DictionaryMessage::new("The ID of the reminder to remove"), ); + dict.messages.insert( + "logs_command.next".to_string(), + DictionaryMessage::new("Next"), + ); + dict.messages.insert( + "logs_command.prev".to_string(), + DictionaryMessage::new("Previous"), + ); + dict.messages.insert( + "slash_commands.logs_command_description".to_string(), + DictionaryMessage::new("View the logs of a specific user"), + ); + dict.messages.insert( + "slash_commands.logs_id_argument_description".to_string(), + DictionaryMessage::new("The ID of the user to view logs for"), + ); + dict.messages.insert( + "slash_commands.no_logs_found".to_string(), + DictionaryMessage::new("No logs found for this user."), + ); + dict.messages.insert( + "new_thread.show_logs".to_string(), + DictionaryMessage::new( + "This user has {count} previous rustmail ticket(s). Use `{prefix}logs` to view them.", + ), + ); + dict.messages.insert( + "reminder.reminder_already_exists".to_string(), + DictionaryMessage::new("You already have a reminder scheduled for that time."), + ); + dict.messages.insert( + "help.add_reminder".to_string(), + DictionaryMessage::new("Sets a reminder for a specific time. To do so, use `!remind ` or `!rem `. If the specified time has already passed today, the reminder will be scheduled for tomorrow."), + ); + dict.messages.insert( + "help.add_staff".to_string(), + DictionaryMessage::new("Adds a staff member to a ticket. To do so, use `!addmod ` or `!am ` inside a ticket."), + ); + dict.messages.insert( + "help.alert".to_string(), + DictionaryMessage::new("Sets an alert for a user when they send a new message. To create an alert, use `!alert` inside a ticket. To cancel a scheduled alert, use `!alert cancel` or `!alert c`."), + ); + dict.messages.insert( + "help.close".to_string(), + DictionaryMessage::new("Closes the current ticket. You can specify a delay before closing using `!close ` or `!c `. You can also add the `--silent` or `-s` option to avoid notifying the user that their ticket has been closed. To cancel a scheduled closure, use `!close --cancel`, `!close -c`, or `!close cancel`."), + ); + dict.messages.insert( + "help.delete".to_string(), + DictionaryMessage::new("Deletes a specific message within a thread. To do so, use `!delete ` inside a ticket."), + ); + dict.messages.insert( + "help.edit".to_string(), + DictionaryMessage::new("Edits the content of a previously sent message in a ticket. To edit a message, use `!edit ` or `!e ` inside a ticket."), + ); + dict.messages.insert( + "help.force_close".to_string(), + DictionaryMessage::new("Force-closes a ticket when an error prevents normal closure. This command will be removed in future versions. To force-close a ticket, use `!force_close` or `!fc` inside a ticket."), + ); + dict.messages.insert( + "help.help".to_string(), + DictionaryMessage::new("Displays a list of all available commands with a short description. To view the help message, use `!help`. If you want help with a specific command, type `!help `."), + ); + dict.messages.insert( + "help.id".to_string(), + DictionaryMessage::new("Displays the Discord ID of the user associated with the ticket. To view the user's ID, use `!id` inside a ticket."), + ); + dict.messages.insert( + "help.logs".to_string(), + DictionaryMessage::new("Retrieves logs from all previous tickets of a user. You can either specify a Discord ID (`!logs `) or run the command inside a ticket to get that ticket’s logs."), + ); + dict.messages.insert( + "help.move".to_string(), + DictionaryMessage::new("Moves the current ticket to another category. To move a ticket, use `!move ` or `!mv ` inside the ticket."), + ); + dict.messages.insert( + "help.new_thread".to_string(), + DictionaryMessage::new("Creates a new ticket for a specified user. To create a ticket, use `!new_thread ` or `!nt `."), + ); + dict.messages.insert( + "help.recover".to_string(), + DictionaryMessage::new("Starts the process of recovering missing messages in Modmail tickets. This process runs automatically, but you can trigger it manually if needed. To do so, use `!recover`."), + ); + dict.messages.insert( + "help.remove_reminder".to_string(), + DictionaryMessage::new("Deletes a reminder you previously set. To remove a reminder, use `!unremind ` or `!urem `."), + ); + dict.messages.insert( + "help.remove_staff".to_string(), + DictionaryMessage::new("Removes a staff member from the current ticket. To remove a staff member, use `!delmod ` or `!dm ` inside the ticket."), + ); + dict.messages.insert( + "help.reply".to_string(), + DictionaryMessage::new("Replies in a ticket. To reply, use `!reply [attachment]` or `!r [attachment]` inside the ticket. If you want to reply anonymously, use `!anonreply`, `!ar`, or specify the option in the slash command `/reply`."), + ); + dict.messages.insert( + "help.message".to_string(), + DictionaryMessage::new("## Commands:\n\n**All commands** are also available as **__slash commands__** with the **__same name__**.\n\nIf you want help with a specific command, type `!help `.\n\n"), + ); + dict.messages.insert( + "help.take".to_string(), + DictionaryMessage::new("Allows you to take ownership of a ticket by replacing its name with yours. To take a ticket, use `!take` in the ticket."), + ); + dict.messages.insert( + "help.release".to_string(), + DictionaryMessage::new("Releases ownership of a ticket previously taken with the `!take` command. To release a ticket, use `!release` in the ticket."), + ); + dict.messages.insert( + "add_reminder.helper".to_string(), + DictionaryMessage::new( + "Incorrect format. Use : `{prefix}remind or {prefix}rem [content]`", + ), + ); + dict.messages.insert( + "take.ticket_already_taken".to_string(), + DictionaryMessage::new("You have already taken this ticket."), + ); + dict.messages.insert( + "take.confirmation".to_string(), + DictionaryMessage::new("The ticket is now taken by {staff}.\nDue to **Discord's API**, the channel name change may take up to **10 minutes**."), + ); + dict.messages.insert( + "take.timeout".to_string(), + DictionaryMessage::new( + "⚠ **Discord’s API** enforces a limit of **2** channel updates every **10 minutes**. + The action will be **__automatically__** applied once the cooldown expires.", + ), + ); + dict.messages.insert( + "slash_command.take_command_description".to_string(), + DictionaryMessage::new("Take ownership of the current ticket."), + ); + dict.messages.insert( + "slash_command.take_command_description".to_string(), + DictionaryMessage::new("Take ownership of the current ticket."), + ); + dict.messages.insert( + "slash_command.release_command_description".to_string(), + DictionaryMessage::new("Release ownership of the current ticket."), + ); + dict.messages.insert( + "release.ticket_already_taken".to_string(), + DictionaryMessage::new("The ticket is not taken by anyone."), + ); + dict.messages.insert( + "release.confirmation".to_string(), + DictionaryMessage::new("The ticket has been released by {staff}.\nDue to **Discord's API**, the channel name change may take up to **10 minutes**."), + ); + dict.messages.insert( + "slash_command.help_command_argument_desc".to_string(), + DictionaryMessage::new("The command to get help with"), + ); } diff --git a/src/i18n/language/fr.rs b/rustmail/src/i18n/language/fr.rs similarity index 77% rename from src/i18n/language/fr.rs rename to rustmail/src/i18n/language/fr.rs index add81569..4a817701 100644 --- a/src/i18n/language/fr.rs +++ b/rustmail/src/i18n/language/fr.rs @@ -1,10 +1,9 @@ -use crate::errors::dictionary::DictionaryMessage; -use crate::errors::dictionary::ErrorDictionary; +use crate::prelude::errors::*; pub fn load_french_messages(dict: &mut ErrorDictionary) { dict.messages.insert("database.connection_failed".to_string(), DictionaryMessage::new("Échec de connexion Ă  la base de donnĂ©es") - .with_description("Le bot n'a pas pu Ă©tablir une connexion Ă  la base de donnĂ©es") + .with_description("Le rustmail n'a pas pu Ă©tablir une connexion Ă  la base de donnĂ©es") .with_help("VĂ©rifiez la configuration de la base de donnĂ©es et assurez-vous que le serveur est en marche")); dict.messages.insert( "database.query_failed".to_string(), @@ -19,7 +18,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "discord.channel_not_found".to_string(), DictionaryMessage::new("Canal non trouvĂ©") - .with_description("Le canal spĂ©cifiĂ© n'existe pas ou le bot n'y a pas accĂšs"), + .with_description("Le canal spĂ©cifiĂ© n'existe pas ou le rustmail n'y a pas accĂšs"), ); dict.messages.insert( "discord.user_not_found".to_string(), @@ -29,7 +28,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "discord.permission_denied".to_string(), DictionaryMessage::new("Permission refusĂ©e").with_description( - "Le bot n'a pas les permissions requises pour effectuer cette action", + "Le rustmail n'a pas les permissions requises pour effectuer cette action", ), ); dict.messages.insert( @@ -44,7 +43,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "discord.user_is_a_bot".to_string(), - DictionaryMessage::new("L'utilisateur spĂ©cifiĂ© est un bot"), + DictionaryMessage::new("L'utilisateur spĂ©cifiĂ© est un rustmail"), ); dict.messages.insert( "command.invalid_format".to_string(), @@ -206,7 +205,9 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "thread.modal_bot_user".to_string(), - DictionaryMessage::new("L'utilisateur spĂ©cifiĂ© est un bot, veuillez en choisir un autre."), + DictionaryMessage::new( + "L'utilisateur spĂ©cifiĂ© est un rustmail, veuillez en choisir un autre.", + ), ); dict.messages.insert( "thread.created".to_string(), @@ -238,13 +239,13 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "reply.send_failed_thread".to_string(), DictionaryMessage::new("Échec de l'envoi du message dans le salon.") - .with_description("Le bot n'a pas pu envoyer le message dans le salon du thread."), + .with_description("Le rustmail n'a pas pu envoyer le message dans le salon du thread."), ); dict.messages.insert( "reply.send_failed_dm".to_string(), DictionaryMessage::new("Échec de l'envoi du message en DM Ă  l'utilisateur.") .with_description( - "Le bot n'a pas pu envoyer le message en message privĂ© Ă  l'utilisateur.", + "Le rustmail n'a pas pu envoyer le message en message privĂ© Ă  l'utilisateur.", ), ); dict.messages.insert( @@ -325,7 +326,9 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "edit.edit_failed_thread".to_string(), DictionaryMessage::new("❌ Échec de la modification du message dans le thread.") - .with_description("Le bot n'a pas pu modifier le message dans le salon du thread."), + .with_description( + "Le rustmail n'a pas pu modifier le message dans le salon du thread.", + ), ); dict.messages.insert( "edit.invalid_id_dm".to_string(), @@ -335,12 +338,12 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "edit.dm_access_failed".to_string(), DictionaryMessage::new("❌ Impossible d'accĂ©der aux DMs de l'utilisateur.") - .with_description("Le bot n'a pas pu envoyer de message privĂ© Ă  l'utilisateur. Il a peut-ĂȘtre bloquĂ© le bot ou dĂ©sactivĂ© ses DMs."), + .with_description("Le rustmail n'a pas pu envoyer de message privĂ© Ă  l'utilisateur. Il a peut-ĂȘtre bloquĂ© le rustmail ou dĂ©sactivĂ© ses DMs."), ); dict.messages.insert( "edit.edit_failed_dm".to_string(), DictionaryMessage::new("❌ Échec de la modification du message en DM.") - .with_description("Le bot n'a pas pu modifier le message en message privĂ©."), + .with_description("Le rustmail n'a pas pu modifier le message en message privĂ©."), ); dict.messages.insert( "permission.insufficient_permissions".to_string(), @@ -387,7 +390,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "config.invalid_configuration".to_string(), DictionaryMessage::new("Configuration invalide") - .with_description("La configuration du bot est incorrecte"), + .with_description("La configuration du rustmail est incorrecte"), ); dict.messages.insert( "general.unknown_error".to_string(), @@ -401,7 +404,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "recovery.messages_recovered".to_string(), DictionaryMessage::new( - "đŸ“„ **{count} message(s) rĂ©cupĂ©rĂ©(s)** pendant la pĂ©riode d'indisponibilitĂ© du bot", + "đŸ“„ **{count} message(s) rĂ©cupĂ©rĂ©(s)** pendant la pĂ©riode d'indisponibilitĂ© du rustmail", ) .with_description("Notification de rĂ©cupĂ©ration de messages manquĂ©s"), ); @@ -427,10 +430,19 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ) .with_description("La commande alert doit ĂȘtre utilisĂ©e dans un canal de thread"), ); + dict.messages.insert( + "alert.alert_not_found".to_string(), + DictionaryMessage::new("Aucune alerte dĂ©finie pour ce ticket"), + ); + dict.messages.insert( + "command.not_in_thread".to_string(), + DictionaryMessage::new( + "Cette commande ne peut ĂȘtre utilisĂ©e que dans un thread de support", + ), + ); dict.messages.insert( "alert.set_failed".to_string(), - DictionaryMessage::new("❌ Échec de la dĂ©finition de l'alerte") - .with_description("Une erreur s'est produite lors de la dĂ©finition de l'alerte"), + DictionaryMessage::new("❌ Vous avez dĂ©jĂ  dĂ©finie une alerte pour ce ticket !"), ); dict.messages.insert( "alert.confirmation".to_string(), @@ -470,7 +482,9 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { dict.messages.insert( "move_thread.failed_to_fetch_categories".to_string(), DictionaryMessage::new("❌ Échec de rĂ©cupĂ©ration des catĂ©gories du serveur") - .with_description("Le bot n'a pas pu rĂ©cupĂ©rer la liste des catĂ©gories du serveur"), + .with_description( + "Le rustmail n'a pas pu rĂ©cupĂ©rer la liste des catĂ©gories du serveur", + ), ); dict.messages.insert( "move_thread.category_not_found".to_string(), @@ -514,7 +528,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "new_thread.user_is_a_bot".to_string(), - DictionaryMessage::new("❌ Vous ne pouvez pas crĂ©er un ticket pour un bot."), + DictionaryMessage::new("❌ Vous ne pouvez pas crĂ©er un ticket pour un rustmail."), ); dict.messages.insert( "new_thread.channel_creation_failed".to_string(), @@ -738,7 +752,7 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { ); dict.messages.insert( "slash_command.recover_command_description".to_string(), - DictionaryMessage::new("RĂ©cupĂ©rer les messages manquĂ©s pendant la pĂ©riode d'indisponibilitĂ© du bot (automatique)"), + DictionaryMessage::new("RĂ©cupĂ©rer les messages manquĂ©s pendant la pĂ©riode d'indisponibilitĂ© du rustmail (automatique)"), ); dict.messages.insert( "slash_command.help_command_description".to_string(), @@ -786,4 +800,144 @@ pub fn load_french_messages(dict: &mut ErrorDictionary) { "slash_command.remove_reminder_id_argument".to_string(), DictionaryMessage::new("L'ID du rappel Ă  supprimer."), ); + dict.messages.insert( + "logs_command.next".to_string(), + DictionaryMessage::new("Suivant"), + ); + dict.messages.insert( + "logs_command.prev".to_string(), + DictionaryMessage::new("PrĂ©cĂ©dent"), + ); + dict.messages.insert( + "slash_commands.logs_command_description".to_string(), + DictionaryMessage::new("Afficher les logs d'un utilisateur"), + ); + dict.messages.insert( + "slash_commands.logs_id_argument_description".to_string(), + DictionaryMessage::new("L'ID de l'utilisateur dont vous souhaitez voir les logs"), + ); + dict.messages.insert( + "slash_commands.no_logs_found".to_string(), + DictionaryMessage::new("Aucun log trouvĂ© pour cet utilisateur."), + ); + dict.messages.insert( + "new_thread.show_logs".to_string(), + DictionaryMessage::new("Cet utilisateur a **{logs_count}** ancien(s) ticket(s) rustmail. Utilisez `{prefix}logs` pour les voir."), + ); + dict.messages.insert( + "reminder.reminder_already_exists".to_string(), + DictionaryMessage::new("Vous avez dĂ©jĂ  un rappel programmĂ© Ă  cette heure."), + ); + dict.messages.insert( + "help.add_reminder".to_string(), + DictionaryMessage::new("Configure un rappel Ă  une heure spĂ©cifique. Pour ce faire, faites `!remind ` ou `!rem `. Si l'heure est dĂ©jĂ  passĂ©e aujourd'hui, le rappel sera programmĂ© pour demain."), + ); + dict.messages.insert( + "help.add_staff".to_string(), + DictionaryMessage::new("Ajoute un membre du staff Ă  un ticket. Pour ce faire, faites `!addmod ` ou `!am ` dans un ticket."), + ); + dict.messages.insert( + "help.alert".to_string(), + DictionaryMessage::new("Configure une alerte pour un utilisateur lorsqu'il envoie un nouveau message. Pour programmer une alerte, faites `!alert` dans un ticket. Pour annuler une alerte programmĂ©e, faites `!alert cancel` ou `!alert c`."), + ); + dict.messages.insert( + "help.close".to_string(), + DictionaryMessage::new("Ferme le ticket actuel. Vous pouvez spĂ©cifier un dĂ©lai avant la fermeture en faisant : `!close ` ou `!c `. Vous pouvez ajouter l'option `--silent` ou `-s` pour ne pas avertir l'utilisateur que son ticket a Ă©tĂ© fermĂ©. Vous pouvez Ă©galement annuler une fermeture programmĂ©e en faisant `!close --cancel`, `!close -c` ou `!close cancel`."), + ); + dict.messages.insert( + "help.delete".to_string(), + DictionaryMessage::new("Supprime un message spĂ©cifique dans un fil de discussion. Pour ce faire, faites `!delete ` dans un ticket."), + ); + dict.messages.insert( + "help.edit".to_string(), + DictionaryMessage::new("Modifie le contenu d'un message prĂ©cĂ©demment envoyĂ© dans un ticket. Pour modifier un message, faites `!edit ` ou `!e ` dans un ticket."), + ); + dict.messages.insert( + "help.force_close".to_string(), + DictionaryMessage::new("Ferme un ticket lorsqu'une erreur empĂȘche la fermeture normale. Cette commande disparaĂźtra dans les prochaines versions. Pour forcer la fermeture d'un ticket, faites `!force_close` ou `!fc` dans un ticket."), + ); + dict.messages.insert( + "help.help".to_string(), + DictionaryMessage::new("Affiche une liste de toutes les commandes disponibles avec une brĂšve description. Pour afficher le message d'aide, faites `!help`. Si vous souhaitez obtenir de l'aide sur une commande spĂ©cifique, faites `!help `."), + ); + dict.messages.insert( + "help.id".to_string(), + DictionaryMessage::new("Affiche l'identifiant Discord de l'utilisateur associĂ© au ticket. Pour afficher l'ID de l'utilisateur, faites `!id` dans un ticket."), + ); + dict.messages.insert( + "help.logs".to_string(), + DictionaryMessage::new("RĂ©cupĂšre les logs de tous les anciens tickets d'un utilisateur. Vous pouvez soit spĂ©cifier un identifiant Discord (`!logs `), soit exĂ©cuter la commande dans un ticket pour obtenir les logs de ce ticket."), + ); + dict.messages.insert( + "help.move".to_string(), + DictionaryMessage::new("DĂ©place le ticket actuel vers une autre catĂ©gorie. Pour dĂ©placer un ticket, faites `!move ` ou `!mv ` dans le ticket."), + ); + dict.messages.insert( + "help.new_thread".to_string(), + DictionaryMessage::new("CrĂ©e un nouveau ticket pour un utilisateur spĂ©cifiĂ©. Pour crĂ©er un ticket, faites `!new_thread ` ou `!nt `."), + ); + dict.messages.insert( + "help.recover".to_string(), + DictionaryMessage::new("Lance le processus de rĂ©cupĂ©ration des messages manquants dans les tickets Modmail. Ce processus est automatique, mais cette commande permet de le relancer manuellement si nĂ©cessaire. Pour cela, faites `!recover`."), + ); + dict.messages.insert( + "help.remove_reminder".to_string(), + DictionaryMessage::new("Supprime un rappel que vous avez prĂ©cĂ©demment configurĂ©. Pour supprimer un rappel, faites `!unremind ` ou `!urem `."), + ); + dict.messages.insert( + "help.remove_staff".to_string(), + DictionaryMessage::new("Retire un membre du staff du ticket actuel. Pour retirer un staff, faites `!delmod ` ou `!dm ` dans le ticket."), + ); + dict.messages.insert( + "help.reply".to_string(), + DictionaryMessage::new("RĂ©pond dans un ticket. Pour rĂ©pondre, faites `!reply [attachment]` ou `!r [attachment]` dans le ticket. Si vous souhaitez rĂ©pondre anonymement, utilisez la commande `!anonreply`, `!ar`, ou spĂ©cifiez l'option dans la commande slash `reply`."), + ); + dict.messages.insert( + "help.message".to_string(), + DictionaryMessage::new("## Commandes :\n\n**Toutes les commandes** disponibles sont Ă©galement utilisables via des **__commandes slash__** portant le __mĂȘme nom__.\n\nSi vous souhaitez obtenir de l'aide sur une commande spĂ©cifique, faites `!help `.\n\n") + ); + dict.messages.insert( + "help.take".to_string(), + DictionaryMessage::new("Permet de prendre en charge un ticket en remplaçant le nom de celui-ci par le vĂŽtre. Pour prendre en charge un ticket, faites `!take` dans le ticket."), + ); + dict.messages.insert( + "help.release".to_string(), + DictionaryMessage::new("Permet de ne plus prendre en charge un ticket pris en charge via la commande `take`. Pour libĂ©rer un ticket, faites `!release` dans le ticket."), + ); + dict.messages.insert( + "add_reminder.helper".to_string(), + DictionaryMessage::new("Format incorrect. Utilisation : `{prefix}remind ou {prefix}rem [contenu du rappel]`"), + ); + dict.messages.insert( + "take.ticket_already_taken".to_string(), + DictionaryMessage::new("Vous avez dĂ©jĂ  pris en charge ce ticket."), + ); + dict.messages.insert( + "take.confirmation".to_string(), + DictionaryMessage::new("Le ticket est maintenant pris en charge par {staff}.\nA cause de **l’API de Discord**, le changement de nom du salon peut prendre jusqu’à **10 minutes**."), + ); + dict.messages.insert( + "take.timeout".to_string(), + DictionaryMessage::new("⚠ **L’API de Discord** impose une limite de **2** changements de salon toutes les **10 minutes**. L’action sera appliquĂ©e **__automatiquement__** dĂšs que le dĂ©lai sera Ă©coulĂ©."), + ); + dict.messages.insert( + "slash_command.take_command_description".to_string(), + DictionaryMessage::new("Prendre en charge le ticket actuel"), + ); + dict.messages.insert( + "slash_command.release_command_description".to_string(), + DictionaryMessage::new("Ne plus prendre en charge le ticket actuel"), + ); + dict.messages.insert( + "release.ticket_already_taken".to_string(), + DictionaryMessage::new("Le ticket n'est pris en charge par personne."), + ); + dict.messages.insert( + "release.confirmation".to_string(), + DictionaryMessage::new("Le ticket n'est plus pris en charge par {staff}.\nA cause de **l’API de Discord**, le changement de nom du salon peut prendre jusqu’à **10 minutes**."), + ); + dict.messages.insert( + "slash_command.help_command_argument_desc".to_string(), + DictionaryMessage::new("Le nom de la commande pour laquelle vous souhaitez de l'aide"), + ); } diff --git a/src/i18n/language/gr.rs b/rustmail/src/i18n/language/gr.rs similarity index 89% rename from src/i18n/language/gr.rs rename to rustmail/src/i18n/language/gr.rs index 0b020994..0778e659 100644 --- a/src/i18n/language/gr.rs +++ b/rustmail/src/i18n/language/gr.rs @@ -1,4 +1,4 @@ -use crate::errors::{DictionaryMessage, ErrorDictionary}; +use crate::prelude::errors::*; pub fn load_german_messages(dict: &mut ErrorDictionary) { dict.messages.insert( diff --git a/src/i18n/language/it.rs b/rustmail/src/i18n/language/it.rs similarity index 89% rename from src/i18n/language/it.rs rename to rustmail/src/i18n/language/it.rs index 5aaa72cf..904448bf 100644 --- a/src/i18n/language/it.rs +++ b/rustmail/src/i18n/language/it.rs @@ -1,4 +1,4 @@ -use crate::errors::{DictionaryMessage, ErrorDictionary}; +use crate::prelude::errors::*; pub fn load_italian_messages(dict: &mut ErrorDictionary) { dict.messages.insert( diff --git a/src/i18n/language/jp.rs b/rustmail/src/i18n/language/jp.rs similarity index 90% rename from src/i18n/language/jp.rs rename to rustmail/src/i18n/language/jp.rs index c5c89612..6622c5fa 100644 --- a/src/i18n/language/jp.rs +++ b/rustmail/src/i18n/language/jp.rs @@ -1,4 +1,4 @@ -use crate::errors::{DictionaryMessage, ErrorDictionary}; +use crate::prelude::errors::*; pub fn load_japanese_messages(dict: &mut ErrorDictionary) { dict.messages.insert( diff --git a/src/i18n/language/kr.rs b/rustmail/src/i18n/language/kr.rs similarity index 89% rename from src/i18n/language/kr.rs rename to rustmail/src/i18n/language/kr.rs index 8627c245..b0f3a3c0 100644 --- a/src/i18n/language/kr.rs +++ b/rustmail/src/i18n/language/kr.rs @@ -1,4 +1,4 @@ -use crate::errors::{DictionaryMessage, ErrorDictionary}; +use crate::prelude::errors::*; pub fn load_korean_messages(dict: &mut ErrorDictionary) { dict.messages.insert( diff --git a/rustmail/src/i18n/language/mod.rs b/rustmail/src/i18n/language/mod.rs new file mode 100644 index 00000000..0ba63da4 --- /dev/null +++ b/rustmail/src/i18n/language/mod.rs @@ -0,0 +1,23 @@ +pub mod cn; +pub mod dt; +pub mod en; +pub mod fr; +pub mod gr; +pub mod it; +pub mod jp; +pub mod kr; +pub mod pr; +pub mod ru; +pub mod sp; + +pub use cn::*; +pub use dt::*; +pub use en::*; +pub use fr::*; +pub use gr::*; +pub use it::*; +pub use jp::*; +pub use kr::*; +pub use pr::*; +pub use ru::*; +pub use sp::*; diff --git a/src/i18n/language/pr.rs b/rustmail/src/i18n/language/pr.rs similarity index 89% rename from src/i18n/language/pr.rs rename to rustmail/src/i18n/language/pr.rs index d8c5f927..ba0fee23 100644 --- a/src/i18n/language/pr.rs +++ b/rustmail/src/i18n/language/pr.rs @@ -1,4 +1,4 @@ -use crate::errors::{DictionaryMessage, ErrorDictionary}; +use crate::prelude::errors::*; pub fn load_portuguese_messages(dict: &mut ErrorDictionary) { dict.messages.insert( diff --git a/src/i18n/language/ru.rs b/rustmail/src/i18n/language/ru.rs similarity index 90% rename from src/i18n/language/ru.rs rename to rustmail/src/i18n/language/ru.rs index bb1b19cf..271827f3 100644 --- a/src/i18n/language/ru.rs +++ b/rustmail/src/i18n/language/ru.rs @@ -1,4 +1,4 @@ -use crate::errors::{DictionaryMessage, ErrorDictionary}; +use crate::prelude::errors::*; pub fn load_russian_messages(dict: &mut ErrorDictionary) { dict.messages.insert( diff --git a/src/i18n/language/sp.rs b/rustmail/src/i18n/language/sp.rs similarity index 89% rename from src/i18n/language/sp.rs rename to rustmail/src/i18n/language/sp.rs index 99d7f9bd..0add1810 100644 --- a/src/i18n/language/sp.rs +++ b/rustmail/src/i18n/language/sp.rs @@ -1,4 +1,4 @@ -use crate::errors::{DictionaryMessage, ErrorDictionary}; +use crate::prelude::errors::*; pub fn load_spanish_messages(dict: &mut ErrorDictionary) { dict.messages.insert( diff --git a/src/i18n/languages.rs b/rustmail/src/i18n/languages.rs similarity index 100% rename from src/i18n/languages.rs rename to rustmail/src/i18n/languages.rs diff --git a/rustmail/src/i18n/mod.rs b/rustmail/src/i18n/mod.rs new file mode 100644 index 00000000..1dfb3fdc --- /dev/null +++ b/rustmail/src/i18n/mod.rs @@ -0,0 +1,7 @@ +pub mod language; +pub mod languages; +pub mod utils; + +pub use language::*; +pub use languages::*; +pub use utils::*; diff --git a/src/i18n/utils.rs b/rustmail/src/i18n/utils.rs similarity index 95% rename from src/i18n/utils.rs rename to rustmail/src/i18n/utils.rs index 02ca2259..0937800c 100644 --- a/src/i18n/utils.rs +++ b/rustmail/src/i18n/utils.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::prelude::config::*; use serenity::all::UserId; use std::collections::HashMap; diff --git a/rustmail/src/main.rs b/rustmail/src/main.rs new file mode 100644 index 00000000..43f158c2 --- /dev/null +++ b/rustmail/src/main.rs @@ -0,0 +1,124 @@ +use crate::bot::{init_bot_state, start_bot_if_config_valid}; +use crate::prelude::api::*; +use axum::extract::Path; +use axum::response::Response; +use rust_embed::RustEmbed; +use std::borrow::Cow; +use std::net::SocketAddr; +use tokio::signal; + +mod api; +mod bot; +mod commands; +mod config; +mod db; +mod errors; +mod features; +mod handlers; +mod i18n; +mod modules; +mod panel_commands; +mod prelude; +mod types; +mod utils; + +#[derive(RustEmbed)] +#[folder = "static/"] +struct Assets; + +async fn static_handler(path: Option>) -> Response { + let path = path.map(|p| p.0).unwrap_or_else(|| "".to_string()); + + let path = if path.is_empty() || path == "/" { + "index.html".to_string() + } else { + path.trim_start_matches('/').to_string() + }; + + match Assets::get(&path) { + Some(content) => { + let mime = mime_guess::from_path(&path).first_or_octet_stream(); + let body = match content.data { + Cow::Borrowed(bytes) => axum::body::Body::from(bytes.to_vec()), + Cow::Owned(bytes) => axum::body::Body::from(bytes), + }; + axum::response::Response::builder() + .header("Content-Type", mime.as_ref()) + .body(body) + .unwrap() + } + None => { + if let Some(index) = Assets::get("index.html") { + let body = match index.data { + Cow::Borrowed(bytes) => axum::body::Body::from(bytes.to_vec()), + Cow::Owned(bytes) => axum::body::Body::from(bytes), + }; + axum::response::Response::builder() + .header("Content-Type", "text/html") + .body(body) + .unwrap() + } else { + axum::response::Response::builder() + .status(404) + .body(axum::body::Body::from("404 Not Found")) + .unwrap() + } + } + } +} + +#[tokio::main] +async fn main() { + let bot_state = init_bot_state().await; + + let _ = start_bot_if_config_valid(bot_state.clone()).await; + + let config = { + let state = bot_state.lock().await; + state.config.clone() + }; + + if let Some(config) = config { + if config.bot.enable_panel { + let bot_state_clone = bot_state.clone(); + + let server_task = tokio::spawn(async move { + let app = create_api_router(bot_state_clone) + .route("/", axum::routing::get(static_handler)) + .route("/{*path}", axum::routing::get(static_handler)); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3002").await.unwrap(); + println!("listening on {}", listener.local_addr().unwrap()); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await + .unwrap(); + }); + + tokio::select! { + _ = server_task => {}, + _ = tokio::signal::ctrl_c() => { println!("Shutting down"); } + } + } else { + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => { + println!("Shutting down"); + break; + } + } + } + } + } +} + +async fn shutdown_signal() { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + println!("Shutdown signal received"); +} diff --git a/rustmail/src/modules/commands.rs b/rustmail/src/modules/commands.rs new file mode 100644 index 00000000..0a0b0c53 --- /dev/null +++ b/rustmail/src/modules/commands.rs @@ -0,0 +1,114 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::features::*; +use crate::prelude::i18n::*; +use crate::prelude::types::*; +use crate::prelude::utils::*; +use serenity::all::{ButtonStyle, ComponentInteraction, Context}; +use serenity::builder::CreateInteractionResponse; + +pub const LOGS_PAGE_SIZE: usize = 10; + +fn parse_command_interaction(custom_id: &str) -> Option { + custom_id.strip_prefix("command:").map(|s| s.to_string()) +} + +async fn handle_logs_action( + session_id: &str, + page: usize, + ctx: &Context, + config: &Config, + pagination: PaginationStore, +) -> Result<(), Box> { + let mut store = pagination.lock().await; + + let next_button = + get_translated_message(&config, "logs_command.next", None, None, None, None).await; + let prev_button = + get_translated_message(&config, "logs_command.prev", None, None, None, None).await; + + if let Some(ctx_data) = store.get_mut(session_id) { + if page == 0 && ctx_data.current_page > 0 { + ctx_data.current_page -= 1; + } else if page == 1 && ctx_data.current_page < LOGS_PAGE_SIZE { + ctx_data.current_page += 1; + } else { + return Ok(()); + } + + let new_content = render_logs_page( + config, + &ctx_data.logs, + ctx_data.current_page, + LOGS_PAGE_SIZE, + ) + .await; + + if new_content.is_empty() { + return Ok(()); + } + + let components = make_buttons(&[ + ( + &prev_button.to_string(), + &format!("command:logs_prev:{}", session_id), + ButtonStyle::Primary, + ctx_data.current_page == 0, + ), + ( + &next_button.to_string(), + &format!("command:logs_next:{}", session_id), + ButtonStyle::Primary, + (ctx_data.current_page + 1) * 10 >= ctx_data.logs.len(), + ), + ]); + + let response = MessageBuilder::system_message(&ctx, &config) + .content(new_content) + .components(components) + .to_channel(ctx_data.channel_id) + .build_edit_message() + .await; + + ctx_data + .channel_id + .edit_message(&ctx.http, ctx_data.message_id, response) + .await?; + } + + Ok(()) +} + +pub async fn handle_command_component_interaction( + ctx: &Context, + config: &Config, + interaction: &mut ComponentInteraction, + pagination: PaginationStore, +) -> Result<(), Box> { + let parts = match parse_command_interaction(&interaction.data.custom_id) { + Some(parts) => parts, + None => return Ok(()), + }; + + if parts.starts_with("logs_next:") { + let session_id = parts.strip_prefix("logs_next:").unwrap(); + + interaction + .create_response(&ctx.http, CreateInteractionResponse::Acknowledge) + .await?; + + handle_logs_action(session_id, 1, ctx, config, pagination.clone()).await?; + } + + if parts.starts_with("logs_prev:") { + let session_id = parts.strip_prefix("logs_prev:").unwrap(); + + interaction + .create_response(&ctx.http, CreateInteractionResponse::Acknowledge) + .await?; + + handle_logs_action(session_id, 0, ctx, config, pagination.clone()).await?; + } + + Ok(()) +} diff --git a/src/modules/message_recovery.rs b/rustmail/src/modules/message_recovery.rs similarity index 95% rename from src/modules/message_recovery.rs rename to rustmail/src/modules/message_recovery.rs index 62ebd004..f58af80a 100644 --- a/src/modules/message_recovery.rs +++ b/rustmail/src/modules/message_recovery.rs @@ -1,11 +1,7 @@ -use crate::config::Config; -use crate::db::operations::{ - get_all_opened_threads, get_last_recovery_timestamp, get_latest_thread_message, - insert_recovered_message, update_last_recovery_timestamp, -}; -use crate::db::repr::Thread; -use crate::i18n::get_translated_message; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; use chrono::{DateTime, Utc}; use serenity::all::{ChannelId, Context, GetMessages, Message, MessageId, UserId}; use std::collections::HashMap; @@ -193,7 +189,7 @@ async fn recover_messages_for_thread( ) .await .to_channel(channel_id) - .send() + .send(true) .await; } diff --git a/rustmail/src/modules/mod.rs b/rustmail/src/modules/mod.rs new file mode 100644 index 00000000..43094091 --- /dev/null +++ b/rustmail/src/modules/mod.rs @@ -0,0 +1,13 @@ +pub mod commands; +pub mod message_recovery; +pub mod reminders; +pub mod scheduled_closures; +pub mod threads; +pub mod threads_status; + +pub use commands::*; +pub use message_recovery::*; +pub use reminders::*; +pub use scheduled_closures::*; +pub use threads::*; +pub use threads_status::*; diff --git a/rustmail/src/modules/reminders.rs b/rustmail/src/modules/reminders.rs new file mode 100644 index 00000000..a28f8532 --- /dev/null +++ b/rustmail/src/modules/reminders.rs @@ -0,0 +1,23 @@ +use crate::prelude::commands::*; +use crate::prelude::config::*; +use crate::prelude::db::*; +use serenity::all::Context; +use std::sync::Arc; +use tokio::sync::watch::Receiver; + +pub async fn load_reminders( + ctx: &Context, + config: &Config, + pool: &sqlx::SqlitePool, + shutdown: Arc>, +) { + let reminders = get_all_pending_reminders(pool).await.unwrap_or_else(|e| { + eprintln!("Failed to fetch pending reminders: {:?}", e); + Vec::new() + }); + + for reminder in reminders { + spawn_reminder(&reminder, None, &ctx, &config, &pool, shutdown.clone()); + } + println!("All pending reminders have been scheduled."); +} diff --git a/src/modules/scheduled_closures.rs b/rustmail/src/modules/scheduled_closures.rs similarity index 77% rename from src/modules/scheduled_closures.rs rename to rustmail/src/modules/scheduled_closures.rs index d923bc74..07707260 100644 --- a/src/modules/scheduled_closures.rs +++ b/rustmail/src/modules/scheduled_closures.rs @@ -1,9 +1,6 @@ -use crate::config::Config; -use crate::db::{ - close_thread, delete_scheduled_closure, get_all_scheduled_closures, get_scheduled_closure, - get_thread_by_id, -}; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::utils::*; use chrono::Utc; use serenity::all::{ChannelId, Context, UserId}; use tokio::time::{Duration, sleep}; @@ -25,13 +22,22 @@ fn schedule_one(ctx: &Context, config: &Config, thread_id: String, close_at: i64 let channel_id = ChannelId::new(thread.channel_id.parse::().unwrap_or(0)); let user_id = UserId::new(thread.user_id as u64); - let _ = close_thread(&thread_id, pool).await; + + let _ = close_thread( + &thread_id, + ¤t.closed_by, + ¤t.category_id, + ¤t.category_name, + current.required_permissions.parse::().unwrap_or(0), + pool, + ) + .await; let _ = delete_scheduled_closure(&thread_id, pool).await; if !current.silent { let _ = MessageBuilder::system_message(&ctx_clone, &config_clone) .content(&config_clone.bot.close_message) .to_user(user_id) - .send() + .send(true) .await; } let _ = channel_id.delete(&ctx_clone.http).await; @@ -67,13 +73,21 @@ pub async fn hydrate_scheduled_closures(ctx: &Context, config: &Config) { if sc.close_at <= Utc::now().timestamp() { let channel_id = ChannelId::new(thread.channel_id.parse::().unwrap_or(0)); let user_id = UserId::new(thread.user_id as u64); - let _ = close_thread(&thread.id, pool).await; + let _ = close_thread( + &thread.id, + &sc.closed_by, + &sc.category_id, + &sc.category_name, + sc.required_permissions.parse::().unwrap_or(0), + pool, + ) + .await; let _ = delete_scheduled_closure(&thread.id, pool).await; if !sc.silent { let _ = MessageBuilder::system_message(ctx, config) .content(&config.bot.close_message) .to_user(user_id) - .send() + .send(true) .await; } let _ = channel_id.delete(&ctx.http).await; diff --git a/src/modules/threads.rs b/rustmail/src/modules/threads.rs similarity index 80% rename from src/modules/threads.rs rename to rustmail/src/modules/threads.rs index 8c795a77..d1d69458 100644 --- a/src/modules/threads.rs +++ b/rustmail/src/modules/threads.rs @@ -1,16 +1,13 @@ -use crate::config::Config; -use crate::db::operations::{create_thread_for_user, get_thread_channel_by_user_id}; -use crate::errors::common::validation_failed; -use crate::i18n::get_translated_message; -use crate::utils::message::message_builder::MessageBuilder; -use crate::utils::message::ui; -use crate::utils::thread::get_thread_lock::get_thread_lock; -use crate::utils::thread::send_to_thread::send_to_thread; -use crate::utils::time::format_duration_since::format_duration_since; -use crate::utils::time::get_member_join_date::get_member_join_date_for_user; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use crate::types::TicketAuthor; +use chrono::Utc; use serenity::all::{ - ActionRowComponent, Channel, ChannelId, ComponentInteraction, Context, CreateChannel, GuildId, - Message, ModalInteraction, UserId, + ActionRowComponent, Channel, ChannelId, ComponentInteraction, Context, CreateChannel, + CreateInteractionResponseFollowup, GuildId, Message, ModalInteraction, UserId, }; use serenity::builder::{CreateInteractionResponse, EditChannel, EditMessage}; use std::collections::HashMap; @@ -36,9 +33,11 @@ async fn create_or_get_thread_for_user( Err(_) => user_id.get().to_string(), }; + let thread_name = format!("đŸ”Žăƒ»{}・0m", username); + let staff_guild_id = GuildId::new(config.bot.get_staff_guild_id()); let channel_builder = - CreateChannel::new(&username).category(ChannelId::new(config.thread.inbox_category_id)); + CreateChannel::new(&thread_name).category(ChannelId::new(config.thread.inbox_category_id)); let channel = staff_guild_id .create_channel(&ctx.http, channel_builder) @@ -74,24 +73,45 @@ async fn create_or_get_thread_for_user( .await .unwrap_or_else(|| "Unknown".to_string()); - let open_thread_message = format!( - "ACCOUNT AGE **{}**, ID **{}**\nNICKNAME **{}**, JOINED **{}**", - format_duration_since(user_id.created_at()), + let logs_count = match get_logs_from_user_id(&user_id.clone().to_string(), pool).await { + Ok(logs) => logs.len(), + Err(_) => 0, + }; + + let params = { + let mut p = HashMap::new(); + p.insert("logs_count".to_string(), logs_count.to_string()); + p.insert("prefix".to_string(), config.command.prefix.clone()); + p + }; + + let logs_info = get_translated_message( + &config, + "new_thread.show_logs", + Some(¶ms), + None, + None, + None, + ) + .await; + + let open_thread_message = get_user_recap( user_id, - username, - member_join_date + &username.to_string(), + &member_join_date, + &logs_info, ); let _ = MessageBuilder::system_message(ctx, config) .to_channel(target_channel_id) .content(open_thread_message) - .send() + .send(true) .await; let _ = MessageBuilder::system_message(ctx, config) .content(&config.bot.welcome_message) .to_user(user_id) - .send() + .send(true) .await; println!("Thread created successfully"); @@ -153,21 +173,6 @@ pub async fn handle_thread_modal_interaction( let parts = match parse_thread_interaction(&interaction.data.custom_id) { Some(parts) => parts, None => { - let response = CreateInteractionResponse::Message( - MessageBuilder::system_message(&ctx, &config) - .translated_content( - "feature.not_implemented", - None, - Some(interaction.user.id), - interaction.guild_id.map(|g| g.get()), - ) - .await - .to_channel(interaction.channel_id) - .build_interaction_message() - .await - .ephemeral(true), - ); - let _ = interaction.create_response(&ctx.http, response).await; return Ok(()); } }; @@ -175,6 +180,10 @@ pub async fn handle_thread_modal_interaction( let key = interaction.channel_id.get(); let lock = get_thread_lock(config, key); + interaction + .create_response(&ctx.http, CreateInteractionResponse::Acknowledge) + .await?; + let guard = match lock.try_lock() { Ok(guard) => guard, Err(_) => { @@ -218,9 +227,9 @@ pub async fn handle_thread_modal_interaction( Err(_) => { eprintln!("Invalid user ID provided in modal interaction: {}", id); let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content( "thread.modal_invalid_user_id", @@ -230,7 +239,7 @@ pub async fn handle_thread_modal_interaction( ) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -249,9 +258,9 @@ pub async fn handle_thread_modal_interaction( Err(_) => { eprintln!("Failed to fetch user by ID: {}", user_id); let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content( "thread.modal_user_not_found", @@ -261,7 +270,7 @@ pub async fn handle_thread_modal_interaction( ) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -272,16 +281,19 @@ pub async fn handle_thread_modal_interaction( }; if user.bot { - eprintln!("Attempted to create thread for a bot user: {}", user_id); + eprintln!( + "Attempted to create thread for a rustmail user: {}", + user_id + ); let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content("thread.modal_bot_user", None, None, None) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -305,9 +317,9 @@ pub async fn handle_thread_modal_interaction( format!("<#{}>", existing_channel_str), ); let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content( "thread.already_exists", @@ -317,7 +329,7 @@ pub async fn handle_thread_modal_interaction( ) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -331,9 +343,9 @@ pub async fn handle_thread_modal_interaction( Ok(Channel::Guild(gc)) => gc, _ => { let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content( "thread.not_a_thread_channel", @@ -343,7 +355,7 @@ pub async fn handle_thread_modal_interaction( ) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -357,9 +369,9 @@ pub async fn handle_thread_modal_interaction( if let Some(parent_id) = guild_channel.parent_id { if parent_id.get() != config.thread.inbox_category_id { let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content( "thread.not_a_thread_channel", @@ -369,7 +381,7 @@ pub async fn handle_thread_modal_interaction( ) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -380,14 +392,14 @@ pub async fn handle_thread_modal_interaction( } } else { let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content("thread.not_a_thread_channel", None, None, None) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -404,14 +416,14 @@ pub async fn handle_thread_modal_interaction( { eprintln!("Failed to create thread record: {}", e); let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content("thread.creation_failed", None, None, None) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -433,14 +445,14 @@ pub async fn handle_thread_modal_interaction( ) .await .to_channel(interaction.channel_id) - .send() + .send(true) .await; let _ = MessageBuilder::system_message(ctx, config) .translated_content("new_thread.dm_notification", None, Some(user_id), None) .await .to_user(user_id) - .send() + .send(true) .await; let mut params = HashMap::new(); @@ -449,14 +461,14 @@ pub async fn handle_thread_modal_interaction( format!("<#{}>", interaction.channel_id), ); let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content("thread.created", Some(¶ms), None, None) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -475,14 +487,14 @@ pub async fn handle_thread_modal_interaction( _ => { eprintln!("Unknown thread modal interaction action: {}", parts); let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content("thread.unknown_action", None, None, None) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await, ), ) @@ -501,24 +513,7 @@ pub async fn handle_thread_component_interaction( ) -> Result<(), Box> { let parts = match parse_thread_interaction(&interaction.data.custom_id) { Some(parts) => parts, - None => { - let response = CreateInteractionResponse::Message( - MessageBuilder::system_message(&ctx, &config) - .translated_content( - "feature.not_implemented", - None, - Some(interaction.user.id), - interaction.guild_id.map(|g| g.get()), - ) - .await - .to_channel(interaction.channel_id) - .build_interaction_message() - .await - .ephemeral(true), - ); - let _ = interaction.create_response(&ctx.http, response).await; - return Ok(()); - } + None => return Ok(()), }; let key = interaction.channel_id.get(); @@ -528,14 +523,14 @@ pub async fn handle_thread_component_interaction( Ok(guard) => guard, Err(_) => { let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content("thread.action_in_progress", None, None, None) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await .ephemeral(true), ), @@ -555,14 +550,14 @@ pub async fn handle_thread_component_interaction( params.insert("user".to_string(), format!("<@{}>", interaction.user.id)); let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content("thread.thread_closing", Some(¶ms), None, None) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await, ), ) @@ -574,14 +569,14 @@ pub async fn handle_thread_component_interaction( } "keep" => { let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content("thread.will_remain_open", None, None, None) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await, ), ) @@ -622,14 +617,14 @@ pub async fn handle_thread_component_interaction( _ => { eprintln!("Unknown thread component interaction action: {}", parts); let _ = interaction - .create_response( + .create_followup( &ctx.http, - CreateInteractionResponse::Message( + CreateInteractionResponseFollowup::from( MessageBuilder::system_message(ctx, config) .translated_content("thread.unknown_action", None, None, None) .await .to_channel(interaction.channel_id) - .build_interaction_message() + .build_interaction_message_followup() .await, ), ) diff --git a/rustmail/src/modules/threads_status.rs b/rustmail/src/modules/threads_status.rs new file mode 100644 index 00000000..54d53111 --- /dev/null +++ b/rustmail/src/modules/threads_status.rs @@ -0,0 +1,64 @@ +use crate::prelude::errors::*; +use crate::prelude::types::*; +use chrono::Utc; +use serenity::all::{ChannelId, UserId}; +use serenity::builder::EditChannel; +use serenity::client::Context; +use std::time::Duration; +use tokio::time::timeout; + +pub async fn update_thread_status_ui(ctx: &Context, ticket: &TicketState) -> ModmailResult<()> { + let channel = ChannelId::new(ticket.channel_id as u64); + + let color = match ticket.last_message_by { + TicketAuthor::Staff => "đŸ””", + TicketAuthor::User => "🔮", + }; + + let elapsed = Utc::now().timestamp() - ticket.last_message_at; + let minutes = elapsed / 60; + let time_str = if minutes < 60 { + format!("{}m", minutes) + } else { + format!("{}h", minutes / 60) + }; + + let owner_id = ticket.owner_id.parse().unwrap_or(0); + let owner_name = UserId::new(owner_id).to_user(&ctx.http).await?.name; + + let mut name = format!("{color}・{}", owner_name); + + if let Some(staff_id) = &ticket.taken_by { + let staff_id_u64 = staff_id.parse().unwrap_or(0); + if staff_id_u64 != 0 { + let staff_name = ctx + .cache + .user(UserId::new(staff_id_u64)) + .map(|u| u.name.clone()) + .unwrap_or_else(|| format!("Staff-{}", staff_id_u64)); + name.push_str(&format!("・{}", staff_name)); + } + } + + name.push_str(&format!("・{}", time_str)); + + let result = timeout( + Duration::from_secs(2), + channel.edit(&ctx.http, EditChannel::new().name(&name)), + ) + .await; + + match result { + Ok(Ok(_)) => Ok(()), + + Ok(Err(e)) => { + eprintln!("Failed to edit channel {}: {:?}", ticket.channel_id, e); + Err(e.into()) + } + + Err(_) => { + eprintln!("Timeout editing channel {} (skipping)", ticket.channel_id); + Ok(()) + } + } +} diff --git a/rustmail/src/panel_commands/mod.rs b/rustmail/src/panel_commands/mod.rs new file mode 100644 index 00000000..3232c864 --- /dev/null +++ b/rustmail/src/panel_commands/mod.rs @@ -0,0 +1,3 @@ +pub mod user; + +pub use user::*; diff --git a/rustmail/src/panel_commands/user/is_member.rs b/rustmail/src/panel_commands/user/is_member.rs new file mode 100644 index 00000000..3d3845ff --- /dev/null +++ b/rustmail/src/panel_commands/user/is_member.rs @@ -0,0 +1,10 @@ +use serenity::all::{GuildId, UserId}; +use serenity::http::Http; +use std::sync::Arc; + +pub async fn is_member(http: Arc, guild_id: u64, user_id: u64) -> bool { + let guild = GuildId::new(guild_id); + let user = UserId::new(user_id); + + guild.member(&http, user).await.is_ok() +} diff --git a/rustmail/src/panel_commands/user/mod.rs b/rustmail/src/panel_commands/user/mod.rs new file mode 100644 index 00000000..340693a6 --- /dev/null +++ b/rustmail/src/panel_commands/user/mod.rs @@ -0,0 +1,3 @@ +pub mod is_member; + +pub use is_member::*; diff --git a/rustmail/src/prelude/api.rs b/rustmail/src/prelude/api.rs new file mode 100644 index 00000000..f5115483 --- /dev/null +++ b/rustmail/src/prelude/api.rs @@ -0,0 +1 @@ +pub use crate::api::*; diff --git a/rustmail/src/prelude/commands.rs b/rustmail/src/prelude/commands.rs new file mode 100644 index 00000000..280a8e85 --- /dev/null +++ b/rustmail/src/prelude/commands.rs @@ -0,0 +1 @@ +pub use crate::commands::*; diff --git a/rustmail/src/prelude/config.rs b/rustmail/src/prelude/config.rs new file mode 100644 index 00000000..2eceedc5 --- /dev/null +++ b/rustmail/src/prelude/config.rs @@ -0,0 +1 @@ +pub use crate::config::*; diff --git a/rustmail/src/prelude/db.rs b/rustmail/src/prelude/db.rs new file mode 100644 index 00000000..a67eb0ae --- /dev/null +++ b/rustmail/src/prelude/db.rs @@ -0,0 +1 @@ +pub use crate::db::*; diff --git a/rustmail/src/prelude/errors.rs b/rustmail/src/prelude/errors.rs new file mode 100644 index 00000000..543733ee --- /dev/null +++ b/rustmail/src/prelude/errors.rs @@ -0,0 +1,10 @@ +pub use crate::errors::*; + +pub use crate::errors::types::CommandError; +pub use crate::errors::types::ConfigError; +pub use crate::errors::types::DatabaseError; +pub use crate::errors::types::DiscordError; +pub use crate::errors::types::MessageError; +pub use crate::errors::types::ModmailError; +pub use crate::errors::types::ThreadError; +pub use crate::errors::types::ValidationError; diff --git a/rustmail/src/prelude/features.rs b/rustmail/src/prelude/features.rs new file mode 100644 index 00000000..9768c6a9 --- /dev/null +++ b/rustmail/src/prelude/features.rs @@ -0,0 +1 @@ +pub use crate::features::*; diff --git a/rustmail/src/prelude/handlers.rs b/rustmail/src/prelude/handlers.rs new file mode 100644 index 00000000..a0c7c04c --- /dev/null +++ b/rustmail/src/prelude/handlers.rs @@ -0,0 +1 @@ +pub use crate::handlers::*; diff --git a/rustmail/src/prelude/i18n.rs b/rustmail/src/prelude/i18n.rs new file mode 100644 index 00000000..b78d79d5 --- /dev/null +++ b/rustmail/src/prelude/i18n.rs @@ -0,0 +1 @@ +pub use crate::i18n::*; diff --git a/rustmail/src/prelude/mod.rs b/rustmail/src/prelude/mod.rs new file mode 100644 index 00000000..b46818a7 --- /dev/null +++ b/rustmail/src/prelude/mod.rs @@ -0,0 +1,12 @@ +pub mod api; +pub mod commands; +pub mod config; +pub mod db; +pub mod errors; +pub mod features; +pub mod handlers; +pub mod i18n; +pub mod modules; +pub mod panel_commands; +pub mod types; +pub mod utils; diff --git a/rustmail/src/prelude/modules.rs b/rustmail/src/prelude/modules.rs new file mode 100644 index 00000000..f98fe384 --- /dev/null +++ b/rustmail/src/prelude/modules.rs @@ -0,0 +1 @@ +pub use crate::modules::*; diff --git a/rustmail/src/prelude/panel_commands.rs b/rustmail/src/prelude/panel_commands.rs new file mode 100644 index 00000000..e3c8b3af --- /dev/null +++ b/rustmail/src/prelude/panel_commands.rs @@ -0,0 +1 @@ +pub use crate::panel_commands::*; diff --git a/rustmail/src/prelude/types.rs b/rustmail/src/prelude/types.rs new file mode 100644 index 00000000..1b6a6584 --- /dev/null +++ b/rustmail/src/prelude/types.rs @@ -0,0 +1 @@ +pub use crate::types::*; diff --git a/rustmail/src/prelude/utils.rs b/rustmail/src/prelude/utils.rs new file mode 100644 index 00000000..7af11e7e --- /dev/null +++ b/rustmail/src/prelude/utils.rs @@ -0,0 +1 @@ +pub use crate::utils::*; diff --git a/rustmail/src/types/bot.rs b/rustmail/src/types/bot.rs new file mode 100644 index 00000000..50028301 --- /dev/null +++ b/rustmail/src/types/bot.rs @@ -0,0 +1,35 @@ +use crate::prelude::config::*; +use serenity::all::Http; +use std::sync::Arc; +use tokio::sync::watch::Sender; +use tokio::task::JoinHandle; + +pub enum BotStatus { + Stopped, + Running { + handle: JoinHandle<()>, + shutdown: Sender, + }, +} + +pub enum BotCommand { + CheckUserRole { + user_id: u64, + role_id: u64, + resp: tokio::sync::oneshot::Sender, + }, + CheckUserIsMember { + user_id: u64, + resp: tokio::sync::oneshot::Sender, + }, + Test, +} + +pub struct BotState { + pub config: Option, + pub status: BotStatus, + pub db_pool: Option, + pub command_tx: tokio::sync::mpsc::Sender, + pub bot_http: Option>, + pub internal_token: String, +} diff --git a/rustmail/src/types/logs.rs b/rustmail/src/types/logs.rs new file mode 100644 index 00000000..7196fa55 --- /dev/null +++ b/rustmail/src/types/logs.rs @@ -0,0 +1,23 @@ +use serenity::all::{ChannelId, MessageId}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Clone, Debug)] +pub struct TicketLog { + pub id: i64, + pub ticket_id: String, + pub user_id: String, + pub created_at: String, +} + +#[derive(Clone)] +pub struct PaginationContext { + pub user_id: String, + pub logs: Vec, + pub current_page: usize, + pub message_id: MessageId, + pub channel_id: ChannelId, +} + +pub type PaginationStore = Arc>>; diff --git a/rustmail/src/types/mod.rs b/rustmail/src/types/mod.rs new file mode 100644 index 00000000..13633bf0 --- /dev/null +++ b/rustmail/src/types/mod.rs @@ -0,0 +1,7 @@ +pub mod bot; +pub mod logs; +pub mod threads_status; + +pub use bot::*; +pub use logs::*; +pub use threads_status::*; diff --git a/rustmail/src/types/threads_status.rs b/rustmail/src/types/threads_status.rs new file mode 100644 index 00000000..c1561ef7 --- /dev/null +++ b/rustmail/src/types/threads_status.rs @@ -0,0 +1,23 @@ +#[derive(Debug, Clone)] +pub struct TicketState { + pub channel_id: i64, + pub owner_id: String, + pub taken_by: Option, + pub last_message_by: TicketAuthor, + pub last_message_at: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TicketAuthor { + Staff, + User, +} + +impl TicketAuthor { + pub fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "staff" => TicketAuthor::Staff, + _ => TicketAuthor::User, + } + } +} diff --git a/rustmail/src/utils/command/category.rs b/rustmail/src/utils/command/category.rs new file mode 100644 index 00000000..95ad01d8 --- /dev/null +++ b/rustmail/src/utils/command/category.rs @@ -0,0 +1,60 @@ +use serenity::all::CommandInteraction; +use serenity::all::{Channel, Context, PermissionOverwriteType, RoleId}; + +pub async fn get_category_id_from_command(ctx: &Context, command: &CommandInteraction) -> String { + match command.channel_id.to_channel(&ctx.http).await { + Ok(channel) => match channel.guild() { + Some(guild_channel) => match guild_channel.parent_id { + Some(category_id) => category_id.to_string(), + None => String::new(), + }, + None => String::new(), + }, + _ => String::new(), + } +} + +pub async fn get_category_name_from_command(ctx: &Context, command: &CommandInteraction) -> String { + match command.channel_id.to_channel(&ctx.http).await { + Ok(channel) => match channel.guild() { + Some(guild_channel) => match guild_channel.parent_id { + Some(category_id) => match category_id.name(&ctx.http).await { + Ok(category_name) => category_name.clone(), + _ => String::new(), + }, + None => String::new(), + }, + None => String::new(), + }, + _ => String::new(), + } +} + +pub async fn get_required_permissions_channel_from_command( + ctx: &Context, + command: &CommandInteraction, +) -> u64 { + match command.channel_id.to_channel(&ctx.http).await { + Ok(Channel::Guild(guild_channel)) => { + let guild_id = guild_channel.guild_id; + let guild = guild_id.to_partial_guild(&ctx.http).await.ok(); + + let everyone_role_id = RoleId::new(guild_id.get()); + + let mut perms = guild + .and_then(|g| g.roles.get(&everyone_role_id).map(|r| r.permissions.bits())) + .unwrap_or(0u64); + + for overwrite in &guild_channel.permission_overwrites { + if let PermissionOverwriteType::Role(_) = overwrite.kind { + let allow = overwrite.allow.bits(); + let deny = overwrite.deny.bits(); + perms = (perms & !deny) | allow; + } + } + + perms + } + _ => 0u64, + } +} diff --git a/src/utils/command/defer_response.rs b/rustmail/src/utils/command/defer_response.rs similarity index 94% rename from src/utils/command/defer_response.rs rename to rustmail/src/utils/command/defer_response.rs index 39da1b5f..26da84b1 100644 --- a/src/utils/command/defer_response.rs +++ b/rustmail/src/utils/command/defer_response.rs @@ -1,4 +1,4 @@ -use crate::errors::ModmailResult; +use crate::prelude::errors::*; use serenity::all::{ CommandInteraction, Context, CreateInteractionResponse, CreateInteractionResponseMessage, }; diff --git a/src/utils/command/extract_reply_content.rs b/rustmail/src/utils/command/extract_reply_content.rs similarity index 100% rename from src/utils/command/extract_reply_content.rs rename to rustmail/src/utils/command/extract_reply_content.rs diff --git a/rustmail/src/utils/command/mod.rs b/rustmail/src/utils/command/mod.rs new file mode 100644 index 00000000..34e0f12d --- /dev/null +++ b/rustmail/src/utils/command/mod.rs @@ -0,0 +1,9 @@ +pub mod category; +pub mod defer_response; +pub mod extract_reply_content; +pub mod wrap_command; + +pub use category::*; +pub use defer_response::*; +pub use extract_reply_content::*; +pub use wrap_command::*; diff --git a/rustmail/src/utils/command/wrap_command.rs b/rustmail/src/utils/command/wrap_command.rs new file mode 100644 index 00000000..da6714db --- /dev/null +++ b/rustmail/src/utils/command/wrap_command.rs @@ -0,0 +1,27 @@ +#[macro_export] +macro_rules! wrap_command { + ($map:expr, [$($name:expr),+], $func:expr) => {{ + let command: std::sync::Arc< + dyn for<'a> Fn( + serenity::prelude::Context, + serenity::model::prelude::Message, + &'a $crate::prelude::config::Config, + Arc, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + Send + 'a + > + > + Send + Sync + 'static + > = std::sync::Arc::new(|ctx, msg, config, handler| { + Box::pin(async move { + $func(ctx, msg, config, handler).await + }) + }); + $( + $map.insert($name.to_string(), std::sync::Arc::clone(&command)); + )+ + }}; + ($map:expr, $name:expr, $func:expr) => {{ + wrap_command!($map, [$name], $func); + }}; +} diff --git a/src/utils/conversion/hex_string_to_int.rs b/rustmail/src/utils/conversion/hex_string_to_int.rs similarity index 100% rename from src/utils/conversion/hex_string_to_int.rs rename to rustmail/src/utils/conversion/hex_string_to_int.rs diff --git a/rustmail/src/utils/conversion/mod.rs b/rustmail/src/utils/conversion/mod.rs new file mode 100644 index 00000000..c3607e48 --- /dev/null +++ b/rustmail/src/utils/conversion/mod.rs @@ -0,0 +1,3 @@ +pub mod hex_string_to_int; + +pub use hex_string_to_int::*; diff --git a/rustmail/src/utils/message/category.rs b/rustmail/src/utils/message/category.rs new file mode 100644 index 00000000..151d29c5 --- /dev/null +++ b/rustmail/src/utils/message/category.rs @@ -0,0 +1,59 @@ +use serenity::all::{Channel, Context, Message, PermissionOverwriteType, RoleId}; + +pub async fn get_category_id_from_message(ctx: &Context, message: &Message) -> String { + match message.channel_id.to_channel(&ctx.http).await { + Ok(channel) => match channel.guild() { + Some(guild_channel) => match guild_channel.parent_id { + Some(category_id) => category_id.to_string(), + None => String::new(), + }, + None => String::new(), + }, + _ => String::new(), + } +} + +pub async fn get_category_name_from_message(ctx: &Context, message: &Message) -> String { + match message.channel_id.to_channel(&ctx.http).await { + Ok(channel) => match channel.guild() { + Some(guild_channel) => match guild_channel.parent_id { + Some(category_id) => match category_id.name(&ctx.http).await { + Ok(category_name) => category_name.clone(), + _ => String::new(), + }, + None => String::new(), + }, + None => String::new(), + }, + _ => String::new(), + } +} + +pub async fn get_required_permissions_channel_from_message( + ctx: &Context, + message: &Message, +) -> u64 { + match message.channel_id.to_channel(&ctx.http).await { + Ok(Channel::Guild(guild_channel)) => { + let guild_id = guild_channel.guild_id; + let guild = guild_id.to_partial_guild(&ctx.http).await.ok(); + + let everyone_role_id = RoleId::new(guild_id.get()); + + let mut perms = guild + .and_then(|g| g.roles.get(&everyone_role_id).map(|r| r.permissions.bits())) + .unwrap_or(0u64); + + for overwrite in &guild_channel.permission_overwrites { + if let PermissionOverwriteType::Role(_) = overwrite.kind { + let allow = overwrite.allow.bits(); + let deny = overwrite.deny.bits(); + perms = (perms & !deny) | allow; + } + } + + perms + } + _ => 0u64, + } +} diff --git a/src/utils/message/message_builder.rs b/rustmail/src/utils/message/message_builder.rs similarity index 89% rename from src/utils/message/message_builder.rs rename to rustmail/src/utils/message/message_builder.rs index 73838754..201d87cf 100644 --- a/src/utils/message/message_builder.rs +++ b/rustmail/src/utils/message/message_builder.rs @@ -1,7 +1,8 @@ -use crate::config::Config; -use crate::db::operations::{insert_staff_message, insert_user_message_with_ids}; -use crate::i18n::get_translated_message; -use crate::utils::conversion::hex_string_to_int::hex_string_to_int; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; use serenity::all::{ ChannelId, Colour, CommandInteraction, Context, CreateAttachment, CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter, CreateInteractionResponseFollowup, CreateMessage, @@ -394,7 +395,14 @@ impl<'a> MessageBuilder<'a> { } } - pub async fn send(self) -> Result { + pub async fn send(self, to_be_recorded: bool) -> Result { + let pool = match &self.config.db_pool { + Some(pool) => pool, + None => { + return Err(ModmailError::Database(DatabaseError::ConnectionFailed)); + } + }; + let target = self .target .clone() @@ -402,38 +410,90 @@ impl<'a> MessageBuilder<'a> { let message = self.build_create_message().await; - match target { + let thread_id: Option; + let mut dm_message_id: Option = None; + + let message = match target { MessageTarget::Channel(channel_id) => { + let db_thread_id = + match get_thread_by_channel_id(&channel_id.to_string(), &pool).await { + Some(thread) => Some(thread.id.clone()), + None => None, + }; + + thread_id = db_thread_id; + let message = match channel_id.send_message(&self.ctx.http, message).await { Ok(message) => message, - Err(err) => return Err(err), + Err(err) => return Err(ModmailError::from(err)), }; Ok(message) } MessageTarget::User(user_id) => { + let db_thread_id = match get_thread_by_user_id(user_id, &pool).await { + Some(thread) => Some(thread.id.clone()), + None => None, + }; + + thread_id = db_thread_id; + let dm_channel = user_id.create_dm_channel(&self.ctx.http).await?; let message = match dm_channel.send_message(&self.ctx.http, message).await { Ok(message) => message, - Err(err) => return Err(err), + Err(err) => return Err(ModmailError::from(err)), }; + dm_message_id = Some(message.id.to_string()); + Ok(message) } MessageTarget::Reply(original_message) => { + let db_thread_id = + match get_thread_by_channel_id(&original_message.channel_id.to_string(), &pool) + .await + { + Some(thread) => Some(thread.id.clone()), + None => None, + }; + + thread_id = db_thread_id; + let message = match original_message .channel_id .send_message(&self.ctx.http, message) .await { Ok(message) => message, - Err(err) => return Err(err), + Err(err) => return Err(ModmailError::from(err)), }; Ok(message) } + }; + + let message = match message { + Ok(msg) => msg, + Err(e) => return Err(e), + }; + + if to_be_recorded { + let _ = insert_staff_message( + &self.ctx, + &message, + dm_message_id, + &thread_id.unwrap_or_default(), + self.bot_user_id, + false, + &pool, + self.config, + None, + ) + .await; } + + Ok(message) } pub async fn build_create_message(&self) -> CreateMessage { @@ -478,6 +538,10 @@ impl<'a> MessageBuilder<'a> { } } + if let Some(components) = &self.components { + message = message.components(components.clone()); + } + message } @@ -493,6 +557,10 @@ impl<'a> MessageBuilder<'a> { } } + if let Some(components) = &self.components { + message = message.components(components.clone()); + } + message = message.ephemeral(self.ephemeral); message @@ -516,6 +584,10 @@ impl<'a> MessageBuilder<'a> { } } + if let Some(components) = &self.components { + message = message.components(components.clone()); + } + message = message.ephemeral(self.ephemeral); message @@ -526,7 +598,7 @@ impl<'a> MessageBuilder<'a> { } pub async fn send_and_forget(self) { - if let Err(e) = self.send().await { + if let Err(e) = self.send(true).await { eprintln!("Failed to send message: {}", e); } } @@ -566,11 +638,11 @@ impl<'a> MessageBuilder<'a> { config: &'a Config, channel_id: ChannelId, content: String, - ) -> Result { + ) -> Result { Self::system_message(ctx, config) .content(content) .to_channel(channel_id) - .send() + .send(true) .await } @@ -579,11 +651,11 @@ impl<'a> MessageBuilder<'a> { config: &'a Config, user_id: UserId, content: String, - ) -> Result { + ) -> Result { Self::system_message(ctx, config) .content(content) .to_user(user_id) - .send() + .send(true) .await } @@ -592,11 +664,11 @@ impl<'a> MessageBuilder<'a> { config: &'a Config, message: Message, content: String, - ) -> Result { + ) -> Result { Self::system_message(ctx, config) .content(content) .reply_to(message) - .send() + .send(true) .await } } @@ -713,7 +785,7 @@ impl<'a> StaffReply<'a> { .color(hex_string_to_int(&self.config.thread.staff_message_color) as u32) .to_user(dm_user); - match dm_builder.send().await { + match dm_builder.send(true).await { Ok(m) => Some(m), Err(e) => { eprintln!("Failed to send DM to user: {}", e); @@ -728,7 +800,7 @@ impl<'a> StaffReply<'a> { pub async fn send_msg_and_record( self, pool: &SqlitePool, - ) -> Result<(Message, Option), serenity::Error> { + ) -> Result<(Message, Option), ModmailError> { let thread_channel = self .thread_channel .ok_or_else(|| serenity::Error::Other("No thread channel for StaffReply"))?; @@ -757,12 +829,13 @@ impl<'a> StaffReply<'a> { .add_attachments(self.attachments.clone()) .to_channel(thread_channel); - let thread_msg = thread_builder.send().await?; + let thread_msg = thread_builder.send(true).await?; let dm_msg_opt: Option = self.build_and_send_message(top_role_name).await; let dm_id_opt = dm_msg_opt.as_ref().map(|m| m.id.to_string()); if let Err(e) = insert_staff_message( + &self.ctx, &thread_msg, dm_id_opt, &self.thread_id, @@ -770,7 +843,7 @@ impl<'a> StaffReply<'a> { self.is_anonymous, pool, self.config, - self.message_number, + Some(self.message_number as i64), ) .await { @@ -784,7 +857,7 @@ impl<'a> StaffReply<'a> { self, command: &CommandInteraction, pool: &SqlitePool, - ) -> Result<(Message, Option), serenity::Error> { + ) -> Result<(Message, Option), ModmailError> { let thread_channel = self .thread_channel .ok_or_else(|| serenity::Error::Other("No thread channel for StaffReply"))?; @@ -822,7 +895,7 @@ impl<'a> StaffReply<'a> { Ok(m) => m, Err(e) => { println!("Failed to send follow-up message: {}", e); - return Err(e); + return Err(ModmailError::from(e)); } }; @@ -830,6 +903,7 @@ impl<'a> StaffReply<'a> { let dm_id_opt = dm_msg_opt.as_ref().map(|m| m.id.to_string()); if let Err(e) = insert_staff_message( + &self.ctx, &thread_msg, dm_id_opt, &self.thread_id, @@ -837,7 +911,7 @@ impl<'a> StaffReply<'a> { self.is_anonymous, pool, self.config, - self.message_number, + Some(self.message_number as i64), ) .await { @@ -894,7 +968,7 @@ impl<'a> UserIncoming<'a> { self } - pub async fn send_and_record(self, pool: &SqlitePool) -> Result { + pub async fn send_and_record(self, pool: &SqlitePool) -> Result { let thread_channel = self .thread_channel .ok_or_else(|| serenity::Error::Other("No thread channel for UserIncoming"))?; @@ -907,7 +981,7 @@ impl<'a> UserIncoming<'a> { .content(self.content) .add_attachments(self.attachments) .to_channel(thread_channel); - let sent = builder.send().await?; + let sent = builder.send(true).await?; if let Err(e) = insert_user_message_with_ids( self.dm_msg, &sent, diff --git a/src/utils/message/mod.rs b/rustmail/src/utils/message/mod.rs similarity index 73% rename from src/utils/message/mod.rs rename to rustmail/src/utils/message/mod.rs index 6ae5ff11..ab0d3a0e 100644 --- a/src/utils/message/mod.rs +++ b/rustmail/src/utils/message/mod.rs @@ -1,7 +1,9 @@ pub mod message_builder; pub mod reply_intent; +pub mod category; pub mod ui_components; + pub mod ui { use super::{ui_components::ButtonsBuilder, ui_components::ModalBuilder}; pub fn modal(id: impl Into, title: impl Into) -> ModalBuilder { @@ -11,3 +13,9 @@ pub mod ui { ButtonsBuilder::new() } } + +pub use category::*; +pub use message_builder::*; +pub use reply_intent::*; +pub use ui::*; +pub use ui_components::*; diff --git a/src/utils/message/reply_intent.rs b/rustmail/src/utils/message/reply_intent.rs similarity index 100% rename from src/utils/message/reply_intent.rs rename to rustmail/src/utils/message/reply_intent.rs diff --git a/src/utils/message/ui_components.rs b/rustmail/src/utils/message/ui_components.rs similarity index 100% rename from src/utils/message/ui_components.rs rename to rustmail/src/utils/message/ui_components.rs diff --git a/rustmail/src/utils/mod.rs b/rustmail/src/utils/mod.rs new file mode 100644 index 00000000..2c41d904 --- /dev/null +++ b/rustmail/src/utils/mod.rs @@ -0,0 +1,11 @@ +pub mod command; +pub mod conversion; +pub mod message; +pub mod thread; +pub mod time; + +pub use command::*; +pub use conversion::*; +pub use message::*; +pub use thread::*; +pub use time::*; diff --git a/rustmail/src/utils/thread/category.rs b/rustmail/src/utils/thread/category.rs new file mode 100644 index 00000000..eb7c044e --- /dev/null +++ b/rustmail/src/utils/thread/category.rs @@ -0,0 +1,59 @@ +use serenity::all::{Channel, Context, GuildChannel, PermissionOverwriteType, RoleId}; + +pub async fn get_category_id_from_guild_channel(ctx: &Context, channel: &GuildChannel) -> String { + match channel.id.to_channel(&ctx.http).await { + Ok(channel) => match channel.guild() { + Some(guild_channel) => match guild_channel.parent_id { + Some(category_id) => category_id.to_string(), + None => String::new(), + }, + None => String::new(), + }, + _ => String::new(), + } +} + +pub async fn get_category_name_from_guild_channel(ctx: &Context, channel: &GuildChannel) -> String { + match channel.id.to_channel(&ctx.http).await { + Ok(channel) => match channel.guild() { + Some(guild_channel) => match guild_channel.parent_id { + Some(category_id) => match category_id.name(&ctx.http).await { + Ok(category_name) => category_name.clone(), + _ => String::new(), + }, + None => String::new(), + }, + None => String::new(), + }, + _ => String::new(), + } +} + +pub async fn get_required_permissions_channel_from_guild_channel( + ctx: &Context, + channel: &GuildChannel, +) -> u64 { + match channel.id.to_channel(&ctx.http).await { + Ok(Channel::Guild(guild_channel)) => { + let guild_id = guild_channel.guild_id; + let guild = guild_id.to_partial_guild(&ctx.http).await.ok(); + + let everyone_role_id = RoleId::new(guild_id.get()); + + let mut perms = guild + .and_then(|g| g.roles.get(&everyone_role_id).map(|r| r.permissions.bits())) + .unwrap_or(0u64); + + for overwrite in &guild_channel.permission_overwrites { + if let PermissionOverwriteType::Role(_) = overwrite.kind { + let allow = overwrite.allow.bits(); + let deny = overwrite.deny.bits(); + perms = (perms & !deny) | allow; + } + } + + perms + } + _ => 0u64, + } +} diff --git a/src/utils/thread/fetch_thread.rs b/rustmail/src/utils/thread/fetch_thread.rs similarity index 55% rename from src/utils/thread/fetch_thread.rs rename to rustmail/src/utils/thread/fetch_thread.rs index 2b78e490..122ea8c7 100644 --- a/src/utils/thread/fetch_thread.rs +++ b/rustmail/src/utils/thread/fetch_thread.rs @@ -1,11 +1,9 @@ -use crate::{ - db::{get_thread_by_channel_id, repr::Thread}, - errors::{ModmailResult, common}, -}; +use crate::prelude::db::*; +use crate::prelude::errors::*; use sqlx::SqlitePool; pub async fn fetch_thread(db_pool: &SqlitePool, channel_id: &str) -> ModmailResult { get_thread_by_channel_id(channel_id, db_pool) .await - .ok_or_else(common::thread_not_found) + .ok_or_else(thread_not_found) } diff --git a/src/utils/thread/get_thread_lock.rs b/rustmail/src/utils/thread/get_thread_lock.rs similarity index 89% rename from src/utils/thread/get_thread_lock.rs rename to rustmail/src/utils/thread/get_thread_lock.rs index 7830692e..737bdf51 100644 --- a/src/utils/thread/get_thread_lock.rs +++ b/rustmail/src/utils/thread/get_thread_lock.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::prelude::config::*; use std::sync::Arc; pub fn get_thread_lock(config: &Config, key: u64) -> Arc> { diff --git a/rustmail/src/utils/thread/mod.rs b/rustmail/src/utils/thread/mod.rs new file mode 100644 index 00000000..487e5430 --- /dev/null +++ b/rustmail/src/utils/thread/mod.rs @@ -0,0 +1,11 @@ +pub mod category; +pub mod fetch_thread; +pub mod get_thread_lock; +pub mod send_to_thread; +pub mod user_recap; + +pub use category::*; +pub use fetch_thread::*; +pub use get_thread_lock::*; +pub use send_to_thread::*; +pub use user_recap::*; diff --git a/src/utils/thread/send_to_thread.rs b/rustmail/src/utils/thread/send_to_thread.rs similarity index 84% rename from src/utils/thread/send_to_thread.rs rename to rustmail/src/utils/thread/send_to_thread.rs index df3cf29f..2794bb1d 100644 --- a/src/utils/thread/send_to_thread.rs +++ b/rustmail/src/utils/thread/send_to_thread.rs @@ -1,11 +1,13 @@ -use crate::config::Config; -use crate::db::operations::{ - get_staff_alerts_for_user, get_thread_id_by_user_id, is_user_left, mark_alert_as_used, -}; -use crate::i18n::get_translated_message; -use crate::utils::message::message_builder::MessageBuilder; +use crate::prelude::config::*; +use crate::prelude::db::*; +use crate::prelude::errors::*; +use crate::prelude::i18n::*; +use crate::prelude::utils::*; +use crate::types::TicketAuthor; +use chrono::Utc; use serenity::all::{ChannelId, Context, CreateAttachment, GuildId, Message, UserId}; use std::collections::HashMap; +use crate::modules::update_thread_status_ui; fn extract_message_content_with_media(msg: &Message) -> (String, Vec) { let content = msg.content.clone(); @@ -69,12 +71,12 @@ pub async fn send_to_thread( msg: &Message, config: &Config, is_anonymous: bool, -) -> serenity::Result { +) -> ModmailResult { let pool = match &config.db_pool { Some(pool) => pool, None => { eprintln!("Database pool is not set in config."); - return Err(serenity::Error::Other("Database pool not available")); + return Err(ModmailError::Database(DatabaseError::ConnectionFailed)); } }; @@ -88,7 +90,7 @@ pub async fn send_to_thread( .translated_content("user.left_server", Some(¶ms), Some(msg.author.id), None) .await .to_channel(channel_id) - .send() + .send(true) .await; } @@ -106,7 +108,7 @@ pub async fn send_to_thread( ) .await .to_channel(channel_id) - .send() + .send(true) .await; } @@ -123,10 +125,22 @@ pub async fn send_to_thread( Some(thread_id) => thread_id, None => { eprintln!("Failed to get thread ID"); - return Err(serenity::Error::Other("Failed to get thread ID")); + return Err(ModmailError::Thread(ThreadError::ThreadNotFound)); } }; + let mut ticket_status = match get_thread_status(&thread_id.clone(), &pool).await { + Some(status) => status, + None => { + return Err(validation_failed("Failed to get thread status")); + } + }; + + ticket_status.last_message_by = TicketAuthor::User; + ticket_status.last_message_at = Utc::now().timestamp(); + update_thread_status_db(&thread_id.clone(), &ticket_status, &pool.clone()).await?; + update_thread_status_ui(&ctx, &ticket_status).await?; + let builder = MessageBuilder::begin_user_incoming(ctx, config, thread_id.clone(), msg) .to_thread(channel_id) .content(content) @@ -170,7 +184,7 @@ pub async fn send_to_thread( .content(full_content) .mention(mentions) .to_channel(channel_id) - .send() + .send(true) .await; for staff_id in &alerts { diff --git a/rustmail/src/utils/thread/user_recap.rs b/rustmail/src/utils/thread/user_recap.rs new file mode 100644 index 00000000..f66c93b3 --- /dev/null +++ b/rustmail/src/utils/thread/user_recap.rs @@ -0,0 +1,18 @@ +use crate::prelude::utils::*; +use serenity::all::UserId; + +pub fn get_user_recap( + user_id: UserId, + username: &str, + member_join_date: &str, + logs_info: &str, +) -> String { + format!( + "ACCOUNT AGE **{}**, ID **{}**\nNICKNAME **{}**, JOINED **{}** ago\n\n{}", + format_duration_since(user_id.created_at()), + user_id, + username, + member_join_date, + logs_info + ) +} diff --git a/src/utils/time/format_duration_since.rs b/rustmail/src/utils/time/format_duration_since.rs similarity index 100% rename from src/utils/time/format_duration_since.rs rename to rustmail/src/utils/time/format_duration_since.rs diff --git a/src/utils/time/get_member_join_date.rs b/rustmail/src/utils/time/get_member_join_date.rs similarity index 89% rename from src/utils/time/get_member_join_date.rs rename to rustmail/src/utils/time/get_member_join_date.rs index d23be15a..08f2e8f3 100644 --- a/src/utils/time/get_member_join_date.rs +++ b/rustmail/src/utils/time/get_member_join_date.rs @@ -1,4 +1,4 @@ -use crate::utils::time::format_duration_since::format_duration_since; +use crate::prelude::utils::*; use serenity::all::{Context, GuildId, Message, UserId}; pub async fn get_member_join_date( diff --git a/rustmail/src/utils/time/mod.rs b/rustmail/src/utils/time/mod.rs new file mode 100644 index 00000000..c6c58f6c --- /dev/null +++ b/rustmail/src/utils/time/mod.rs @@ -0,0 +1,8 @@ +pub mod format_duration_since; +pub mod get_member_join_date; + +pub use get_member_join_date::*; + +pub use format_duration_since::*; +pub use format_duration_since::*; +pub use get_member_join_date::*; diff --git a/rustmail_panel/Cargo.toml b/rustmail_panel/Cargo.toml new file mode 100644 index 00000000..d5d54e97 --- /dev/null +++ b/rustmail_panel/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "rustmail_panel" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[dependencies] +rustmail_types = { path = "../rustmail_types" } +yew = { version = "0.21", features = ["csr"] } +yew-router = "0.18.0" +wasm-bindgen-futures = "0.4.50" +gloo-net = "0.6.0" +gloo-utils = "0.2.0" +web-sys = { version = "0.3.77", features = ["HtmlSelectElement"] } +serde_json = "1.0.145" +i18nrs = { version = "0.1.7", features = ["yew"] } +serde = { version = "1.0.228", features = ["derive"] } +js-sys = "0.3.77" +wasm-bindgen = "0.2.100" +urlencoding = "2.1.3" +ammonia = "4.1.2" +pulldown-cmark = "0.13.0" +chrono-tz = { version = "0.10", features = ["serde"] } +[build-dependencies] \ No newline at end of file diff --git a/rustmail_panel/Trunk.toml b/rustmail_panel/Trunk.toml new file mode 100644 index 00000000..88d51a0c --- /dev/null +++ b/rustmail_panel/Trunk.toml @@ -0,0 +1,3 @@ +[build] +release = true +generate-integrity = false \ No newline at end of file diff --git a/rustmail_panel/index.html b/rustmail_panel/index.html new file mode 100644 index 00000000..445a6a80 --- /dev/null +++ b/rustmail_panel/index.html @@ -0,0 +1,16 @@ + + + + + + + Rustmail Panel + + + + + + + + + \ No newline at end of file diff --git a/rustmail_panel/src/components/configuration.rs b/rustmail_panel/src/components/configuration.rs new file mode 100644 index 00000000..a8ca7fed --- /dev/null +++ b/rustmail_panel/src/components/configuration.rs @@ -0,0 +1,1381 @@ +use gloo_net::http::Request; +use i18nrs::yew::use_translation; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; + +use rustmail_types::*; + +#[function_component(ConfigurationPage)] +pub fn configuration_page() -> Html { + let (i18n, _set_language) = use_translation(); + + let bot_status = use_state(|| "running".to_string()); + let is_loading = use_state(|| false); + let config = use_state(|| None::); + let config_loading = use_state(|| true); + let show_restart_modal = use_state(|| false); + let save_message = use_state(|| None::<(bool, String)>); + + let expanded_sections = use_state(|| vec![true, false, false, false, false, false, false, false]); + + { + let bot_status = bot_status.clone(); + let is_loading = is_loading.clone(); + + use_effect_with((), move |_| { + spawn_local(async move { + is_loading.set(true); + if let Ok(resp) = Request::get("/api/bot/status").send().await { + if resp.ok() { + if let Ok(json) = resp.json::().await { + if let Some(status) = json["status"].as_str() { + bot_status.set(status.to_string()); + } + } + } + } + is_loading.set(false); + }); + || () + }); + } + + { + let config = config.clone(); + let config_loading = config_loading.clone(); + + use_effect_with((), move |_| { + spawn_local(async move { + config_loading.set(true); + if let Ok(resp) = Request::get("/api/bot/config").send().await { + if resp.ok() { + if let Ok(config_data) = resp.json::().await { + config.set(Some(config_data)); + } + } + } + config_loading.set(false); + }); + || () + }); + } + + let handle_bot_action = { + let bot_status = bot_status.clone(); + let is_loading = is_loading.clone(); + + Callback::from(move |action: String| { + let bot_status = bot_status.clone(); + let is_loading = is_loading.clone(); + + spawn_local(async move { + is_loading.set(true); + let url = format!("/api/bot/{}", action); + if let Ok(resp) = Request::post(&url).send().await { + if resp.ok() { + match action.as_str() { + "start" | "restart" => bot_status.set("running".to_string()), + "stop" => bot_status.set("stopped".to_string()), + _ => {} + } + } + } + is_loading.set(false); + }); + }) + }; + + let handle_save = { + let show_restart_modal = show_restart_modal.clone(); + let save_message = save_message.clone(); + let i18n = i18n.clone(); + + Callback::from(move |new_config: ConfigResponse| { + let show_restart_modal = show_restart_modal.clone(); + let save_message = save_message.clone(); + let i18n = i18n.clone(); + + spawn_local(async move { + match Request::put("/api/bot/config").json(&new_config) { + Ok(req) => match req.send().await { + Ok(resp) => { + if resp.ok() { + save_message.set(Some((true, i18n.t("panel.configuration.save_success")))); + show_restart_modal.set(true); + } else { + let error_msg = resp.text().await.unwrap_or_else(|_| "Erreur inconnue".to_string()); + save_message.set(Some((false, error_msg))); + } + } + Err(e) => { + save_message.set(Some((false, format!("Erreur rĂ©seau : {:?}", e)))); + } + }, + Err(e) => { + save_message.set(Some((false, format!("Erreur : {:?}", e)))); + } + } + }); + }) + }; + + let handle_close_modal = { + let show_restart_modal = show_restart_modal.clone(); + let handle_bot_action = handle_bot_action.clone(); + + Callback::from(move |should_restart: bool| { + show_restart_modal.set(false); + if should_restart { + handle_bot_action.emit("restart".to_string()); + } + }) + }; + + let toggle_section = { + let expanded_sections = expanded_sections.clone(); + Callback::from(move |index: usize| { + let mut sections = (*expanded_sections).clone(); + sections[index] = !sections[index]; + expanded_sections.set(sections); + }) + }; + + html! { +
+
+ +
+

{i18n.t("panel.configuration.title")}

+

{i18n.t("panel.configuration.description")}

+
+ +
+
+
+

{i18n.t("panel.configuration.bot_status")}

+

{i18n.t("panel.configuration.bot_status_description")}

+
+
+
+ + { if *bot_status == "running" { i18n.t("panel.configuration.online") } else { i18n.t("panel.configuration.offline") } } + +
+
+ +
+ + + + + +
+
+ +
+

{i18n.t("panel.configuration.config_file.title")}

+ + { + if let Some((is_success, message)) = (*save_message).clone() { + html! { +
+ {message} +
+ } + } else { + html! {} + } + } + + { + if *config_loading { + html! { +
+
{i18n.t("panel.configuration.loading")}
+
+ } + } else if let Some(cfg) = (*config).clone() { + html! { + + } + } else { + html! { +
+ {i18n.t("panel.configuration.load_error")} +
+ } + } + } +
+ + { + if *show_restart_modal { + html! { +
+
+

{i18n.t("panel.configuration.restart_modal.title")}

+

+ {i18n.t("panel.configuration.restart_modal.message")} +

+
+ + +
+
+
+ } + } else { + html! {} + } + } + +
+
+ } +} + +#[derive(Properties, PartialEq)] +struct ConfigFormProps { + config: ConfigResponse, + on_save: Callback, + expanded_sections: Vec, + on_toggle_section: Callback, +} + +#[function_component(ConfigForm)] +fn config_form(props: &ConfigFormProps) -> Html { + let (i18n, _set_language) = use_translation(); + let config = use_state(|| props.config.clone()); + + html! { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

+ {i18n.t("panel.configuration.save_help")} +

+
+
+ } +} + +#[derive(Properties, PartialEq)] +struct AccordionSectionProps { + title: String, + is_expanded: bool, + on_toggle: Callback<()>, + children: Children, +} + +#[function_component(AccordionSection)] +fn accordion_section(props: &AccordionSectionProps) -> Html { + html! { +
+ + + { + if props.is_expanded { + html! { +
+ {for props.children.iter()} +
+ } + } else { + html! {} + } + } +
+ } +} + +#[derive(Properties, PartialEq)] +struct TextInputProps { + label: String, + value: String, + on_change: Callback, + #[prop_or_default] + input_type: Option, + #[prop_or_default] + placeholder: Option, + #[prop_or_default] + help: Option, +} + +#[function_component(TextInput)] +fn text_input(props: &TextInputProps) -> Html { + let input_type = props.input_type.clone().unwrap_or_else(|| "text".to_string()); + let placeholder = props.placeholder.clone().unwrap_or_default(); + + html! { +
+ + () { + on_change.emit(input.value()); + } + } + }} + class="w-full px-4 py-2 bg-slate-900/50 border border-slate-600 rounded-md text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + { + if let Some(help) = &props.help { + html! {

{help}

} + } else { + html! {} + } + } +
+ } +} + +#[derive(Properties, PartialEq)] +struct TextAreaInputProps { + label: String, + value: String, + on_change: Callback, + #[prop_or(3)] + rows: u32, +} + +#[function_component(TextAreaInput)] +fn textarea_input(props: &TextAreaInputProps) -> Html { + html! { +
+ +