Merge pull request #1 from ByteDice/embed_buttons

Embed buttons
This commit is contained in:
Byte Dice
2026-02-23 16:16:41 +01:00
committed by GitHub
57 changed files with 2777 additions and 2050 deletions
+6 -4
View File
@@ -3,10 +3,12 @@ target/
Cargo.lock Cargo.lock
**/*.rs.bk **/*.rs.bk
*.pdb *.pdb
__pycache__/ **/__pycache__/
.vscode/ .vscode/
# program-created data # program-created data
data/reddit_data.json **/reddit_data.json
data/discord_data.json **/discord_data.json
data/cfg.json **/re_data.json
**/dc_data.json
**/cfg.toml
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+4
View File
@@ -6,7 +6,10 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
base64 = "0.22.1"
clap = { version = "4.5.28", features = ["derive"] } clap = { version = "4.5.28", features = ["derive"] }
flate2 = "1.1.2"
formatx = "0.2.3"
futures = "0.3.31" futures = "0.3.31"
poise = "0.6.1" poise = "0.6.1"
pyo3 = "0.23.4" pyo3 = "0.23.4"
@@ -16,3 +19,4 @@ serde = "1.0.217"
serde_json = "1.0.138" serde_json = "1.0.138"
tokio = { version = "1.43.0", features = ["rt-multi-thread"] } tokio = { version = "1.43.0", features = ["rt-multi-thread"] }
tokio-tungstenite = "0.26.1" tokio-tungstenite = "0.26.1"
toml = "0.8.23"
+62 -44
View File
@@ -1,72 +1,90 @@
<!-- If for some reason you're reading this without MD formatting - please disable word-wrap for your own good. -->
# ByteDiceAssistant # ByteDiceAssistant
An automation tool for Byte Dice. It's both a Discord and Reddit bot in one program. ![](/BDA_icon_64x64.png)\
An automation tool primarily made for myself (Byte Dice) but publicly available for anyone to use. It's both a Discord and Reddit bot in one program.
> [!CAUTION] > [!CAUTION]
> This tool is not intended for public use outside of the official *Byte Dice Assistant* bots. Expect issues if you host this yourself.\ > This tool is not intended for public use outside of the official *Byte Dice Assistant* bots. Expect issues if you host this yourself.\
> This tool is only designed to run on Windows (10 and 11) and XUbuntu and may not work on any other OS. > This tool is only designed to run on Windows (10 and 11) and XUbuntu (24.04 and above) and may not work on any other OS.
> [!NOTE] > [!NOTE]
> This is an older version of the software. The newest version can be found in the [embed_buttons branch](https://github.com/ByteDice/ByteDiceAssistant/tree/embed_buttons).\ > This bot <ins>never modifies any Reddit content</ins>. All it does and will ever do is read/scrape.
> Keep in mind that said branch is unstable and constantly updated with untested stuff. Download code from there at your own risk or wait until this notice is removed or replaced. > The data will never be sold and will only be shared with permission from subreddit moderators.
# Open-source - Copyright ## Open-source - Copyright
**ByteDiceAssistant © 2025 by Byte Dice is licensed under CC BY-NC-SA 4.0.**\ **ByteDiceAssistant © 2025 by Byte Dice is licensed under CC BY-NC-SA 4.0.**\
**You can learn more about copyright by reading the full [license](/LICENSE.txt).** **You can learn more about copyright by reading the full [license](/LICENSE.txt).**
## Commands
| Name | Category | Description |
| -----------------------| -------- | -------------------------------------------------------------------- |
| `help` | help | Sends a help menu. |
| `8_ball` | fun | Sends a random answer to a prompt. |
| `add_server` | admin | Adds your server to the bots database for storage (no data is sold). |
| `embed` | owner | Creates an embed (requires `ASSISTANT_OWNERS` for security reasons). |
| `ping` | fun | Makes the bot reply with "pong" or a custom message. |
| `send` | owner | Sends a message (requires `ASSISTANT_OWNERS` for security reasons). |
| `stop` | owner | Stops the bot (requires `ASSISTANT_OWNERS` for security reasons). |
| ---------------------- | -------- | -------------------------------------------------------------------- |
| `admin_re_bindchannel` | admin | Sets the channel the command was run in as the one where `re_updatediscord` dumps information. This command is required for any of the other "re" commands to work. |
| `re_addpost` | re | Adds a post to the database. |
| `re_approvepost` | re | Flags a post in the database as approved. |
| `re_getpost` | re | Sends information about a post in the database. |
| `re_removepost` | re | "Removes" a post from the database (It actually only flags it as removed). |
| `re_shorturl` | re | Converts a long URL `https://www.reddit.com/r/SUBREDDIT/comments/POST_ID/POST_TITLE/` to a short one `https://redd.it/POST_ID`. |
| `re_topposts` | re | Sends the top posts in a category (such as upvotes). The posts have to be within the database. |
| `re_updatediscord` | re | Dumps the entire database (with a few restrictions) in the `admin_re_bindchannel` channel. |
| `re_vote` | re | Adds a vote (separate from Reddit upvotes) to a post. Use votes however you'd like. |
## How to run ## How to run
### Dependencies: ### Dependencies:
This program uses Rust (v1.82.0) and Python (v3.11.4), you can likely use other versions if they are compatible. This program also uses these Python modules: This program uses Rust (v1.82.0) and Python (v3.11.4), you can likely use other versions if they are compatible.\
* asyncio It is required to install all used Python modules. You can find those in [req.txt](/req.txt). Installation instructions are in the *How to start the program* section.
* asyncpraw
* asyncprawcore
* emoji
* io
* json
* os
* sys
* threading
* time
* typing
* websockets
You can install Python modules by running `$ pip install {module}` or `$ python -m pip install {module}` in a terminal.
### Environment variables: ### Environment variables:
| **Name** | **Description** | | **Name** | **Description** |
| --- | --- | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ASSISTANT_TOKEN` | The Discord bot token. (Create a Discord bot [here](https://discord.com/developers/docs/intro)!) | | `ASSISTANT_TOKEN` | The Discord bot token. (Create a Discord bot [here](https://discord.com/developers/docs/intro)!) |
| `ASSISTANT_TOKEN_TEST` | (Optional) A testing Discord bot token. This is only needed when the program is run with `-t` or `--test`. | | `ASSISTANT_TOKEN_TEST` | (Optional) A testing Discord bot token. This is only needed when the program is run with `-t` or `--test`. |
| `ASSISTANT_R_ID` | The id for the Reddit bot/account. (Create a Reddit bot [here](https://www.reddit.com/prefs/apps)!) | | `ASSISTANT_R_ID` | The id for the Reddit bot/account. (Create a Reddit bot [here](https://www.reddit.com/prefs/apps)!) |
| `ASSISTANT_R_TOKEN` | The token for the Reddit bot/account. | | `ASSISTANT_R_TOKEN` | The token for the Reddit bot/account. |
| `ASSISTANT_R_NAME` | The username of the Reddit bot/account. | | `ASSISTANT_R_NAME` | The username of the Reddit bot/account. |
| `ASSISTANT_R_PASS` | The password for the Reddit bot/account. | | `ASSISTANT_R_PASS` | The password for the Reddit bot/account. |
| `ASSISTANT_OWNERS` | (OPTIONAL) A list of Discord user IDs that "own" the bot. Separate each ID with a single comma and **no** spaces. This will allow the specified user IDs to run root commands such as `/stop`, it will also DM these users when *certain* errors occur. | | `ASSISTANT_OWNERS` | (OPTIONAL) A list of Discord user IDs that "own" the bot. Separate each ID with a single comma and **no** spaces. This will allow the specified user IDs to run root commands such as `/stop`, it will also DM these users when *certain* errors occur. |
| `ASSISTANT_BK_MODS` | (OPTIONAL) Same format as `ASSISTANT_OWNERS` but for people who are allowed to use the `/bk_week` commands. | | `ASSISTANT_BK_MODS` | (OPTIONAL) Same format as `ASSISTANT_OWNERS` but for people who are allowed to use the `/re_*` commands. |
### Required permissions: ### Required permissions:
**These are automatically set if you use the [official invite link](https://discord.com/oauth2/authorize?client_id=1212127255795335208&permissions=84992&integration_type=0&scope=bot) or an invite link with the permissions integer set to `84992`.** **These are automatically set if you use the [official invite link](https://discord.com/oauth2/authorize?client_id=1212127255795335208&permissions=84992&integration_type=0&scope=bot) or an invite link with the permissions integer set to `84992`.** (The permission integer is this part of the URL `&permissions=84992`)
* Send Messages * Send Messages
* Read Message History * Read Message History
* View Channels * View Channels
* Embed Links * Embed Links
### How to run: ### Configuration:
You can find config files in the [cfg/](cfg/) folder. You can also find the default configs in the [data/defaults/cfg_default.toml](data/defaults/cfg_default.toml) file.\
**NOTE:** Some config files are automatically generated, and you will need to run the app once for them to generate.
### How to start the program:
#### Short answer for experienced people:
* Download the code.
* Set the environment variables (listed above).
* Restart the terminal.
* Navigate to the project root directory.
* Install all Python modules. (`pip install -r req.txt`)
* Run with `cargo run`.
* Alternatively, run `cargo run -- {args here}` to add args.
* For help, run `cargo run -- -h` or `cargo run -- --help`.
* To only run the Python part, use `cargo run -- --py`, or for a better error output, `python ./src/python/main.py`
#### Long answer for beginners:
* Download the code (and extract it if needed). * Download the code (and extract it if needed).
* Open a terminal. * Open a terminal.
* Set the environment variables. * Set the environment variables (listed above).
* On Windows: * Run `setx VARIABLE_NAME "value in quotes"` in a terminal. (WINDOWS ONLY)
* Run `$ setx VARIABLE_NAME "value"` in a terminal.
* On Unix (Linux / Mac):
* Run `$ sudo nano /etc/environment` or `$ sudo vim /etc/environment` in the terminal (and enter your password if prompted to).
* press `i` (only if you used VIM).
* write `VARIABLE_NAME="value"` + a new line for every variable.
* if nano: `ctrl + O` (and press enter) then `ctrl + X`.
* if VIM: press `esc` then write `:wq` (and press enter).
* Restart the terminal if you added/changed the environment variables. * Restart the terminal if you added/changed the environment variables.
* Run `$ cd path/to/extracted/folder`. * Run `$ cd path/to/extracted/folder`.
* Run `$ cargo run`. You can view a list of options by running `$ cargo run -- --help` or `$ cargo run -- -h`. * Run `$ cargo run`. You can view a list of options by running `$ cargo run -- --help` or `$ cargo run -- -h`.
* If you only want to run the Python code, you can either run `$ cargo run -- --py`, or `$ python ./src/python/main.py`. The second option is recommended for better error output. * If you only want to run the Python code, you can either run `$ cargo run -- --py`, or `$ python ./src/python/main.py`. The second option is recommended for better performance.
+2 -42
View File
@@ -1,55 +1,15 @@
### High priority: ### High priority:
<!-- - [x] ~~Embed creation tool~~ -->
- [ ] Reddit bot that scrapes images with tag "Original Art" and posts them in Discord server - [ ] Reddit bot that scrapes images with tag "Original Art" and posts them in Discord server
<!-- - [x] ~~Discord bot /bk_help command~~ --> - [ ] handle dm_on_error cfg
<!-- - [x] ~~Scrape the data~~ -->
<!-- - [x] ~~Put it in a JSON~~ -->
<!-- - [x] ~~Multithread so it can run both Discord and Reddit bot!!!~~ -->
<!-- - [x] Security that only allows bk mods to run these commands. -->
<!-- - [x] Some kind of voting system. -->
<!-- - [x] ~~`/bk_week_top [category] [amount]` to get the top N posts in a category (e.g upvotes)~~ -->
<!-- - [x] ~~`/bk_cfg_sr [subreddit]` to change the target subreddit(s)~~ -->
- [ ] Allow disabling bk_week
- [ ] Language files?
- [ ] Allow updating the data autonomously and via manual commands. - [ ] Allow updating the data autonomously and via manual commands.
<!-- - [ ] 10-minute schedule for updating Discord channel (IMPOSSIBLE / REALLY FUCKING HARD) --> - [ ] 10-minute schedule for updating Discord channel (IMPOSSIBLE / REALLY FUCKING HARD)
<!-- - [x] ~~Manually add posts~~ -->
<!-- - [x] ~~via `u/[bot] add`~~ -->
<!-- - [x] ~~via `/bk_week_add [url]`~~ -->
<!-- - [x] ~~2 minute schedule for responding to commands~~ -->
<!-- - [x] ~~Manually remove posts via `/bk_week_remove [url]`~~ -->
<!-- - [x] ~~Manually approve posts via `/bk_week_approve [url]`~~ -->
<!-- - [x] ~~Manually un-approve posts via `/bk_week_disapprove [url]`~~ -->
<!-- - [x] ~~Automatically add scraped posts to JSON~~ -->
<!-- - [x] ~~Remove posts (from data) that are older than 7 days~~ -->
- [ ] Automatically approve posts that don't get caught by reverse image search (ris)
<!-- - [x] Log all posts in a Discord thread -->
<!-- - [x] ~~`/bk_week_bind` to bind a channel for bk_week logs~~ -->
<!-- - [x] Add post if it exists in data but not in channel -->
<!-- - [x] Edit post if it exists in channel and is different in data -->
<!-- - [x] Remove post if its `"removed": true` in data -->
<!-- - [x] `/bk_week_update` to forcefully trigger these ^ -->
<!-- - [x] ~~`/bk_week_get [url]` get the data of a single post from the data~~ -->
### Medium priority: ### Medium priority:
<!-- - [x] ~~JSON -> Rules list~~ -->
- [ ] View single rule (/rule {rule_name})
- [ ] Postfix calculator - [ ] Postfix calculator
- [ ] Postfix generator - [ ] Postfix generator
- [ ] JSON -> BPS class init
- [ ] BPS args -> JSON
- [ ] Random tip (from ByteDice.net/data/loadingScreenTips.json) - [ ] Random tip (from ByteDice.net/data/loadingScreenTips.json)
- [ ] A command that just sends my socials - [ ] A command that just sends my socials
<!-- - [x] ~~Magic 8 ball~~ -->
### Low priority: ### Low priority:
- [ ] Particle of the week
* Starts a 1 week contest where people make particles based on a theme using BDE_ParticleSys
- [ ] Weekly coding competition
* Same as particle of the week but with coding
- [ ] Content update sender - [ ] Content update sender
* Automatically sends sneak peeks (like commit history or manual) of projects when they're updated * Automatically sends sneak peeks (like commit history or manual) of projects when they're updated
- [ ] Language TLDR command
* Shows a TLDR with pros/cons on a programming language
- [ ] PowerPlate info viewer
* Shows basic info on a PowerPlate
+7 -9
View File
@@ -1,12 +1,10 @@
# Reddit Commands # Reddit Commands
To execute a command on the Reddit bot, include `u/ByteDiceAssistant [cmd]` in a comment. To execute a command on the Reddit bot, include `u/{BOTNAME} [cmd]` in a comment. (Replace {BOTNAME} with the actual bot name, and [cmd] with any command listed below).
- **`[cmd]`**: The command you want to run and its arguments.
## `bk_week_add` ## `add_post`
Adds the post to the list of posts. Adds a post to the bots database.
- **Only moderators of a subreddit or the OP (Original Poster) can use this command.** - **Only moderators of a subreddit or the OP (Original Poster) can use this command.**
### **Examples** ### **Examples**
``` * `u/{BOTNAME} add_post`
"u/ByteDiceAssistant bk_week_add" * `Cool art, let me add that. u/{BOTNAME} weekly_art` (weekly art is an alias for add_post that is enabled by default)
"Cool art, let me add that. u/ByteDiceAssistant bk_week_add" * `Cool art. Just gonna u/{BOTNAME} add_post so it can become featured.`
"Cool art. Just gonna u/ByteDiceAssistant bk_week_add so it can become featured."
```
@@ -4,11 +4,11 @@ Error 404: Answer not found.
Consult your nearest wizard. Consult your nearest wizard.
Why should I know? Go ask your cat or something. Why should I know? Go ask your cat or something.
The stars say yes, but the moon disagrees. The stars say yes, but the moon disagrees.
You dont want to know... trust me. You don't want to know... trust me.
Sure, but dont quote me on that. Sure, but don't quote me on that.
In an alternate universe, yes. In an alternate universe, yes.
Why are you asking a stupid bot? Why are you asking a stupid bot?
Only if you bring snacks. Only if you bring snacks.
Flip a coin, Im on break. Flip a coin, I'm on break.
The spirits are buffering... please wait. The spirits are buffering... please wait.
You already know the answer. You already know the answer.
-6
View File
@@ -1,6 +0,0 @@
{
"bk_week": {
"subreddits": "bytedicetesting",
"fetch_limit": 100
}
}
+2
View File
@@ -0,0 +1,2 @@
This file is only here so git saves this folder.
The file is not important and may be removed unless you are contributing to the project.
+37
View File
@@ -0,0 +1,37 @@
[general]
# The language of the bot. Available languages can be found in the data/lang folder.
# NOTE: Do not include the file extension of the language. For example, "en.json" is just "en".
lang = "en"
# The discord bots status text
status = "🎲 https://bytedice.net"
[reddit]
# Which subreddits the bot will scan when executing "re"-category commands.
# Is automatically disabled when `disabled_categories` includes "re".
subreddits = ["bytedicetesting"]
# How many posts the bot scans when running `/re_updatediscord`.
fetch_limit = 100
# A list of flairs the post must be tagged under to be scraped.
# Leave empty to search for everything
search_flairs = ["Original Art"]
# Command aliases for the Reddit bot, since those aren't autocomplete-able.
[reddit.aliases]
add_post = ["weekly_art", "weekly"]
[commands]
# Disabled selected command categories. All commands and their categories can be viewed in the README.
# "admin" and "owner" categories are always enabled.
# [REQUIRES RESTART]
disabled_categories = [
# "fun",
# "help"
# "re"
]
# The chance (between 0..1) for the `/8_ball` command to output a "quirky" answer.
eight_ball_quirky_chance = 0.2
+8
View File
@@ -0,0 +1,8 @@
{
"servers": {
"SERVER ID": {
"re_posts_channel": 0,
"re_disabled": false
}
}
}
@@ -1,7 +1,13 @@
{ {
"bk_weekly_art_posts": { "posts": {
"EXAMPLE VALUE": { "EXAMPLE URL": {
"removed": {
"removed": false,
"by": null,
"reason": null
},
"post_data": { "post_data": {
"subreddit": "bytedicetesting",
"title": "I JUST BOUGHT THE CONTINENT OF NORTH AMERICA FOR A DOLLAR!", "title": "I JUST BOUGHT THE CONTINENT OF NORTH AMERICA FOR A DOLLAR!",
"upvotes": 69420, "upvotes": 69420,
"date_unix": 1738614657, "date_unix": 1738614657,
@@ -21,11 +27,6 @@
"by_human": true, "by_human": true,
"by_ris": true "by_ris": true
} }
},
"EXAMPLE VALUE DELETED": {
"removed": true,
"removed_by": "ME!!!!",
"remove_reason": "i HATED that post >:("
} }
} }
} }
-13
View File
@@ -1,13 +0,0 @@
{
"servers": {
"SERVER ID": {
"bk_week_channel": "CHANNEL ID INT",
"bk_mod_role": "bk mod",
"bk_mods": [
"USER ID 1",
"USER ID 2"
],
"bk_week_disabled": false
}
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"dc_btn_approve": "Approve",
"dc_btn_remove": "Remove",
"dc_btn_unapprove": "Disapprove",
"dc_btn_unremove": "Restore",
"dc_btn_unvote": "Un-vote",
"dc_btn_vote": "Vote",
"dc_msg_8-ball_answer": "## You shook a magic 8-ball...\n**Q:** {0}\n**A:** {1}",
"dc_msg_add_to_data": "Added your server to my data! Thanks for letting me steal it! (/s)",
"dc_msg_bound_channel": "Successfully bound channel ID `{0}` as the \"where all collected Reddit data gets dumped\" channel!",
"dc_msg_cmd_404": "No command \"{0}\" found!\nHint: Try `/help` without any arguments or `/help <category>`",
"dc_msg_corrupted_data": "Oopsies `(。>\\\\<)`. It looks like my data i-is \\**sob*\\*... c-corrupted!\n[From Byte Dice]: I have no idea what I was thinking while writing this at 2am. I'm not removing it.",
"dc_msg_data_server_404": "This server is not in the data!\n Hint: Run the command `/add_server` inside of a Discord server (requires administrator permission).",
"dc_msg_dm_python_err_socket": "Unknown internal Python error occurred: Websocket response error",
"dc_msg_dm_python_err": "Unknown internal Python Error:\n```\n{0}\n```",
"dc_msg_embed_default_embed_desc": "Default english embed description.",
"dc_msg_embed_re_post": "Spoilers and vote length anonymizer for fair review!\n## Post Data:\n**Subreddit:** {0}\n**Post upvotes:** ||`{1:>6}`||\n**Moderator votes:** ||`{2:>6}`||\n**Media type:** `{3}`\n## Listing Data:\n**Added by:** human: {4} | bot: {5}\n**Approved:** {6}",
"dc_msg_embed_re_removed": "## Removed by `{0}`\n**Reason:** {1}",
"dc_msg_err_trace": "Unknown error!\nError trace: {0}",
"dc_msg_failed_shorturl_conversion": "Couldn't convert to shortURL: Invalid Reddit URL format.",
"dc_msg_mandatory_response": "Mandatory response message, please ignore.",
"dc_msg_owner_data_save_complete": "Saving data... Done!\nShutting down...",
"dc_msg_owner_data_save": "Saving data...",
"dc_msg_owner_shutdown_failed_confirmation": "Failed to shut down: Invalid confirmation.",
"dc_msg_re_already_voted": "Couldn't cast a vote: You have already voted on this post!",
"dc_msg_re_also_approved": "Also approved it!",
"dc_msg_re_data_corrupted": "Post URL \"<{0}>\" not found: Post data is corrupted!\n Full details: Could not find key \"posts\" in data file \"re_data.json\"",
"dc_msg_re_help_removed": "Reddit help file not found, someone likely deleted it.\n Hint: You can find the official help text at https://github.com/ByteDice/ByteDiceAssistant in `re_help.md`.\n**CAUTION:** The official help file may contain incorrect information if you're not using the official bot by Byte Dice.",
"dc_msg_re_permdeny_not_re_mod": "Permission denied: You are not a moderator of the subreddit(s) {0}",
"dc_msg_re_post_404": "Post URL \"<{0}>\" not found: Post doesn't exist in the data!\n Hint: Run the command `/re_addpost [URL]` in a Discord channel or `u/ByteDiceAssistant add_post` in a Reddit post.",
"dc_msg_re_post_add_success": "Added post with URL \"<{0}>\"!",
"dc_msg_re_post_approve_remove": "Couldn't approve the post because it has been removed!",
"dc_msg_re_post_approve_success": "Successfully approved the post!",
"dc_msg_re_post_disapprove_success": "Successfully disapproved the post!",
"dc_msg_re_post_remove_success": "Successfully removed post with URL \"<{0}>\"!",
"dc_msg_re_post_unremove_success": "Successfully restored post with URL \"<{0}>\"!",
"dc_msg_re_post_update_success": "Updated post with URL \"<{0}>\"!",
"dc_msg_re_post_vote_removed_post": "Couldn't [vote / un-vote] the post because it has been removed!",
"dc_msg_re_posts_channel_404": "Could not find `re_posts_channel` in data!\nHint: Run `/admin_re_bindchannel` in a (preferably read-only) channel (requires administrator permission).",
"dc_msg_re_vote_err": "Failed to [vote / un-vote]: Unknown internal error.",
"dc_msg_re_vote_mod_success": "Successfully voted (as moderator vote)!",
"dc_msg_re_vote_remove_havent": "Couldn't remove your vote: You haven't voted on this post yet!",
"dc_msg_re_vote_remove_success": "Successfully removed vote!",
"dc_msg_re_vote_success": "Successfully voted!",
"dc_msg_reload_cfg_python_fail": "Failed to reload configs: Failed-type response from Python.",
"dc_msg_reload_cfg_success": "Successfully reloaded the configs!\nNew configs:\n```\n{0}\n```",
"dc_msg_removed_square_brackets": "[REMOVED] {0}",
"dc_msg_shorturl": "ShortURL: <{0}>",
"dc_msg_update_add": "{0}Adding new posts...",
"dc_msg_update_done": "{0}Done!",
"dc_msg_update_editing": "{0}Editing updated posts...",
"dc_msg_update_fetch": "{0}Fetching new posts & updating data file...",
"dc_msg_update_parse": "{0}Parsing messages to JSON...",
"dc_msg_update_read": "{0}Reading messages in <#{1}>...",
"dc_msg_update_removing_dupe": "{0}Removing duplicate posts...",
"dc_msg_update_removing_old": "{0}Removing old posts (threshold: {1}d)...",
"dc_msg_update_removing": "{0}Removing removed posts...",
"dc_msg_whoami": "**Bot \"owner\":** {0}\n**BK moderator:** {1}",
"log_lang_load_success": "Successfully loaded the english language file!",
"none": "None",
"py_re_response_suffix": "^(I am not an AI, I am just a bot. This action was performed automatically by the way. You can report bugs and view my source code [here](https://github.com/ByteDice/ByteDiceAssistant)!)",
"py_re_response_weekly_add": "Successfully added your post to the weekly art submissions! Thank you for participating!",
"py_re_response_weekly_exists": "Couldn't add this post to the submissions! Luckily, it's already there! Thank you for participating!",
"py_re_response_weekly_mod_add": "[MOD ACTION] Successfully added this post to the weekly art submissions!",
"py_re_response_weekly_mod_unremove": "[MOD ACTION] Successfully un-removed this post from the weekly art submissions!"
}
+66
View File
@@ -0,0 +1,66 @@
{
"dc_btn_approve": "Approuver",
"dc_btn_remove": "Supprimer",
"dc_btn_unapprove": "Désapprouver",
"dc_btn_unremove": "Restaurer",
"dc_btn_unvote": "Annuler le vote",
"dc_btn_vote": "Voter",
"dc_msg_8-ball_answer": "## Vous avez secoué une boule magique 8...\n**Q :** {0}\n**R :** {1}",
"dc_msg_add_to_data": "Serveur ajouté à mes données ! Merci de me laisser les voler ! (/s)",
"dc_msg_bound_channel": "Canal avec lidentifiant `{0}` lié avec succès comme canal \"où toutes les données Reddit collectées sont envoyées\" !",
"dc_msg_cmd_404": "Commande \"{0}\" introuvable !\nAstuce : Essayez `/help` sans arguments ou `/help <catégorie>`",
"dc_msg_corrupted_data": "Oups `(。>\\\\<)`. On dirait que mes données sont \\**snif*\\*... c-corrompues !\n[De Byte Dice] : Je nai aucune idée de ce que je faisais à 2h du matin. Je ne le supprime pas.",
"dc_msg_data_server_404": "Ce serveur nest pas dans les données !\n Astuce : Exécutez la commande `/add_server` dans un serveur Discord (nécessite les droits administrateur).",
"dc_msg_dm_python_err_socket": "Erreur Python interne inconnue : erreur de réponse WebSocket",
"dc_msg_dm_python_err": "Erreur Python interne inconnue :\n```\n{0}\n```",
"dc_msg_embed_default_embed_desc": "Description par défaut de l'encart en anglais.",
"dc_msg_embed_re_post": "Spoilers et anonymiseur de durée des votes pour une évaluation équitable !\n## Données du post :\n**Subreddit :** {0}\n**Votes positifs du post :** ||`{1:>6}`||\n**Votes des modérateurs :** ||`{2:>6}`||\n**Type de média :** `{3}`\n## Données d'inscription :\n**Ajouté par :** humain : {4} | bot : {5}\n**Approuvé:** {6}",
"dc_msg_embed_re_removed": "## Supprimé par `{0}`\n**Raison :** {1}",
"dc_msg_err_trace": "Erreur inconnue !\nTrace de lerreur : {0}",
"dc_msg_failed_shorturl_conversion": "Échec de la conversion en shortURL : format dURL Reddit invalide.",
"dc_msg_mandatory_response": "Message de réponse obligatoire, merci dignorer.",
"dc_msg_owner_data_save_complete": "Sauvegarde des données... Terminé !\nFermeture...",
"dc_msg_owner_data_save": "Sauvegarde des données...",
"dc_msg_owner_shutdown_failed_confirmation": "Échec de larrêt : confirmation invalide.",
"dc_msg_re_already_voted": "Impossible de voter : vous avez déjà voté pour ce post !",
"dc_msg_re_also_approved": "Également approuvé !",
"dc_msg_re_data_corrupted": "URL du post \"<{0}>\" introuvable : données du post corrompues !\n Détails : clé \"posts\" introuvable dans le fichier \"re_data.json\"",
"dc_msg_re_help_removed": "Fichier daide Reddit introuvable, il a probablement été supprimé.\n Astuce : Vous pouvez trouver le fichier daide officiel à https://github.com/ByteDice/ByteDiceAssistant dans `re_help.md`.\n**ATTENTION :** Le fichier daide peut contenir des informations incorrectes si vous nutilisez pas le bot officiel de Byte Dice.",
"dc_msg_re_permdeny_not_re_mod": "Permission refusée : vous n’êtes pas modérateur des subreddit(s) {0}",
"dc_msg_re_post_404": "URL du post \"<{0}>\" introuvable : le post nexiste pas dans les données !\n Astuce : Exécutez la commande `/re_addpost [URL]` dans un canal Discord ou `u/ByteDiceAssistant add_post` sur un post Reddit.",
"dc_msg_re_post_add_success": "Post ajouté avec succès à lURL \"<{0}>\" !",
"dc_msg_re_post_approve_remove": "Impossible dapprouver le post car il a été supprimé !",
"dc_msg_re_post_approve_success": "Post approuvé avec succès !",
"dc_msg_re_post_disapprove_success": "Post désapprouvé avec succès !",
"dc_msg_re_post_remove_success": "Post supprimé avec succès à lURL \"<{0}>\" !",
"dc_msg_re_post_unremove_success": "Post restauré avec succès à lURL \"<{0}>\" !",
"dc_msg_re_post_update_success": "Post mis à jour avec succès à lURL \"<{0}>\" !",
"dc_msg_re_post_vote_removed_post": "Impossible de [voter / annuler le vote] : le post a été supprimé !",
"dc_msg_re_posts_channel_404": "Impossible de trouver `re_posts_channel` dans les données !\nAstuce : Exécutez `/admin_re_bindchannel` dans un canal (de préférence en lecture seule) (nécessite les droits administrateur).",
"dc_msg_re_vote_err": "Échec du [vote / annulation du vote] : erreur interne inconnue.",
"dc_msg_re_vote_mod_success": "Vote enregistré avec succès (vote modérateur) !",
"dc_msg_re_vote_remove_havent": "Impossible de retirer le vote : vous navez pas encore voté pour ce post !",
"dc_msg_re_vote_remove_success": "Vote retiré avec succès !",
"dc_msg_re_vote_success": "Vote enregistré avec succès !",
"dc_msg_reload_cfg_python_fail": "Échec du rechargement des configurations : réponse invalide de Python.",
"dc_msg_reload_cfg_success": "Configurations rechargées avec succès !\nNouvelles configurations :\n```\n{0}\n```",
"dc_msg_removed_square_brackets": "[SUPPRIMÉ] {0}",
"dc_msg_shorturl": "ShortURL : <{0}>",
"dc_msg_update_add": "{0}Ajout de nouveaux posts...",
"dc_msg_update_done": "{0}Terminé !",
"dc_msg_update_editing": "{0}Modification des posts mis à jour...",
"dc_msg_update_fetch": "{0}Récupération des nouveaux posts et mise à jour du fichier de données...",
"dc_msg_update_parse": "{0}Analyse des messages vers JSON...",
"dc_msg_update_read": "{0}Lecture des messages dans <#{1}>...",
"dc_msg_update_removing_dupe": "{0}Suppression des posts en double...",
"dc_msg_update_removing_old": "{0}Suppression des anciens posts (seuil : {1}j)...",
"dc_msg_update_removing": "{0}Suppression des posts supprimés...",
"dc_msg_whoami": "**Propriétaire du bot :** {0}\n**Modérateur BK :** {1}",
"log_lang_load_success": "Fichier de langue français chargé avec succès !",
"none": "Aucun",
"py_re_response_suffix": "^(Je ne suis pas une IA, juste un bot. Cette action a été effectuée automatiquement. Vous pouvez signaler des bugs et consulter mon code source [ici](https://github.com/ByteDice/ByteDiceAssistant) !)",
"py_re_response_weekly_add": "Post ajouté avec succès aux soumissions hebdomadaires d'art ! Merci pour votre participation !",
"py_re_response_weekly_exists": "Impossible dajouter ce post, il est déjà présent ! Merci pour votre participation !",
"py_re_response_weekly_mod_add": "[ACTION MOD] Post ajouté avec succès aux soumissions hebdomadaires dart !",
"py_re_response_weekly_mod_unremove": "[ACTION MOD] Post restauré avec succès dans les soumissions hebdomadaires dart !"
}
-1
View File
@@ -1 +0,0 @@
🎲 https://bytedice.net
+5
View File
@@ -0,0 +1,5 @@
asyncpraw
asyncprawcore
emoji
toml
websockets
-752
View File
@@ -1,752 +0,0 @@
use crate::websocket::send_cmd_json;
use crate::{cmds, rs_println, websocket, Context, Error, BK_WEEK};
use crate::messages::*;
use crate::data::{self, dc_bind_bk, get_mutex_data};
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use poise::serenity_prelude::{ChannelId, EditMessage, GetMessages, Http, Message, MessageId, UserId};
use poise::ReplyHandle;
use serde_json::{json, Map, Value};
#[derive(poise::ChoiceParameter, PartialEq)]
enum TopCategory {
Upvotes,
ModVotes,
Oldest,
Newest
}
fn is_bk_mod(mod_list: Vec<u64>, uid: u64) -> bool {
return mod_list.contains(&uid);
}
async fn not_bk_mod_msg(ctx: Context<'_>) {
send_msg(ctx, "Permission denied: You are not a moderator of r/boykisser or r/boykisser2".to_string(), true, true).await;
}
#[poise::command(
slash_command,
prefix_command,
category = "bk_week",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS"
)]
/// Fetches the data of a single post, just for you. The data has to be within the database to work.
pub async fn bk_week_get(
ctx: Context<'_>,
#[description = "The post URL."] url: String
) -> Result<(), Error>
{
data::update_re_data(ctx.data()).await;
let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?;
if let Some(post) = get_post_from_data(ctx, &reddit_data, &url).await? {
send_embed_for_post(ctx, post, &url).await?;
}
return Ok(());
}
async fn get_post_from_data(ctx: Context<'_>, reddit_data: &Value, url: &str) -> Result<Option<Value>, Error> {
if let Some(bk_week) = reddit_data.get(BK_WEEK) {
if let Some(post) = bk_week.get(url) {
if post.get("removed").is_some() {
send_post_removed_message(ctx, url, post).await;
return Ok(None);
}
return Ok(Some(post.clone()));
}
else {
send_post_not_found_message(ctx, url).await;
}
}
else {
send_data_corrupted_message(ctx, url).await;
rs_println!("{}", serde_json::to_string_pretty(reddit_data)?);
}
return Ok(None);
}
async fn send_embed_for_post(ctx: Context<'_>, post: Value, url: &str) -> Result<(), Error> {
send_embed(ctx, embed_post(&post, url, true), true).await;
Ok(())
}
async fn send_post_not_found_message(ctx: Context<'_>, url: &str) {
send_msg(
ctx,
format!(
r#"Post URL \"<{}>\" not found: Post doesn't exist in the data!
Hint: Run the command `/bk_week_add [URL]` in a Discord channel or `u/ByteDiceAssistant bk_week_add` in a Reddit post."#,
url
).trim().to_string(),
true,
true
).await;
}
async fn send_post_removed_message(ctx: Context<'_>, url: &str, post: &Value) {
send_embed(
ctx,
embed_post_removed(post, url, true),
true
).await;
}
async fn send_data_corrupted_message(ctx: Context<'_>, url: &str) {
send_msg(
ctx,
format!(
r#"Post URL \"<{}>\" not found: Post data is corrupted!
Full details: Could not find key \"bk_weekly_art_posts\" in data file \"reddit_data.json\""#,
url,
).trim().to_string(),
true,
true
).await;
}
#[poise::command(
slash_command,
prefix_command,
category = "bk_week",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Fetches a post from Reddit and adds it to the database.
pub async fn bk_week_add(
ctx: Context<'_>,
#[description = "The post URL."] url: String,
#[description = "Wether to approve it after adding it"] approve: Option<bool>
) -> Result<(), Error>
{
if !is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) {
not_bk_mod_msg(ctx).await;
return Ok(());
}
let shorturl_u = cmds::to_shorturl(&url);
let shorturl = &shorturl_u.unwrap_or(url.clone());
data::update_re_data(ctx.data()).await;
let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?;
if let Some(bk_week) = reddit_data.get(BK_WEEK) {
let a = approve.unwrap_or(false);
let r = websocket::send_cmd_json("add_post_url", Some(json!([&shorturl, a, true]))).await.unwrap();
if !r["value"].as_bool().unwrap() {
send_msg(
ctx,
r#"Unknown error!
Error trace: `bk_week_cmds.rs -> bk_week_add() -> Unknown error`.
Common reasons: The URL provided was likely invalid or 403: forbidden (e.g a private subreddit)."#.to_string(),
true,
true
).await;
return Ok(());
}
if let Some(post) = bk_week.get(shorturl) {
if post.get("removed").is_some() {
send_unremove_msg(ctx, shorturl).await;
}
else {
send_updated_msg(ctx, shorturl).await;
}
}
else {
send_msg(ctx, format!("Added post with URL \"<{}>\"!", &shorturl), true, true).await;
}
if a {
send_msg(ctx, "Also approved it!".to_string(), true, true).await;
}
}
return Ok(());
}
async fn send_unremove_msg(ctx: Context<'_>, url: &str) {
send_msg(ctx, format!("Un-removed post with URL \"<{}>\"!", url), true, true).await;
}
async fn send_updated_msg(ctx: Context<'_>, url: &str) {
send_msg(ctx, format!("Updated post with URL \"<{}>\"!", url), true, true).await;
}
#[poise::command(
slash_command,
prefix_command,
category = "bk_week",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Removes a post from the database. It will show who last removed it.
pub async fn bk_week_remove(
ctx: Context<'_>,
#[description = "The post URL."] url: String,
#[description = "The reason of the removal."] reason: Option<String>
) -> Result<(), Error>
{
if !is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) {
not_bk_mod_msg(ctx).await;
return Ok(());
}
let auth = &ctx.author().name;
let r = send_cmd_json("remove_post_url", Some(json!([&url, &auth, &reason]))).await.unwrap();
if r["value"].as_bool().unwrap() {
send_msg(
ctx,
"Successfully flagged the post as removed!".to_string(),
true,
true
).await;
}
else {
send_post_not_found_message(ctx, &url).await;
}
return Ok(());
}
#[poise::command(
slash_command,
prefix_command,
category = "bk_week",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Approves a post in the database. Approving posts tells the bot that it's original.
pub async fn bk_week_approve(
ctx: Context<'_>,
#[description = "The post URL."] url: String,
#[description = "Wether to approve or disapprove the post"] disapprove: Option<bool>
) -> Result<(), Error>
{
if !is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) {
not_bk_mod_msg(ctx).await;
return Ok(());
}
data::update_re_data(ctx.data()).await;
let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?;
approve_cmd(ctx, &url, &reddit_data, !disapprove.unwrap_or(false)).await;
return Ok(());
}
async fn approve_cmd(ctx: Context<'_>, url: &str, reddit_data: &Value, approve: bool) {
if let Some(post) = reddit_data.get(BK_WEEK).unwrap().get(url) {
if post.get("removed").is_some() {
send_post_removed_message(ctx, url, post).await;
return;
}
let r = websocket::send_cmd_json("set_approve_post", Some(json!([approve, &url]))).await.unwrap();
if r.get("value").is_some() {
if approve {
send_msg(ctx, "Successfully flagged the post as approved (by a human)!".to_string(), true, true).await;
}
else {
send_msg(ctx, "Successfully removed the \"approved (by a human)\" flag from the post!".to_string(), true, true).await;
}
}
else {
send_msg(ctx, "Unknown error!\nError trace: `bk_week_cmds.rs -> bk_week_approve() -> unwrap websocket result error`.".to_string(), true, true).await;
}
}
else {
send_post_not_found_message(ctx, url).await;
}
}
#[poise::command(
slash_command,
prefix_command,
category = "admin",
default_member_permissions = "ADMINISTRATOR",
guild_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Sets the channel where the bot will dump all log info. It's recommended to only run this once.
pub async fn bk_admin_bind(
ctx: Context<'_>
) -> Result<(), Error>
{
let c_id = ctx.channel_id().into();
let r = dc_bind_bk(ctx.data(), ctx.guild_id().unwrap().into(), c_id).await;
if r.is_ok() {
send_msg(ctx, format!("Successfully bound channel ID `{}` as the bk_week channel!", c_id), true, true).await;
}
else {
send_server_not_in_data_msg(ctx).await;
}
return Ok(());
}
async fn send_server_not_in_data_msg(ctx: Context<'_>) {
send_msg(ctx, "Your server is not in the data!\nHint: Run the command `/add_server` inside of a Discord server.".to_string(), true, true).await;
}
#[poise::command(
slash_command,
prefix_command,
category = "bk_week",
guild_only,
guild_cooldown = 120,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | READ_MESSAGE_HISTORY | EMBED_LINKS"
)]
/// Updates all logs
pub async fn bk_week_update(
ctx: Context<'_>,
#[description = "Only adds new posts, leaves everything else unchanged."]
only_add: Option<bool>,
#[description = "The max age of a post (in days). Any post older than this will be removed. (0 is infinite.)"]
#[min = 0]
#[max = 65535]
max_age: Option<u16>
) -> Result<(), Error>
{
let http = ctx.http();
let mut p_text = "`/bk_week_update`:".to_string();
let progress = send_msg(ctx, p_text.clone(), true, true).await.unwrap();
p_text = update_progress(ctx, progress.clone(), p_text, "\nFetching new posts & updating data file...".to_string()).await;
let max_age_u = max_age.unwrap_or(8);
let max_age_secs = max_age_u as u64 * (60 * 60 * 24);
send_cmd_json("add_new_posts", Some(json!([max_age_secs]))).await;
data::update_re_data(ctx.data()).await;
let r_data = get_mutex_data(&ctx.data().reddit_data).await?;
let c_id_u = get_c_id(ctx).await;
if c_id_u.is_none() {
send_msg(ctx, "Could not find bk_week_channel in data!\nHint: Run (or tell an admin to run) `/bk_admin_bind` in a (preferably read-only) channel.".to_string(), true, true).await;
return Ok(());
}
let c_id = c_id_u.unwrap();
// Reading messages
p_text = update_progress(ctx, progress.clone(), p_text.clone(), format!("\nReading messages in <#{}>...", c_id)).await;
let msgs = read_msgs(http, ctx.framework().bot_id, c_id).await;
// Parsing messages to JSON
p_text = update_progress(ctx, progress.clone(), p_text.clone(), "\nParsing messages to JSON...".to_string()).await;
let msgs_json = msgs_to_json(msgs, &r_data, max_age_secs).await;
// Adding new posts
p_text = update_progress(ctx, progress.clone(), p_text.clone(), "\nAdding new posts...".to_string()).await;
let weekly_art = r_data[BK_WEEK].as_object().unwrap();
add_posts(http, c_id, weekly_art, &msgs_json, max_age_secs).await;
// Stop if only_add
if only_add.unwrap_or(false) {
send_msg(ctx, "`/bk_week_update`\n## Done!".to_string(), true, true).await;
update_progress(ctx, progress.clone(), p_text, "\n## Done!".to_string()).await;
return Ok(());
}
// Editing updated posts
p_text = update_progress(ctx, progress.clone(), p_text.clone(), "\nEditing updated posts...".to_string()).await;
edit_posts(http, c_id, weekly_art, &msgs_json).await;
// Removing removed posts
p_text = update_progress(ctx, progress.clone(), p_text.clone(), "\nRemoving removed posts...".to_string()).await;
remove_posts(http, c_id, weekly_art, &msgs_json).await;
// Removing old posts
if max_age_u > 0 {
p_text = update_progress(ctx, progress.clone(), p_text.clone(), format!("\nRemoving old posts (threshold: {}d)...", max_age_u)).await;
remove_old(http, c_id, &msgs_json).await;
send_cmd_json("remove_old_posts", Some(json!([max_age_secs]))).await;
}
// Removing duplicate posts
p_text = update_progress(ctx, progress.clone(), p_text.clone(), "\nRemoving duplicate posts...".to_string()).await;
remove_dupes(http, c_id, &msgs_json).await;
// Done
update_progress(ctx, progress.clone(), p_text, "\n## Done!".to_string()).await;
send_msg(ctx, "`/bk_week_update`\n## Done!".to_string(), true, true).await;
return Ok(());
}
async fn update_progress(ctx: Context<'_>, p: ReplyHandle<'_>, t: String, added_t: String) -> String {
let p_text = format!("{} {}", t, added_t);
edit_reply(ctx, p, p_text.clone()).await;
return p_text;
}
async fn get_c_id(ctx: Context<'_>) -> Option<ChannelId> {
if !data::dc_contains_server(ctx.data(), ctx.guild_id().unwrap().into()).await {
send_server_not_in_data_msg(ctx).await;
return None;
}
let d = get_mutex_data(&ctx.data().discord_data).await.unwrap();
let c_id_u =
d["servers"]
[ctx.guild_id().unwrap().to_string()]
["bk_week_channel"].as_u64().unwrap();
let c_id = ChannelId::new(c_id_u);
return Some(c_id);
}
async fn read_msgs(http: &Http, bot_id: UserId, c_id: ChannelId) -> Vec<Message> {
let b = GetMessages::new().limit(100);
let mut msgs = c_id.messages(http, b).await.unwrap();
msgs.retain(|item| item.author.id == bot_id);
let mut last_msg: Option<Message> = msgs.last().cloned();
while last_msg.is_some() {
let new_b = GetMessages::new().limit(100).before(last_msg.clone().unwrap());
let new_msgs = c_id.messages(http, new_b).await.unwrap();
last_msg = new_msgs.last().cloned();
if new_msgs.is_empty() {
break;
}
let filtered_msgs: Vec<Message> = new_msgs
.into_iter()
.filter(|item| item.author.id == bot_id)
.collect();
msgs.extend(filtered_msgs);
}
return msgs;
}
async fn msgs_to_json(msgs: Vec<Message>, reddit_data: &Value, max_age: u64) -> Value {
let mut msgs_json: Value = json!({"no_change": {}, "updated": {}, "removed": {}, "duplicates": {}, "old": {}});
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
for msg in msgs {
if msg.embeds.is_empty() { continue; }
if msg.embeds[0].url.is_none() { continue; }
let url = msg.embeds[0].url.clone().unwrap();
// duplicates
if ["no_change", "updated", "removed", "old"]
.iter()
.any(|key| msgs_json[key].as_object().unwrap().contains_key(&url))
{
let dupes_mut = msgs_json["duplicates"].as_object_mut().unwrap();
if !dupes_mut.contains_key(&url) {
dupes_mut.insert(url.clone(), json!(msg.id.get()));
}
continue;
}
let msg_desc = &msg.embeds[0].description.clone().unwrap();
let msg_lines = msg_desc.split("\n");
let msg_last_len = msg_lines.clone().last().unwrap().len();
if msg_last_len < 13 { continue; }
let msg_json_str = &msg_lines.clone().last().unwrap()[9..msg_last_len - 3];
let msg_json = serde_json::from_str(msg_json_str);
if msg_json.is_err() { continue; }
let mut u_json: Value = msg_json.unwrap();
let re_url = &reddit_data[BK_WEEK][&url];
let post_date = re_url["post_data"]["date_unix"].as_u64().unwrap_or(0);
// old
if now - post_date > max_age {
if let Some(obj) = msgs_json["old"].as_object_mut() {
obj.insert(url.clone(), json!(msg.id.get()));
continue;
}
}
// removed
if re_url.get("removed").is_some() {
if u_json.get("removed").is_some() {
// no change
if let Some(obj) = msgs_json["no_change"].as_object_mut() {
obj.insert(url.clone(), json!(msg.id.get()));
continue;
}
}
// removed
if let Some(obj) = msgs_json["removed"].as_object_mut() {
obj.insert(url.clone(), json!(msg.id.get()));
continue;
}
}
// updated
if u_json["added"] != re_url["added"]
|| u_json["approved"] != re_url["approved"]
|| u_json["post_data"]["upvotes"] != re_url["post_data"]["upvotes"]
|| u_json["votes"]["mod_voters"] != re_url["votes"]["mod_voters"]
{
u_json.as_object_mut().unwrap().insert("msg_id".to_string(), Value::String(msg.id.clone().to_string()));
if let Some(obj) = msgs_json["updated"].as_object_mut() {
obj.insert(url.clone(), json!(msg.id.get()));
continue;
}
}
// no change
if let Some(obj) = msgs_json["no_change"].as_object_mut() {
obj.insert(url.clone(), json!(msg.id.get()));
}
}
return msgs_json;
}
async fn add_posts(http: &Http, c_id: ChannelId, r_data: &Map<String, Value>, msgs_json: &Value, max_age: u64) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
for url in r_data.keys() {
if ["no_change", "updated", "removed", "old", "duplicates"]
.iter()
.any(|key| msgs_json[key].as_object().unwrap().contains_key(url))
{ continue; }
let post_date = r_data[url]["post_data"]["date_unix"].as_u64().unwrap();
if now - post_date > max_age { continue; }
if r_data[url].get("removed").is_some() {
http_send_embed(http, c_id, embed_post_removed(&r_data[url], url, false)).await;
continue;
}
http_send_embed(http, c_id, embed_post(&r_data[url], url, false)).await;
}
}
async fn edit_posts(http: &Http, c_id: ChannelId, r_data: &Map<String, Value>, msgs_json: &Value) {
for (url, msg_id) in msgs_json["updated"].as_object().unwrap() {
let mut msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap();
let r = EditMessage::new()
.embeds(vec![embed_from_options(embed_post(&r_data[url], url, false))]);
let _ = msg.edit(http, r).await;
}
}
async fn remove_posts(http: &Http, c_id: ChannelId, r_data: &Map<String, Value>, msgs_json: &Value) {
for (url, msg_id) in msgs_json["removed"].as_object().unwrap() {
let mut msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap();
let r = EditMessage::new()
.embeds(vec![embed_from_options(embed_post_removed(&r_data[url], url, false))]);
let _ = msg.edit(http, r).await;
}
}
async fn remove_old(http: &Http, c_id: ChannelId, msgs_json: &Value) {
for (_url, msg_id) in msgs_json["old"].as_object().unwrap() {
let msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap();
let _ = msg.delete(http).await;
}
}
async fn remove_dupes(http: &Http, c_id: ChannelId, msgs_json: &Value) {
for (_url, msgs) in msgs_json["duplicates"].as_object().unwrap() {
for msg_id in msgs.as_array().unwrap() {
let msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap();
let _ = msg.delete(http).await;
}
}
}
#[poise::command(
slash_command,
prefix_command,
category = "bk_week",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Adds/removes a vote from a post. These votes are not tied to Reddit upvotes.
pub async fn bk_week_vote(
ctx: Context<'_>,
#[description = "The post URL."] url: String,
#[description = "Wether to undo your vote or not"] un_vote: Option<bool>
) -> Result<(), Error>
{
data::update_re_data(ctx.data()).await;
let uid = ctx.author().id.get();
let re_data = get_mutex_data(&ctx.data().reddit_data).await?;
let post_data = re_data[BK_WEEK].clone();
let unw_vote = un_vote.unwrap_or(false);
if post_data.get(&url).is_none() {
send_post_not_found_message(ctx, &url).await;
return Ok(());
}
if post_data[&url].get("removed").is_some() {
send_post_removed_message(ctx, &url, &post_data[&url]).await;
return Ok(());
}
let url_data = &post_data[&url];
let is_mod = is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get());
let voters_dc = url_data["votes"]["voters_dc"].as_array().unwrap();
let mod_voters = url_data["votes"]["mod_voters"].as_array().unwrap();
let voters = if is_mod { mod_voters } else { voters_dc };
if voters.contains(&json!(uid)) && !unw_vote {
send_msg(ctx, "Couldn't cast a vote: You have already voted on this post!".to_string(), true, true).await;
return Ok(());
}
else if !voters.contains(&json!(uid)) && unw_vote {
send_msg(ctx, "Couldn't remove your vote: You haven't voted on this post yet!".to_string(), true, true).await;
return Ok(());
}
let r = send_cmd_json("set_vote_post", Some(json!([url, uid, is_mod, true, unw_vote]))).await.unwrap();
let unw_r = r["value"].as_bool().unwrap();
if unw_r && !unw_vote && is_mod {
send_msg(ctx, "Successfully voted (as moderator vote)!".to_string(), true, true).await;
}
else if unw_r && !unw_vote && !is_mod {
send_msg(ctx, "Successfully voted!".to_string(), true, true).await;
}
else if unw_r && unw_vote {
send_msg(ctx, "Successfully removed vote!".to_string(), true, true).await;
}
else {
send_msg(ctx, "Failed to vote/un-vote: Unknown internal error".to_string(), true, true).await;
}
return Ok(());
}
#[poise::command(
slash_command,
prefix_command,
category = "bk_week",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS"
)]
/// Gets the top N (up to 10) posts within a certain category, such as upvotes. (Sorted descending.)
pub async fn bk_week_top(
ctx: Context<'_>,
#[description = "The sorting criteria, such as upvotes."]
category: TopCategory,
#[description = "The amount of posts to show (max 10)."]
#[min = 1]
#[max = 10]
amount: Option<u8>
) -> Result<(), Error>
{
let mut all: HashMap<&str, i32> = HashMap::new();
let posts = &get_mutex_data(&ctx.data().reddit_data).await?[BK_WEEK];
let posts_u = posts.as_object().unwrap();
for (url, dat) in posts_u {
if dat.get("removed").is_some() { continue; }
let val: i32 = match category {
TopCategory::Upvotes => dat["post_data"]["upvotes"].as_i64().unwrap() as i32,
TopCategory::ModVotes => dat["votes"]["mod_voters"].as_array().unwrap().len() as i32,
TopCategory::Oldest
| TopCategory::Newest => dat["post_data"]["date_unix"].as_i64().unwrap() as i32,
};
all.insert(url, val);
}
let amount_u = amount.unwrap_or(3);
let amount_clamped = amount_u.clamp(1, 10);
let top =
if category != TopCategory::Oldest
{ largest_n (&all, amount_clamped as usize) }
else { smallest_n(&all, amount_clamped as usize) };
for post in top {
let url = post.0;
let _ = send_embed_for_post(ctx, posts_u[url].clone(), url).await;
}
return Ok(());
}
fn largest_n<'a>(map: &'a HashMap<&'a str, i32>, n: usize) -> Vec<(&'a str, i32)> {
let mut vec: Vec<_> = map.iter().collect();
vec.sort_unstable_by(|a, b| b.1.cmp(a.1));
vec.into_iter().take(n).map(|(&k, &v)| (k, v)).collect()
}
fn smallest_n<'a>(map: &'a HashMap<&'a str, i32>, n: usize) -> Vec<(&'a str, i32)> {
let mut vec: Vec<_> = map.iter().collect();
vec.sort_unstable_by(|a, b| a.1.cmp(b.1));
vec.into_iter().take(n).map(|(&k, &v)| (k, v)).collect()
}
-505
View File
@@ -1,505 +0,0 @@
use std::collections::HashMap;
use std::process;
use std::error::Error as StdErr;
use crate::data::{dc_add_server, get_mutex_data, read_cfg_data};
use crate::websocket::send_cmd_json;
use crate::{data, Context, Data, Error};
use crate::messages::{edit_reply, send_embed, send_msg, Author, EmbedOptions, MANDATORY_MSG};
use poise::serenity_prelude::{OnlineStatus, Timestamp};
use poise::Command;
use rand::{seq::IteratorRandom, Rng};
use regex::Regex;
use serde_json::json;
use tokio::fs;
#[derive(poise::ChoiceParameter, PartialEq)]
enum HelpOptions {
Admin,
All,
BkWeek,
BkWeekReddit,
Generic
}
type Cmd = Command<Data, Box<dyn StdErr + Send + Sync>>;
#[poise::command(
slash_command,
prefix_command,
category = "fun",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Check if you have connection to the bot.
pub async fn ping(
ctx: Context<'_>,
#[description = "The text to echo back."] text: Option<String>,
) -> Result<(), Error>
{
send_msg(ctx, text.unwrap_or_else(|| "Pong".to_string()), true, true).await;
return Ok(());
}
#[poise::command(
slash_command,
prefix_command,
category = "owner",
owners_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// I have security measures, even in developer mode. You wont access this without being a bot "owner".
pub async fn stop(
ctx: Context<'_>,
#[description = "Type \"i want to stop the bot now\" to confirm."] confirmation: Option<String>,
) -> Result<(), Error>
{
let should_stop = ctx.data().args.dev
|| confirmation.unwrap_or_default().to_lowercase() == "i want to stop the bot now";
if should_stop {
let msg = send_msg(ctx, "Saving data...".to_string(), true, true).await.unwrap();
data::write_dc_data(ctx.data()).await;
data::write_re_data().await;
send_cmd_json("stop_praw", None).await;
edit_reply(ctx, msg, "Saving data... Done!\nShutting down...".to_string()).await;
ctx.serenity_context().set_presence(None, OnlineStatus::Invisible);
ctx.framework().shard_manager.shutdown_all().await;
process::exit(0);
}
else {
send_msg(ctx, "Failed to shut down: Invalid confirmation.".to_string(), true, true).await;
}
return Ok(());
}
#[allow(clippy::too_many_arguments)]
#[poise::command(
slash_command,
prefix_command,
category = "owner",
owners_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS"
)]
/// Creates an embed.
pub async fn embed(
ctx: Context<'_>,
#[description = "Title of embed."] title: Option<String>,
#[description = "Body text of embed."] description: String,
#[description = "Color of side strip."] color: Option<u32>,
#[description = "A URL the title is bound to."] url: Option<String>,
#[description = "Timestamp at bottom (best to leave empty)."] timestamp: Option<Timestamp>,
#[description = "Ephemeral (only visible to you)."] ephemeral: Option<bool>,
#[description = "Shows \"used {Command}\" reply text."] reply: Option<bool>,
#[description = "Text that appears above and outside of the embed."] message: Option<String>,
#[description = "A URL for a thumbnail image."] thumbnail: Option<String>,
#[description = "Sets yourself as the author."] author: Option<bool>
) -> Result<(), Error>
{
let reply_unwrap = reply.unwrap_or(false);
send_embed(
ctx,
EmbedOptions {
desc: description.replace("\\n", "\n"),
title: title.map(|t| t.replace("\\n", "\n")),
col: color,
url,
ts: timestamp,
ephemeral: ephemeral.unwrap_or(false),
message,
thumbnail,
author: if author.unwrap_or(false) { Some(Author { name: ctx.author().name.clone(), url: "".to_string(), icon_url: ctx.author().avatar_url().unwrap() }) } else { None }
},
reply_unwrap
).await;
if !reply_unwrap {
send_msg(ctx, MANDATORY_MSG.to_string(), true, true).await;
}
return Ok(());
}
#[poise::command(
slash_command,
prefix_command,
category = "owner",
owners_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Sends a message.
pub async fn send(
ctx: Context<'_>,
#[description = "The message to send (NOT EPHEMERAL)"] msg: String
) -> Result<(), Error>
{
send_msg(ctx, msg.replace("\\n", "\n"), false, false).await;
send_msg(ctx, MANDATORY_MSG.to_string(), true, true).await;
return Ok(());
}
#[poise::command(
slash_command,
prefix_command,
category = "fun",
rename = "8_ball",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Magic 8-ball. Ask a question, get an answer.
pub async fn eight_ball(
ctx: Context<'_>,
#[description = "Question to ask."] question: String
) -> Result<(), Error>
{
let is_quirky = rand::rng().random_bool(0.2);
let list = &ctx.data().ball_prompts[if is_quirky { 1 } else { 0 }];
let rand_item = list.iter().choose(&mut rand::rng());
send_msg(
ctx,
format!("Q: {}\nA: {}", question, rand_item.unwrap()),
true,
true
).await;
return Ok(());
}
#[poise::command(
slash_command,
prefix_command,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Convert a long reddit URL to a short one. The bot ONLY uses shortURLs when asking for one.
pub async fn re_shorturl(
ctx: Context<'_>,
#[description = "A Reddit post URL"] url: String
) -> Result<(), Error>
{
let shorturl = to_shorturl(&url);
if shorturl.is_ok() {
send_msg(ctx, format!("ShortURL: <{}>", shorturl.unwrap()), true, true).await;
}
else {
send_msg(ctx, "Couldn't convert to shortURL: Invalid URL".to_string(), true, true).await;
}
return Ok(());
}
pub fn to_shorturl(url: &str) -> Result<String, &str> {
let re = Regex::new(r"comments/([a-zA-Z0-9]+)").unwrap();
if let Some(caps) = re.captures(url) {
let post_id = &caps[1];
let short_url = format!("https://redd.it/{}", post_id);
return Ok(short_url);
}
return Err("Invalid URL");
}
#[poise::command(
slash_command,
prefix_command,
category = "admin",
default_member_permissions = "ADMINISTRATOR",
guild_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Add your server to my database so I can sell it! (/s), I only store some minimal data the bot needs.
pub async fn add_server(
ctx: Context<'_>
) -> Result<(), Error>
{
let r = dc_add_server(ctx.data(), ctx.guild_id().unwrap().into()).await;
if r.is_ok() {
send_msg(ctx, "Added your server to my data! Thanks for letting me steal it! (/s)".to_string(), true, true).await;
}
else {
send_msg(ctx, "Oopsies `(。>\\\\<)`. It looks like my data i-is \\**sob*\\*... c-corrupted!".to_string(), true, true).await;
}
return Ok(());
}
#[poise::command(
slash_command,
prefix_command,
category = "owner",
owners_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Reloads the entire config file.
pub async fn reload_cfg(
ctx: Context<'_>
) -> Result<(), Error>
{
read_cfg_data(&ctx.data(), false).await;
let d = get_mutex_data(&ctx.data().cfg).await?;
let d_str = serde_json::to_string(&d)?;
let r = send_cmd_json("update_cfg", Some(json!([d_str]))).await;
if r.is_some() && r.unwrap()["value"].as_bool().unwrap() {
send_msg(
ctx,
format!("Successfully reloaded the configs!\nNew configs:\n```\n{}\n```", serde_json::to_string_pretty(&d)?),
true,
true
).await;
return Ok(());
}
send_msg(ctx, "Failed to reload configs: Failed-type response from Python.".to_string(), true, true).await;
return Ok(());
}
#[poise::command(
slash_command,
prefix_command,
category = "help",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Shows helpful information on how to use the bk_week section of the bot.
pub async fn help(
ctx: Context<'_>,
#[description = "A full category of commands."] category: Option<HelpOptions>,
#[description = "The name of a single command. This argument will be prioritized over `category`."] command: Option<String>
) -> Result<(), Error>
{
match (category.is_some(), command.is_some()) {
(false, false) => send_category_help(ctx, HelpOptions::Generic).await,
(true, false) => send_category_help(ctx, category.unwrap()).await,
_ => send_single_help(ctx, command.unwrap()).await
}
return Ok(());
}
fn format_cmd(cmd: &Cmd) -> String {
let arg_names: Vec<_> = cmd.parameters
.iter()
.map(|a| a.name.as_str())
.collect();
let cmds_max_len = arg_names
.iter()
.max_by_key(|&&s| s.len())
.map(|&s| s.len())
.unwrap_or(0);
let args_format: Vec<_> = cmd.parameters
.iter()
.map(
|a|
format!(
"{} {}{}{}",
a.name,
" ".repeat(cmds_max_len - a.name.len()),
if !a.required { "(OPTIONAL) " } else { "" },
a.description.as_ref().unwrap_or(&"".to_string())
)
)
.collect();
let t = format!(
"**`{}`**:\n```{}```",
cmd.name,
args_format.join("\n")
);
return t;
}
async fn send_single_help(ctx: Context<'_>, mut cmd_name: String) {
if cmd_name.starts_with("/") { cmd_name = cmd_name[1..].to_string(); }
let cmds = &ctx.framework().options().commands;
let cmd = cmds.iter().find(|c| c.name == cmd_name);
if cmd.is_none() {
send_msg(
ctx,
format!("No command with the name \"{}\" found!\nHint: Try `/help` without any arguments or `/help <category>`", cmd_name),
true,
true
).await;
return;
}
let t = format_cmd(cmd.unwrap());
send_msg(ctx, t, true, true).await;
}
async fn send_category_help(ctx: Context<'_>, category: HelpOptions) {
match category {
HelpOptions::BkWeekReddit => send_bk_week_help_re(ctx).await,
HelpOptions::BkWeek => send_bk_week_help (ctx).await,
HelpOptions::Generic => send_generic_help (ctx).await,
HelpOptions::Admin => send_admin_help (ctx).await,
HelpOptions::All => send_all_help (ctx).await
}
}
async fn send_bk_week_help_re(ctx: Context<'_>) {
let t: String = fs::read_to_string("./bk_week_help_re.md").await
.unwrap_or("Help text not found. Someone deleted it. :(".to_string());
send_msg(ctx, t, true, true).await;
}
fn format_cmds(cmds: Vec<(&str, Vec<&Cmd>)>) -> String {
let cmd_names: Vec<_> = cmds
.iter()
.flat_map(
|t|
t.1.iter().map(|c| c.name.as_str())
)
.collect();
let cmds_max_len = cmd_names
.iter()
.max_by_key(|&&s| s.len())
.map(|&s| s.len())
.unwrap_or(0);
let mut categories: Vec<(&str, Vec<String>)> = Vec::new();
for c_tuple in cmds {
let cmds_format: Vec<_> = c_tuple.1
.iter()
.map(
|c|
format!(
"{} {}{}",
c.name,
" ".repeat(cmds_max_len - c.name.len()),
c.description.as_ref().unwrap_or(&"".to_string())
)
)
.collect();
categories.push((c_tuple.0, cmds_format));
}
let c_text: Vec<String> = categories
.iter()
.map(|i| format!("{}:\n {}", i.0, i.1.join("\n ")))
.collect();
let t = format!("```{}```", c_text.join("\n\n"));
return t;
}
fn separate_by_category(cmds: Vec<&Cmd>) -> Vec<(String, Vec<&Cmd>)> {
let mut grouped: HashMap<String, Vec<&Cmd>> = HashMap::new();
for cmd in cmds {
grouped
.entry(cmd.category.clone().unwrap_or("No category".to_string()))
.or_insert_with(Vec::new).push(cmd);
}
return grouped.into_iter().collect();
}
async fn send_bk_week_help(ctx: Context<'_>) {
let cmds = &ctx.framework().options().commands;
let bk_week_cmds: Vec<_> = cmds
.iter()
.filter(|cmd| cmd.category == Some("bk_week".to_string()))
.collect();
let t = format_cmds(vec![("bk_week", bk_week_cmds)]);
send_msg(ctx, t, true, true).await;
}
async fn send_generic_help(ctx: Context<'_>) {
let cmds = &ctx.framework().options().commands;
let filtered_cmds: Vec<_> = cmds
.iter()
.filter(
|cmd|
cmd.category != Some("bk_week".to_string())
|| cmd.category != Some("owner".to_string())
|| cmd.category != Some("admin".to_string())
)
.collect();
let categories = separate_by_category(filtered_cmds);
let cmds_format: Vec<(&str, Vec<&Cmd>)> = categories
.iter()
.map(|c| (c.0.as_str(), c.1.clone()))
.collect();
let t = format_cmds(cmds_format);
send_msg(ctx, t, true, true).await;
}
async fn send_admin_help(ctx: Context<'_>) {
let cmds = &ctx.framework().options().commands;
let filtered_cmds: Vec<_> = cmds
.iter()
.filter(
|cmd|
cmd.category == Some("owner".to_string())
|| cmd.category == Some("admin".to_string())
)
.collect();
let categories = separate_by_category(filtered_cmds);
let cmds_format: Vec<(&str, Vec<&Cmd>)> = categories
.iter()
.map(|c| (c.0.as_str(), c.1.clone()))
.collect();
let t = format_cmds(cmds_format);
send_msg(ctx, t, true, true).await;
}
async fn send_all_help(ctx: Context<'_>) {
let cmds = &ctx.framework().options().commands;
let cmds_clone: Vec<_> = cmds.iter().clone().collect();
let categories = separate_by_category(cmds_clone);
let cmds_format: Vec<(&str, Vec<&Cmd>)> = categories
.iter()
.map(|c| (c.0.as_str(), c.1.clone()))
.collect();
let t = format_cmds(cmds_format);
send_msg(ctx, t, true, true).await;
}
+28
View File
@@ -0,0 +1,28 @@
use crate::{data::dc_add_server, lang, messages::send_msg, Context, Error};
#[poise::command(
slash_command,
prefix_command,
rename = "add_server",
category = "admin",
default_member_permissions = "ADMINISTRATOR",
guild_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Add your server to my database so I can sell it! (/s), I only store some minimal data the bot needs.
pub async fn cmd(
ctx: Context<'_>
) -> Result<(), Error>
{
let r = dc_add_server(ctx.data(), ctx.guild_id().unwrap().into()).await;
if r.is_ok() {
send_msg(ctx, lang!("dc_msg_add_to_data"), true, true).await;
}
else {
send_msg(ctx, lang!("dc_msg_corrupted_data"), true, true).await;
}
return Ok(());
}
+32
View File
@@ -0,0 +1,32 @@
use rand::{seq::IteratorRandom, Rng};
use crate::{data::get_toml_mutex, lang, messages::send_msg, Context, Error};
#[poise::command(
slash_command,
prefix_command,
category = "fun",
rename = "8_ball",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Magic 8-ball. Ask a question, get an answer.
pub async fn cmd(
ctx: Context<'_>,
#[description = "Question to ask."] question: String
) -> Result<(), Error>
{
let quirky_chance = get_toml_mutex(&ctx.data().cfg).await.unwrap()["commands"]["eight_ball_quirky_chance"].as_float().unwrap();
let is_quirky = rand::rng().random_bool(quirky_chance.clamp(0.0, 1.0));
let list = &ctx.data().ball_prompts[if is_quirky { 1 } else { 0 }];
let rand_item = list.iter().choose(&mut rand::rng());
send_msg(
ctx,
lang!("dc_msg_8-ball_answer", question, rand_item.unwrap()),
false,
true
).await;
return Ok(());
}
+53
View File
@@ -0,0 +1,53 @@
use poise::serenity_prelude::Timestamp;
use crate::{lang, messages::{send_embed, send_msg, Author, EmbedOptions}, Context, Error};
#[allow(clippy::too_many_arguments)]
#[poise::command(
slash_command,
prefix_command,
rename = "embed",
category = "owner",
owners_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS"
)]
/// Creates an embed.
pub async fn cmd(
ctx: Context<'_>,
#[description = "Title of embed."] title: Option<String>,
#[description = "Body text of embed."] description: String,
#[description = "Color of side strip."] color: Option<u32>,
#[description = "A URL the title is bound to."] url: Option<String>,
#[description = "Timestamp at bottom (best to leave empty)."] timestamp: Option<Timestamp>,
#[description = "Ephemeral (only visible to you)."] ephemeral: Option<bool>,
#[description = "Shows \"used {Command}\" reply text."] reply: Option<bool>,
#[description = "Text that appears above and outside of the embed."] message: Option<String>,
#[description = "A URL for a thumbnail image."] thumbnail: Option<String>,
#[description = "Sets yourself as the author."] author: Option<bool>
) -> Result<(), Error>
{
let reply_unwrap = reply.unwrap_or(false);
send_embed(
ctx,
EmbedOptions {
desc: description.replace("\\n", "\n"),
title: title.map(|t| t.replace("\\n", "\n")),
col: color,
url,
ts: timestamp,
ephemeral: ephemeral.unwrap_or(false),
message,
thumbnail,
author: if author.unwrap_or(false) { Some(Author { name: ctx.author().name.clone(), url: "".to_string(), icon_url: ctx.author().avatar_url().unwrap() }) } else { None },
..Default::default()
},
reply_unwrap
).await;
if !reply_unwrap {
send_msg(ctx, lang!("dc_msg_mandatory_response"), true, true).await;
}
return Ok(());
}
+245
View File
@@ -0,0 +1,245 @@
use std::collections::HashMap;
use tokio::fs;
use crate::{lang, messages::send_msg, Context, Cmd, Error};
#[derive(poise::ChoiceParameter, PartialEq)]
enum HelpOptions {
Admin,
All,
Reddit,
RedditBot,
Generic
}
#[poise::command(
slash_command,
prefix_command,
rename = "help",
category = "help",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Shows helpful information on how to use the bk_week section of the bot.
pub async fn cmd(
ctx: Context<'_>,
#[description = "A full category of commands."] category: Option<HelpOptions>,
#[description = "The name of a single command. This argument will be prioritized over `category`."] command: Option<String>
) -> Result<(), Error>
{
match (category.is_some(), command.is_some()) {
(false, false) => send_category_help(ctx, HelpOptions::Generic).await,
(true, false) => send_category_help(ctx, category.unwrap()).await,
_ => send_single_help(ctx, command.unwrap()).await
}
return Ok(());
}
fn format_cmd(cmd: &Cmd) -> String {
let arg_names: Vec<_> = cmd.parameters
.iter()
.map(|a| a.name.as_str())
.collect();
let cmds_max_len = arg_names
.iter()
.max_by_key(|&&s| s.len())
.map(|&s| s.len())
.unwrap_or(0);
let args_format: Vec<_> = cmd.parameters
.iter()
.map(
|a|
format!(
"{} {}{}{}",
a.name,
" ".repeat(cmds_max_len - a.name.len()),
if !a.required { "(OPTIONAL) " } else { "" },
a.description.as_ref().unwrap_or(&"".to_string())
)
)
.collect();
let t = format!(
"**`{}`**:\n```{}```",
cmd.name,
args_format.join("\n")
);
return t;
}
async fn send_single_help(ctx: Context<'_>, mut cmd_name: String) {
if cmd_name.starts_with("/") { cmd_name = cmd_name[1..].to_string(); }
let cmds = &ctx.framework().options().commands;
let cmd = cmds.iter().find(|c| c.name == cmd_name);
if cmd.is_none() {
send_msg(
ctx,
lang!("dc_msg_cmd_404", cmd_name),
true,
true
).await;
return;
}
let t = format_cmd(cmd.unwrap());
send_msg(ctx, t, true, true).await;
}
async fn send_category_help(ctx: Context<'_>, category: HelpOptions) {
match category {
HelpOptions::RedditBot => send_bk_week_help_re(ctx).await,
HelpOptions::Reddit => send_bk_week_help (ctx).await,
HelpOptions::Generic => send_generic_help (ctx).await,
HelpOptions::Admin => send_admin_help (ctx).await,
HelpOptions::All => send_all_help (ctx).await
}
}
async fn send_bk_week_help_re(ctx: Context<'_>) {
let t: String = fs::read_to_string("./bk_week_help_re.md").await
.unwrap_or(lang!("dc_msg_re_help_removed"));
send_msg(ctx, t, true, true).await;
}
fn format_cmds(cmds: Vec<(&str, Vec<&Cmd>)>) -> String {
let cmd_names: Vec<_> = cmds
.iter()
.flat_map(
|t|
t.1.iter().map(|c| c.name.as_str())
)
.collect();
let cmds_max_len = cmd_names
.iter()
.max_by_key(|&&s| s.len())
.map(|&s| s.len())
.unwrap_or(0);
let mut categories: Vec<(&str, Vec<String>)> = Vec::new();
for c_tuple in cmds {
let cmds_format: Vec<_> = c_tuple.1
.iter()
.map(
|c|
format!(
"{} {}{}",
c.name,
" ".repeat(cmds_max_len - c.name.len()),
c.description.as_ref().unwrap_or(&"".to_string())
)
)
.collect();
categories.push((c_tuple.0, cmds_format));
}
let c_text: Vec<String> = categories
.iter()
.map(|i| format!("{}:\n {}", i.0, i.1.join("\n ")))
.collect();
let t = format!("```{}```", c_text.join("\n\n"));
return t;
}
fn separate_by_category(cmds: Vec<&Cmd>) -> Vec<(String, Vec<&Cmd>)> {
let mut grouped: HashMap<String, Vec<&Cmd>> = HashMap::new();
for cmd in cmds {
grouped
.entry(cmd.category.clone().unwrap_or("No category".to_string()))
.or_default().push(cmd);
}
return grouped.into_iter().collect();
}
async fn send_bk_week_help(ctx: Context<'_>) {
let cmds = &ctx.framework().options().commands;
let bk_week_cmds: Vec<_> = cmds
.iter()
.filter(|cmd| cmd.category == Some("re".to_string()))
.collect();
let t = format_cmds(vec![("re", bk_week_cmds)]);
send_msg(ctx, t, true, true).await;
}
async fn send_generic_help(ctx: Context<'_>) {
let cmds = &ctx.framework().options().commands;
let filtered_cmds: Vec<_> = cmds
.iter()
.filter(
|cmd|
cmd.category != Some("re".to_string())
|| cmd.category != Some("owner".to_string())
|| cmd.category != Some("admin".to_string())
)
.collect();
let categories = separate_by_category(filtered_cmds);
let cmds_format: Vec<(&str, Vec<&Cmd>)> = categories
.iter()
.map(|c| (c.0.as_str(), c.1.clone()))
.collect();
let t = format_cmds(cmds_format);
send_msg(ctx, t, true, true).await;
}
async fn send_admin_help(ctx: Context<'_>) {
let cmds = &ctx.framework().options().commands;
let filtered_cmds: Vec<_> = cmds
.iter()
.filter(
|cmd|
cmd.category == Some("owner".to_string())
|| cmd.category == Some("admin".to_string())
)
.collect();
let categories = separate_by_category(filtered_cmds);
let cmds_format: Vec<(&str, Vec<&Cmd>)> = categories
.iter()
.map(|c| (c.0.as_str(), c.1.clone()))
.collect();
let t = format_cmds(cmds_format);
send_msg(ctx, t, true, true).await;
}
async fn send_all_help(ctx: Context<'_>) {
let cmds = &ctx.framework().options().commands;
let cmds_clone: Vec<_> = cmds.iter().clone().collect();
let categories = separate_by_category(cmds_clone);
let cmds_format: Vec<(&str, Vec<&Cmd>)> = categories
.iter()
.map(|c| (c.0.as_str(), c.1.clone()))
.collect();
let t = format_cmds(cmds_format);
send_msg(ctx, t, true, true).await;
}
+20
View File
@@ -0,0 +1,20 @@
use crate::{messages::send_msg, Context, Error};
#[poise::command(
slash_command,
prefix_command,
rename = "ping",
category = "fun",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Check if you have connection to the bot.
pub async fn cmd(
ctx: Context<'_>,
#[description = "The text to echo back."] text: Option<String>,
) -> Result<(), Error>
{
send_msg(ctx, text.unwrap_or_else(|| "Pong".to_string()), true, true).await;
return Ok(());
}
+38
View File
@@ -0,0 +1,38 @@
use crate::{data::{self, get_toml_mutex, read_cfg_data}, lang, messages::send_msg, Context, Error};
#[poise::command(
slash_command,
prefix_command,
rename = "reload_cfg",
category = "owner",
owners_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Reloads the entire config file.
pub async fn cmd(
ctx: Context<'_>
) -> Result<(), Error>
{
let r = read_cfg_data(ctx.data(), false).await;
let d = get_toml_mutex(&ctx.data().cfg).await.unwrap();
if r.is_none() { return Ok(()); }
let data_binding = get_toml_mutex(&ctx.data().cfg).await.unwrap();
let lang_cfg = data_binding["general"]["lang"].as_str().unwrap();
data::load_lang_data(lang_cfg.to_string());
if r.unwrap()["value"].as_bool().unwrap() {
send_msg(
ctx,
lang!("dc_msg_reload_cfg_success", toml::to_string_pretty(&d).unwrap()),
true,
true
).await;
return Ok(());
}
send_msg(ctx, lang!("dc_msg_reload_cfg_python_fail"), true, true).await;
return Ok(());
}
+21
View File
@@ -0,0 +1,21 @@
use crate::{lang, messages::send_msg, Context, Error};
#[poise::command(
slash_command,
prefix_command,
rename = "send",
category = "owner",
owners_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Sends a message.
pub async fn cmd(
ctx: Context<'_>,
#[description = "The message to send (NOT EPHEMERAL)"] msg: String
) -> Result<(), Error>
{
send_msg(ctx, msg.replace("\\n", "\n"), false, false).await;
send_msg(ctx, lang!("dc_msg_mandatory_response"), true, true).await;
return Ok(());
}
+43
View File
@@ -0,0 +1,43 @@
use std::process;
use poise::serenity_prelude::OnlineStatus;
use crate::{data, lang, messages::{edit_reply, send_msg}, websocket::send_cmd_json, Context, Error};
#[poise::command(
slash_command,
prefix_command,
rename = "stop",
category = "owner",
owners_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// I have security measures, even in developer mode. You wont access this without being a bot "owner".
pub async fn cmd(
ctx: Context<'_>,
#[description = "Type \"i want to stop the bot now\" to confirm."] confirmation: Option<String>,
) -> Result<(), Error>
{
let stop_confirm = "i want to stop the bot now".replace(" ", "");
let confirm_formatted = confirmation.unwrap_or_default().to_lowercase().replace(" ", "");
let should_stop = ctx.data().args.dev || confirm_formatted == stop_confirm;
if should_stop {
let msg = send_msg(ctx, lang!("dc_msg_owner_data_save"), true, true).await.unwrap();
data::write_dc_data(ctx.data()).await;
data::write_re_data().await;
send_cmd_json("stop_praw", None, true).await;
edit_reply(ctx, msg, lang!("dc_msg_owner_data_save_complete")).await;
ctx.serenity_context().set_presence(None, OnlineStatus::Invisible);
ctx.framework().shard_manager.shutdown_all().await;
process::exit(0);
}
else {
send_msg(ctx, lang!("dc_msg_owner_shutdown_failed_confirmation"), true, true).await;
}
return Ok(());
}
+20
View File
@@ -0,0 +1,20 @@
use crate::{lang, messages::send_msg, Context, Error};
#[poise::command(
slash_command,
prefix_command,
rename = "whoami",
category = "help",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
pub async fn cmd(ctx: Context<'_>) -> Result<(), Error> {
let data = ctx.data();
let uid: u64 = ctx.author().id.into();
let is_owner = data.owners .contains(&uid);
let is_bk_mod = data.bk_mods.contains(&uid);
send_msg(ctx, lang!("dc_msg_whoami", is_owner, is_bk_mod), true, true).await;
return Ok(());
}
+59 -24
View File
@@ -4,16 +4,22 @@ use std::path::Path;
use serde_json::{self, Value, json}; use serde_json::{self, Value, json};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{Data, BK_WEEK, rs_println, Error}; use crate::{errln, rs_println, rs_warnln, Data, Error, CFG_DATA_RE, LANG, LANG_NAME};
use crate::websocket::send_cmd_json; use crate::websocket::send_cmd_json;
static DATA_PATH_DC: &str = "./data/discord_data.json"; static DATA_PATH_DC: &str = "./data/db/dc_data.json";
static PRESET_PATH_DC: &str = "./data/discord_data_preset.json"; static PRESET_PATH_DC: &str = "./data/defaults/dc_data_preset.json";
static DATA_PATH_RE: &str = "./data/reddit_data.json";
static PRESET_PATH_RE: &str = "./data/reddit_data_preset.json"; static DATA_PATH_RE: &str = "./data/db/re_data.json";
static DATA_PATH_CFG: &str = "./data/cfg.json"; static PRESET_PATH_RE: &str = "./data/defaults/re_data_preset.json";
static PRESET_PATH_CFG: &str = "./data/cfg_default.json";
static DATA_PATH_CFG: &str = "./cfg/cfg.toml";
static PRESET_PATH_CFG: &str = "./data/defaults/cfg_default.toml";
static DATA_PATH_LANG: &str = "./data/lang/";
pub static DC_POSTS_CHANNEL_KEY: &str = "re_posts_channel";
pub async fn read_dc_data(data: &Data, wipe: bool) { pub async fn read_dc_data(data: &Data, wipe: bool) {
@@ -77,7 +83,7 @@ pub async fn read_re_data(data: &Data, wipe: bool) {
} }
let str_data = fs::read_to_string(DATA_PATH_RE).unwrap(); let str_data = fs::read_to_string(DATA_PATH_RE).unwrap();
let json_data = serde_json::from_str(&str_data).unwrap(); let json_data: Option<Value> = serde_json::from_str(&str_data).unwrap();
let mut re_data = data.reddit_data.lock().await; let mut re_data = data.reddit_data.lock().await;
*re_data = json_data; *re_data = json_data;
} }
@@ -87,9 +93,11 @@ fn generate_re_data() {
let preset_str = fs::read_to_string(PRESET_PATH_RE).unwrap(); let preset_str = fs::read_to_string(PRESET_PATH_RE).unwrap();
let mut preset_json: Value = serde_json::from_str(&preset_str).unwrap(); let mut preset_json: Value = serde_json::from_str(&preset_str).unwrap();
if let Some(bk_week) = preset_json[BK_WEEK].as_object_mut() { if let Some(bk_week) = preset_json[CFG_DATA_RE].as_object_mut() {
bk_week.remove("EXAMPLE VALUE"); bk_week.remove("EXAMPLE URL");
bk_week.remove("EXAMPLE VALUE DELETED"); }
else {
rs_warnln!("Couldn't find key \"{}\" in the Reddit data file ({})!", CFG_DATA_RE, DATA_PATH_RE);
} }
let json_str = serde_json::to_string_pretty(&preset_json).unwrap(); let json_str = serde_json::to_string_pretty(&preset_json).unwrap();
@@ -100,42 +108,40 @@ fn generate_re_data() {
pub async fn update_re_data(data: &Data) { pub async fn update_re_data(data: &Data) {
send_cmd_json("update_data_file", None).await; send_cmd_json("update_data_file", None, true).await;
read_re_data(data, false).await; read_re_data(data, false).await;
} }
pub async fn write_re_data() { pub async fn write_re_data() {
send_cmd_json("update_data_file", None).await; send_cmd_json("update_data_file", None, true).await;
} }
pub async fn read_cfg_data(data: &Data, wipe: bool) { pub async fn read_cfg_data(data: &Data, wipe: bool) -> Option<Value> {
if !Path::new(DATA_PATH_CFG).exists() || wipe { if !Path::new(DATA_PATH_CFG).exists() || wipe {
rs_println!( rs_println!(
"{} creating new from preset...", "{} creating new from preset...",
if !wipe { "cfg.json not found," } else { "[WIPE] (cfg.json)" } if !wipe { "cfg.toml not found," } else { "[WIPE] (cfg.toml)" }
); );
generate_cfg_data(); generate_cfg_data();
} }
let str_data = fs::read_to_string(DATA_PATH_CFG).unwrap(); let str_data = fs::read_to_string(DATA_PATH_CFG).unwrap();
let json_data = serde_json::from_str(&str_data).unwrap(); let json_data: toml::Value = str_data.parse().unwrap();
let mut cfg_data = data.cfg.lock().await; let mut cfg_data = data.cfg.lock().await;
*cfg_data = json_data; *cfg_data = Some(json_data.clone());
send_cmd_json("update_cfg", Some(json!([str_data]))).await; let r = send_cmd_json("update_cfg", Some(json!([toml::to_string(&json_data).unwrap()])), true).await;
return r;
} }
fn generate_cfg_data() { fn generate_cfg_data() {
let preset_str = fs::read_to_string(PRESET_PATH_CFG).unwrap(); let preset_str = fs::read_to_string(PRESET_PATH_CFG).unwrap();
let preset_json: Value = serde_json::from_str(&preset_str).unwrap();
let json_str = serde_json::to_string_pretty(&preset_json).unwrap();
let mut file = fs::File::create(DATA_PATH_CFG).unwrap(); let mut file = fs::File::create(DATA_PATH_CFG).unwrap();
file.write_all(json_str.as_bytes()).unwrap(); file.write_all(preset_str.as_bytes()).unwrap();
} }
@@ -148,7 +154,7 @@ pub async fn dc_add_server(data: &Data, server_id: u64) -> Result<(), ()> {
let servers = dc_data["servers"].as_object_mut().unwrap(); let servers = dc_data["servers"].as_object_mut().unwrap();
if !servers.contains_key(&server_id.to_string()) { if !servers.contains_key(&server_id.to_string()) {
servers.insert(server_id.to_string(), json!({ "bk_week_channel": 0, "bk_mod_role": "bk mod", "bk_mods": [] })); servers.insert(server_id.to_string(), json!({ DC_POSTS_CHANNEL_KEY: 0 }));
} }
return Ok(()); return Ok(());
@@ -169,7 +175,7 @@ pub async fn dc_bind_bk(data: &Data, server_id: u64, channel_id: u64) -> Result<
let server = servers[&server_id.to_string()].as_object_mut().unwrap(); let server = servers[&server_id.to_string()].as_object_mut().unwrap();
server.insert("bk_week_channel".to_string(), channel_id.into()); server.insert(DC_POSTS_CHANNEL_KEY.to_string(), channel_id.into());
return Ok(()); return Ok(());
} }
@@ -194,4 +200,33 @@ pub async fn get_mutex_data(data: &Mutex<Option<Value>>) -> Result<Value, Error>
Some(data) => Ok(data.clone()), Some(data) => Ok(data.clone()),
None => Err("Cannot get mutex data: The data is corrupted!".into()), None => Err("Cannot get mutex data: The data is corrupted!".into()),
}; };
}
pub async fn get_toml_mutex(data: &Mutex<Option<toml::Value>>) -> Result<toml::Value, Error> {
let data_lock = data.lock().await;
return match data_lock.as_ref() {
Some(data) => Ok(data.clone()),
None => Err("Cannot get mutex data: The data is corrupted!".into()),
};
}
pub fn load_lang_data(lang: String) {
let full_path = format!("{}{}.json", DATA_PATH_LANG, lang);
if !Path::new(&full_path).exists() {
errln!(
"File for language \"{0}\" ({0}.json) not found!\n Hint: You can download official language files at https://github.com/ByteDice/ByteDiceAssistant in the data/langs/... folder",
lang
);
}
let str_data = fs::read_to_string(full_path).unwrap();
let json_data: Option<Value> = serde_json::from_str(&str_data).unwrap();
unsafe {
LANG = json_data;
LANG_NAME = Some(lang);
};
} }
+151 -17
View File
@@ -1,6 +1,11 @@
use crate::{Data, Error, rs_println}; use crate::data::{get_mutex_data, get_toml_mutex, update_re_data};
use crate::messages::{make_post_embed, make_removed_embed, EmbedOptions};
use crate::re_cmds::generic_fns::{is_bk_mod, is_bk_mod_serenity, serenity_edit_msg_embed, serenity_send_msg};
use crate::websocket::send_cmd_json;
use crate::{lang, rs_println, Data, Error, CFG_DATA_RE};
use poise::serenity_prelude::{self as serenity, ActivityData}; use poise::serenity_prelude::{self as serenity, ActivityData, ChannelId, ComponentInteraction, Interaction, Member, MessageId, Ready};
use serde_json::{json, Value};
use std::future::Future; use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
@@ -9,24 +14,153 @@ pub fn event_handler<'a>(
ctx: &'a serenity::Context, ctx: &'a serenity::Context,
event: &'a serenity::FullEvent, event: &'a serenity::FullEvent,
_framework: poise::FrameworkContext<'a, Data, Error>, _framework: poise::FrameworkContext<'a, Data, Error>,
_data: &'a Data, data: &'a Data,
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> { ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
Box::pin(async move { Box::pin(async move {
if let serenity::FullEvent::Ready { data_about_bot } = event { match event {
rs_println!( serenity::FullEvent::Ready { data_about_bot } => on_ready(ctx, data_about_bot, data).await,
"Bot started as user \"{}\" with id {}", serenity::FullEvent::InteractionCreate { interaction } => { let _ = handle_buttons(ctx, data, interaction).await; },
data_about_bot.user.name, _ => {}
data_about_bot.user.id
);
let file_text = std::fs::read_to_string("./data/status.txt").unwrap();
let custom_activity = ActivityData::custom(file_text);
// TODO: make custom rich presence
//let playing_activity
ctx.online();
ctx.set_activity(Some(custom_activity));
} }
return Ok(()); return Ok(());
}) })
}
async fn on_ready(ctx: &serenity::Context, data_about_bot: &Ready, data: &Data) {
rs_println!(
"Bot started as user \"{}\" with id {}",
data_about_bot.user.name,
data_about_bot.user.id
);
let m_data = get_toml_mutex(&data.cfg).await.unwrap();
let custom_activity = ActivityData::custom(m_data["general"]["status"].as_str().unwrap());
ctx.online();
ctx.set_activity(Some(custom_activity));
}
async fn handle_buttons(ctx: &serenity::Context, data: &Data, interaction: &Interaction) -> Result<(), Error> {
let component = match interaction {
Interaction::Component(component) => component,
_ => return Err(Error::from("Not a message component interaction"))
};
let i_msg = interaction.clone().message_component();
if i_msg.is_none() { return Err(Error::from("message_component is None!")); }
let i_embed = i_msg.unwrap().message.embeds[0].clone();
let url = i_embed.url.clone().unwrap();
return match component.data.custom_id.as_str() {
"approve_btn" => approve_btn(ctx, data, component.member.as_ref().unwrap(), component, url, true).await,
"remove_btn" => remove_btn (ctx, data, component.member.as_ref().unwrap(), component, url, true).await,
"unapprove_btn" => approve_btn(ctx, data, component.member.as_ref().unwrap(), component, url, false).await,
"unremove_btn" => remove_btn (ctx, data, component.member.as_ref().unwrap(), component, url, false).await,
"unvote_btn" => vote_btn (ctx, data, component.member.as_ref().unwrap(), component, url, false).await,
"vote_btn" => vote_btn (ctx, data, component.member.as_ref().unwrap(), component, url, true).await,
_ => Err("Message button with that ID isn't handled.".into())
}
}
async fn update_embed(ctx: &serenity::Context, url: &str, new_data: &Value, c_id: &ChannelId, m_id: &MessageId) {
let remove = new_data["removed"]["removed"].as_bool().unwrap();
let e: EmbedOptions =
if remove { make_removed_embed(new_data, url, true) }
else { make_post_embed (new_data, url, true) };
serenity_edit_msg_embed(ctx, c_id, m_id, e).await;
}
async fn approve_btn(ctx: &serenity::Context, data: &Data, c_member: &Member, component: &ComponentInteraction, url: String, approve: bool) -> Result<(), Error> {
if !is_bk_mod_serenity(ctx, data, c_member, component).await { return Ok(()); }
let r = send_cmd_json("set_approve_post", Some(json!([approve, url])), true).await.unwrap();
let c_id = component.channel_id;
let m_id = component.message.id;
update_re_data(data).await;
let new_data = &get_mutex_data(&data.reddit_data).await.unwrap()[CFG_DATA_RE][&url];
update_embed(ctx, &url, new_data, &c_id, &m_id).await;
if r["value"].as_bool().unwrap() {
if approve {
serenity_send_msg(ctx, component, lang!("dc_msg_re_post_approve_success"), true).await;
}
else {
serenity_send_msg(ctx, component, lang!("dc_msg_re_post_disapprove_success"), true).await;
}
}
else {
serenity_send_msg(ctx, component, lang!("dc_msg_re_post_approve_remove"), true).await;
}
return Ok(());
}
async fn remove_btn(ctx: &serenity::Context, data: &Data, c_member: &Member, component: &ComponentInteraction, url: String, remove: bool) -> Result<(), Error> {
if !is_bk_mod_serenity(ctx, data, c_member, component).await { return Ok(()); }
let r: Value = if remove {
send_cmd_json("remove_post_url", Some(json!([&url, &c_member.user.name, None::<String>])), true).await.unwrap()
}
else {
send_cmd_json("add_post_url", Some(json!([&url, false, true])), true).await.unwrap()
};
let c_id = component.channel_id;
let m_id = component.message.id;
update_re_data(data).await;
let new_data = &get_mutex_data(&data.reddit_data).await.unwrap()[CFG_DATA_RE][&url];
update_embed(ctx, &url, new_data, &c_id, &m_id).await;
if r["value"].as_bool().unwrap() {
if remove {
serenity_send_msg(ctx, component, lang!("dc_msg_re_post_remove_success", &url), true).await;
}
else {
serenity_send_msg(ctx, component, lang!("dc_msg_re_post_unremove_success", &url), true).await;
}
}
return Ok(());
}
async fn vote_btn(ctx: &serenity::Context, data: &Data, c_member: &Member, component: &ComponentInteraction, url: String, vote: bool) -> Result<(), Error> {
let uid: u64 = c_member.user.id.into();
let is_mod = is_bk_mod(data.bk_mods.clone(), uid);
let r = send_cmd_json("set_vote_post", Some(json!([&url, uid, is_mod, true, !vote])), true).await.unwrap();
let c_id = component.channel_id;
let m_id = component.message.id;
update_re_data(data).await;
let new_data = &get_mutex_data(&data.reddit_data).await.unwrap()[CFG_DATA_RE][&url];
update_embed(ctx, &url, new_data, &c_id, &m_id).await;
if r["value"].as_bool().unwrap() {
if vote {
if is_mod { serenity_send_msg(ctx, component, lang!("dc_msg_re_vote_mod_success"), true).await; }
else { serenity_send_msg(ctx, component, lang!("dc_msg_re_vote_success"), true).await; }
}
else {
serenity_send_msg(ctx, component, lang!("dc_msg_re_vote_remove_success"), true).await;
}
}
else if new_data["removed"]["removed"].as_bool().unwrap() {
serenity_send_msg(ctx, component, lang!("dc_msg_re_post_vote_removed_post"), true).await;
}
else if !vote {
serenity_send_msg(ctx, component, lang!("dc_msg_re_vote_remove_havent"), true).await;
}
return Ok(());
} }
+115
View File
@@ -0,0 +1,115 @@
use std::collections::HashSet;
use poise::serenity_prelude::UserId;
use poise::serenity_prelude as serenity;
use poise::serenity_prelude::Client;
use crate::{cmds, data::{self, get_toml_mutex}, events, re_cmds, rs_println, Args, Cmd, Data};
pub async fn gen_data(args: Args, owners: Vec<u64>) -> Data {
let ball_classic_str = std::fs::read_to_string("./cfg/8-ball_classic.txt").unwrap();
let ball_quirk_str = std::fs::read_to_string("./cfg/8-ball_quirky.txt").unwrap();
let ball_classic: Vec<String> = ball_classic_str.lines().map(String::from).collect();
let ball_quirk: Vec<String> = ball_quirk_str .lines().map(String::from).collect();
let mods_env = std::env::var("ASSISTANT_BK_MODS").unwrap_or("0".to_string());
let mods_vec_str: Vec<String> = mods_env.split(",").map(String::from).collect();
let mods_vec_u64: Vec<u64> = mods_vec_str
.iter()
.map(|s| s.parse::<u64>().expect("Failed to parse ASSISTANT_BK_MODS. Invalid syntax."))
.collect();
let data = Data {
owners,
ball_prompts: [ball_classic, ball_quirk],
bk_mods: mods_vec_u64,
reddit_data: None.into(),
discord_data: None.into(),
cfg: None.into(),
args: args.clone()
};
data::read_dc_data (&data, args.clone().wipe).await;
data::read_re_data (&data, args.clone().wipe).await;
data::read_cfg_data(&data, args.clone().wipe).await;
return data;
}
pub async fn gen_bot(data: Data, args: Args) -> Client {
let token =
if !args.test { std::env::var("ASSISTANT_TOKEN").expect("Missing ASSISTANT_TOKEN env var!") }
else { std::env::var("ASSISTANT_TOKEN_TEST").expect("Missing ASSISTANT_TOKEN_TEST env var!") };
let intents = serenity::GatewayIntents::all();
let peek_len = 27;
let token_peek = &token[..peek_len];
let token_end_len = token[peek_len..].len();
rs_println!("Token: {}{}", token_peek, "*".repeat(token_end_len));
let own: HashSet<UserId> = data.owners.clone().into_iter().map(UserId::from).collect();
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
owners: own,
commands: make_cmd_vec(&data).await,
event_handler: events::event_handler,
..Default::default()
})
.setup(|ctx, _ready, framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
return Ok(data);
})
})
.build();
return serenity::ClientBuilder::new(token, intents)
.framework(framework)
.await
.unwrap();
}
async fn make_cmd_vec(data: &Data) -> Vec<Cmd> {
let mut cmds = vec![
// GENERIC
cmds::help::cmd(),
cmds::whoami::cmd(),
cmds::ping::cmd(),
cmds::eight_ball::cmd(),
// REDDIT
re_cmds::add::cmd(),
re_cmds::approve::cmd(),
re_cmds::get::cmd(),
re_cmds::remove::cmd(),
re_cmds::top::cmd(),
re_cmds::update::cmd(),
re_cmds::vote::cmd(),
re_cmds::shorturl::cmd(),
// [ADMIN / OWNER]
cmds::embed::cmd(),
cmds::send::cmd(),
cmds::stop::cmd(),
cmds::add_server::cmd(),
cmds::reload_cfg::cmd(),
// REDDIT [ADMIN / OWNER]
re_cmds::admin_bind::cmd()
];
let cfg = get_toml_mutex(&data.cfg).await.unwrap();
let disabled = cfg["commands"]["disabled_categories"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>();
cmds.retain(|cmd| !disabled.contains(&cmd.category.as_ref().unwrap().as_str()));
return cmds;
}
+61
View File
@@ -24,6 +24,19 @@ macro_rules! rs_errln {
} }
#[macro_export]
macro_rules! rs_warnln {
($($arg:tt)*) => {
println!("{}WARNING{} RS - {}{}",
"\x1b[33m",
"\x1b[0m\x1b[31m",
format!($($arg)*),
"\x1b[0m"
);
};
}
#[macro_export] #[macro_export]
macro_rules! errln { macro_rules! errln {
($($arg:tt)*) => { ($($arg:tt)*) => {
@@ -34,4 +47,52 @@ macro_rules! errln {
); );
std::process::exit(1); std::process::exit(1);
}; };
}
#[macro_export]
macro_rules! warnln {
($($arg:tt)*) => {
println!("{}WARNING{} - {}",
"\x1b[33m",
"\x1b[0m",
format!($($arg)*)
);
};
}
#[macro_export]
macro_rules! lang {
($key:expr) => {
{
use $crate::{LANG, errln};
let value = unsafe {
LANG
.as_ref()
.expect("LANG must be initialized before use")
.get($key)
};
if value.is_none() { errln!("Key not found in LANG JSON: \"{}\"", $key); }
value.unwrap().as_str().expect("LANG JSON value is not a string!").to_string()
}
};
($key:expr, $($arg:expr),*) => {{
use $crate::{LANG, errln};
use formatx::formatx;
let value = unsafe {
LANG
.as_ref()
.expect("LANG must be initialized before use")
.get($key)
};
if value.is_none() { errln!("Key not found in LANG JSON: \"{}\"", $key); }
let format_str = value.unwrap().as_str().expect("LANG JSON value is not a string!");
formatx!(format_str, $($arg),*).unwrap()
}};
} }
+86 -153
View File
@@ -1,8 +1,29 @@
#![warn(unused_extern_crates)] #![warn(unused_extern_crates)]
#![allow(clippy::needless_return)] #![allow(clippy::needless_return)]
mod cmds; mod cmds {
mod bk_week_cmds; pub mod add_server;
pub mod eight_ball;
pub mod embed;
pub mod help;
pub mod ping;
pub mod reload_cfg;
pub mod send;
pub mod stop;
pub mod whoami;
}
mod re_cmds {
pub mod add;
pub mod admin_bind;
pub mod approve;
pub mod generic_fns;
pub mod get;
pub mod remove;
pub mod shorturl;
pub mod top;
pub mod update;
pub mod vote;
}
mod events; mod events;
mod messages; mod messages;
mod python; mod python;
@@ -10,27 +31,29 @@ mod macros;
#[allow(unknown_lints)] #[allow(unknown_lints)]
mod websocket; mod websocket;
mod data; mod data;
mod schedule;
mod gen;
use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use std::process; use std::process;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use std::vec; use std::vec;
use std::error::Error as StdErr;
use clap::Parser; use clap::Parser;
use poise::serenity_prelude::UserId; use r#gen::gen_bot;
use poise::serenity_prelude as serenity; use r#gen::gen_data;
use poise::serenity_prelude::Client; use poise::Command;
use schedule::run_schedules;
use serde::Serialize; use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time;
use websocket::send_cmd_json; use websocket::send_cmd_json;
use crate::data::get_toml_mutex;
use crate::schedule::Schedule;
#[derive(Parser, Serialize, Clone)] #[derive(Parser, Serialize, Clone)]
struct Args { struct Args {
@@ -46,16 +69,16 @@ struct Args {
wipe: bool, wipe: bool,
#[arg(short = 't', long, help = "Makes the program use the ASSISTANT_TOKEN_TEST env var instead of ASSISTANT_TOKEN. This env var should hold the token of a non-production bot.")] #[arg(short = 't', long, help = "Makes the program use the ASSISTANT_TOKEN_TEST env var instead of ASSISTANT_TOKEN. This env var should hold the token of a non-production bot.")]
test: bool, test: bool,
#[arg(long, help = "Removes the annoying ping prints.")] #[arg(long, help = "Adds annoying prints when the websockets send a ping. Why though?")]
noping: bool, ping: bool,
#[arg(long, help = "Makes the program not use the schedules.")] #[arg(long, help = "Makes the program not use the schedule system.")]
nosched: bool nosched: bool
} }
type Error = Box<dyn std::error::Error + Send + Sync>; type Error = Box<dyn std::error::Error + Send + Sync>;
type Context<'a> = poise::Context<'a, Data, Error>; type Context<'a> = poise::Context<'a, Data, Error>;
type Schedule = (Duration, fn() -> Pin<Box<dyn Future<Output = ()> + Send>>); type Cmd = Command<Data, Box<dyn StdErr + Send + Sync>>;
struct Data { struct Data {
@@ -63,19 +86,24 @@ struct Data {
ball_prompts: [Vec<String>; 2], ball_prompts: [Vec<String>; 2],
reddit_data: Mutex<Option<Value>>, reddit_data: Mutex<Option<Value>>,
discord_data: Mutex<Option<Value>>, discord_data: Mutex<Option<Value>>,
cfg: Mutex<Option<Value>>, cfg: Mutex<Option<toml::Value>>,
bk_mods: Vec<u64>, bk_mods: Vec<u64>,
args: Args args: Args,
} }
static BK_WEEK: &str = "bk_weekly_art_posts"; static CFG_DATA_RE: &str = "posts";
pub static mut LANG_NAME: Option<String> = None;
pub static mut LANG: Option<serde_json::Value> = None;
pub static mut NOPING: bool = false;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let args = <Args as clap::Parser>::parse(); let args = <Args as clap::Parser>::parse();
let args_str = serde_json::to_string(&args).expect("Error serializing args to JSON"); let args_str = serde_json::to_string(&args).expect("Error serializing args to JSON");
unsafe { NOPING = !args.ping; }
let own_env = std::env::var("ASSISTANT_OWNERS").unwrap_or("0".to_string()); let own_env = std::env::var("ASSISTANT_OWNERS").unwrap_or("0".to_string());
let own_vec_str: Vec<String> = own_env.split(",").map(String::from).collect(); let own_vec_str: Vec<String> = own_env.split(",").map(String::from).collect();
@@ -84,47 +112,64 @@ async fn main() {
.map(|s| s.parse::<u64>().expect("Failed to parse ASSISTANT_OWNERS. Invalid syntax.")) .map(|s| s.parse::<u64>().expect("Failed to parse ASSISTANT_OWNERS. Invalid syntax."))
.collect(); .collect();
if args.test { println!("----- USING TEST BOT -----"); } rs_println!("Generating and/or fetching data and config...");
if args.dev { println!("----- DEV MODE ENABLED -----"); } let data = gen_data(args.clone(), own_vec_u64.clone()).await;
rs_println!("Fetching language file...");
let data_binding = get_toml_mutex(&data.cfg).await.unwrap();
let lang_cfg = data_binding["general"]["lang"].as_str().unwrap();
data::load_lang_data(lang_cfg.to_string());
rs_println!("[IMPORTANT] The below message is a test message, it should be written in the language you've selected\nTest message: {}", lang!("log_lang_load_success"));
if args.test { println!("----- USING TEST BOT -----"); }
if args.dev { println!("----- DEV MODE ENABLED -----"); }
if args.dev && args.wipe { println!("----- \"DON'T WORRY ABOUT IT\" MODE ENABLED -----"); } if args.dev && args.wipe { println!("----- \"DON'T WORRY ABOUT IT\" MODE ENABLED -----"); }
if args.nosched { println!("----- NO SCHEDULES -----") } if args.nosched { println!("----- NO SCHEDULES -----"); }
if args.py && !args.rs { if args.py && !args.rs {
println!("----- PYTHON ONLY MODE -----"); println!("----- PYTHON ONLY MODE -----");
rs_println!("ARGS: {}", args_str); rs_println!("ARGS: {}", args_str);
let _ = python::start(args); let _ = python::start(args).await;
process::exit(0); process::exit(0);
} }
else if args.rs && ! args.py { else if args.rs && ! args.py {
println!("----- RUST ONLY MODE -----"); println!("----- RUST ONLY MODE -----");
rs_println!("ARGS: {}", args_str); rs_println!("ARGS: {}", args_str);
start(args, own_vec_u64.clone()).await; start(args, data).await;
process::exit(0); process::exit(0);
} }
else if args.py && args.rs {
errln!("Invalid arguments: Arguments cannot include both --rs and --py.");
}
rs_println!("ARGS: {}", args_str); rs_println!("ARGS: {}", args_str);
let rt = Runtime::new().unwrap(); let cfg = get_toml_mutex(&data.cfg).await.unwrap();
let cfg_arr = cfg["commands"]["disabled_categories"].as_array().unwrap();
let run_py = !cfg_arr.iter().any(|val| val.as_str() == Some("re"));
let rt_rs = Runtime::new().unwrap();
let rt_py = Runtime::new().unwrap();
let python_args = args.clone(); let python_args = args.clone();
let rust_args = args.clone(); let rust_args = args.clone();
if !run_py { rs_println!("[IMPORTANT] You have disabled the \"re\" commands in the CFG. The app will not run the Python code and the websockets to save resources!"); }
let rust = thread::spawn(move || { let rust = thread::spawn(move || {
rt.block_on(async { rt_rs.block_on(async {
websocket::start(rust_args.clone(), own_vec_u64.clone()).await; if run_py { websocket::start(rust_args.clone(), own_vec_u64.clone()).await; }
start(rust_args, own_vec_u64).await; start(rust_args, data).await;
}); });
}); });
let python = thread::spawn(|| { let python = thread::spawn(move || {
let _ = python::start(python_args); rt_py.block_on(async {
if run_py { let _ = python::start(python_args).await; }
});
}); });
if !args.nosched { if !args.nosched {
let dur = if args.test { Duration::from_secs(60) } else { Duration::from_secs(60 * 10) };
let schedules: Vec<Schedule> = vec![ let schedules: Vec<Schedule> = vec![
(Duration::from_secs(2 * 60), || Box::pin(read_reddit_inbox())) (dur, || Box::pin(read_reddit_inbox()))
]; ];
run_schedules(schedules).await; run_schedules(schedules).await;
@@ -135,129 +180,17 @@ async fn main() {
} }
async fn start(args: Args, owners: Vec<u64>) { async fn start(args: Args, data: Data) {
let data = gen_data(args.clone(), owners).await;
let mut bot = gen_bot(data, args).await; let mut bot = gen_bot(data, args).await;
rs_println!("Starting bot..."); rs_println!("Starting Discord bot...");
bot.start().await.unwrap(); bot.start().await.unwrap();
} }
async fn gen_data(args: Args, owners: Vec<u64>) -> Data {
let ball_classic_str = std::fs::read_to_string("./data/8-ball_classic.txt").unwrap();
let ball_quirk_str = std::fs::read_to_string("./data/8-ball_quirky.txt").unwrap();
let ball_classic: Vec<String> = ball_classic_str.lines().map(String::from).collect();
let ball_quirk: Vec<String> = ball_quirk_str .lines().map(String::from).collect();
let mods_env = std::env::var("ASSISTANT_BK_MODS").unwrap_or("0".to_string());
let mods_vec_str: Vec<String> = mods_env.split(",").map(String::from).collect();
let mods_vec_u64: Vec<u64> = mods_vec_str
.iter()
.map(|s| s.parse::<u64>().expect("Failed to parse ASSISTANT_BK_MODS. Invalid syntax."))
.collect();
let data = Data {
owners,
ball_prompts: [ball_classic, ball_quirk],
bk_mods: mods_vec_u64,
reddit_data: None.into(),
discord_data: None.into(),
cfg: None.into(),
args: args.clone()
};
data::read_dc_data (&data, args.clone().wipe).await;
data::read_re_data (&data, args.clone().wipe).await;
data::read_cfg_data(&data, args.clone().wipe).await;
return data;
}
async fn gen_bot(data: Data, args: Args) -> Client {
let token =
if !args.test { std::env::var("ASSISTANT_TOKEN").expect("Missing ASSISTANT_TOKEN env var!") }
else { std::env::var("ASSISTANT_TOKEN_TEST").expect("Missing ASSISTANT_TOKEN_TEST env var!") };
let intents = serenity::GatewayIntents::all();
let peek_len = 27;
let token_peek = &token[..peek_len];
let token_end_len = token[peek_len..].len();
rs_println!("Token: {}{}", token_peek, "*".repeat(token_end_len));
let own: HashSet<UserId> = data.owners.clone().into_iter().map(UserId::from).collect();
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
owners: own,
commands: vec![
cmds::help(),
cmds::ping(),
cmds::embed(),
cmds::send(),
cmds::stop(),
cmds::eight_ball(),
cmds::re_shorturl(),
cmds::add_server(),
// bk_week
bk_week_cmds::bk_week_get(),
bk_week_cmds::bk_week_add(),
bk_week_cmds::bk_week_remove(),
bk_week_cmds::bk_week_approve(),
bk_week_cmds::bk_week_update(),
bk_week_cmds::bk_week_vote(),
bk_week_cmds::bk_week_top(),
// bk_admin
bk_week_cmds::bk_admin_bind(),
// cfg
cmds::reload_cfg()
],
event_handler: events::event_handler,
..Default::default()
})
.setup(|ctx, _ready, framework| {
Box::pin(async move {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
return Ok(data);
})
})
.build();
return serenity::ClientBuilder::new(token, intents)
.framework(framework)
.await
.unwrap();
}
async fn run_schedule<F: Fn() -> Pin<Box<dyn Future<Output = ()> + Send>>>(d: Duration, f: F) {
let mut ticker = time::interval(d);
loop {
ticker.tick().await;
f().await;
}
}
async fn run_schedules(schedules: Vec<Schedule>) {
let mut handles: Vec<JoinHandle<()>> = vec![];
rs_println!("Starting schedules...");
for (d, f) in schedules {
let handle = tokio::spawn(run_schedule(d, f));
handles.push(handle);
}
for handle in handles {
let _ = handle.await;
}
}
async fn read_reddit_inbox() { async fn read_reddit_inbox() {
unsafe { if !websocket::HAS_CONNECTED { return; } } unsafe {
send_cmd_json("respond_mentions", None).await; if !websocket::HAS_CONNECTED { return; }
send_cmd_json("respond_mentions", None, !NOPING).await;
}
} }
+118 -40
View File
@@ -1,10 +1,16 @@
use std::env; use std::env;
use std::io::{Read, Write};
use crate::{Args, Context}; use crate::{lang, Args, Context};
use base64::engine::general_purpose;
use base64::Engine;
use flate2::read::ZlibDecoder;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use poise::serenity_prelude::json::Value; use poise::serenity_prelude::json::Value;
use poise::{serenity_prelude::CreateMessage, CreateReply, ReplyHandle}; use poise::{serenity_prelude::CreateMessage, CreateReply, ReplyHandle};
use poise::serenity_prelude::{ChannelId, Color, CreateEmbed, CreateEmbedAuthor, EditMessage, Http, Message, Timestamp, UserId}; use poise::serenity_prelude::{ChannelId, Color, CreateActionRow, CreateButton, CreateEmbed, CreateEmbedAuthor, EditMessage, Http, Message, ReactionType, Timestamp, UserId};
use serde_json::json; use serde_json::json;
@@ -24,12 +30,13 @@ pub struct EmbedOptions {
pub ephemeral: bool, pub ephemeral: bool,
pub message: Option<String>, pub message: Option<String>,
pub author: Option<Author>, pub author: Option<Author>,
pub thumbnail: Option<String> pub thumbnail: Option<String>,
pub actionrows: Option<Vec<CreateActionRow>>
} }
impl Default for EmbedOptions { impl Default for EmbedOptions {
fn default() -> Self { fn default() -> Self {
return EmbedOptions { return EmbedOptions {
desc: "default description".to_string(), desc: lang!("dc_msg_embed_default_embed_desc"),
title: None, title: None,
col: None, col: None,
url: None, url: None,
@@ -37,7 +44,8 @@ impl Default for EmbedOptions {
ephemeral: false, ephemeral: false,
message: None, message: None,
author: None, author: None,
thumbnail: None thumbnail: None,
actionrows: None
}; };
} }
} }
@@ -45,7 +53,9 @@ impl Default for EmbedOptions {
static DEFAULT_DC_COL: u32 = 5793266; static DEFAULT_DC_COL: u32 = 5793266;
static REMOVED_DC_COL: u32 = 16716032; static REMOVED_DC_COL: u32 = 16716032;
pub static MANDATORY_MSG: &str = "Mandatory response, please ignore.";
pub static JSON_TEXT_START: &str = "-# Data: ||`";
pub static JSON_TEXT_END: &str = "`||";
fn none_to_empty(string: Option<String>) -> String { fn none_to_empty(string: Option<String>) -> String {
@@ -105,6 +115,7 @@ pub async fn send_embed(
embeds: vec![embed], embeds: vec![embed],
content: options.message, content: options.message,
ephemeral: Some(options.ephemeral), ephemeral: Some(options.ephemeral),
components: options.actionrows,
..Default::default() ..Default::default()
}; };
@@ -112,13 +123,19 @@ pub async fn send_embed(
return Some(msg.unwrap()); return Some(msg.unwrap());
} }
else { else {
let r = CreateMessage::new().embeds(vec![embed]); let mut r = CreateMessage::new().embeds(vec![embed]);
if let Some(actionrows) = options.actionrows {
r = r.components(actionrows);
}
let _ = ctx.channel_id().send_message(ctx.http(), r).await; let _ = ctx.channel_id().send_message(ctx.http(), r).await;
return None; return None;
} }
} }
#[allow(dead_code)]
pub async fn http_send_embed( pub async fn http_send_embed(
http: &Http, http: &Http,
c_id: ChannelId, c_id: ChannelId,
@@ -127,7 +144,11 @@ pub async fn http_send_embed(
{ {
let embed = embed_from_options(options.clone()); let embed = embed_from_options(options.clone());
let r = CreateMessage::new().embeds(vec![embed]); let mut r = CreateMessage::new().embeds(vec![embed]);
if let Some(actionrows) = options.actionrows {
r = r.components(actionrows);
}
let msg = c_id.send_message(http, r).await; let msg = c_id.send_message(http, r).await;
return msg.ok(); return msg.ok();
@@ -194,25 +215,15 @@ pub async fn send_dm(msg: String, args: Args, owners: Vec<u64>) {
} }
pub fn embed_post(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions { pub fn make_post_embed(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions {
let media_type = &post_data["post_data"]["media_type"]; let media_type = &post_data["post_data"]["media_type"];
let desc_str = format!( let desc_str = lang!(
r#"Sorted by what I think will be most important "dc_msg_embed_re_post",
Spoilers and vote length anonymizer for fair review! post_data["post_data"]["subreddit"].as_str().unwrap(),
## Post Data:
**Media type:** `{}`
**Post upvotes:** ||`{:>6}`||
**Moderator votes:** ||`{:>6}`||
**URL:** ||<{}>||
## Listing Data:
**Added by:** `{{ human: {}, bot: {} }}`
**Approved by:** `{{ human: {}, bot: [not implemented] }}`"#,
if !media_type.is_null() { media_type.as_str().unwrap() } else { "None" },
post_data["post_data"]["upvotes"].as_i64().unwrap(), post_data["post_data"]["upvotes"].as_i64().unwrap(),
post_data["votes"]["mod_voters"].as_array().unwrap().len(), post_data["votes"]["mod_voters"].as_array().unwrap().len(),
url, if !media_type.is_null() { media_type.as_str().unwrap() } else { "None" },
if post_data["added"] ["by_human"].as_bool().unwrap() { "" } else { "" }, if post_data["added"] ["by_human"].as_bool().unwrap() { "" } else { "" },
if post_data["added"] ["by_bot"].as_bool().unwrap() { "" } else { "" }, if post_data["added"] ["by_bot"].as_bool().unwrap() { "" } else { "" },
@@ -225,17 +236,14 @@ pub fn embed_post(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let json_min = json!(
{"post_data": json!({ "upvotes": post_data["post_data"]["upvotes"] }),
"added": post_data["added"],
"approved": post_data["approved"],
"votes": json!({"mod_voters": post_data["votes"]["mod_voters"]})}
);
let media_urls = post_data["post_data"]["media_urls"].as_array().unwrap(); let media_urls = post_data["post_data"]["media_urls"].as_array().unwrap();
let action_row = make_post_components();
let json_encoded = trim_compress_and_encode_json(post_data);
return EmbedOptions { return EmbedOptions {
title: Some(post_data["post_data"]["title"].as_str().unwrap().to_string()), title: Some(post_data["post_data"]["title"].as_str().unwrap().to_string()),
desc: format!("{}\n\nJSON: ||`{}`||", trimmed, serde_json::to_string(&json_min).unwrap()), desc: format!("{}\n\n{}{}{}", trimmed, JSON_TEXT_START, json_encoded, JSON_TEXT_END),
col: Some(DEFAULT_DC_COL), col: Some(DEFAULT_DC_COL),
url: Some(url.to_string()), url: Some(url.to_string()),
ts: Some(Timestamp::from_unix_timestamp(post_data["post_data"]["date_unix"].as_i64().unwrap()).unwrap()), ts: Some(Timestamp::from_unix_timestamp(post_data["post_data"]["date_unix"].as_i64().unwrap()).unwrap()),
@@ -243,26 +251,96 @@ pub fn embed_post(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions
thumbnail: media_urls.first() thumbnail: media_urls.first()
.and_then(|url| url.as_str().map(|s| s.to_string())) .and_then(|url| url.as_str().map(|s| s.to_string()))
.or(None), .or(None),
actionrows: Some(vec![action_row]),
..Default::default() ..Default::default()
}; };
} }
pub fn embed_post_removed(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions { pub fn make_removed_embed(post_data: &Value, url: &str, ephemeral: bool) -> EmbedOptions {
let action_row = make_removed_components();
let none = lang!("none");
let desc = lang!(
"dc_msg_embed_re_removed",
if !post_data["removed"]["by"].is_null() { post_data["removed"]["by"].as_str().unwrap() }
else { &none },
if !post_data["removed"]["reason"].is_null() { post_data["removed"]["reason"].as_str().unwrap() }
else { &none }
);
let json_encoded = trim_compress_and_encode_json(post_data);
return EmbedOptions { return EmbedOptions {
title: Some("REMOVED!".to_string()), title: Some(lang!("dc_msg_removed_square_brackets", post_data["post_data"]["title"].clone())),
desc: format!( desc: format!("{}\n\n{}{}{}", desc, JSON_TEXT_START, json_encoded, JSON_TEXT_END),
"## Removed by `{}`\n**Reason:** {}\nURL: ||<{}>||\n\nJSON: ||`{}`||",
post_data["removed_by"].as_str().unwrap(),
if !post_data["remove_reason"].is_null() { post_data["remove_reason"].as_str().unwrap() }
else { "None" },
url,
serde_json::to_string(&post_data).unwrap()
),
col: Some(REMOVED_DC_COL), col: Some(REMOVED_DC_COL),
url: Some(url.to_string()), url: Some(url.to_string()),
ts: Some(Timestamp::from_unix_timestamp(post_data["post_data"]["date_unix"].as_i64().unwrap()).unwrap()), ts: Some(Timestamp::from_unix_timestamp(post_data["post_data"]["date_unix"].as_i64().unwrap()).unwrap()),
ephemeral, ephemeral,
actionrows: Some(vec![action_row]),
..Default::default() ..Default::default()
}; };
}
pub fn trim_post_json(j: &Value) -> Value {
let mut json_trimmed = j.clone();
if let Some(obj) = json_trimmed["post_data"].as_object_mut() {
obj.remove("media_urls");
obj.remove("subreddit");
obj.remove("title");
obj.remove("date_unix");
obj.remove("media_type");
}
if let Some(obj) = json_trimmed["votes"].as_object_mut() {
obj.remove("voters_re");
obj["voters_dc"] = json!(obj["voters_dc"].as_array().unwrap().len());
obj["mod_voters"] = json!(obj["mod_voters"].as_array().unwrap().len());
}
if let Some(obj) = json_trimmed["approved"].as_object_mut() {
obj.remove("by_ris");
}
return json_trimmed;
}
pub fn trim_compress_and_encode_json(j: &Value) -> String {
let trim = trim_post_json(j);
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(serde_json::to_string(&trim).unwrap().as_bytes()).unwrap();
let compressed = encoder.finish().unwrap();
return general_purpose::STANDARD.encode(&compressed);
}
pub fn decode_and_decompress_json(t: String) -> Result<Value, serde_json::Error> {
let compressed = general_purpose::STANDARD.decode(t).unwrap();
let mut decoder = ZlibDecoder::new(&compressed[..]);
let mut decompressed = String::new();
decoder.read_to_string(&mut decompressed).unwrap();
return serde_json::from_str(&decompressed);
}
fn make_post_components() -> CreateActionRow {
return CreateActionRow::Buttons(vec![
CreateButton::new("vote_btn") .label(lang!("dc_btn_vote")) .emoji(ReactionType::Unicode("⬆️".to_string())),
CreateButton::new("unvote_btn") .label(lang!("dc_btn_unvote")),
CreateButton::new("approve_btn") .label(lang!("dc_btn_approve")) .emoji(ReactionType::Unicode("".to_string())),
CreateButton::new("unapprove_btn").label(lang!("dc_btn_unapprove")) .emoji(ReactionType::Unicode("".to_string())),
CreateButton::new("remove_btn") .label(lang!("dc_btn_remove")) .emoji(ReactionType::Unicode("🗑️".to_string()))
]);
}
fn make_removed_components() -> CreateActionRow {
return CreateActionRow::Buttons(vec![
CreateButton::new("unremove_btn").label(lang!("dc_btn_unremove")).emoji(ReactionType::Unicode("↩️".to_string()))
]);
} }
+39 -11
View File
@@ -1,5 +1,5 @@
use crate::messages::send_dm; use crate::messages::send_dm;
use crate::{errln, rs_println, Args}; use crate::{errln, lang, rs_println, Args, LANG_NAME};
use std::fs; use std::fs;
use std::ffi::CString; use std::ffi::CString;
@@ -21,21 +21,42 @@ pub async fn start(args: Args) -> PyResult<()> {
let code = get_code(&format!("{}{}main.py", path, slash)); let code = get_code(&format!("{}{}main.py", path, slash));
let py_args = args_str.replace(":true", ":True").replace(":false", ":False"); let py_args = args_str.replace(":true", ":True").replace(":false", ":False");
let app_path = CString::new(format!("args = {}\n{}", py_args, code)).unwrap(); let app_path: CString;
unsafe {
app_path = CString::new(
format!("args = {}\nlang_name = \"{}\"\n{}",
py_args,
LANG_NAME.clone().unwrap(),
code
)
).unwrap();
}
let mut traceback: String = String::new();
let mut is_error = false;
pyo3::prepare_freethreaded_python(); pyo3::prepare_freethreaded_python();
let from_python = Python::with_gil(|py| -> PyResult<Py<PyAny>> { let _ = Python::with_gil(|py| -> Result<(), PyErr> {
let syspath = py.import("sys")?.getattr("path")?.downcast_into::<PyList>()?; let syspath = py.import("sys")?.getattr("path")?.downcast_into::<PyList>()?;
syspath.insert(0, path)?; syspath.insert(0, path)?;
let empty = CString::new("").unwrap(); let empty = CString::new("").unwrap();
let app: Py<PyAny> = PyModule::from_code(py, &app_path, &empty, &empty)?.into(); let py_result = PyModule::from_code(py, &app_path, &empty, &empty);
return Ok(app); if let Err(ref e) = py_result {
traceback = py.import("traceback")?
.call_method1("format_exception", (e.get_type(py), e.value(py), e.traceback(py)))?
.extract::<Vec<String>>()?
.join("");
is_error = true;
}
return Ok(());
}); });
if from_python.is_err() { if is_error {
let own_env = std::env::var("ASSISTANT_OWNERS").unwrap_or("0".to_string()); let own_env = std::env::var("ASSISTANT_OWNERS").unwrap_or("0".to_string());
let own_vec_str: Vec<String> = own_env.split(",").map(String::from).collect(); let own_vec_str: Vec<String> = own_env.split(",").map(String::from).collect();
let own_vec_u64: Vec<u64> = own_vec_str let own_vec_u64: Vec<u64> = own_vec_str
@@ -43,15 +64,22 @@ pub async fn start(args: Args) -> PyResult<()> {
.map(|s| s.parse::<u64>().expect("Failed to parse ASSISTANT_OWNERS. Invalid syntax.")) .map(|s| s.parse::<u64>().expect("Failed to parse ASSISTANT_OWNERS. Invalid syntax."))
.collect(); .collect();
send_dm(format!("Unknown internal Python Error: {:?}", from_python), args, own_vec_u64).await; send_dm(
errln!("pyO3: {:?}", from_python); lang!("dc_msg_dm_python_err", format!("{}", traceback)),
args,
own_vec_u64
).await;
errln!("pyO3: {}", traceback);
} }
return Ok(()); return Ok(());
} }
fn get_code(path: &str) -> String { fn get_code(path: &str) -> String {
return fs::read_to_string(path) let file = fs::read_to_string(path);
.unwrap_or_else(|_| errln!("Failed to read Python file.\nPath: {}", path)) if file.is_err() { errln!("Failed to read Python file.\nPath: {}", path); }
.to_string();
return file.unwrap().to_string();
} }
+30 -25
View File
@@ -1,28 +1,29 @@
from io import TextIOWrapper from io import TextIOWrapper
import asyncpraw as praw import asyncpraw as praw # type: ignore
import os import os
from typing import Final from typing import Final, Any
from macros import * from macros import *
import json import toml
BK_WEEKLY: Final[str] = "bk_weekly_art_posts" RE_DATA_POSTS: Final[str] = "posts"
BK_WEEK: Final[str] = "bk_week" CFG_DATA_RE: Final[str] = "reddit"
# TODO: add wipe arg
# TODO: add test-bot arg
class Bot: class Bot:
args: dict = {"NO_RUST": True, "dev": True, "py": True, "port": 2920} args: dict[str, Any] = {"NO_RUST": True, "dev": True, "py": True, "port": 2920}
r_id: str = os.environ.get("ASSISTANT_R_ID") r_id: str | None = os.environ.get("ASSISTANT_R_ID")
secret: str = os.environ.get("ASSISTANT_R_TOKEN") secret: str | None = os.environ.get("ASSISTANT_R_TOKEN")
username: str = os.environ.get("ASSISTANT_R_NAME") username: str | None = os.environ.get("ASSISTANT_R_NAME")
password: str = os.environ.get("ASSISTANT_R_PASS") password: str | None = os.environ.get("ASSISTANT_R_PASS")
fetch_limit = 0 fetch_limit = 0
useragent: str =\ useragent: str =\
f"{username} by u/RandomPersonDotExe aka u/Byte_Dice"\ f"{username} by u/RandomPersonDotExe aka u/Byte_Dice"\
if r_id == "YmZjr4zLr2qtHdpQXtj0sBOOdJzrXQ"\ if r_id == "YmZjr4zLr2qtHdpQXtj0sBOOdJzrXQ" or r_id == "Q-eBDGS8sFHlUCi9kpBepQ"\
else f"{username} (original program by u/RandomPersonDotExe aka u/Byte_Dice)" else f"{username} (Original program by u/RandomPersonDotExe aka u/Byte_Dice)"
if password is None: if password is None:
py_error("Environment variable \"ASSISTANT_R_PASS\" is null!") py_error("Environment variable \"ASSISTANT_R_PASS\" is null!")
@@ -37,14 +38,14 @@ class Bot:
password = self.password, password = self.password,
user_agent = self.useragent user_agent = self.useragent
) )
self.sr_list: list[str] = []
self.sr = None self.sr = None
self.data_f: TextIOWrapper = None self.data_f: TextIOWrapper | None = None
self.data: dict = {} self.data: dict[str, Any] = {}
self.flairs: list[str] = []
self.aliases: dict[str, list[str]] = {}
async def initialize(self): async def set_args(self, args: dict[str, Any]):
self.sr = await self.r.subreddit("bytedicetesting")
async def set_args(self, args: dict):
self.args = args self.args = args
async def stop(self) -> bool: async def stop(self) -> bool:
@@ -56,12 +57,16 @@ class Bot:
return False return False
async def update_cfg_str(self, new_cfg: str) -> bool: async def update_cfg_str(self, new_cfg: str) -> bool:
json_cfg = json.loads(new_cfg) json_cfg = toml.loads(new_cfg)
self.sr = await self.r.subreddit(json_cfg[BK_WEEK]["subreddits"]) await self.update_cfg(json_cfg)
self.fetch_limit = json_cfg[BK_WEEK]["fetch_limit"]
return True return True
async def update_cfg(self, new_cfg: dict) -> bool: async def update_cfg(self, new_cfg: dict[str, Any]) -> bool:
self.sr = await self.r.subreddit(new_cfg[BK_WEEK]["subreddits"]) self.fetch_limit = new_cfg[CFG_DATA_RE]["fetch_limit"]
self.fetch_limit = new_cfg[BK_WEEK]["fetch_limit"] self.flairs = new_cfg[CFG_DATA_RE]["search_flairs"]
self.aliases = new_cfg[CFG_DATA_RE]["aliases"]
self.sr_list = new_cfg[CFG_DATA_RE]["subreddits"]
self.sr = await self.r.subreddit("+".join(self.sr_list))
init_lang(new_cfg["general"]["lang"])
py_print("Successfully updated the configs!")
return True return True
-81
View File
@@ -1,81 +0,0 @@
import asyncpraw.models as models
from macros import *
import bot as botPy
import data
import posts
BOT_ACTION_POSTFIX = "\n\n^(I am not an AI, I am just a bot. This action was performed automatically by the way.)"
async def make_cmd(cmd: str, bot: botPy.Bot) -> str:
return f"u/{await bot.r.user.me()} {cmd}"
async def is_cmd(cmd: str, text: str, bot: botPy.Bot) -> bool:
command: str = await make_cmd(cmd, bot)
return command.lower() in text.lower()
async def respond_to_mention(bot: botPy.Bot) -> bool:
async for mention in bot.r.inbox.mentions(limit=25):
if not mention.new:
continue
max_len = 100
body = mention.body
truncated = body[:max_len] + "..." if len(body) > max_len else body
py_print(f"New mention: {truncated}")
if await is_cmd("bk_week_add", body, bot):
await bk_week_add(mention, bot)
else:
await mention.mark_read()
return True
async def bk_week_add(mention: models.Comment, bot: botPy.Bot):
if not mention.subreddit.display_name not in bot.sr:
await mention.mark_read()
return
await mention.submission.load()
author = mention.author
is_op = author == mention.submission.author
is_mod = author in await mention.subreddit.moderator()
if not is_op and not is_mod:
await mention.mark_read()
return
short_url = mention.submission.shortlink
r = ""
bd = bot.data[botPy.BK_WEEKLY]
# TODO: ask if the messages should be changed
if short_url not in bd:
posts.add_post_url(bot, short_url)
r = "Successfully added this post to the data!"
if short_url in bd and is_mod:
if "removed" in bd[short_url]:
r = "(Mod action) Successfully un-removed this post from the data!"
else:
r = "(Mod action) Successfully added this post to the data!"
post = await posts.from_url(bot, short_url)
post_data = posts.get_post_details(post[1])
data.add_post_to_data(bot, post_data, True)
elif short_url in bd:
r = "Could not add this post to the data. Luckily, it's already there, so there's nothing to worry about!"
await mention.reply(r + " Thank you for participating!" + BOT_ACTION_POSTFIX)
await mention.mark_read()
-221
View File
@@ -1,221 +0,0 @@
import os
import json
import time
import bot as botPy
from macros import *
DATA_PATH = os.path.abspath(os.path.join(os.path.join(os.getcwd(), "data")))
class PostData:
def __init__(
self,
url: str,
title: str,
upvotes: int,
date_unix: int,
media_type: str,
media_urls: list[str],
voters_re: list[str] = [],
voters_dc: list[int] = [],
mod_voters: list[int] = [],
added_by_human: bool = False,
added_by_bot: bool = False,
approved_by_human: bool = False,
approved_by_ris: bool = False
):
self.url = url
self.title = title
self.upvotes = upvotes
self.date_unix = date_unix
self.media_type = media_type
self.media_urls = media_urls
self.voters_re = voters_re
self.voters_dc = voters_dc
self.mod_voters = mod_voters
self.added_by_human = added_by_human
self.added_by_bot = added_by_bot
self.approved_by_human = approved_by_human
self.approved_by_ris = approved_by_ris
def to_json(self):
return {
"post_data": {
"title": self.title,
"upvotes": self.upvotes,
"date_unix": self.date_unix,
"media_type": self.media_type,
"media_urls": self.media_urls
},
"votes": {
"voters_re": self.voters_re,
"voters_dc": self.voters_dc,
"mod_voters": self.mod_voters
},
"added": {
"by_human": self.added_by_human,
"by_bot": self.added_by_bot
},
"approved": {
"by_human": self.approved_by_human,
"by_ris": self.approved_by_ris
}
}
def read_data(bot: botPy.Bot) -> bool:
r_path = os.path.join(DATA_PATH, "reddit_data.json")
if os.path.isfile(r_path):
bot.data_f = open(r_path, "r+")
else:
if not bot.args["py"]:
return False
py_print("reddit_data.json not found, creating new from preset...")
with open(os.path.join(DATA_PATH, "reddit_data_preset.json", "r")) as f:
data_preset_json = json.load(f)
data_preset_json[botPy.BK_WEEKLY].pop("EXAMPLE VALUE", None)
data_preset_json[botPy.BK_WEEKLY].pop("EXAMPLE VALUE DELETED", None)
with open(r_path, "w") as f:
json.dump(data_preset_json, f, indent = 2)
bot.data_f = open(r_path, "r+")
data_str = bot.data_f.read()
json_data = json.loads(data_str)
bot.data = json_data
return True
def write_data(bot: botPy.Bot) -> bool:
bot.data_f.seek(0)
json.dump(bot.data, bot.data_f, indent=2)
bot.data_f.truncate()
return True
async def read_cfg(bot: botPy.Bot) -> bool:
r_path = os.path.join(DATA_PATH, "cfg.json")
if os.path.isfile(r_path):
bot.data_f = open(r_path, "r+")
else:
py_print("cfg.json not found, creating new from preset...")
with open(os.path.join(DATA_PATH, "cfg_default.json", "r")) as f:
data_preset_json = json.load(f)
with open(r_path, "w") as f:
json.dump(data_preset_json, f, indent = 2)
bot.data_f = open(r_path, "r+")
data_str = bot.data_f.read()
json_data = json.loads(data_str)
await bot.update_cfg(json_data)
return True
def add_post_to_data(bot: botPy.Bot, new_data: PostData, bypass_conditions: bool = False) -> bool:
if bypass_conditions:
bot.data[botPy.BK_WEEKLY][new_data.url] = new_data.to_json()
if bot.args["dev"]:
py_print(f"Added post \"{new_data.url}\" (Conditions bypassed)")
return True
# not sure what this is for
updated = False
if new_data.url not in bot.data[botPy.BK_WEEKLY] or updated:
bot.data[botPy.BK_WEEKLY][new_data.url] = new_data.to_json()
if bot.args["dev"]:
py_print(f"Added post \"{new_data.url}\"")
return True
if "removed" not in bot.data[botPy.BK_WEEKLY][new_data.url]:
updated = new_data.upvotes != bot.data[botPy.BK_WEEKLY][new_data.url]["post_data"]
else:
py_print(f"Failed to add post \"{new_data.url}\": Removed flag is True.")
return False
def set_approve_post(bot: botPy.Bot, approved: bool, url: str) -> bool:
if not hasattr(bot.data[botPy.BK_WEEKLY][url], "removed"):
bot.data[botPy.BK_WEEKLY][url]["approved"]["by_human"] = approved
return True
return False
def remove_post(bot: botPy.Bot, url: str, removed_by: str = "UNKNOWN", reason: str = "None") -> bool:
weekly = bot.data[botPy.BK_WEEKLY]
if url in weekly:
weekly[url] = {
"removed": True,
"removed_by": removed_by,
"remove_reason": reason,
"post_data": { "date_unix": weekly[url]["post_data"]["date_unix"] }
}
return True
else:
return False
def remove_old_posts(bot: botPy.Bot, max_age: int) -> bool:
now = int(time.time())
weekly = bot.data[botPy.BK_WEEKLY]
remove: list[str] = []
for url, post in weekly.items():
if now - post["post_data"]["date_unix"] > max_age:
remove.append(url)
for key in remove:
weekly.pop(key)
return True
def set_vote_post(
bot: botPy.Bot,
url: str,
user: str | int,
mod_vote: bool = False,
from_dc: bool = False,
remove_vote: bool = False,
) -> bool:
if url not in bot.data[botPy.BK_WEEKLY]:
return False
votes = bot.data[botPy.BK_WEEKLY][url]["votes"]
re_voters: set[str] = set(votes["voters_re"])
dc_voters: set[int] = set(votes["voters_dc"])
mod_voters: set[int] = set(votes["mod_voters"])
target_voters = mod_voters if mod_vote else (dc_voters if from_dc else re_voters)
if remove_vote:
if user not in target_voters:
return False
target_voters.remove(user)
else:
if user in target_voters:
return False
target_voters.add(user)
bot.data[botPy.BK_WEEKLY][url]["votes"]["voters_re"] = list(re_voters)
bot.data[botPy.BK_WEEKLY][url]["votes"]["voters_dc"] = list(dc_voters)
bot.data[botPy.BK_WEEKLY][url]["votes"]["mod_voters"] = list(mod_voters)
return True
+41 -3
View File
@@ -1,17 +1,55 @@
import os
import json
from printColors import PrintColors from printColors import PrintColors
def py_print(*args):
G_LANG_NAME: str = ""
G_LANG: dict[str, str] = {}
DATA_PATH_LANG: str = "./data/lang/"
def py_print(*args: str):
print( print(
PrintColors.FG.blue + "Py", PrintColors.FG.blue + "Py",
"-", "-",
" ".join(args) + PrintColors.Special.reset " ".join(args) + PrintColors.Special.reset
) )
def py_error(*args):
def py_error(*args: str):
print( print(
PrintColors.BG.red + "ERROR" + PrintColors.Special.reset, PrintColors.BG.red + "ERROR" + PrintColors.Special.reset,
PrintColors.FG.blue + "Py", PrintColors.FG.blue + "Py",
"-", "-",
" ".join(args) + PrintColors.Special.reset " ".join(args) + PrintColors.Special.reset
) )
quit() quit()
def lang(k: str) -> str:
if G_LANG == {}:
py_error("Language must be initialized before use!")
t = G_LANG.get(k)
if t is None: py_error(f"Key not found in language \"{G_LANG_NAME}\": {k}")
return str(t)
def init_lang(lang_name: str):
global G_LANG, G_LANG_NAME
G_LANG_NAME = lang_name # type: ignore
full_path = f"{DATA_PATH_LANG}{lang_name}.json"
if not os.path.exists(full_path):
py_error(f"File for language \"{lang_name}\" ({lang_name}.json) not found!\n Hint: You can download official language files at https://github.com/ByteDice/ByteDiceAssistant in the data/langs/... folder")
with open(full_path, "r") as f:
str_data = f.read()
try:
json_data = json.loads(str_data)
except json.JSONDecodeError as e:
py_error(f"Failed to parse JSON for language \"{lang_name}\":\n{e}")
G_LANG = json_data # type: ignore
+21 -16
View File
@@ -4,45 +4,48 @@ import time
from macros import * from macros import *
import bot as botPy import bot as botPy
import data import py_data
import py_websocket import py_websocket
async def main(): async def main():
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8") # type: ignore
py_print("Creating Reddit bot...") py_print("Creating Reddit bot...")
bot = botPy.Bot() bot = botPy.Bot()
await bot.initialize() # args and lang_name are supposed to be undefined.
py_print(f"Successfully created Reddit bot: {await bot.r.user.me()}")
# args is supposed to be undefined.
# It gets defined in Rust. # It gets defined in Rust.
try: await bot.set_args(args) try:
await bot.set_args(args) # type: ignore
py_print("Fetching language file...")
init_lang(lang_name) # type: ignore
py_print(f"[IMPORTANT] The below message is a test message, it should be written in the language you've selected\nTest message: {lang('log_lang_load_success')}")
except NameError: except NameError:
py_print("No command args found from Rust. Don't worry though, we have backup in place.") py_print("No command args or language name found from Rust. Don't worry though, we have backup in place.")
init_lang("en")
if bot.args["dev"]: if bot.args["dev"]:
py_print("ARGS:", str(bot.args)) py_print("ARGS:", str(bot.args))
py_print("Reading config file...") py_print("Reading config file...")
await data.read_cfg(bot) await py_data.read_cfg(bot)
py_print("Reading Reddit data...") py_print("Reading Reddit data...")
rd = data.read_data(bot) rd = py_data.read_data(bot)
data_retries = 0 data_retries = 0
while not rd : while not rd :
data_retries += 1 data_retries += 1
time.sleep(1) time.sleep(1)
py_print(f"Failed to read data: File doesn't exist yet. Retrying (#{data_retries}/5)...") py_print(f"Failed to read data: File doesn't exist yet. Retrying (#{data_retries}/5)...")
rd = data.read_data(bot) rd = py_data.read_data(bot)
if data_retries == 5 and not rd: if data_retries == 5 and not rd:
raise Exception("Couldn't read reddit_data.json: File doesn't exist") raise Exception("Couldn't read re_data.json: File doesn't exist")
py_print("Successfully read data!") py_print("Successfully read all data!")
py_print(f"Successfully created Reddit bot: {await bot.r.user.me()}")
if not bot.args["py"]: if not bot.args["py"]:
py_print("Connecting to local websocket...") py_print("Connecting to local websocket...")
@@ -53,5 +56,7 @@ async def main():
await bot.stop() await bot.stop()
try:
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt:
raise SystemExit
+47 -40
View File
@@ -1,54 +1,45 @@
import emoji import emoji
from asyncpraw import models from asyncpraw import models # type: ignore
import asyncprawcore as prawcore import asyncprawcore as prawcore # type: ignore
import asyncpraw.exceptions as exc import asyncpraw.exceptions as exc # type: ignore
import time import time
import data import py_data
import bot as botPy import bot as botPy
from macros import * from macros import *
async def add_new_posts(bot: botPy.Bot, max_age: int) -> bool: async def add_new_posts(bot: botPy.Bot, max_age: int, max_results: int) -> bool:
check_emoji = emoji.emojize(":check_mark_button:") check_emoji = emoji.emojize(":check_mark_button:")
cross_emoji = emoji.emojize(":cross_mark:") cross_emoji = emoji.emojize(":cross_mark:")
py_print("Fetching posts...") py_print("Fetching posts...")
posts = await fetch_posts_with_flair(bot, "Original Art") posts = await fetch_posts_with_flair(bot, bot.flairs, max_age, max_results)
py_print("Evaluating posts...") py_print("Evaluating posts...")
added_posts = 0 added_posts = 0
without_media = 0 without_media = 0
not_added = 0 not_added = 0
old_posts = 0
for post in posts: for post in posts:
media = has_media(post) details = get_post_details(post)
media_urls = "\n ".join(media[3]) media_urls = "\n ".join(details.media_urls)
media_check = check_emoji if details.media_type is not None else cross_emoji
if bot.args["dev"]: if bot.args["dev"]:
py_print( py_print(
f"\n{post.title}", f"\n{details.title}",
f"\n {post.shortlink}" f"\n {details.url}"
f"\n {check_emoji if media[0] else cross_emoji} Media ({media[1]}) [{media[2]}]", f"\n {media_check} Media ({details.media_type}) [{len(details.media_urls)}]",
f"\n {media_urls}\n" f"\n {media_urls}\n"
) )
details = get_post_details(post)
now = int(time.time())
if now - details.date_unix > max_age and max_age > 0: if details.media_type is not None:
old_posts += 1
continue
if not media[0]:
without_media += 1 without_media += 1
continue continue
post_added = False
post_added = data.add_post_to_data( post_added = py_data.add_post_to_data(
bot, bot,
details details
) )
@@ -57,26 +48,41 @@ async def add_new_posts(bot: botPy.Bot, max_age: int) -> bool:
else: not_added += 1 else: not_added += 1
py_print(f"Successfully fetched {len(posts)} posts.\n" + py_print(f"Successfully fetched {len(posts)} posts.\n" +
f" Out of which were {added_posts} added.\n" + f" Out of which were {added_posts} added.\n" +
f" {without_media} had no media, " + f" {without_media} had no media, " +
f"{not_added} are removed or already existed, " + f" {not_added} are removed or already existed, ")
f"and {old_posts} were older than the max age threshold.")
py_data.write_data(bot)
return True return True
async def fetch_posts_with_flair(bot: botPy.Bot, flair_name: str) -> list[models.Submission]: async def fetch_posts_with_flair(
bot: botPy.Bot,
flair_names: list[str],
max_age_secs: int,
max_results: int
) -> list[models.Submission]:
posts: list[models.Submission] = [] posts: list[models.Submission] = []
# ~36 OG-art posts per week, round limit to 50, 75 or 100 flair_names_str = \
async for post in bot.sr.search(f"flair:\"{flair_name}\"", sort="new", limit=bot.fetch_limit): f"flair:{flair_names[0].replace(" ", "_")}" if len(flair_names) == 1\
else " OR ".join(f"flair:{flair.replace(" ", "_")}" for flair in flair_names)
if bot.sr is None: return []
now = int(time.time())
# ~20 OG-art posts per week, round limit to 50, 75 or 100 for 2 subreddits
async for post in bot.sr.search(f"{flair_names_str}", sort="new", limit=max_results):
if now - int(post.created_utc) > max_age_secs and max_age_secs > 0: continue
posts.append(post) posts.append(post)
return posts return posts
def has_media(post: models.Submission) -> tuple[bool, str, int, list[str]]: def has_media(post: models.Submission) -> tuple[bool, str | None, int, list[str]]:
media_type: str = None media_type: str | None = None
media_count = 0 media_count = 0
media_urls: list[str] = [] media_urls: list[str] = []
@@ -105,7 +111,7 @@ def has_media(post: models.Submission) -> tuple[bool, str, int, list[str]]:
return (media_type != None, media_type, media_count, media_urls) return (media_type != None, media_type, media_count, media_urls)
async def from_url(bot: botPy.Bot, url: str) -> tuple[bool, models.Submission]: async def from_url(bot: botPy.Bot, url: str) -> tuple[bool, models.Submission | None]:
try: try:
post: models.Submission = await bot.r.submission(url=url) post: models.Submission = await bot.r.submission(url=url)
return True, post return True, post
@@ -116,11 +122,12 @@ async def from_url(bot: botPy.Bot, url: str) -> tuple[bool, models.Submission]:
return False, None return False, None
def get_post_details(post: models.Submission, added_by_h: bool = False) -> data.PostData: def get_post_details(post: models.Submission, added_by_h: bool = False) -> py_data.PostData:
media = has_media(post) media = has_media(post)
return data.PostData( return py_data.PostData(
post.shortlink, post.shortlink,
post.subreddit.display_name,
post.title, post.title,
post.score, post.score,
int(post.created_utc), int(post.created_utc),
@@ -131,12 +138,12 @@ def get_post_details(post: models.Submission, added_by_h: bool = False) -> data.
) )
async def add_post_url(bot, url: str, approve: bool = False, added_by_h: bool = False) -> bool: async def add_post_url(bot: botPy.Bot, url: str, approve: bool = False, added_by_h: bool = False) -> bool:
result, post = await from_url(bot, url) result, post = await from_url(bot, url)
if not result: if not result: return False
return False if post is None: return False
post_data = get_post_details(post, added_by_h) post_data = get_post_details(post, added_by_h)
post_data.approved_by_human = approve post_data.approved_by_human = approve
return data.add_post_to_data(bot, post_data, True) return py_data.add_post_to_data(bot, post_data, True)
+86
View File
@@ -0,0 +1,86 @@
import asyncpraw.models as models # type: ignore
from macros import *
import bot as botPy
import posts
async def make_cmd(cmd: str, bot: botPy.Bot) -> str:
return f"u/{await bot.r.user.me()} {cmd}"
async def is_cmd(cmd: str, text: str, bot: botPy.Bot) -> bool:
command: str = await make_cmd(cmd, bot)
if command.lower() in text.lower(): return True
for alias in bot.aliases[cmd]:
a_cmd = await make_cmd(alias, bot)
if a_cmd.lower() in text.lower(): return True
return False
async def respond_to_mention(bot: botPy.Bot) -> bool:
async for mention in bot.r.inbox.mentions(limit=100):
if not mention.new:
continue
max_len = 100
body = mention.body
truncated = body[:max_len] + "..." if len(body) > max_len else body
py_print(f"New mention: {truncated}")
if await is_cmd("add_post", body, bot):
await bk_week_add(mention, bot)
else:
if bot.args["dev"]: py_print("Mention was not a command.")
await mention.mark_read()
return True
async def bk_week_add(mention: models.Comment, bot: botPy.Bot):
if bot.args["dev"]: py_print("Mention was a command: add_post")
subreddit = mention.subreddit.display_name
if subreddit not in bot.sr_list:
await mention.mark_read()
if bot.args["dev"]: py_print(f"Mention wasn't in a selected subreddit. Subreddit: {subreddit}")
return
await mention.submission.load()
author = mention.author
is_op = author == mention.submission.author
is_mod = author in await mention.subreddit.moderator()
if (not is_op) and (not is_mod):
await mention.mark_read()
if bot.args["dev"]: py_print("Mention wasn't by the OP or a moderator.")
return
short_url = mention.submission.shortlink
r = ""
bd = bot.data[botPy.RE_DATA_POSTS]
if short_url not in bd:
if not is_mod: r = lang("py_re_response_weekly_add")
if is_mod: r = lang("py_re_response_weekly_mod_add")
else:
if bd[short_url]["removed"]["removed"] and is_mod:
r = lang("py_re_response_weekly_mod_unremove")
elif not bd[short_url]["removed"]["removed"]:
r = lang("py_re_response_weekly_exists")
await posts.add_post_url(bot, short_url)
if r != "":
await mention.reply(r + "\n\n" + lang("py_re_response_suffix"))
if bot.args["dev"]: py_print("Responded to mention.")
elif bot.args["dev"]: py_print("Response is empty.")
await mention.mark_read()
+243
View File
@@ -0,0 +1,243 @@
import os
import toml
import json
import time
from typing import Any
import bot as botPy
from macros import *
DATA_PATH = os.path.join(os.path.join(os.getcwd(), "data"))
DB_PATH = os.path.join(DATA_PATH, "db")
DEFAULT_PATH = os.path.join(DATA_PATH, "defaults")
CFG_PATH = os.path.join(os.path.join(os.getcwd(), "cfg"))
class PostData:
def __init__(
self,
url: str,
subreddit: str,
title: str,
upvotes: int,
date_unix: int,
media_type: str | None,
media_urls: list[str],
removed: bool = False,
removed_by: str | None = None,
removed_reason: str | None = None,
voters_re: list[str] = [],
voters_dc: list[int] = [],
mod_voters: list[int] = [],
added_by_human: bool = False,
added_by_bot: bool = False,
approved_by_human: bool = False,
approved_by_ris: bool = False
):
self.subreddit = subreddit
self.removed = removed
self.removed_by = removed_by
self.removed_reason = removed_reason
self.url = url
self.title = title
self.upvotes = upvotes
self.date_unix = date_unix
self.media_type = media_type
self.media_urls = media_urls
self.voters_re = voters_re
self.voters_dc = voters_dc
self.mod_voters = mod_voters
self.added_by_human = added_by_human
self.added_by_bot = added_by_bot
self.approved_by_human = approved_by_human
self.approved_by_ris = approved_by_ris
def to_json(self) -> dict[str, Any]:
return {
"removed": {
"removed": self.removed,
"by": self.removed_reason,
"reason": self.removed_reason
},
"post_data": {
"subreddit": self.subreddit,
"title": self.title,
"upvotes": self.upvotes,
"date_unix": self.date_unix,
"media_type": self.media_type,
"media_urls": self.media_urls
},
"votes": {
"voters_re": self.voters_re,
"voters_dc": self.voters_dc,
"mod_voters": self.mod_voters
},
"added": {
"by_human": self.added_by_human,
"by_bot": self.added_by_bot
},
"approved": {
"by_human": self.approved_by_human,
"by_ris": self.approved_by_ris
}
}
def read_data(bot: botPy.Bot) -> bool:
r_path = os.path.join(DB_PATH, "re_data.json")
if os.path.isfile(r_path):
bot.data_f = open(r_path, "r+")
else:
if not bot.args["py"]:
return False
py_print("re_data.json not found, creating new from preset...")
with open(os.path.join(DEFAULT_PATH, "re_data_preset.json"), "r") as f:
data_preset_json: dict[str, Any] = json.load(f)
data_preset_json[botPy.RE_DATA_POSTS].pop("EXAMPLE URL", None)
with open(r_path, "w") as f:
json.dump(data_preset_json, f, indent = 2)
bot.data_f = open(r_path, "r+")
data_str = bot.data_f.read()
json_data = json.loads(data_str)
bot.data = json_data
return True
def write_data(bot: botPy.Bot) -> bool:
if bot.data_f is None: return False
bot.data_f.seek(0)
json.dump(bot.data, bot.data_f, indent=2)
bot.data_f.truncate()
return True
async def read_cfg(bot: botPy.Bot) -> bool:
r_path = os.path.join(CFG_PATH, "cfg.toml")
if os.path.isfile(r_path):
bot.data_f = open(r_path, "r+")
else:
py_print("cfg.toml not found, creating new from preset...")
with open(os.path.join(DEFAULT_PATH, "cfg_default.toml", "r")) as f:
data_preset_json = toml.load(f)
with open(r_path, "w") as f:
toml.dump(data_preset_json, f, indent = 2) # type: ignore
bot.data_f = open(r_path, "r+")
data_str = bot.data_f.read()
json_data = toml.loads(data_str)
await bot.update_cfg(json_data)
return True
def add_post_to_data(bot: botPy.Bot, new_data: PostData, bypass_conditions: bool = False) -> bool:
if new_data.removed:
new_data.removed = False
new_data.removed_by = None
new_data.removed_reason = None
if bot.args["dev"]: py_print(f"Un-removed post \"{new_data.url}\"")
return True
if bypass_conditions:
bot.data[botPy.RE_DATA_POSTS][new_data.url] = new_data.to_json()
if bot.args["dev"]: py_print(f"Added post \"{new_data.url}\" (Conditions bypassed)")
return True
elif new_data.url not in bot.data[botPy.RE_DATA_POSTS]:
bot.data[botPy.RE_DATA_POSTS][new_data.url] = new_data.to_json()
if bot.args["dev"]: py_print(f"Added post \"{new_data.url}\"")
return True
return False
def set_approve_post(bot: botPy.Bot, approved: bool, url: str) -> bool:
if url not in bot.data[botPy.RE_DATA_POSTS]:
return False
if not bot.data[botPy.RE_DATA_POSTS][url]["removed"]["removed"]:
bot.data[botPy.RE_DATA_POSTS][url]["approved"]["by_human"] = approved
return True
return False
def remove_post(bot: botPy.Bot, url: str, removed_by: str = "UNKNOWN", reason: str = "None") -> bool:
weekly = bot.data[botPy.RE_DATA_POSTS]
if url in weekly:
rm = weekly[url]["removed"]
rm["removed"] = True
rm["by"] = removed_by
rm["reason"] = reason
weekly[url]["removed"] = rm
return True
else:
return False
def remove_old_posts(bot: botPy.Bot, max_age: int) -> bool:
now = int(time.time())
weekly = bot.data[botPy.RE_DATA_POSTS]
remove: list[str] = []
for url, post in weekly.items():
if now - post["post_data"]["date_unix"] > max_age:
remove.append(url)
for key in remove:
weekly[key]["removed"]["removed"] = True
return True
def set_vote_post(
bot: botPy.Bot,
url: str,
user: str | int,
mod_vote: bool = False,
from_dc: bool = False,
remove_vote: bool = False,
) -> bool:
if url not in bot.data[botPy.RE_DATA_POSTS]:
return False
if bot.data[botPy.RE_DATA_POSTS][url]["removed"]["removed"]:
return False
votes = bot.data[botPy.RE_DATA_POSTS][url]["votes"]
re_voters: set[str] = set(votes["voters_re"])
dc_voters: set[int] = set(votes["voters_dc"])
mod_voters: set[int] = set(votes["mod_voters"])
target_voters = mod_voters if mod_vote else (dc_voters if from_dc else re_voters)
if remove_vote:
if user not in target_voters:
return False
target_voters.remove(user) # type: ignore
else:
if user in target_voters:
return False
target_voters.add(user) # type: ignore
bot.data[botPy.RE_DATA_POSTS][url]["votes"]["voters_re"] = list(re_voters)
bot.data[botPy.RE_DATA_POSTS][url]["votes"]["voters_dc"] = list(dc_voters)
bot.data[botPy.RE_DATA_POSTS][url]["votes"]["mod_voters"] = list(mod_voters)
return True
+28 -24
View File
@@ -1,12 +1,13 @@
import websockets import websockets
import asyncio import asyncio
import json import json
from typing import Any
from macros import * from macros import *
import bot as botPy import bot as botPy
import data import py_data
import posts import posts
import cmds import py_cmds
ws_global = None ws_global = None
is_connected = False is_connected = False
@@ -35,9 +36,10 @@ async def websocket_client(bot: botPy.Bot):
while True: while True:
response = await ws.recv() response = await ws.recv()
if not response.startswith("json:"): str_response = str(response)
if not str_response.startswith("json:"): # type: ignore
py_print(f"Received from Rust: {response}") py_print(f"Received from Rust: {response}")
await parse_json(response, bot) await parse_json(str_response, bot)
async def parse_json(response: str, bot: botPy.Bot): async def parse_json(response: str, bot: botPy.Bot):
@@ -46,10 +48,10 @@ async def parse_json(response: str, bot: botPy.Bot):
try: try:
json_response = json.loads(json_str) json_response = json.loads(json_str)
if json_response["value"] not in ["respond_mentions"] or bot.args["dev"]: if json_response["value"] not in ["respond_mentions"] or bot.args["dev"]:
py_print(f"Received from Rust: {response}") if json_response["print"]: py_print(f"Received from Rust: {response}")
result = await json_to_func(json_response, bot) result = await json_to_func(json_response, bot)
await ws_global.ping() if ws_global is not None: await ws_global.ping()
await send_message(f"json:{json.dumps(result)}") await send_message(f"json:{json.dumps(result)}")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
if bot.args["dev"]: py_print(f"failed to parse json: {json_str}\n reason: {e}") if bot.args["dev"]: py_print(f"failed to parse json: {json_str}\n reason: {e}")
@@ -61,38 +63,40 @@ def run_thread(bot: botPy.Bot):
loop.run_until_complete(websocket_client(bot)) loop.run_until_complete(websocket_client(bot))
async def json_to_func(v: dict, bot: botPy.Bot) -> dict: async def json_to_func(v: dict[str, Any], bot: botPy.Bot) -> dict[str, Any]:
if "type" not in v or "value" not in v or not isinstance(v, dict): if "type" not in v or "value" not in v:
if bot.args["dev"]: py_print("JSON is not a dictionary or does not include \"type\" and \"value\" keys.") if bot.args["dev"]: py_print("JSON is not a dictionary or does not include \"type\" and \"value\" keys.")
return return result_json(False, True)
if v["type"] != "function": if v["type"] != "function":
v_type = v["type"] v_type = v["type"]
if bot.args["dev"]: py_print(f"Type \"{v_type}\" is not supported.") if bot.args["dev"]: py_print(f"Type \"{v_type}\" is not supported.")
return return result_json(False, True)
value_supported = True value_supported = True
r = False r = False
match v["value"]: match v["value"]:
case "update_data_file": r = data .write_data (bot) case "update_data_file": r = py_data.write_data (bot)
case "respond_mentions": r = await cmds .respond_to_mention(bot) case "respond_mentions": r = await py_cmds.respond_to_mention(bot)
case "add_new_posts": r = await posts.add_new_posts (bot, *v["args"]) case "add_new_posts": r = await posts .add_new_posts (bot, *v["args"])
case "add_post_url": r = await posts.add_post_url (bot, *v["args"]) case "add_post_url": r = await posts .add_post_url (bot, *v["args"])
case "remove_post_url": r = data .remove_post (bot, *v["args"]) case "remove_post_url": r = py_data.remove_post (bot, *v["args"])
case "set_approve_post": r = data .set_approve_post (bot, *v["args"]) case "set_approve_post": r = py_data.set_approve_post (bot, *v["args"])
case "set_vote_post": r = data .set_vote_post (bot, *v["args"]) case "set_vote_post": r = py_data.set_vote_post (bot, *v["args"])
case "remove_old_posts": r = data .remove_old_posts (bot, *v["args"]) case "remove_old_posts": r = py_data.remove_old_posts (bot, *v["args"])
case "update_cfg": r = await bot .update_cfg_str (*v["args"]) case "update_cfg": r = await bot .update_cfg_str (*v["args"])
case "stop_praw": r = await bot .stop () case "stop_praw": r = await bot .stop ()
case _: value_supported = False case _: value_supported = False
print_result = v["print"]
if not value_supported: if not value_supported:
val = v["value"] val = v["value"]
py_print(f"Value \"{val}\" is not supported") py_print(f"Value \"{val}\" is not supported")
return {"type": "result", "value": False} return result_json(False, print_result)
return result_json(r) return result_json(r, print_result)
def result_json(bool: bool) -> dict: def result_json(bool: bool, print_result: bool) -> dict[str, Any]:
return {"type": "result", "value": bool} return {"type": "result", "value": bool, "print": print_result}
+63
View File
@@ -0,0 +1,63 @@
use serde_json::json;
use crate::data::{get_mutex_data};
use crate::messages::send_msg;
use crate::re_cmds::get::get_post_from_data;
use crate::{data, websocket::send_cmd_json, Context, Error, CFG_DATA_RE};
use crate::re_cmds::generic_fns::{is_bk_mod_msg, send_embed_for_post, to_shorturl};
use crate::lang;
#[poise::command(
slash_command,
prefix_command,
rename = "re_addpost",
category = "re",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Fetches a post from Reddit and adds it to the database.
pub async fn cmd(
ctx: Context<'_>,
#[description = "The post URL."] url: String,
#[description = "Wether to approve it after adding it"] approve: Option<bool>
) -> Result<(), Error>
{
if !is_bk_mod_msg(ctx).await { return Ok(()); }
let shorturl_u = to_shorturl(&url);
let shorturl = &shorturl_u.unwrap_or(url.clone());
let a = approve.unwrap_or(false);
let r = send_cmd_json("add_post_url", Some(json!([&shorturl, a, true])), true).await.unwrap();
if !r["value"].as_bool().unwrap() {
send_msg(
ctx,
r#"Unknown error!
Error trace: `re_cmds/add.rs -> cmd() -> Unknown error`.
Common reasons: The URL provided was likely invalid or 403: forbidden (e.g a private subreddit)."#.to_string(),
true,
true
).await;
return Ok(());
}
data::update_re_data(ctx.data()).await;
let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?;
if let Some(bk_week) = reddit_data.get(CFG_DATA_RE) {
if let Some(post) = bk_week.get(shorturl) {
if post["removed"]["removed"].as_bool().unwrap()
{ send_msg(ctx, lang!("dc_msg_re_post_unremove_success", &shorturl), true, true).await; }
else { send_msg(ctx, lang!("dc_msg_re_post_update_success", &shorturl), true, true).await; }
}
else { send_msg(ctx, lang!("dc_msg_re_post_add_success", &shorturl), true, true).await; }
if a { send_msg(ctx, lang!("dc_msg_re_also_approved"), true, true).await; }
}
if let Some(post) = get_post_from_data(ctx, &reddit_data, shorturl).await? {
send_embed_for_post(ctx, post, &url).await?;
}
return Ok(());
}
+28
View File
@@ -0,0 +1,28 @@
use crate::{data::dc_bind_bk, lang, messages::send_msg, Context, Error};
#[poise::command(
slash_command,
prefix_command,
rename = "admin_re_bindchannel",
category = "admin",
default_member_permissions = "ADMINISTRATOR",
guild_only,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Sets the channel where the bot will dump all Reddit data upon using /re_updateDiscord.
pub async fn cmd(
ctx: Context<'_>
) -> Result<(), Error>
{
let c_id = ctx.channel_id().into();
let r = dc_bind_bk(ctx.data(), ctx.guild_id().unwrap().into(), c_id).await;
if r.is_ok() {
send_msg(ctx, lang!("dc_msg_bound_channel", c_id), true, true).await;
}
else {
send_msg(ctx, lang!("dc_msg_data_server_404"), true, true).await;
}
return Ok(());
}
+58
View File
@@ -0,0 +1,58 @@
use serde_json::{json, Value};
use crate::{data::{self, get_mutex_data}, lang, messages::send_msg, re_cmds::generic_fns::{is_bk_mod_msg, to_shorturl}, websocket, Context, Error, CFG_DATA_RE};
use super::generic_fns::send_embed_for_removed;
#[poise::command(
slash_command,
prefix_command,
rename = "re_approvepost",
category = "re",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Approves a post in the database.
pub async fn cmd(
ctx: Context<'_>,
#[description = "The post URL."] url: String,
#[description = "Wether to approve or disapprove the post"] disapprove: Option<bool>
) -> Result<(), Error>
{
if !is_bk_mod_msg(ctx).await { return Ok(()); }
let shorturl_u = to_shorturl(&url);
let shorturl = &shorturl_u.unwrap_or(url.clone());
data::update_re_data(ctx.data()).await;
let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?;
approve_cmd(ctx, shorturl, &reddit_data, !disapprove.unwrap_or(false)).await;
return Ok(());
}
async fn approve_cmd(ctx: Context<'_>, url: &str, reddit_data: &Value, approve: bool) {
if let Some(post) = reddit_data.get(CFG_DATA_RE).unwrap().get(url) {
if post["removed"]["removed"].as_bool().unwrap() {
send_embed_for_removed(ctx, url, post).await;
return;
}
let r = websocket::send_cmd_json("set_approve_post", Some(json!([approve, &url])), true).await.unwrap();
if r["value"].as_bool().unwrap() {
if approve {
send_msg(ctx, lang!("dc_msg_re_post_approve_success"), true, true).await;
}
else {
send_msg(ctx, lang!("dc_msg_re_post_disapprove_success"), true, true).await;
}
}
else {
send_msg(ctx, lang!("dc_msg_err_trace", "`re_cmds -> approve.rs -> cmd() -> unwrap websocket result error`"), true, true).await;
}
}
else {
send_msg(ctx, lang!("dc_msg_re_post_404"), false, false).await;
}
}
+96
View File
@@ -0,0 +1,96 @@
use poise::serenity_prelude::{self as serenity, ChannelId, ComponentInteraction, CreateInteractionResponse, CreateInteractionResponseMessage, EditMessage, Embed, Member, MessageId};
use regex::Regex;
use serde_json::Value;
use crate::{data::get_toml_mutex, lang, messages::{decode_and_decompress_json, embed_from_options, make_post_embed, make_removed_embed, send_embed, send_msg, EmbedOptions, JSON_TEXT_END, JSON_TEXT_START}, Context, Data, Error};
pub fn is_bk_mod(mod_list: Vec<u64>, uid: u64) -> bool {
return mod_list.contains(&uid);
}
pub async fn is_bk_mod_msg(ctx: Context<'_>) -> bool {
if is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get()) { return true; }
let sr = get_readable_subreddits(ctx.data()).await.unwrap();
send_msg(ctx, lang!("dc_msg_re_permdeny_not_re_mod", sr), true, true).await;
return false
}
pub async fn is_bk_mod_serenity(ctx: &serenity::Context, data: &Data, author: &Member, component: &ComponentInteraction) -> bool {
if is_bk_mod(data.bk_mods.clone(), author.user.id.get()) { return true; }
let sr = get_readable_subreddits(data).await.unwrap();
serenity_send_msg(ctx, component, lang!("dc_msg_re_permdeny_not_re_mod", sr), true).await;
return true
}
pub async fn serenity_send_msg(ctx: &serenity::Context, component: &ComponentInteraction, t: String, ephemeral: bool) {
let r = CreateInteractionResponse::Message(CreateInteractionResponseMessage::new().content(t).ephemeral(ephemeral));
let _ = component.create_response(ctx.http.clone(), r).await;
}
pub async fn serenity_edit_msg_embed(ctx: &serenity::Context, c_id: &ChannelId, m_id: &MessageId, e: EmbedOptions) {
let r = EditMessage::new()
.embed(embed_from_options(e.clone()))
.components(e.actionrows.unwrap());
let _ = c_id.edit_message(ctx.http.clone(), m_id, r).await;
}
pub fn to_shorturl(url: &str) -> Result<String, &str> {
let re = Regex::new(r"comments/([a-zA-Z0-9]+)").unwrap();
if let Some(caps) = re.captures(url) {
let post_id = &caps[1];
let short_url = format!("https://redd.it/{}", post_id);
return Ok(short_url);
}
return Err("Invalid URL");
}
pub async fn send_embed_for_post(ctx: Context<'_>, post: Value, url: &str) -> Result<(), Error> {
send_embed(ctx, make_post_embed(&post, url, true), true).await;
return Ok(());
}
pub async fn send_embed_for_removed(ctx: Context<'_>, url: &str, post: &Value) {
send_embed(
ctx,
make_removed_embed(post, url, true),
true
).await;
}
pub async fn get_readable_subreddits(data: &Data) -> Result<String, Error> {
let d = get_toml_mutex(&data.cfg).await.unwrap();
let sr = d["reddit"]["subreddits"].as_array().unwrap();
let sr_str: Vec<&str> = sr
.iter()
.map(|v| v.as_str().unwrap())
.collect();
let mut join = sr_str.join(", r/");
if join.len() != 0 { join = format!("r/{}", join); }
else { join = "[no subreddits assigned]".to_string(); }
return Ok(join);
}
pub fn embed_to_json(embed: &Embed) -> Result<Value, serde_json::Error> {
let msg_desc = embed.description.clone().unwrap();
let msg_lines = msg_desc.split("\n");
let msg_last_len = msg_lines.clone().last().unwrap().len();
let msg_json_str = &msg_lines.clone().last().unwrap()[JSON_TEXT_START.len()..msg_last_len - JSON_TEXT_END.len()];
return decode_and_decompress_json(msg_json_str.to_string());
}
+53
View File
@@ -0,0 +1,53 @@
use serde_json::Value;
use crate::{data::{self, get_mutex_data}, lang, messages::send_msg, re_cmds::generic_fns::{send_embed_for_post, to_shorturl}, rs_println, Context, Error, CFG_DATA_RE};
use super::generic_fns::send_embed_for_removed;
#[poise::command(
slash_command,
prefix_command,
rename = "re_getpost",
category = "re",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS"
)]
/// Fetches and shows a single post, just for you. The post has to be within the database.
pub async fn cmd(
ctx: Context<'_>,
#[description = "The post URL."] url: String
) -> Result<(), Error>
{
data::update_re_data(ctx.data()).await;
let shorturl_u = to_shorturl(&url);
let shorturl = &shorturl_u.unwrap_or(url.clone());
let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?;
if let Some(post) = get_post_from_data(ctx, &reddit_data, shorturl).await? {
send_embed_for_post(ctx, post, shorturl).await?;
}
return Ok(());
}
pub async fn get_post_from_data(ctx: Context<'_>, reddit_data: &Value, url: &str) -> Result<Option<Value>, Error> {
if let Some(bk_week) = reddit_data.get(CFG_DATA_RE) {
if let Some(post) = bk_week.get(url) {
if post["removed"]["removed"].as_bool().unwrap() {
send_embed_for_removed(ctx, url, post).await;
return Ok(None);
}
return Ok(Some(post.clone()));
}
else {
send_msg(ctx, lang!("dc_msg_re_post_404", url), true, true).await;
}
}
else {
send_msg(ctx, lang!("dc_msg_re_data_corrupted", url), true, true).await;
rs_println!("{}", serde_json::to_string_pretty(reddit_data)?);
}
return Ok(None);
}
+49
View File
@@ -0,0 +1,49 @@
use serde_json::json;
use crate::{data::{self, get_mutex_data}, lang, messages::send_msg, re_cmds::{generic_fns::{is_bk_mod_msg, send_embed_for_removed, to_shorturl}, get::get_post_from_data}, websocket::send_cmd_json, Context, Error};
#[poise::command(
slash_command,
prefix_command,
rename = "re_removepost",
category = "re",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Removes a post from the database.
pub async fn cmd(
ctx: Context<'_>,
#[description = "The post URL."] url: String,
#[description = "The reason of the removal."] reason: Option<String>
) -> Result<(), Error>
{
if !is_bk_mod_msg(ctx).await { return Ok(()); }
let shorturl_u = to_shorturl(&url);
let shorturl = &shorturl_u.unwrap_or(url.clone());
let auth = &ctx.author().name;
let r = send_cmd_json("remove_post_url", Some(json!([&shorturl, &auth, &reason])), true).await.unwrap();
if r["value"].as_bool().unwrap() {
send_msg(
ctx,
lang!("dc_msg_re_post_remove_success", &shorturl),
true,
true
).await;
}
else {
send_msg(ctx, lang!("dc_msg_re_post_404"), true, true).await;
}
data::update_re_data(ctx.data()).await;
let reddit_data = get_mutex_data(&ctx.data().reddit_data).await?;
if let Some(post) = get_post_from_data(ctx, &reddit_data, shorturl).await? {
if post["removed"]["removed"].as_bool().unwrap() {
send_embed_for_removed(ctx, shorturl, &post).await;
}
}
return Ok(());
}
+27
View File
@@ -0,0 +1,27 @@
use crate::{lang, messages::send_msg, re_cmds::generic_fns::to_shorturl, Context, Error};
#[poise::command(
slash_command,
prefix_command,
rename = "re_shorturl",
category = "re",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Convert a long reddit URL to a short one. The bot ONLY uses shortURLs when asking for one.
pub async fn cmd(
ctx: Context<'_>,
#[description = "A Reddit post URL"] url: String
) -> Result<(), Error>
{
let shorturl = to_shorturl(&url);
if shorturl.is_ok() {
send_msg(ctx, lang!("dc_msg_shorturl", shorturl.unwrap()), true, true).await;
}
else {
send_msg(ctx, lang!("dc_msg_failed_shorturl_conversion"), true, true).await;
}
return Ok(());
}
+77
View File
@@ -0,0 +1,77 @@
use std::collections::HashMap;
use crate::{data::get_mutex_data, re_cmds::generic_fns::send_embed_for_post, Context, Error, CFG_DATA_RE};
#[derive(poise::ChoiceParameter, PartialEq)]
enum TopCategory {
Upvotes,
ModVotes,
Oldest,
Newest
}
#[poise::command(
slash_command,
prefix_command,
rename = "re_topposts",
category = "re",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | EMBED_LINKS"
)]
/// Shows the top N (up to 10, default is 3) posts within a certain category, such as upvotes.
pub async fn cmd(
ctx: Context<'_>,
#[description = "The sorting criteria, such as upvotes."]
category: TopCategory,
#[description = "The amount of posts to show (max 10, default is 3)."]
#[min = 1]
#[max = 10]
amount: Option<u8>
) -> Result<(), Error>
{
let mut all: HashMap<&str, i32> = HashMap::new();
let posts = &get_mutex_data(&ctx.data().reddit_data).await?[CFG_DATA_RE];
let posts_u = posts.as_object().unwrap();
for (url, dat) in posts_u {
if dat["removed"]["removed"].as_bool().unwrap() { continue; }
let val: i32 = match category {
TopCategory::Upvotes => dat["post_data"]["upvotes"].as_i64().unwrap() as i32,
TopCategory::ModVotes => dat["votes"]["mod_voters"].as_array().unwrap().len() as i32,
TopCategory::Oldest
| TopCategory::Newest => dat["post_data"]["date_unix"].as_i64().unwrap() as i32,
};
all.insert(url, val);
}
let amount_u = amount.unwrap_or(3);
let amount_clamped = amount_u.clamp(1, 10);
let top =
if category != TopCategory::Oldest
{ largest_n (&all, amount_clamped as usize) }
else { smallest_n(&all, amount_clamped as usize) };
for post in top {
let url = post.0;
let _ = send_embed_for_post(ctx, posts_u[url].clone(), url).await;
}
return Ok(());
}
fn largest_n<'a>(map: &'a HashMap<&'a str, i32>, n: usize) -> Vec<(&'a str, i32)> {
let mut vec: Vec<_> = map.iter().collect();
vec.sort_unstable_by(|a, b| b.1.cmp(a.1));
vec.into_iter().take(n).map(|(&k, &v)| (k, v)).collect()
}
fn smallest_n<'a>(map: &'a HashMap<&'a str, i32>, n: usize) -> Vec<(&'a str, i32)> {
let mut vec: Vec<_> = map.iter().collect();
vec.sort_unstable_by(|a, b| a.1.cmp(b.1));
vec.into_iter().take(n).map(|(&k, &v)| (k, v)).collect()
}
+306
View File
@@ -0,0 +1,306 @@
use std::time::{SystemTime, UNIX_EPOCH};
use poise::{serenity_prelude::{ChannelId, EditMessage, GetMessages, Http, Message, MessageId, UserId}, ReplyHandle};
use serde_json::{json, Map, Value};
use crate::{data::{self, get_mutex_data, get_toml_mutex, DC_POSTS_CHANNEL_KEY}, lang, messages::{edit_reply, embed_from_options, make_post_embed, make_removed_embed, send_embed, send_msg, trim_post_json}, re_cmds::generic_fns::embed_to_json, rs_println, websocket::send_cmd_json, Context, Error, CFG_DATA_RE};
#[poise::command(
slash_command,
prefix_command,
rename = "re_updatediscord",
category = "re",
guild_only,
guild_cooldown = 120,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL | READ_MESSAGE_HISTORY | EMBED_LINKS"
)]
/// Updates the bound Discord channel with the bot's current Reddit data.
pub async fn cmd(
ctx: Context<'_>,
#[description = "Make this true to only add new posts and leave everything else unchanged."]
only_add: Option<bool>,
#[description = "The max age of a post in days. Any post older than this will be removed. (0 is infinite)"]
#[min = 0]
#[max = 65535]
max_age: Option<u16>,
#[description = "The max amount of posts to fetch (no value uses default value)."]
#[min = 1]
#[max = 100]
max_results: Option<u16>
) -> Result<(), Error>
{
let http = ctx.http();
let mut p_text = "`/re_updatediscord`:".to_string();
let progress = send_msg(ctx, p_text.clone(), true, true).await.unwrap();
p_text = update_progress(ctx, progress.clone(), p_text, lang!("dc_msg_update_fetch", "\n")).await;
let max_age_u = max_age.unwrap_or(8);
let max_age_secs = max_age_u as u64 * (60 * 60 * 24);
let max_results_toml = &get_toml_mutex(&ctx.data().cfg).await.unwrap();
let max_results_pre = max_results_toml["reddit"]["fetch_limit"].as_integer().unwrap();
let max_results_final = max_results.unwrap_or(max_results_pre as u16);
send_cmd_json("add_new_posts", Some(json!([max_age_secs, max_results_final])), true).await;
data::update_re_data(ctx.data()).await;
let r_data = get_mutex_data(&ctx.data().reddit_data).await?;
let c_id_u = get_c_id(ctx).await;
if c_id_u.is_none() {
send_msg(ctx, lang!("dc_msg_re_posts_channel_404"), true, true).await;
return Ok(());
}
let c_id = c_id_u.unwrap();
// Reading messages
p_text = update_progress(ctx, progress.clone(), p_text.clone(), lang!("dc_msg_update_read", "\n", c_id)).await;
let msgs = read_msgs(http, ctx.framework().bot_id, c_id).await;
// Parsing messages to JSON
p_text = update_progress(ctx, progress.clone(), p_text.clone(), lang!("dc_msg_update_parse", "\n")).await;
let msgs_json = msgs_to_json(msgs, &r_data, max_age_secs).await;
if ctx.data().args.dev { rs_println!("Posts changelog: {}", msgs_json); }
// Adding new posts
p_text = update_progress(ctx, progress.clone(), p_text.clone(), lang!("dc_msg_update_add", "\n")).await;
let weekly_art = r_data[CFG_DATA_RE].as_object().unwrap();
add_posts(ctx, weekly_art, &msgs_json, max_age_secs, max_results_final).await;
// Stop if only_add
if only_add.unwrap_or(false) {
send_msg(ctx, lang!("dc_msg_update_done", "`/bk_week_update`\n## "), true, true).await;
update_progress(ctx, progress.clone(), p_text, lang!("dc_msg_update_done", "\n## ")).await;
return Ok(());
}
// Removing duplicate posts
p_text = update_progress(ctx, progress.clone(), p_text.clone(), lang!("dc_msg_update_removing_dupe", "\n")).await;
remove_dupes(http, c_id, &msgs_json).await;
// Removing removed posts
p_text = update_progress(ctx, progress.clone(), p_text.clone(), lang!("dc_msg_update_removing", "\n")).await;
remove_posts(http, c_id, weekly_art, &msgs_json).await;
// Removing old posts
if max_age_u > 0 {
p_text = update_progress(ctx, progress.clone(), p_text.clone(), lang!("dc_msg_update_removing_old", "\n", max_age_u)).await;
remove_old(http, c_id, &msgs_json).await;
send_cmd_json("remove_old_posts", Some(json!([max_age_secs])), true).await;
}
// Editing updated posts
p_text = update_progress(ctx, progress.clone(), p_text.clone(), lang!("dc_msg_update_editing", "\n")).await;
edit_posts(http, c_id, weekly_art, &msgs_json).await;
// Done
update_progress(ctx, progress.clone(), p_text, lang!("dc_msg_update_done", "\n## ")).await;
send_msg(ctx, lang!("dc_msg_update_done", "`/bk_week_update`\n## "), true, true).await;
return Ok(());
}
async fn update_progress(ctx: Context<'_>, p: ReplyHandle<'_>, t: String, added_t: String) -> String {
let p_text = format!("{} {}", t, added_t);
edit_reply(ctx, p, p_text.clone()).await;
return p_text;
}
async fn get_c_id(ctx: Context<'_>) -> Option<ChannelId> {
if !data::dc_contains_server(ctx.data(), ctx.guild_id().unwrap().into()).await {
send_msg(ctx, lang!("dc_msg_data_server_404"), true, true).await;
return None;
}
let d = get_mutex_data(&ctx.data().discord_data).await.unwrap();
let c_id_u =
d["servers"]
[ctx.guild_id().unwrap().to_string()]
[DC_POSTS_CHANNEL_KEY].as_u64().unwrap();
let c_id = ChannelId::new(c_id_u);
return Some(c_id);
}
async fn read_msgs(http: &Http, bot_id: UserId, c_id: ChannelId) -> Vec<Message> {
let b = GetMessages::new().limit(100);
let mut msgs = c_id.messages(http, b).await.unwrap();
msgs.retain(|item| item.author.id == bot_id);
let mut last_msg: Option<Message> = msgs.last().cloned();
while last_msg.is_some() {
let new_b = GetMessages::new().limit(100).before(last_msg.clone().unwrap());
let new_msgs = c_id.messages(http, new_b).await.unwrap();
last_msg = new_msgs.last().cloned();
if new_msgs.is_empty() {
break;
}
let filtered_msgs: Vec<Message> = new_msgs
.into_iter()
.filter(|item| item.author.id == bot_id)
.collect();
msgs.extend(filtered_msgs);
}
return msgs;
}
async fn msgs_to_json(msgs: Vec<Message>, reddit_data: &Value, max_age: u64) -> Value {
let mut msgs_json: Value = json!({"no_change": {}, "updated": {}, "removed": {}, "duplicates": {}, "old": {}});
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
for msg in msgs {
if msg.embeds.is_empty() { continue; }
if msg.embeds[0].url.is_none() { continue; }
let url = msg.embeds[0].url.clone().unwrap();
// duplicates
if ["no_change", "updated", "removed", "old"]
.iter()
.any(|key| msgs_json[key].as_object().unwrap().contains_key(&url))
{
let dupes_mut = msgs_json["duplicates"].as_object_mut().unwrap();
if !dupes_mut.contains_key(&url) {
dupes_mut.insert(url.clone(), json!(msg.id.get()));
}
continue;
}
let msg_json = embed_to_json(&msg.embeds[0]);
if msg_json.is_err() { continue; }
let u_json: Value = msg_json.unwrap();
let re_url = &reddit_data[CFG_DATA_RE][&url];
let json_trimmed = trim_post_json(re_url);
let post_date = re_url["post_data"]["date_unix"].as_u64().unwrap_or(0);
// old
if now - post_date > max_age && max_age > 0 {
if let Some(obj) = msgs_json["old"].as_object_mut() {
rs_println!("old: {}", url);
obj.insert(url.clone(), json!(msg.id.get()));
continue;
}
}
// removed
if json_trimmed["removed"]["removed"].as_bool().unwrap() {
if u_json["removed"]["removed"].as_bool().unwrap() {
// no change
if let Some(obj) = msgs_json["no_change"].as_object_mut() {
obj.insert(url.clone(), json!(msg.id.get()));
continue;
}
}
// removed
if let Some(obj) = msgs_json["removed"].as_object_mut() {
obj.insert(url.clone(), json!(msg.id.get()));
continue;
}
}
// updated
if u_json != json_trimmed
{
if let Some(obj) = msgs_json["updated"].as_object_mut() {
obj.insert(url.clone(), json!(msg.id.get()));
continue;
}
}
// no change
if let Some(obj) = msgs_json["no_change"].as_object_mut() {
obj.insert(url.clone(), json!(msg.id.get()));
}
}
return msgs_json;
}
async fn add_posts(ctx: Context<'_>, r_data: &Map<String, Value>, msgs_json: &Value, max_age: u64, max_results: u16) {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs();
for (i, url) in r_data.keys().enumerate() {
if i + 1 > max_results.into() { break }
if ["no_change", "updated", "removed", "old", "duplicates"]
.iter()
.any(|key| msgs_json[key].as_object().unwrap().contains_key(url))
{ continue; }
let post_date = r_data[url]["post_data"]["date_unix"].as_u64().unwrap();
if now - post_date > max_age && max_age > 0 { continue; }
if r_data[url]["removed"]["removed"].as_bool().unwrap() {
send_embed(ctx, make_removed_embed(&r_data[url], url, false), false).await;
continue;
}
send_embed(ctx, make_post_embed(&r_data[url], url, false), false).await;
}
}
async fn edit_posts(http: &Http, c_id: ChannelId, r_data: &Map<String, Value>, msgs_json: &Value) {
for (url, msg_id) in msgs_json["updated"].as_object().unwrap() {
let mut msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap();
let r = EditMessage::new()
.embeds(vec![embed_from_options(make_post_embed(&r_data[url], url, false))]);
let _ = msg.edit(http, r).await;
}
}
async fn remove_posts(http: &Http, c_id: ChannelId, r_data: &Map<String, Value>, msgs_json: &Value) {
for (url, msg_id) in msgs_json["removed"].as_object().unwrap() {
let mut msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap();
let r = EditMessage::new()
.embeds(vec![embed_from_options(make_removed_embed(&r_data[url], url, false))]);
let _ = msg.edit(http, r).await;
}
}
async fn remove_old(http: &Http, c_id: ChannelId, msgs_json: &Value) {
for (_url, msg_id) in msgs_json["old"].as_object().unwrap() {
let msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap();
let _ = msg.delete(http).await;
}
}
async fn remove_dupes(http: &Http, c_id: ChannelId, msgs_json: &Value) {
for (_url, msgs) in msgs_json["duplicates"].as_object().unwrap() {
for msg_id in msgs.as_array().unwrap() {
let msg = http.get_message(c_id, MessageId::new(msg_id.as_u64().unwrap())).await.unwrap();
let _ = msg.delete(http).await;
}
}
}
+70
View File
@@ -0,0 +1,70 @@
use serde_json::json;
use crate::{data::{self, get_mutex_data}, lang, messages::send_msg, re_cmds::generic_fns::{is_bk_mod, send_embed_for_removed, to_shorturl}, websocket::send_cmd_json, Context, Error, CFG_DATA_RE};
#[poise::command(
slash_command,
prefix_command,
rename = "re_vote",
category = "re",
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
/// Adds/removes a vote from a post. These votes are not tied to Reddit upvotes.
pub async fn cmd(
ctx: Context<'_>,
#[description = "The post URL."] url: String,
#[description = "Wether to undo your vote or not"] un_vote: Option<bool>
) -> Result<(), Error>
{
data::update_re_data(ctx.data()).await;
let uid = ctx.author().id.get();
let re_data = get_mutex_data(&ctx.data().reddit_data).await?;
let post_data = re_data[CFG_DATA_RE].clone();
let unw_vote = un_vote.unwrap_or(false);
let shorturl_u = to_shorturl(&url);
let shorturl = &shorturl_u.unwrap_or(url.clone());
if post_data.get(shorturl).is_none() {
send_msg(ctx, lang!("dc_msg_re_post_404"), false, false).await;
return Ok(());
}
if post_data[&shorturl]["removed"]["removed"].as_bool().unwrap() {
send_embed_for_removed(ctx, shorturl, &post_data[&shorturl]).await;
return Ok(());
}
let url_data = &post_data[&shorturl];
let is_mod = is_bk_mod(ctx.data().bk_mods.clone(), ctx.author().id.get());
let voters_dc = url_data["votes"]["voters_dc"].as_array().unwrap();
let mod_voters = url_data["votes"]["mod_voters"].as_array().unwrap();
let voters = if is_mod { mod_voters } else { voters_dc };
if voters.contains(&json!(uid)) && !unw_vote {
send_msg(ctx, lang!("dc_msg_re_already_voted"), true, true).await;
return Ok(());
}
else if !voters.contains(&json!(uid)) && unw_vote {
send_msg(ctx, lang!("dc_msg_re_vote_remove_havent"), true, true).await;
return Ok(());
}
let r = send_cmd_json("set_vote_post", Some(json!([shorturl, uid, is_mod, true, unw_vote])), true).await.unwrap();
let unw_r = r["value"].as_bool().unwrap();
if unw_r && !unw_vote && is_mod {
send_msg(ctx, lang!("dc_msg_re_vote_mod_success"), true, true).await;
}
else if unw_r && !unw_vote && !is_mod {
send_msg(ctx, lang!("dc_msg_re_vote_success"), true, true).await;
}
else if unw_r && unw_vote {
send_msg(ctx, lang!("dc_msg_re_vote_remove_success"), true, true).await;
}
else {
send_msg(ctx, lang!("dc_msg_re_vote_err"), true, true).await;
}
return Ok(());
}
+35
View File
@@ -0,0 +1,35 @@
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio::time;
use crate::rs_println;
pub type Schedule = (Duration, fn() -> Pin<Box<dyn Future<Output = ()> + Send>>);
pub async fn run_schedule<F: Fn() -> Pin<Box<dyn Future<Output = ()> + Send>>>(d: Duration, f: F) {
let mut ticker = time::interval(d);
loop {
ticker.tick().await;
f().await;
}
}
pub async fn run_schedules(schedules: Vec<Schedule>) {
let mut handles: Vec<JoinHandle<()>> = vec![];
rs_println!("Starting schedules...");
for (d, f) in schedules {
let handle = tokio::spawn(run_schedule(d, f));
handles.push(handle);
}
for handle in handles {
let _ = handle.await;
}
}
+14 -9
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::messages::send_dm; use crate::messages::send_dm;
use crate::rs_println; use crate::{lang, rs_println};
use crate::Args; use crate::Args;
type Sender = Arc<Mutex<Option<futures::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, tungstenite::Message>>>>; type Sender = Arc<Mutex<Option<futures::stream::SplitSink<tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, tungstenite::Message>>>>;
@@ -47,7 +47,7 @@ pub async fn send_msg(msg: &str) {
#[allow(static_mut_refs)] #[allow(static_mut_refs)]
pub async fn send_cmd_json(func_name: &str, func_args: Option<Value>) -> Option<Value> { pub async fn send_cmd_json(func_name: &str, func_args: Option<Value>, print_output: bool) -> Option<Value> {
unsafe { unsafe {
let Some(sender) = &GLOBAL_SENDER else { return None }; let Some(sender) = &GLOBAL_SENDER else { return None };
let mut sender = sender.lock().await; let mut sender = sender.lock().await;
@@ -56,7 +56,7 @@ pub async fn send_cmd_json(func_name: &str, func_args: Option<Value>) -> Option<
let unw_args = func_args.unwrap_or(json!([])); let unw_args = func_args.unwrap_or(json!([]));
let json_str = format!( let json_str = format!(
"json:{{\"type\": \"function\", \"value\":\"{}\", \"args\": {}}}", "json:{{\"type\": \"function\", \"value\":\"{}\", \"args\": {}, \"print\": {print_output}}}",
func_name, unw_args func_name, unw_args
); );
@@ -65,12 +65,17 @@ pub async fn send_cmd_json(func_name: &str, func_args: Option<Value>) -> Option<
} }
let r = receive_response().await; let r = receive_response().await;
if !["respond_mentions"].contains(&func_name) || <Args as clap::Parser>::parse().dev { if let Some(rs) = r.clone() {
if !rs.get("print").unwrap_or(&json![false]).as_bool().unwrap()
{ return r; }
}
if <Args as clap::Parser>::parse().dev {
rs_println!("Received from Python: [RESPONSE] {:?}", r); rs_println!("Received from Python: [RESPONSE] {:?}", r);
} }
if r.is_none() { if r.is_none() {
rs_println!("--- Response from Python is None!"); rs_println!("[IMPORTANT] Response from Python is None!");
} }
return r; return r;
@@ -99,7 +104,7 @@ async fn receive_response() -> Option<Value> {
pub async fn start(args: Args, owners: Vec<u64>) { pub async fn start(args: Args, owners: Vec<u64>) {
rs_println!("Starting local websocket..."); rs_println!("Running local websocket...");
let ip = format!("127.0.0.1:{}", args.port); let ip = format!("127.0.0.1:{}", args.port);
let listener = TcpListener::bind(&ip).await.unwrap(); let listener = TcpListener::bind(&ip).await.unwrap();
rs_println!("WebSocket server running on ws://{}", ip); rs_println!("WebSocket server running on ws://{}", ip);
@@ -134,7 +139,7 @@ async fn handle_message(msg: tungstenite::protocol::Message, args: Args, owners:
if let Some(stripped) = text.strip_prefix("json:") { if let Some(stripped) = text.strip_prefix("json:") {
let t_json: Value = serde_json::from_str(stripped).unwrap(); let t_json: Value = serde_json::from_str(stripped).unwrap();
if t_json.get("error").is_some() { if t_json.get("error").is_some() {
send_dm("Unknown internal Python error occurred: Websocket response error.".to_string(), args, owners).await; send_dm(lang!("dc_msg_dm_python_err_socket"), args, owners).await;
} }
} }
@@ -147,12 +152,12 @@ async fn handle_message(msg: tungstenite::protocol::Message, args: Args, owners:
} }
} }
tungstenite::Message::Binary(bytes) => { tungstenite::Message::Binary(bytes) => {
if args.dev && !args.noping { if args.dev && args.ping {
rs_println!("[Binary] from Python: {:?}", bytes); rs_println!("[Binary] from Python: {:?}", bytes);
} }
} }
_ => { _ => {
if args.dev && !args.noping { if args.dev && args.ping {
rs_println!("Received from Python: [UNKNOWN / OTHER]"); rs_println!("Received from Python: [UNKNOWN / OTHER]");
} }
} }