118 Commits

Author SHA1 Message Date
Klemek 78ad50be22 fix counter 2023-10-16 11:16:22 +02:00
Klemek 87217ac31c fix fix 2023-10-16 11:13:42 +02:00
Klemek c503673cbc fix utc_today 2023-10-16 11:10:02 +02:00
Klemek fffaae130c fix dates 2023-10-16 11:03:46 +02:00
Klemek eb6607de60 bump version 2023-10-16 10:18:01 +02:00
Klemek 18fe35e10f 30 to 90 days 2023-10-16 10:17:49 +02:00
Klemek cb37b052c9 fix sanity check 2022-09-02 10:22:12 +02:00
Klemek d8e9e48a13 update requirements.txt 2022-09-02 09:52:33 +02:00
Klemek 6dd45af14f discord v2 2022-09-02 09:41:52 +02:00
Klemek 3cca5c38f5 fix requirements.txt 2022-08-23 09:33:00 +02:00
Klemek 667fb0d414 Merge branch 'master' of github.com:klemek/discord-analyst 2022-08-22 09:22:26 +02:00
Klemek 5089d7d10b force discord.py version 2022-08-22 09:22:23 +02:00
Klemek afb3d7d663 remove invalid stats 2022-05-29 00:09:33 +02:00
Klemek cf2fa3208e Update gdpr.py 2022-03-07 23:08:34 +01:00
Klemek 7b4d952f9e Update GDPR.md 2022-03-07 23:08:11 +01:00
Klemek 2850c7e630 Update gdpr.py 2022-03-07 22:29:37 +01:00
Klemek c252f4cc67 Update GDPR.md 2022-03-07 22:29:09 +01:00
Klemek 96a335bea6 update requirements.txt 2022-03-07 14:12:56 +01:00
Klemek fb48a256ce v1.17 2022-03-07 13:59:42 +01:00
Klemek 0ceffca196 merge with master 2022-03-07 13:28:29 +01:00
Klemek 51911604a9 Update main.py 2022-03-07 13:07:02 +01:00
Klemek 660341127d Update guild_logs.py 2022-03-07 13:06:28 +01:00
Klemek d419a7f2d2 Rename TOS.md to GDPR.md 2022-03-07 12:53:49 +01:00
Klemek b4b818a5c3 Create TOS.md 2022-03-07 12:53:29 +01:00
Klemek b39bc5c16b Update presence_scanner.py 2021-11-05 13:05:17 +01:00
Klemek 130cc5370c Merge pull request #56 from Klemek/dev
v1.16.1
2021-07-13 18:47:12 +02:00
Klemek ef17c599cd Merge branch 'master' into dev 2021-07-13 18:46:51 +02:00
Klemek a6b963557c improv: black 2021-07-13 18:46:22 +02:00
Klemek 19d09ee6bc improv: better graph 2021-07-13 18:45:50 +02:00
Klemek 1a7c041f67 fix: new channel not loading 2021-07-13 18:35:15 +02:00
Klemek 444c65f343 Merge pull request #55 from Klemek/dev
v1.16
2021-07-13 18:14:52 +02:00
Klemek 20e4c05cc5 improv: black 2021-07-13 18:07:44 +02:00
Klemek 8f4f09bb86 v1.16 2021-07-13 18:06:33 +02:00
Klemek 8b0fe859a7 feat: (BETA) %freq graph 2021-07-13 18:04:46 +02:00
Klemek 07aed12463 feat: use discord new time format 2021-07-13 17:05:16 +02:00
Klemek 499ada0b26 feat: quietest hour of day/week 2021-07-13 16:51:52 +02:00
Klemek c3d3b7ac2e improv: changed the way frequency was stored 2021-07-13 16:47:01 +02:00
Klemek fa840725dd improv: first tests 2021-07-13 16:43:50 +02:00
Klemek e1e1bf117f improv: black 2021-07-13 16:26:04 +02:00
Klemek 14f5709241 fix: frequency scanner using invalid parameter 2021-07-13 15:34:46 +02:00
Klemek dbd859a828 Update Dockerfile 2021-06-09 15:40:33 +02:00
Klemek a3eb623205 Update Dockerfile 2021-06-09 15:38:20 +02:00
Klemek acbcce304e Create docker.yml 2021-06-09 15:32:23 +02:00
Klemek ea82877fd2 Merge branch 'dev' of github.com:klemek/discord-analyst into dev 2021-06-04 15:47:51 +02:00
Klemek 9136cf4ad2 small fix 2021-06-04 15:47:48 +02:00
Klemek ead5f66608 Merge pull request #51 from Klemek/dev
1.15.3 small improvement
2021-06-04 15:38:42 +02:00
Klemek 5b91ca63a9 Merge branch 'master' into dev 2021-06-04 15:37:40 +02:00
Klemek f7116787fc Merge branch 'dev' of github.com:klemek/discord-analyst into dev 2021-06-04 15:36:27 +02:00
Klemek 8ef1b50e3c "valid-arg" skip arg processing 2021-06-04 15:36:24 +02:00
Klemek eb82fcf2aa Merge pull request #49 from Klemek/dev
Dev
2021-06-01 12:10:16 +02:00
Klemek c86af98406 Merge branch 'master' into dev 2021-06-01 12:09:26 +02:00
Klemek 634285f4fc Merge branch 'dev' of github.com:klemek/discord-analyst into dev 2021-06-01 12:08:36 +02:00
Klemek 887f612486 cleaning => set 2021-06-01 12:08:33 +02:00
Klemek be552b6cf3 Merge pull request #48 from Klemek/dev
fix duplicate messages bug
2021-06-01 11:31:45 +02:00
Klemek e808f1f957 Merge branch 'master' into dev 2021-06-01 11:31:04 +02:00
Klemek 975ee7430d fix duplicate messages bug 2021-06-01 11:30:40 +02:00
Klemek ebdc33029c Merge pull request #47 from Klemek/dev
1.15.1 bug fix on images
2021-06-01 09:53:10 +02:00
Klemek 99cd2b301b 1.15.1 bug fix on images 2021-06-01 09:52:14 +02:00
Klemek b838fc7408 Merge pull request #46 from Klemek/dev
bug fix
2021-05-19 15:34:48 +02:00
Klemek b1eddf0b4b bug fix 2021-05-19 15:34:24 +02:00
Klemek 4b42f13d28 Merge pull request #45 from Klemek/dev
updated dockerfile
2021-05-19 15:27:51 +02:00
Klemek 84734c7d4e updated dockerfile 2021-05-19 15:27:17 +02:00
Klemek f2a9cf410e Merge pull request #44 from Klemek/dev
v1.15
2021-05-19 15:19:52 +02:00
Klemek 5b448fe237 Merge branch 'dev' of github.com:klemek/discord-analyst into dev 2021-05-19 15:16:48 +02:00
Klemek 2d32dc37bf updated README 2021-05-19 15:16:43 +02:00
Klemek a6f99256ef updated README 2021-05-19 15:14:07 +02:00
Klemek a8b1ede962 spoiler filtering 2021-05-19 15:11:29 +02:00
Klemek da5e3fdb35 blacked 2021-05-19 13:33:15 +02:00
Klemek 516eb75b5c %first/%rand/%last image 2021-05-19 13:31:07 +02:00
Klemek 13447ff869 fix channel preload 2021-05-19 13:29:37 +02:00
Klemek 1a17e232ed allow queries in %first/%history/%last 2021-05-19 11:59:19 +02:00
Klemek c101002b6c backticks in %find can use regexes 2021-05-19 11:43:32 +02:00
Klemek d5a3667cfb prepare history scanner for images 2021-05-18 18:13:51 +02:00
Klemek b2858cca95 nsfw filters 2021-05-18 18:13:37 +02:00
Klemek a01414dce7 small improvments 2021-05-18 16:54:18 +02:00
Klemek 38056f430f small fixes 2021-05-18 16:08:38 +02:00
Klemek cd9b6b4d00 new alias for random 2021-05-18 16:04:28 +02:00
Klemek 620982f37b Merge pull request #39 from Klemek/dev
v1.14 minor fix
2021-04-22 20:16:20 +02:00
Klemek 245ae3f1df Merge branch 'master' into dev 2021-04-22 20:15:47 +02:00
Klemek 452f53c8f2 assume top if query is singular 2021-04-22 20:14:52 +02:00
Klemek 8e5bab22e7 small fix of formating 2021-04-22 16:26:11 +02:00
Klemek e878aa92d7 Merge pull request #38 from Klemek/dev
v1.14 fix
2021-04-22 16:23:18 +02:00
Klemek 8d1875a362 top arg for %find 2021-04-22 16:22:26 +02:00
Klemek d2cdea3db6 escape text in find scanner 2021-04-22 16:12:13 +02:00
Klemek 2fda54a6f5 Merge pull request #37 from Klemek/dev
v1.14
2021-04-22 15:23:23 +02:00
Klemek 3f7abd9a15 check for argument in %find 2021-04-22 15:21:01 +02:00
Klemek e77e46b361 fix help position in arguments 2021-04-22 15:20:49 +02:00
Klemek 5f8dfce640 %find command 2021-04-22 15:15:32 +02:00
Klemek fc5d9b82c1 fix relative date regex 2021-04-22 15:13:47 +02:00
Klemek 3721f1aef2 imports refactor 2021-04-22 14:58:08 +02:00
Klemek 4ce3d6023e more info when available 2021-04-22 14:50:48 +02:00
Klemek 1871ff1d13 fix relative time at start of day 2021-04-22 13:25:07 +02:00
Klemek f8e294f647 emotes => emojis 2021-04-22 13:09:07 +02:00
Klemek 6afb05148d scanner exception handling 2021-04-21 20:22:36 +02:00
Klemek 634f34fb54 better help for %gdpr 2021-04-21 20:14:15 +02:00
Klemek 7fad35a4b3 command_cache for %repeat and %mobile 2021-04-21 20:14:06 +02:00
Klemek 3100e6fa20 mobile/mention to fix @invalid-user bug 2021-04-21 11:26:37 +02:00
Klemek 0399fd8e61 Merge pull request #32 from Klemek/dev
v1.13
2021-04-09 19:51:54 +02:00
Klemek 76af4661ed fixed time range loading 2021-04-09 19:50:12 +02:00
Klemek cf6fa7ccf2 smol fix 2021-04-09 19:49:34 +02:00
Klemek 715a598513 fix cancelled bug 2021-04-09 19:11:30 +02:00
Klemek 0e4ed0eb6b only fetch history of given time 2021-04-09 19:07:43 +02:00
Klemek 09161850c5 clarified not serialized attributes 2021-04-09 18:29:27 +02:00
Klemek 5c570ee09b fix no value in relative time 2021-04-09 18:25:51 +02:00
Klemek 8c0605797a clarified dates syntax 2021-04-09 18:23:46 +02:00
Klemek 802e208092 alternative syntax for relative time range 2021-04-09 18:19:40 +02:00
Klemek 90a26bcc9c flattened results in data_type 2021-04-09 18:04:36 +02:00
Klemek 2062f08721 start en stop dates 2021-04-09 17:39:42 +02:00
Klemek b7a6f3313b factorized help and triple-quote multi-line 2021-04-09 15:34:03 +02:00
Klemek 5f903db929 updated version before forgeting 2021-04-09 15:02:08 +02:00
Klemek 737806a4ba updated readme 2021-04-09 15:00:53 +02:00
Klemek 6a70663201 gdpr agreements 2021-04-09 14:57:55 +02:00
Klemek 0550a16c51 create log dir before checking 2021-04-09 12:20:36 +02:00
Klemek 48c4e82cdf remove old and unused logs at start and guild leaving 2021-04-09 12:19:43 +02:00
Klemek 6cacb832bf removed black check 2021-04-09 00:46:36 +02:00
Klemek ee71314c41 removed black check 2021-04-09 00:45:57 +02:00
Klemek a26b90f392 simple CI 2021-04-09 00:41:54 +02:00
Klemek 04f681dba6 %words improvement 2021-04-09 00:40:28 +02:00
48 changed files with 2032 additions and 737 deletions
+4
View File
@@ -0,0 +1,4 @@
DISCORD_TOKEN=
PYTHONPATH=./src
CRYPT_KEY=
LOG_DIR=logs
+34
View File
@@ -0,0 +1,34 @@
name: Docker
on: ["push", "pull_request"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build
uses: docker/build-push-action@v2
with:
context: ./
file: ./Dockerfile
builder: ${{ steps.buildx.outputs.name }}
push: false
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
+26
View File
@@ -0,0 +1,26 @@
name: Python
on: ["push", "pull_request"]
jobs:
syntax:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python FROM python:3.8.10
# Create app directory # Create app directory
WORKDIR /usr/src/app WORKDIR /usr/src/app
@@ -9,7 +9,7 @@ COPY requirements.txt ./
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
RUN touch logs/guilds.log && ln -s logs/guilds.log guilds.log RUN mkdir -p logs && touch logs/guilds.log && ln -s logs/guilds.log guilds.log
# Bundle app source # Bundle app source
COPY . . COPY . .
+46
View File
@@ -0,0 +1,46 @@
# About Analyst-bot's data usage
## TL;DR
Analyst-bot collects text message information. It does not share collected data with any third-party and data is retained 90 days or until the bot is leaving the guild/server.
## Data collection
Analyst-bot collects a Discord guild/server's history when asked to.
This includes:
- Visible text channel names
- Visible text messages: date and time of creation and edition, author, content, reactions and other available metadata (pinned, tts, etc.)
This does __not__ includes:
- Voice channels and not visible channels
- Not visible text messages
- Visible text messages' embedded content, images and other attachments
## Data processing
Any data collected is only processed in order to produce a one-time report sent to the user immediately. No temporary data are retained.
## Data storage and retain policy
Analyst-bot stores the collected data in files that are accessible by the software and its administrator only.
Any collected data are retained maximum 90 days until deletion or when the bot is leaving a guild/server.
## Data sharing
Analyst-bot does not share the data collected with any third-party.
## Right to retract
If you want to have your data removed, you can use the `%gdpr revoke` command or remove this bot from your guild/server.
## Terms agreement
By agreeing to these terms, you ensure having the legal age if you are in a country that does have one and you also ensure having the consent of every member involved.
*If you want more information, please contact the creator of this bot: <https://github.com/Klemek/discord-analyst>.*
Type `%gdpr agree` to agree to these terms, `%gdpr revoke` to remove this guild/server's collected data or `%gdpr` to see this message again.
+53 -7
View File
@@ -18,14 +18,29 @@
* %freq - frequency analysis * %freq - frequency analysis
* %compo - composition analysis * %compo - composition analysis
* %pres - presence analysis * %pres - presence analysis
* %first - read first message * %repeat - repeat last analysis (adding supplied arguments)
* %rand - read a random message * %mobile - fix @invalid-user for last command but mentions users
* %last - read last message * %gdpr - displays GDPR information
* %emojis - rank emotes by their usage * %find - find specific words or phrases (you can use quotes to add spaces in queries, backticks define regexes)
* arguments:
* top - rank users for these queries
* %first - read first message (add text to filter like %find)
* arguments:
* image - pull an image instead of a message
* spoiler:allow/only - allow spoiler images
* %rand - read a random message (add text to filter like %find)
* arguments:
* image - pull an image instead of a message
* spoiler:allow/only - allow spoiler images
* %last - read last message (add text to filter like %find)
* arguments:
* image - pull an image instead of a message
* spoiler:allow/only - allow spoiler images
* %emojis - rank emojis by their usage
* arguments: * arguments:
* <n> - top <n> emojis, default is 20 * <n> - top <n> emojis, default is 20
* all - list all common emojis in addition to this guild's * all - list all common emojis in addition to this guild's
* members - show top member for each emote * members - show top member for each emoji
* sort:usage/reaction - other sorting methods * sort:usage/reaction - other sorting methods
* %mentions - rank mentions by their usage * %mentions - rank mentions by their usage
* arguments: * arguments:
@@ -43,7 +58,7 @@
* %react - rank users by their reactions * %react - rank users by their reactions
* arguments: * arguments:
* <n> - top <n> messages, default is 10 * <n> - top <n> messages, default is 10
* %words - rank words by their usage * %words - (BETA) rank words by their usage
* arguments: * arguments:
* <n> - words containings <n> or more letters, default is 3 * <n> - words containings <n> or more letters, default is 3
* <n2> - top <n2> words, default is 10 * <n2> - top <n2> words, default is 10
@@ -52,9 +67,15 @@
* Common arguments: * Common arguments:
* @member/me: filter for one or more member * @member/me: filter for one or more member
* #channel/here: filter for one or more channel * #channel/here: filter for one or more channel
* <date1> - filter after <date1>
* <date2> - filter before <date2>
* all/everyone - include bots messages * all/everyone - include bots messages
* fast: only read cache * fast: only read cache
* fresh: does not read cache * fresh: does not read cache
* nsfw:allow/only - allow messages from nsfw channels
* mobile/mention: mentions users (fix @invalid-user bug)
(Sample dates: 2020 / 2021-11 / 2021-06-28 / 2020-06-28T23:00 / today / week / 8days / 1y)
``` ```
## Running this bot ## Running this bot
@@ -104,6 +125,31 @@ python3 src/main.py
## Changelog ## Changelog
* **v1.17**
* compliency with 30 days data keeping policy and data encryption
* improvements and bug fix
* **v1.16**
* `%freq graph` graph hours frequency along the week
* uses discord new time format
* `%freq` now shows quietest day of week and hour of day
* improvements and bug fix
* **v1.15**
* `nsfw:allow/only` filter nsfw channels
* `%find` can use regexes
* `%first`, `%rand` and `%last` can be filter with specific keywords
* `%first`, `%rand` and `%last` can pull images
* bug fix
* **v1.14**
* `mobile/mention` arg to fix mobile bug
* `%repeat`, `%mobile` to repeat commands
* more scan: `%find`
* bug fix
* **v1.13**
* improved scan `%words`
* remove old and unused logs at start and guild leaving
* GDPR disclaimer before scanning
* start and stop dates
* bug fix and improvements
* **v1.12** * **v1.12**
* more scans: `%words` * more scans: `%words`
* concurrent `fast` analysis * concurrent `fast` analysis
@@ -132,7 +178,7 @@ python3 src/main.py
* more scans: `%scan`, `%freq`, `%compo`, `%pres` * more scans: `%scan`, `%freq`, `%compo`, `%pres`
* huge bug fix * huge bug fix
* **v1.5**: * **v1.5**:
* top <n> emotes * top <n> emojis
* bug fix * bug fix
* **v1.4**: * **v1.4**:
* integrate miniscord * integrate miniscord
+6 -3
View File
@@ -1,3 +1,6 @@
discord.py discord.py>=2.0.0
python-dotenv python-dotenv>=0.15.0
git+git://github.com/Klemek/miniscord.git python-dateutil>=2.8.1
matplotlib>=3.4.2
cryptography>=2.8
git+https://github.com/Klemek/miniscord.git
+3 -3
View File
@@ -1,6 +1,6 @@
from .emote import Emote, get_emote_dict from .emoji import Emoji, get_emoji_dict
from .frequency import Frequency
from .composition import Composition from .composition import Composition
from .presence import Presence
from .counter import Counter from .counter import Counter
from .frequency import Frequency
from .history import History from .history import History
from .presence import Presence
+38 -42
View File
@@ -8,9 +8,9 @@ class Composition:
def __init__(self): def __init__(self):
self.total_characters = 0 self.total_characters = 0
self.plain_text = 0 self.plain_text = 0
self.emote_msg = 0 self.emoji_msg = 0
self.emote_only = 0 self.emoji_only = 0
self.emotes = defaultdict(int) self.emojis = defaultdict(int)
self.edited = 0 self.edited = 0
self.everyone = 0 self.everyone = 0
self.answers = 0 self.answers = 0
@@ -23,49 +23,45 @@ class Composition:
self.spoilers = 0 self.spoilers = 0
def to_string(self, msg_count: int) -> List[str]: def to_string(self, msg_count: int) -> List[str]:
ret = [] total_emojis = val_sum(self.emojis)
ret += [ top_emoji = top_key(self.emojis)
f"- **avg. characters / message**: {self.total_characters/msg_count:.2f}" ret = [
] f"- **avg. characters / message**: {self.total_characters/msg_count:.2f}",
if self.plain_text > 0:
ret += [
f"- **plain text messages**: {self.plain_text:,} ({percent(self.plain_text/msg_count)})" f"- **plain text messages**: {self.plain_text:,} ({percent(self.plain_text/msg_count)})"
] if self.plain_text > 0
if self.edited > 0: else "",
ret += [
f"- **edited messages**: {self.edited:,} ({percent(self.edited/msg_count)})" f"- **edited messages**: {self.edited:,} ({percent(self.edited/msg_count)})"
] if self.edited > 0
if self.everyone > 0: else "",
ret += [
f"- **@\u200beveryone**: {self.everyone:,} ({percent(self.everyone/msg_count)})" f"- **@\u200beveryone**: {self.everyone:,} ({percent(self.everyone/msg_count)})"
] if self.everyone > 0
if self.mentions > 0: else "",
ret += [ f"- **mentions**: {self.mentions:,} (in {percent(self.mention_msg/msg_count)} of msg, avg. {precise(self.mentions/msg_count)}/msg)"
f"- **mentions**: {self.mentions:,} (in {percent(self.mention_msg/msg_count)} of msg, avg. {precise(self.mentions/msg_count)}/msg)", if self.mentions > 0
] else "",
if self.answers > 0:
ret += [
f"- **answers**: {self.answers:,} ({percent(self.answers/msg_count)})" f"- **answers**: {self.answers:,} ({percent(self.answers/msg_count)})"
] if self.answers > 0
total_emotes = val_sum(self.emotes) else "",
if total_emotes > 0: f"- **emojis**: {total_emojis:,} (in {percent(self.emoji_msg/msg_count)} of msg, avg. {precise(total_emojis/msg_count)}/msg)"
top_emote = top_key(self.emotes) if total_emojis > 0
ret += [ else "",
f"- **emojis**: {total_emotes:,} (in {percent(self.emote_msg/msg_count)} of msg, avg. {precise(total_emotes/msg_count)}/msg)", f"- **most used emoji**: {top_emoji} ({plural(self.emojis[top_emoji], 'time')}, {percent(self.emojis[top_emoji]/total_emojis)})"
f"- **most used emoji**: {top_emote} ({plural(self.emotes[top_emote], 'time')}, {percent(self.emotes[top_emote]/total_emotes)})", if total_emojis > 0
] else "",
if self.emote_only > 0: f"- **emoji-only messages**: {self.emoji_only:,} ({percent(self.emoji_only/msg_count)})"
ret += [ if self.emoji_only > 0
f"- **emoji-only messages**: {self.emote_only:,} ({percent(self.emote_only/msg_count)})" else "",
] f"- **images**: {self.images:,} ({percent(self.images/msg_count)})"
if self.images > 0: if self.images > 0
ret += [f"- **images**: {self.images:,} ({percent(self.images/msg_count)})"] else "",
if self.links > 0: f"- **links**: {self.links:,} ({percent(self.link_msg/msg_count)})"
ret += [f"- **links**: {self.links:,} ({percent(self.link_msg/msg_count)})"] if self.links > 0
if self.spoilers > 0: else "",
ret += [
f"- **spoilers**: {self.spoilers:,} ({percent(self.spoilers/msg_count)})" f"- **spoilers**: {self.spoilers:,} ({percent(self.spoilers/msg_count)})"
if self.spoilers > 0
else "",
f"- **tts messages**: {self.tts:,} ({percent(self.tts/msg_count)})"
if self.tts > 0
else "",
] ]
if self.tts > 0:
ret += [f"- **tts messages**: {self.tts:,} ({percent(self.tts/msg_count)})"]
return ret return ret
+15 -5
View File
@@ -4,7 +4,7 @@ from collections import defaultdict
# Custom libs # Custom libs
from utils import plural, from_now, percent, val_sum, top_key from utils import plural, from_now, percent, val_sum, top_key, utc_today
class Counter: class Counter:
@@ -14,16 +14,18 @@ class Counter:
def update_use(self, count: int, date: datetime, item: int = 0): def update_use(self, count: int, date: datetime, item: int = 0):
self.usages[item] += count self.usages[item] += count
if self.last_used is None or date > self.last_used: if count > 0 and (self.last_used is None or date > self.last_used):
self.last_used = date self.last_used = date
def score(self) -> float: def score(self) -> float:
# Score is compose of usages + reactions # Score is compose of usages + reactions
# When 2 emotes have the same score, # When 2 emojis have the same score,
# the days since last use is stored in the digits # the days since last use is stored in the digits
# (more recent first) # (more recent first)
if self.last_used is None:
return 0
return self.all_usages() + 1 / ( return self.all_usages() + 1 / (
100000 * ((datetime.today() - self.last_used).days + 1) 100000 * (abs((utc_today() - self.last_used).days) + 1)
) )
def all_usages(self) -> int: def all_usages(self) -> int:
@@ -37,9 +39,12 @@ class Counter:
total_usage: int, total_usage: int,
counted: str = "time", counted: str = "time",
transform: Optional[Callable[[int], str]] = None, transform: Optional[Callable[[int], str]] = None,
ranking: bool = True,
top: bool = True,
) -> str: ) -> str:
# place # place
output = "" output = ""
if ranking:
if i == 0: if i == 0:
output += ":first_place: " output += ":first_place: "
elif i == 1: elif i == 1:
@@ -48,10 +53,15 @@ class Counter:
output += ":third_place: " output += ":third_place: "
else: else:
output += f"**#{i + 1}** " output += f"**#{i + 1}** "
else:
output += f"- "
sum = val_sum(self.usages) sum = val_sum(self.usages)
if sum > 0:
output += f"{name} - {plural(sum, counted)} ({percent(sum/total_usage)}, last {from_now(self.last_used)})" output += f"{name} - {plural(sum, counted)} ({percent(sum/total_usage)}, last {from_now(self.last_used)})"
else:
output += f"{name} - unused"
top_item = top_key(self.usages) top_item = top_key(self.usages)
if top_item != 0 and transform is not None: if sum > 0 and top and top_item != 0 and transform is not None:
if self.usages[top_item] == sum: if self.usages[top_item] == sum:
output += f" (all{transform(top_item)})" output += f" (all{transform(top_item)})"
else: else:
@@ -5,12 +5,12 @@ import discord
# Custom libs # Custom libs
from utils import mention, plural, from_now, top_key, percent from utils import mention, plural, from_now, top_key, percent, utc_today
class Emote: class Emoji:
""" """
Custom class to store emotes data Custom class to store emojis data
""" """
def __init__(self, emoji: Optional[discord.Emoji] = None): def __init__(self, emoji: Optional[discord.Emoji] = None):
@@ -34,7 +34,7 @@ class Emote:
def score(self, *, usage_weight: int = 1, react_weight: int = 1) -> float: def score(self, *, usage_weight: int = 1, react_weight: int = 1) -> float:
# Score is compose of usages + reactions # Score is compose of usages + reactions
# When 2 emotes have the same score, # When 2 emojis have the same score,
# the days since last use is stored in the digits # the days since last use is stored in the digits
# (more recent first) # (more recent first)
return ( return (
@@ -44,14 +44,14 @@ class Emote:
) )
def life_days(self) -> int: def life_days(self) -> int:
return (datetime.today() - self.emoji.created_at).days return (utc_today() - self.emoji.created_at).days
def use_days(self) -> int: def use_days(self) -> int:
# If never used, use creation date instead # If never used, use creation date instead
if self.last_used is None: if self.last_used is None:
return self.life_days() return self.life_days()
else: else:
return (datetime.today() - self.last_used).days return (utc_today() - self.last_used).days
def get_top_member(self) -> int: def get_top_member(self) -> int:
return top_key(self.members) return top_key(self.members)
@@ -99,8 +99,8 @@ class Emote:
return output return output
def get_emote_dict(guild: discord.Guild) -> Dict[str, Emote]: def get_emoji_dict(guild: discord.Guild) -> Dict[str, Emoji]:
emotes = defaultdict(Emote) emojis = defaultdict(Emoji)
for emoji in guild.emojis: for emoji in guild.emojis:
emotes[str(emoji)] = Emote(emoji) emojis[str(emoji)] = Emoji(emoji)
return emotes return emojis
+85 -22
View File
@@ -1,10 +1,12 @@
from typing import List from typing import List
from datetime import timedelta from datetime import timedelta
import calendar import calendar
import matplotlib.pyplot as plt
from io import BytesIO
import discord
import time
from utils import ( from utils import (
str_date,
str_datetime,
from_now, from_now,
plural, plural,
percent, percent,
@@ -19,8 +21,7 @@ class Frequency:
self.dates = [] self.dates = []
self.longest_break = timedelta(seconds=0) self.longest_break = timedelta(seconds=0)
self.longest_break_start = None self.longest_break_start = None
self.week = {i: 0 for i in range(7)} self.hours = {i: {j: 0 for j in range(24)} for i in range(7)}
self.day = {i: 0 for i in range(24)}
self.busiest_day = None self.busiest_day = None
self.busiest_day_count = 0 self.busiest_day_count = 0
self.busiest_hour = None self.busiest_hour = None
@@ -33,42 +34,104 @@ class Frequency:
self.longest_streak_start = None self.longest_streak_start = None
self.longest_streak_author = None self.longest_streak_author = None
def to_graph(self) -> List[str]:
self.dates.sort()
delta = self.dates[-1] - self.dates[0]
if delta.days == 0:
delta = timedelta(days=1)
day = {j: sum(self.hours[i][j] for i in range(7)) for j in range(24)}
busiest_hour = top_key(day)
n_hours = delta.days
if self.dates[0].hour <= busiest_hour and self.dates[-1].hour >= busiest_hour:
n_hours += 1
plt.style.use("dark_background")
fig, ax = plt.subplots()
times = range(25)
ax.set_xticks(times)
ax.set_xticklabels([f"{t:0>2}h" if t % 2 == 0 else "" for t in times])
for i in range(7):
hours = [self.hours[i][hour] * 7 / n_hours for hour in range(24)] + [
self.hours[i][0] * 7 / n_hours
]
ax.plot(
times, hours, label=calendar.day_name[i], linestyle="--", linewidth=0.8
)
hours = [day[hour] / n_hours for hour in range(24)] + [day[0] / n_hours]
ax.plot(times, hours, c="r", label="average", linewidth=1.5)
fig.patch.set_facecolor("#36393F")
ax.patch.set_alpha(0)
ax.set_xlim([0, 24])
ax.set_ylim([0, None])
ax.set_ylabel("average messages")
ax.legend(framealpha=0)
ax.grid(True, alpha=0.1)
with BytesIO() as f:
plt.savefig(
f,
format="png",
facecolor=fig.get_facecolor(),
edgecolor="none",
bbox_inches="tight",
dpi=300,
)
f.seek(0)
return [discord.File(f, f"{time.time()}-plot.png")]
def to_string( def to_string(
self, self,
*, *,
member_specific: bool, member_specific: bool,
) -> List[str]: ) -> List[str]:
self.dates.sort()
delta = self.dates[-1] - self.dates[0] delta = self.dates[-1] - self.dates[0]
if delta.days == 0:
delta = timedelta(days=1)
total_msg = len(self.dates) total_msg = len(self.dates)
busiest_weekday = top_key(self.week)
busiest_hour = top_key(self.day) week = {i: sum(self.hours[i].values()) for i in range(7)}
day = {j: sum(self.hours[i][j] for i in range(7)) for j in range(24)}
busiest_weekday = top_key(week)
busiest_hour = top_key(day)
quietest_weekday = top_key(week, reverse=True)
quietest_hour = top_key(day, reverse=True)
n_weekdays = delta.days // 7 n_weekdays = delta.days // 7
if ( if (
self.dates[0].weekday() <= busiest_weekday self.dates[0].weekday() <= busiest_weekday
and self.dates[-1].weekday() >= busiest_weekday and self.dates[-1].weekday() >= busiest_weekday
): ) or n_weekdays == 0:
n_weekdays += 1 n_weekdays += 1
n_hours = delta.days n_hours = delta.days
if self.dates[0].hour <= busiest_hour and self.dates[-1].hour >= busiest_hour: if self.dates[0].hour <= busiest_hour and self.dates[-1].hour >= busiest_hour:
n_hours += 1 n_hours += 1
ret = [ ret = [
f"- **earliest message**: {str_datetime(self.dates[0])} ({from_now(self.dates[0])})", f"- **earliest message**: {from_now(self.dates[0])}",
f"- **latest message**: {str_datetime(self.dates[-1])} ({from_now(self.dates[-1])})", f"- **latest message**: {from_now(self.dates[-1])}",
f"- **messages/day**: {precise(total_msg/delta.days, precision=3)}", f"- **messages/day**: {precise(total_msg/delta.days, precision=3)}",
f"- **busiest day of week**: {calendar.day_name[busiest_weekday]} (~{precise(self.week[busiest_weekday]/n_weekdays, precision=3)} msg, {percent(self.week[busiest_weekday]/total_msg)})", f"- **busiest day of week**: {calendar.day_name[busiest_weekday]} (~{precise(week[busiest_weekday]/n_weekdays, precision=3)} msg, {percent(week[busiest_weekday]/total_msg)})",
f"- **busiest day ever**: {str_date(self.busiest_day)} ({from_now(self.busiest_day)}, {self.busiest_day_count} msg)", f"- **quietest day of week**: {calendar.day_name[quietest_weekday]} (~{precise(week[quietest_weekday]/n_weekdays, precision=3)} msg, {percent(week[quietest_weekday]/total_msg)})"
if week[quietest_weekday] > 0
else "",
f"- **busiest day ever**: {from_now(self.busiest_day)} ({self.busiest_day_count} msg)"
if self.busiest_day is not None
else "",
f"- **messages/hour**: {precise(total_msg*3600/delta.total_seconds(), precision=3)}", f"- **messages/hour**: {precise(total_msg*3600/delta.total_seconds(), precision=3)}",
f"- **busiest hour of day**: {busiest_hour:0>2}:00 (~{precise(self.day[busiest_hour]/n_hours, precision=3)} msg, {percent(self.day[busiest_hour]/total_msg)})", f"- **busiest hour of day**: {busiest_hour:0>2}:00 (~{precise(day[busiest_hour]/n_hours, precision=3)} msg, {percent(day[busiest_hour]/total_msg)})",
f"- **busiest hour ever**: {str_datetime(self.busiest_hour)} ({from_now(self.busiest_hour)}, {self.busiest_hour_count} msg)", f"- **quietest hour of day**: {quietest_hour:0>2}:00 (~{precise(day[quietest_hour]/n_hours, precision=3)} msg, {percent(day[quietest_hour]/total_msg)})"
f"- **longest break**: {plural(round(self.longest_break.total_seconds()/3600), 'hour')} ({plural(self.longest_break.days,'day')}) from {str_datetime(self.longest_break_start)} ({from_now(self.longest_break_start)})", if day[quietest_hour] > 0
else "",
f"- **busiest hour ever**: {from_now(self.busiest_hour)} ({self.busiest_hour_count} msg)",
f"- **longest break**: {plural(round(self.longest_break.total_seconds()/3600), 'hour')} ({plural(self.longest_break.days,'day')}), started {from_now(self.longest_break_start)}",
f"- **avg. streak**: {precise(sum(self.streaks)/len(self.streaks), precision=3)} msg", f"- **avg. streak**: {precise(sum(self.streaks)/len(self.streaks), precision=3)} msg",
] f"- **longest streak**: {self.longest_streak:,} msg, started {from_now(self.longest_streak_start)}"
if member_specific: if member_specific
ret += [ else f"- **longest streak**: {mention(self.longest_streak_author)} ({self.longest_streak:,} msg, started {from_now(self.longest_streak_start)})",
f"- **longest streak**: {self.longest_streak:,} msg from {str_datetime(self.longest_streak_start)} ({from_now(self.longest_streak_start)})"
]
else:
ret += [
f"- **longest streak**: {mention(self.longest_streak_author)} ({self.longest_streak:,} msg from {str_datetime(self.longest_streak_start)}, {from_now(self.longest_streak_start)})"
] ]
return ret return ret
+75 -2
View File
@@ -3,13 +3,86 @@ import random
# Custom libs # Custom libs
from utils import mention, from_now, str_datetime, message_link from utils import (
mention,
from_now,
message_link,
SPLIT_TOKEN,
FilterLevel,
should_allow_spoiler,
is_image_gif,
)
MAX_RANDOM_TRIES = 100
class History: class History:
def __init__(self): def __init__(self):
self.messages = [] self.messages = []
async def to_string_image(
self, *, type: str, spoiler: FilterLevel, gif_only: bool
) -> List[str]:
if len(self.messages) == 0:
return ["There was no messages matching your filters"]
message = None
intro = None
real_message = None
if type == "first":
self.messages.sort(key=lambda m: m.created_at)
index = 0
while real_message is None and index < len(self.messages):
message = self.messages[index]
real_message = await message.fetch()
if real_message is not None and (
not should_allow_spoiler(real_message, spoiler)
or (gif_only and not is_image_gif(real_message))
):
real_message = None
index += 1
intro = f"First image out of {len(self.messages):,}"
elif type == "last":
self.messages.sort(key=lambda m: m.created_at, reverse=True)
index = 0
while real_message is None and index < len(self.messages):
message = self.messages[index]
real_message = await message.fetch()
if real_message is not None and (
not should_allow_spoiler(real_message, spoiler)
or (gif_only and not is_image_gif(real_message))
):
real_message = None
index += 1
intro = f"Last image out of {len(self.messages):,}"
elif type == "random":
intro = f"Random image out of {len(self.messages):,}"
tries = 0
while real_message is None and tries < MAX_RANDOM_TRIES:
message = random.choice(self.messages)
real_message = await message.fetch()
if real_message is not None and (
not should_allow_spoiler(real_message, spoiler)
or (gif_only and not is_image_gif(real_message))
):
real_message = None
tries += 1
if real_message is None:
return ["There was no messages matching your filters"]
image = "<Error>"
if len(real_message.attachments) > 0:
image = real_message.attachments[0].url
elif len(real_message.embeds) > 0:
image = real_message.embeds[0].url
return [
intro,
f"{from_now(message.created_at)}, {mention(message.author)} sent:",
f"<{message_link(message)}>",
SPLIT_TOKEN,
image,
]
def to_string(self, *, type: str) -> List[str]: def to_string(self, *, type: str) -> List[str]:
if len(self.messages) == 0: if len(self.messages) == 0:
return ["There was no messages matching your filters"] return ["There was no messages matching your filters"]
@@ -33,7 +106,7 @@ class History:
return [ return [
intro, intro,
f"{str_datetime(message.created_at)} ({from_now(message.created_at)}) {mention(message.author)} said:", f"{from_now(message.created_at)}, {mention(message.author)} said:",
*text, *text,
f"<{message_link(message)}>", f"<{message_link(message)}>",
] ]
+51 -55
View File
@@ -25,74 +25,70 @@ class Presence:
show_top_channel: bool, show_top_channel: bool,
member_specific: bool, member_specific: bool,
) -> List[str]: ) -> List[str]:
ret = []
if chan_count is None: if chan_count is None:
type = "server's" type = "server's"
elif chan_count == 1: elif chan_count == 1:
type = "channel's" type = "channel's"
else: else:
type = "channels'" type = "channels'"
if member_specific:
ret += [
f"- **messages**: {msg_count:,} ({percent(msg_count/total_msg)} of {type})"
]
else:
top_member = top_key(self.messages) top_member = top_key(self.messages)
ret += [
f"- **top messages**: {mention(top_member)} ({self.messages[top_member]:,} msg, {percent(self.messages[top_member]/val_sum(self.messages))})"
]
if show_top_channel:
top_channel = top_key(self.channel_usage) top_channel = top_key(self.channel_usage)
channel_sum = val_sum(self.channel_usage) channel_sum = val_sum(self.channel_usage)
found_in = sorted( found_in = top_key(
self.channel_usage, self.channel_usage,
key=lambda k: self.channel_usage[k] / self.channel_total[k], key=lambda k: self.channel_usage[k] / self.channel_total[k],
)[-1] )
ret += [
f"- **most visited channel**: {channel_mention(top_channel)} ({self.channel_usage[top_channel]:,} msg, {percent(self.channel_usage[top_channel]/channel_sum)})",
]
if member_specific:
ret += [
f"- **most contributed channel**: {channel_mention(found_in)} ({self.channel_usage[found_in]:,} msg, {percent(self.channel_usage[found_in]/self.channel_total[found_in])} of {type})"
]
if member_specific:
if len(self.mentions) > 0:
top_mention = top_key(self.mentions) top_mention = top_key(self.mentions)
mention_sum = val_sum(self.mentions) mention_sum = val_sum(self.mentions)
ret += [ top_mention_others = top_key(self.mention_others)
f"- **was mentioned**: {plural(mention_sum, 'time')} ({percent(mention_sum/val_sum(self.mention_count))} of {type})", mention_others_sum = val_sum(self.mention_others)
f"- **mostly mentioned by**: {mention(top_mention)} ({plural(self.mentions[top_mention], 'time')}, {percent(self.mentions[top_mention]/mention_sum)})", top_member_mentioned = top_key(self.mention_count)
] total_reaction_used = val_sum(self.reactions)
if len(self.mention_others) > 0:
top_mention = top_key(self.mention_others)
mention_sum = val_sum(self.mention_others)
if member_specific:
ret += [
f"- **mentioned others**: {plural(mention_sum, 'time')} ({percent(mention_sum/val_sum(self.mention_count))} of {type})",
f"- **mostly mentioned**: {mention(top_mention)} ({plural(self.mention_others[top_mention], 'time')}, {percent(self.mention_others[top_mention]/mention_sum)})",
]
else:
top_member = top_key(self.mention_count)
ret += [
f"- **mentioned**: {plural(mention_sum, 'time')} ({mention(top_member)}, {percent(self.mention_count[top_member]/val_sum(self.mention_count))})",
f"- **top mentions**: {mention(top_member)} ({plural(self.mention_count[top_member], 'time')}, {percent(self.mention_count[top_member]/val_sum(self.mention_count))})",
f"- **most mentioned**: {mention(top_mention)} ({plural(self.mention_others[top_mention], 'time')}, {percent(self.mention_others[top_mention]/mention_sum)})",
]
if len(self.reactions) > 0:
total_used = val_sum(self.reactions)
top_reaction = top_key(self.reactions) top_reaction = top_key(self.reactions)
ret += [ top_reaction_member = top_key(self.used_reaction)
f"- **reactions**: {plural(total_used, 'time')}",
f"- **most used reaction**: {top_reaction} ({plural(self.reactions[top_reaction], 'time')}, {percent(self.reactions[top_reaction]/total_used)})", ret = [
f"- **messages**: {msg_count:,} ({percent(msg_count/total_msg)} of {type})"
if member_specific
else f"- **top messages**: {mention(top_member)} ({self.messages[top_member]:,} msg, {percent(self.messages[top_member]/val_sum(self.messages))})",
f"- **most visited channel**: {channel_mention(top_channel)} ({self.channel_usage[top_channel]:,} msg, {percent(self.channel_usage[top_channel]/channel_sum)})"
if show_top_channel
else "",
f"- **most contributed channel**: {channel_mention(found_in)} ({self.channel_usage[found_in]:,} msg,)"
if show_top_channel and member_specific
else "",
f"- **was mentioned**: {plural(mention_sum, 'time')}"
if member_specific and len(self.mentions) > 0
else "",
f"- **mostly mentioned by**: {mention(top_mention)} ({plural(self.mentions[top_mention], 'time')}, {percent(self.mentions[top_mention]/mention_sum)})"
if member_specific and len(self.mentions) > 0
else "",
f"- **mentioned others**: {plural(mention_others_sum, 'time')}"
if len(self.mention_others) > 0 and member_specific
else "",
f"- **mostly mentioned**: {mention(top_mention_others)} ({plural(self.mention_others[top_mention_others], 'time')}, {percent(self.mention_others[top_mention_others]/mention_others_sum)})"
if len(self.mention_others) > 0 and member_specific
else "",
f"- **mentioned**: {plural(mention_others_sum, 'time')} ({mention(top_member_mentioned)}, {percent(self.mention_count[top_member_mentioned]/val_sum(self.mention_count))})"
if len(self.mention_others) > 0 and not member_specific
else "",
f"- **top mentions**: {mention(top_member_mentioned)} ({plural(self.mention_count[top_member_mentioned], 'time')}, {percent(self.mention_count[top_member_mentioned]/val_sum(self.mention_count))})"
if len(self.mention_others) > 0 and not member_specific
else "",
f"- **most mentioned**: {mention(top_mention_others)} ({plural(self.mention_others[top_mention_others], 'time')}, {percent(self.mention_others[top_mention_others]/mention_others_sum)})"
if len(self.mention_others) > 0 and not member_specific
else "",
f"- **reactions**: {plural(total_reaction_used, 'time')}"
if len(self.reactions) > 0 and not member_specific
else "",
f"- **reactions**: {plural(total_reaction_used, 'time')}"
if len(self.reactions) > 0 and member_specific
else "",
f"- **top reactions**: {mention(top_reaction_member)} ({plural(self.used_reaction[top_reaction_member], 'time')}, {percent(self.used_reaction[top_reaction_member]/val_sum(self.used_reaction))})"
if len(self.reactions) > 0 and not member_specific
else "",
f"- **most used reaction**: {top_reaction} ({plural(self.reactions[top_reaction], 'time')}, {percent(self.reactions[top_reaction]/total_reaction_used)})"
if len(self.reactions) > 0
else "",
] ]
if member_specific:
ret[
-2
] += f" ({percent(total_used/val_sum(self.used_reaction))} of {type})"
else:
top_member = top_key(self.used_reaction)
ret.insert(
-1,
f"- **top reactions**: {mention(top_member)} ({plural(self.used_reaction[top_member], 'time')}, {percent(self.used_reaction[top_member]/val_sum(self.used_reaction))})",
)
return ret return ret
+1 -1
View File
@@ -1,3 +1,3 @@
from .message_log import MessageLog from .message_log import MessageLog
from .channel_logs import ChannelLogs from .channel_logs import ChannelLogs
from .guild_logs import GuildLogs, ALREADY_RUNNING, CANCELLED from .guild_logs import GuildLogs, ALREADY_RUNNING, CANCELLED, NO_FILE
+86 -35
View File
@@ -1,8 +1,10 @@
import logging
from typing import Union, Tuple, Any from typing import Union, Tuple, Any
import discord import discord
from datetime import datetime
from . import MessageLog from . import MessageLog
from utils import FakeMessage from utils import serialize, FakeMessage
CHUNK_SIZE = 2000 CHUNK_SIZE = 2000
FORMAT = 3 FORMAT = 3
@@ -15,8 +17,10 @@ class ChannelLogs:
self.id = channel.id self.id = channel.id
self.name = channel.name self.name = channel.name
self.last_message_id = None self.last_message_id = None
self.first_message_id = None
self.format = FORMAT self.format = FORMAT
self.messages = [] self.messages = set()
self.start_date = None
elif isinstance(channel, dict): elif isinstance(channel, dict):
self.format = channel["format"] if "format" in channel else None self.format = channel["format"] if "format" in channel else None
if not self.is_format(): if not self.is_format():
@@ -28,63 +32,110 @@ class ChannelLogs:
if channel["last_message_id"] is not None if channel["last_message_id"] is not None
else None else None
) )
self.messages = [ self.first_message_id = (
int(channel["first_message_id"])
if "first_message_id" in channel
and channel["first_message_id"] is not None
else None
)
self.messages = {
MessageLog(message, self) for message in channel["messages"] MessageLog(message, self) for message in channel["messages"]
] }
self.start_date = (
self.sorted_messages[0].created_at if len(self.messages) > 0 else None
)
def is_format(self): def is_format(self):
return self.format == FORMAT return self.format == FORMAT
async def load(self, channel: discord.TextChannel) -> Tuple[int, int]: def preload(self, channel: discord.TextChannel):
self.name = channel.name self.name = channel.name
self.channel = channel self.channel = channel
@property
def sorted_messages(self):
return sorted(self.messages)
@property
def nsfw(self):
self.channel.nsfw
async def load(
self, channel: discord.TextChannel, start_date: datetime, stop_date: datetime
) -> Tuple[int, int]:
is_empty = self.last_message_id is None
try: try:
if self.last_message_id is not None: # append if is_empty:
tmp_message_id = None sanity_check = len([message async for message in channel.history(limit=1)])
if sanity_check < 1:
yield len(self.messages), True
return
# load backward
if is_empty or (
self.first_message_id is not None
and (
start_date is None
or (self.start_date is not None and self.start_date > start_date)
)
):
first_message_date = None
tmp_message_id = 0
done = 0
while ( while (
self.last_message_id != channel.last_message_id first_message_date is None
and self.last_message_id != tmp_message_id or (
): done >= CHUNK_SIZE
tmp_message_id = self.last_message_id and (start_date is None or first_message_date > start_date)
async for message in channel.history( )
limit=CHUNK_SIZE, ) and tmp_message_id != self.first_message_id:
after=FakeMessage(self.last_message_id), tmp_message_id = self.first_message_id
oldest_first=True,
):
self.last_message_id = message.id
m = MessageLog(message, self)
await m.load(message)
self.messages.insert(0, m)
yield len(self.messages), False
else: # first load
last_message_id = None
done = 0
sanity_check = len(await channel.history(limit=1).flatten())
if sanity_check == 1:
while done >= CHUNK_SIZE or last_message_id is None:
done = 0 done = 0
async for message in channel.history( async for message in channel.history(
limit=CHUNK_SIZE, limit=CHUNK_SIZE,
before=FakeMessage(last_message_id) before=FakeMessage(self.first_message_id)
if last_message_id is not None if self.first_message_id is not None
else None, else None,
oldest_first=False, oldest_first=False,
): ):
done += 1 done += 1
last_message_id = message.id self.first_message_id = message.id
first_message_date = message.created_at
m = MessageLog(message, self) m = MessageLog(message, self)
await m.load(message) await m.load(message)
self.messages += [m] self.messages.add(m)
yield len(self.messages), False yield len(self.messages), False
if done < CHUNK_SIZE: # reached bottom
self.first_message_id = None
self.last_message_id = channel.last_message_id self.last_message_id = channel.last_message_id
except discord.errors.HTTPException: # load forward
last_message_date = self.sorted_messages[-1].created_at
if not is_empty and (stop_date is None or last_message_date < stop_date):
tmp_message_id = None
while (
self.last_message_id != channel.last_message_id
and (stop_date is None or last_message_date < stop_date)
) and self.last_message_id != tmp_message_id:
tmp_message_id = self.last_message_id
async for message in channel.history(
limit=CHUNK_SIZE,
after=FakeMessage(self.first_message_id),
oldest_first=True,
):
last_message_date = message.created_at
self.last_message_id = message.id
m = MessageLog(message, self)
await m.load(message)
self.messages.add(m)
yield len(self.messages), False
except discord.errors.HTTPException as e:
yield -1, True yield -1, True
return # When an exception occurs (like Forbidden) return # When an exception occurs (like Forbidden)
self.start_date = (
self.sorted_messages[0].created_at if len(self.messages) > 0 else None
)
yield len(self.messages), True yield len(self.messages), True
def dict(self) -> dict: def dict(self) -> dict:
channel = dict(self.__dict__) channel = serialize(self, not_serialized=["channel", "guild", "start_date"])
channel.pop("channel", None)
channel.pop("guild", None)
channel["messages"] = [message.dict() for message in self.messages] channel["messages"] = [message.dict() for message in self.messages]
return channel return channel
+126 -29
View File
@@ -8,26 +8,39 @@ import time
import logging import logging
import asyncio import asyncio
import threading import threading
from dotenv import load_dotenv
from cryptography.fernet import Fernet
from . import ChannelLogs from . import ChannelLogs
from utils import code_message, delta, deltas from utils import code_message, delta, deltas
LOG_DIR = "logs"
current_analysis = [] current_analysis = []
current_analysis_lock = threading.Lock() current_analysis_lock = threading.Lock()
ALREADY_RUNNING = -100 ALREADY_RUNNING = -100
CANCELLED = -200 CANCELLED = -200
NO_FILE = -300
MIN_MODIFICATION_TIME = 5 * 60 load_dotenv()
LOG_DIR = os.getenv("LOG_DIR", "logs")
LOG_EXT = os.getenv("LOG_EXT", ".logz")
CRYPT_KEY = os.getenv("CRYPT_KEY", "")
# 5 minutes, assume 'fast' arg
MIN_MODIFICATION_TIME = int(os.getenv("MAX_MODIFICATION_TIME", 5 * 60))
# 90 days, remove log file
MAX_MODIFICATION_TIME = int(os.getenv("MAX_MODIFICATION_TIME", 90 * 24 * 60 * 60))
class Worker: class Worker:
def __init__(self, channel_log: ChannelLogs, channel: discord.TextChannel): def __init__(
self,
channel_log: ChannelLogs,
channel: discord.TextChannel,
start_date: datetime,
stop_date: datetime,
):
self.channel_log = channel_log self.channel_log = channel_log
self.channel = channel self.channel = channel
self.start_msg = len(channel_log.messages) self.start_msg = len(channel_log.messages)
@@ -36,12 +49,16 @@ class Worker:
self.done = False self.done = False
self.cancelled = False self.cancelled = False
self.loop = asyncio.get_event_loop() self.loop = asyncio.get_event_loop()
self.start_date = start_date
self.stop_date = stop_date
def start(self): def start(self):
asyncio.run_coroutine_threadsafe(self.process(), self.loop) asyncio.run_coroutine_threadsafe(self.process(), self.loop)
async def process(self): async def process(self):
async for count, done in self.channel_log.load(self.channel): async for count, done in self.channel_log.load(
self.channel, self.start_date, self.stop_date
):
if count > 0: if count > 0:
self.queried_msg = count - self.start_msg self.queried_msg = count - self.start_msg
self.total_msg = count self.total_msg = count
@@ -54,7 +71,7 @@ class GuildLogs:
def __init__(self, guild: discord.Guild): def __init__(self, guild: discord.Guild):
self.id = guild.id self.id = guild.id
self.guild = guild self.guild = guild
self.log_file = os.path.join(LOG_DIR, f"{guild.id}.logz") self.log_file = os.path.join(LOG_DIR, f"{guild.id}{LOG_EXT}")
self.channels = {} self.channels = {}
self.locked = False self.locked = False
@@ -74,16 +91,17 @@ class GuildLogs:
return self.locked and self.log_file not in current_analysis return self.locked and self.log_file not in current_analysis
def lock(self) -> bool: def lock(self) -> bool:
self.locked = True
current_analysis_lock.acquire() current_analysis_lock.acquire()
if self.log_file in current_analysis: if self.log_file in current_analysis:
current_analysis_lock.release() current_analysis_lock.release()
return False return False
self.locked = True
current_analysis.append(self.log_file) current_analysis.append(self.log_file)
current_analysis_lock.release() current_analysis_lock.release()
return True return True
def unlock(self): def unlock(self):
if self.locked:
self.locked = False self.locked = False
current_analysis_lock.acquire() current_analysis_lock.acquire()
if self.log_file in current_analysis: if self.log_file in current_analysis:
@@ -93,7 +111,9 @@ class GuildLogs:
async def load( async def load(
self, self,
progress: discord.Message, progress: discord.Message,
target_channels: List[discord.TextChannel] = [], target_channels: List[discord.TextChannel],
start_date: datetime,
stop_date: datetime,
*, *,
fast: bool, fast: bool,
fresh: bool, fresh: bool,
@@ -106,35 +126,46 @@ class GuildLogs:
if not os.path.exists(LOG_DIR): if not os.path.exists(LOG_DIR):
os.mkdir(LOG_DIR) os.mkdir(LOG_DIR)
last_time = None last_time = None
if os.path.exists(self.log_file): if not os.path.exists(self.log_file):
return NO_FILE, 0
channels = {} channels = {}
try: try:
last_time = os.path.getmtime(self.log_file) last_time = os.path.getmtime(self.log_file)
gziped_data = None encrypted_data = None
await code_message(progress, "Reading saved history (1/4)...") await code_message(progress, "Reading saved history (1/5)...")
t0 = datetime.now() t0 = datetime.now()
with open(self.log_file, mode="rb") as f: with open(self.log_file, mode="rb") as f:
gziped_data = f.read() encrypted_data = f.read()
logging.info(f"log {self.guild.id} > read in {delta(t0):,}ms") logging.info(f"log {self.guild.id} > read in {delta(t0):,}ms")
if self.check_cancelled(): if self.check_cancelled():
return CANCELLED, 0 return CANCELLED, 0
await code_message(progress, "Reading saved history (2/4)...") await code_message(progress, "Reading saved history (2/5)...")
if CRYPT_KEY == "" or CRYPT_KEY is None:
gziped_data = encrypted_data
try:
t0 = datetime.now()
fernet = Fernet(CRYPT_KEY)
gziped_data = fernet.decrypt(encrypted_data)
logging.info(f"log {self.guild.id} > decrypted in {delta(t0):,}ms")
except:
gziped_data = encrypted_data
if self.check_cancelled():
return CANCELLED, 0
await code_message(progress, "Reading saved history (3/5)...")
t0 = datetime.now() t0 = datetime.now()
json_data = gzip.decompress(gziped_data) json_data = gzip.decompress(gziped_data)
del gziped_data del gziped_data
logging.info( logging.info(f"log {self.guild.id} > gzip decompress in {delta(t0):,}ms")
f"log {self.guild.id} > gzip decompress in {delta(t0):,}ms"
)
if self.check_cancelled(): if self.check_cancelled():
return CANCELLED, 0 return CANCELLED, 0
await code_message(progress, "Reading saved history (3/4)...") await code_message(progress, "Reading saved history (4/5)...")
t0 = datetime.now() t0 = datetime.now()
channels = json.loads(json_data) channels = json.loads(json_data)
del json_data del json_data
logging.info(f"log {self.guild.id} > json parse in {delta(t0):,}ms") logging.info(f"log {self.guild.id} > json parse in {delta(t0):,}ms")
if self.check_cancelled(): if self.check_cancelled():
return CANCELLED, 0 return CANCELLED, 0
await code_message(progress, "Reading saved history (4/4)...") await code_message(progress, "Reading saved history (5/5)...")
t0 = datetime.now() t0 = datetime.now()
self.channels = { self.channels = {
int(id): ChannelLogs(channels[id], self) for id in channels int(id): ChannelLogs(channels[id], self) for id in channels
@@ -150,8 +181,6 @@ class GuildLogs:
logging.error(f"log {self.guild.id} > invalid JSON") logging.error(f"log {self.guild.id} > invalid JSON")
except IOError: except IOError:
logging.error(f"log {self.guild.id} > cannot read") logging.error(f"log {self.guild.id} > cannot read")
else:
fast = False
if len(target_channels) == 0: if len(target_channels) == 0:
target_channels = ( target_channels = (
@@ -171,6 +200,8 @@ class GuildLogs:
if ( if (
not fast not fast
and not fresh and not fresh
and start_date is None
and stop_date is None
and last_time is not None and last_time is not None
and (time.time() - last_time) < MIN_MODIFICATION_TIME and (time.time() - last_time) < MIN_MODIFICATION_TIME
): ):
@@ -178,8 +209,10 @@ class GuildLogs:
channel channel
for channel in target_channels for channel in target_channels
if channel.id not in self.channels if channel.id not in self.channels
or self.channels[channel.id].first_message_id is not None
] ]
if len(invalid_target_channels) == 0: if len(invalid_target_channels) == 0:
logging.info(f"log {self.guild.id} > assumed fast")
fast = True fast = True
if self.locked: if self.locked:
self.unlock() self.unlock()
@@ -196,6 +229,8 @@ class GuildLogs:
] ]
) )
total_chan = len(target_channels) total_chan = len(target_channels)
for channel in target_channels:
self.channels[channel.id].preload(channel)
else: else:
if not self.locked and not self.lock(): if not self.locked and not self.lock():
return ALREADY_RUNNING, 0 return ALREADY_RUNNING, 0
@@ -212,7 +247,10 @@ class GuildLogs:
if channel.id not in self.channels or fresh: if channel.id not in self.channels or fresh:
loading_new += 1 loading_new += 1
self.channels[channel.id] = ChannelLogs(channel, self) self.channels[channel.id] = ChannelLogs(channel, self)
workers += [Worker(self.channels[channel.id], channel)] self.channels[channel.id].preload(channel)
workers += [
Worker(self.channels[channel.id], channel, start_date, stop_date)
]
warning_msg = "(this might take a while)" warning_msg = "(this might take a while)"
if len(target_channels) > 5 and loading_new > 5: if len(target_channels) > 5 and loading_new > 5:
warning_msg = "(most channels are new, this will take a long while)" warning_msg = "(most channels are new, this will take a long while)"
@@ -253,7 +291,7 @@ class GuildLogs:
f"Reading new history...\n{total_msg:,} messages in {total_chan:,}/{max_chan:,} channels ({round(queried_msg/deltas(t0)):,}m/s)\n{warning_msg}{remaining_msg}", f"Reading new history...\n{total_msg:,} messages in {total_chan:,}/{max_chan:,} channels ({round(queried_msg/deltas(t0)):,}m/s)\n{warning_msg}{remaining_msg}",
) )
logging.info( logging.info(
f"log {self.guild.id} > queried in {delta(t0):,}ms -> {queried_msg / deltas(t0):,.3f} m/s" f"log {self.guild.id} > queried {queried_msg} in {delta(t0):,}ms -> {queried_msg / deltas(t0):,.3f} m/s"
) )
# write logs # write logs
real_total_msg = sum( real_total_msg = sum(
@@ -264,7 +302,7 @@ class GuildLogs:
return CANCELLED, 0 return CANCELLED, 0
await code_message( await code_message(
progress, progress,
f"Saving history (1/3)...\n{real_total_msg:,} messages in {real_total_chan:,} channels", f"Saving history (1/4)...\n{real_total_msg:,} messages in {real_total_chan:,} channels",
) )
t0 = datetime.now() t0 = datetime.now()
json_data = bytes(json.dumps(self.dict()), "utf-8") json_data = bytes(json.dumps(self.dict()), "utf-8")
@@ -275,7 +313,7 @@ class GuildLogs:
return CANCELLED, 0 return CANCELLED, 0
await code_message( await code_message(
progress, progress,
f"Saving history (2/3)...\n{real_total_msg:,} messages in {real_total_chan:,} channels", f"Saving history (2/4)...\n{real_total_msg:,} messages in {real_total_chan:,} channels",
) )
t0 = datetime.now() t0 = datetime.now()
gziped_data = gzip.compress(json_data) gziped_data = gzip.compress(json_data)
@@ -287,12 +325,28 @@ class GuildLogs:
return CANCELLED, 0 return CANCELLED, 0
await code_message( await code_message(
progress, progress,
f"Saving history (3/3)...\n{real_total_msg:,} messages in {real_total_chan:,} channels", f"Saving history (3/4)...\n{real_total_msg:,} messages in {real_total_chan:,} channels",
)
if CRYPT_KEY == "" or CRYPT_KEY is None:
encrypted_data = gziped_data
try:
t0 = datetime.now()
fernet = Fernet(CRYPT_KEY)
encrypted_data = fernet.encrypt(gziped_data)
logging.info(f"log {self.guild.id} > encrypted in {delta(t0):,}ms -> {len(gziped_data) / deltas(t0):,.3f} b/s")
except:
encrypted_data = gziped_data
if self.check_cancelled():
return CANCELLED, 0
await code_message(
progress,
f"Saving history (4/4)...\n{real_total_msg:,} messages in {real_total_chan:,} channels",
) )
t0 = datetime.now() t0 = datetime.now()
with open(self.log_file, mode="wb") as f: with open(self.log_file, mode="wb") as f:
f.write(gziped_data) f.write(encrypted_data)
del gziped_data del gziped_data
del encrypted_data
logging.info( logging.info(
f"log {self.guild.id} > saved in {delta(t0):,}ms -> {real_total_msg / deltas(t0):,.3f} m/s" f"log {self.guild.id} > saved in {delta(t0):,}ms -> {real_total_msg / deltas(t0):,.3f} m/s"
) )
@@ -322,3 +376,46 @@ class GuildLogs:
f"No cancellable analysis are currently running on this server", f"No cancellable analysis are currently running on this server",
reference=message, reference=message,
) )
@staticmethod
def init_log(guild: List[discord.Guild]):
if not os.path.exists(LOG_DIR):
os.mkdir(LOG_DIR)
filename = os.path.join(LOG_DIR, f"{guild.id}{LOG_EXT}")
if not os.path.exists(filename):
with open(filename, mode="wb") as f:
f.write(gzip.compress(bytes("{}", "utf-8")))
logging.info(f"log {guild.id} > created")
else:
logging.info(f"log {guild.id} > already exists")
@staticmethod
def remove_log(guild: List[discord.Guild]):
if not os.path.exists(LOG_DIR):
os.mkdir(LOG_DIR)
filename = os.path.join(LOG_DIR, f"{guild.id}{LOG_EXT}")
if os.path.exists(filename):
os.unlink(filename)
logging.info(f"log {guild.id} > removed")
else:
logging.info(f"log {guild.id} > does not exists")
@staticmethod
def check_logs(guilds: List[discord.Guild]):
logging.info(f"checking logs...")
if not os.path.exists(LOG_DIR):
os.mkdir(LOG_DIR)
guild_ids = [str(guild.id) for guild in guilds]
for item in os.listdir(LOG_DIR):
path = os.path.join(LOG_DIR, item)
name, ext = os.path.splitext(item)
if os.path.isfile(path) and ext == LOG_EXT:
if (
name in guild_ids
and (time.time() - os.path.getmtime(path)) > MAX_MODIFICATION_TIME
):
logging.info(f"> removing old log '{path}'")
os.unlink(path)
elif name not in guild_ids:
logging.info(f"> removing unused log '{path}'")
os.unlink(path)
+23 -20
View File
@@ -1,11 +1,11 @@
from typing import Union, Any from typing import Optional, Union, Any
import discord import discord
from datetime import datetime from datetime import datetime
from utils import is_extension from utils import (
serialize,
IMAGE_FORMAT = [".gif", ".gifv", ".png", ".jpg", ".jpeg", ".bmp"] has_image,
EMBED_IMAGES = ["image", "gifv"] )
class MessageLog: class MessageLog:
@@ -36,15 +36,7 @@ class MessageLog:
self.image = False self.image = False
self.attachment = len(message.attachments) > 0 self.attachment = len(message.attachments) > 0
self.embed = len(message.embeds) > 0 self.embed = len(message.embeds) > 0
for attachment in message.attachments: self.image = has_image(message)
if is_extension(attachment.filename, IMAGE_FORMAT):
self.image = True
break
else:
for embed in message.embeds:
if embed.type in EMBED_IMAGES:
self.image = True
break
self.reactions = {} self.reactions = {}
elif isinstance(message, dict): elif isinstance(message, dict):
self.id = int(message["id"]) self.id = int(message["id"])
@@ -71,17 +63,28 @@ class MessageLog:
self.attachment = message["attachment"] self.attachment = message["attachment"]
self.reactions = message["reactions"] self.reactions = message["reactions"]
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__) and other.id == self.id
def __gt__(self, other: "MessageLog") -> bool:
return self.created_at > other.created_at
def __hash__(self) -> int:
return self.id
async def load(self, message: discord.Message): async def load(self, message: discord.Message):
for reaction in message.reactions: for reaction in message.reactions:
self.reactions[str(reaction.emoji)] = [] self.reactions[str(reaction.emoji)] = []
async for user in reaction.users(): async for user in reaction.users():
self.reactions[str(reaction.emoji)] += [user.id] self.reactions[str(reaction.emoji)] += [user.id]
async def fetch(self) -> Optional[discord.Message]:
try:
return await self.channel.channel.fetch_message(self.id)
except (discord.NotFound, discord.Forbidden, discord.HTTPException):
return None
def dict(self) -> dict: def dict(self) -> dict:
message = dict(self.__dict__) return serialize(
message.pop("channel", None) self, not_serialized=["channel"], dates=["created_at", "edited_at"]
message["created_at"] = self.created_at.isoformat()
message["edited_at"] = (
self.edited_at.isoformat() if self.edited_at is not None else None
) )
return message
+81 -58
View File
@@ -6,23 +6,8 @@ if sys.version_info < (3, 7):
print("Please upgrade your Python version to 3.7.0 or higher") print("Please upgrade your Python version to 3.7.0 or higher")
sys.exit(1) sys.exit(1)
from utils import emojis from utils import emojis, gdpr, command_cache
from scanners import ( import scanners
EmotesScanner,
FullScanner,
FrequencyScanner,
CompositionScanner,
PresenceScanner,
MentionsScanner,
MentionedScanner,
MessagesScanner,
ChannelsScanner,
ReactionsScanner,
FirstScanner,
RandomScanner,
LastScanner,
WordsScanner,
)
from logs import GuildLogs from logs import GuildLogs
logging.basicConfig( logging.basicConfig(
@@ -33,101 +18,139 @@ emojis.load_emojis()
bot = Bot( bot = Bot(
"Discord Analyst", "Discord Analyst",
"1.12", "1.17.5",
alias="%", alias="%",
) )
bot.log_calls = True bot.log_calls = True
async def on_ready():
GuildLogs.check_logs(bot.client.guilds)
return True
async def on_guild_remove():
GuildLogs.check_logs(bot.client.guilds)
return True
bot.register_event(on_ready)
bot.register_event(on_guild_remove)
bot.register_command( bot.register_command(
"(cancel|stop)", "(cancel|stop)",
GuildLogs.cancel, GuildLogs.cancel,
"cancel: stop current analysis (not launched with fast)", "cancel: stop current analysis (not launched with fast)",
"```\n" + "%cancel: Stop current analysis (not launched with fast)\n" + "```", "```\n%cancel: Stop current analysis (not launched with fast)\n```",
) )
bot.register_command( bot.register_command(
"last", "gdpr",
lambda *args: LastScanner().compute(*args), gdpr.process,
"last: read last message", "gdpr: displays GDPR information",
LastScanner.help(), gdpr.HELP,
)
bot.register_command(
"rand(om)?",
lambda *args: RandomScanner().compute(*args),
"rand: read a random message",
RandomScanner.help(),
)
bot.register_command(
"first",
lambda *args: FirstScanner().compute(*args),
"first: read first message",
FirstScanner.help(),
) )
bot.register_command( bot.register_command(
"words", "words",
lambda *args: WordsScanner().compute(*args), lambda *args: scanners.WordsScanner().compute(*args),
"words: rank words by their usage", "words: (BETA) rank words by their usage",
WordsScanner.help(), scanners.WordsScanner.help(),
)
bot.register_command(
"repeat",
command_cache.repeat,
"repeat: repeat last analysis (adding supplied arguments)",
"```\n%repeat: repeat last analysis (adding supplied arguments)\n```",
)
bot.register_command(
"mobile",
lambda *args: command_cache.repeat(*args, add_args=["mobile"]),
"mobile: fix @invalid-user for last command but mentions users",
"```\n%mobile: fix @invalid-user for last command but mentions users\n```",
)
bot.register_command(
"find",
lambda *args: scanners.FindScanner().compute(*args),
"find: find specific words or phrases",
scanners.FindScanner.help(),
)
bot.register_command(
"last",
lambda *args: scanners.LastScanner().compute(*args),
"last: read last message",
scanners.LastScanner.help(),
)
bot.register_command(
"(rand(om)?|mood)",
lambda *args: scanners.RandomScanner().compute(*args),
"rand: read a random message",
scanners.RandomScanner.help(),
)
bot.register_command(
"first",
lambda *args: scanners.FirstScanner().compute(*args),
"first: read first message",
scanners.FirstScanner.help(),
) )
bot.register_command( bot.register_command(
"mentioned", "mentioned",
lambda *args: MentionedScanner().compute(*args), lambda *args: scanners.MentionedScanner().compute(*args),
"mentioned: rank specific user mentions by their usage", "mentioned: rank specific user mentions by their usage",
MentionedScanner.help(), scanners.MentionedScanner.help(),
) )
bot.register_command( bot.register_command(
"(mentions?)", "(mentions?)",
lambda *args: MentionsScanner().compute(*args), lambda *args: scanners.MentionsScanner().compute(*args),
"mentions: rank mentions by their usage", "mentions: rank mentions by their usage",
MentionsScanner.help(), scanners.MentionsScanner.help(),
) )
bot.register_command( bot.register_command(
"(emojis?|emotes?)", "(emojis?|emotes?)",
lambda *args: EmotesScanner().compute(*args), lambda *args: scanners.EmojisScanner().compute(*args),
"emojis: rank emojis by their usage", "emojis: rank emojis by their usage",
EmotesScanner.help(), scanners.EmojisScanner.help(),
) )
bot.register_command( bot.register_command(
"(react(ions?)?)", "(react(ions?)?)",
lambda *args: ReactionsScanner().compute(*args), lambda *args: scanners.ReactionsScanner().compute(*args),
"react: rank users by their reactions", "react: rank users by their reactions",
ReactionsScanner.help(), scanners.ReactionsScanner.help(),
) )
bot.register_command( bot.register_command(
"(channels?|chan)", "(channels?|chan)",
lambda *args: ChannelsScanner().compute(*args), lambda *args: scanners.ChannelsScanner().compute(*args),
"chan: rank channels by their messages", "chan: rank channels by their messages",
ChannelsScanner.help(), scanners.ChannelsScanner.help(),
) )
bot.register_command( bot.register_command(
"(messages?|msg)", "(messages?|msg)",
lambda *args: MessagesScanner().compute(*args), lambda *args: scanners.MessagesScanner().compute(*args),
"msg: rank users by their messages", "msg: rank users by their messages",
MessagesScanner.help(), scanners.MessagesScanner.help(),
) )
bot.register_command( bot.register_command(
"pres(ence)?", "pres(ence)?",
lambda *args: PresenceScanner().compute(*args), lambda *args: scanners.PresenceScanner().compute(*args),
"pres: presence analysis", "pres: presence analysis",
PresenceScanner.help(), scanners.PresenceScanner.help(),
) )
bot.register_command( bot.register_command(
"compo(sition)?", "compo(sition)?",
lambda *args: CompositionScanner().compute(*args), lambda *args: scanners.CompositionScanner().compute(*args),
"compo: composition analysis", "compo: composition analysis",
CompositionScanner.help(), scanners.CompositionScanner.help(),
) )
bot.register_command( bot.register_command(
"freq(ency)?", "freq(ency)?",
lambda *args: FrequencyScanner().compute(*args), lambda *args: scanners.FrequencyScanner().compute(*args),
"freq: frequency analysis", "freq: frequency analysis",
FrequencyScanner.help(), scanners.FrequencyScanner.help(),
) )
bot.register_command( bot.register_command(
"(full|scan)", "(full|scan)",
lambda *args: FullScanner().compute(*args), lambda *args: scanners.FullScanner().compute(*args),
"scan: full analysis", "scan: full analysis",
FullScanner.help(), scanners.FullScanner.help(),
) )
bot.start() bot.start()
+12 -9
View File
@@ -1,14 +1,17 @@
from .emotes_scanner import EmotesScanner from .scanner import Scanner
from .frequency_scanner import FrequencyScanner
from .composition_scanner import CompositionScanner
from .presence_scanner import PresenceScanner
from .full_scanner import FullScanner
from .mentions_scanner import MentionsScanner
from .mentioned_scanner import MentionedScanner
from .messages_scanner import MessagesScanner
from .channels_scanner import ChannelsScanner from .channels_scanner import ChannelsScanner
from .reactions_scanner import ReactionsScanner from .composition_scanner import CompositionScanner
from .emojis_scanner import EmojisScanner
from .find_scanner import FindScanner
from .first_scanner import FirstScanner from .first_scanner import FirstScanner
from .frequency_scanner import FrequencyScanner
from .full_scanner import FullScanner
from .last_scanner import LastScanner from .last_scanner import LastScanner
from .mentioned_scanner import MentionedScanner
from .mentions_scanner import MentionsScanner
from .messages_scanner import MessagesScanner
from .presence_scanner import PresenceScanner
from .random_scanner import RandomScanner from .random_scanner import RandomScanner
from .reactions_scanner import ReactionsScanner
from .words_scanner import WordsScanner from .words_scanner import WordsScanner
+7 -11
View File
@@ -8,21 +8,17 @@ import discord
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from .scanner import Scanner from .scanner import Scanner
from data_types import Counter from data_types import Counter
from utils import COMMON_HELP_ARGS, mention, channel_mention from utils import generate_help, mention, channel_mention
class ChannelsScanner(Scanner): class ChannelsScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help(
"```\n" "chan",
+ "%chan: Rank channels by their messages\n" "Rank channels by their messages",
+ "arguments:\n" args=["<n> - top <n>, default is 10", "all/everyone - include bots"],
+ COMMON_HELP_ARGS example="5 @user",
+ "* <n> - top <n>, default is 10\n"
+ "* all/everyone - include bots\n"
+ "Example: %chan 10 @user\n"
+ "```"
) )
def __init__(self): def __init__(self):
@@ -34,7 +30,6 @@ class ChannelsScanner(Scanner):
) )
async def init(self, message: discord.Message, *args: str) -> bool: async def init(self, message: discord.Message, *args: str) -> bool:
# get max emotes to view
self.top = 10 self.top = 10
for arg in args: for arg in args:
if arg.isdigit(): if arg.isdigit():
@@ -66,6 +61,7 @@ class ChannelsScanner(Scanner):
total_usage=usage_count, total_usage=usage_count,
counted="message", counted="message",
transform=lambda id: f" by {mention(id)}", transform=lambda id: f" by {mention(id)}",
top=len(self.members) != 1,
) )
for name in names for name in names
] ]
+13 -21
View File
@@ -8,21 +8,13 @@ import discord
from .scanner import Scanner from .scanner import Scanner
from data_types import Composition from data_types import Composition
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from utils import emojis, COMMON_HELP_ARGS from utils import emojis, generate_help
class CompositionScanner(Scanner): class CompositionScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help("compo", "Show composition statistics")
"```\n"
+ "%compo: Show composition statistics\n"
+ "arguments:\n"
+ COMMON_HELP_ARGS
+ "* all/everyone - include bots\n"
+ "Example: %compo #mychannel1 @user\n"
+ "```"
)
def __init__(self): def __init__(self):
super().__init__( super().__init__(
@@ -65,19 +57,19 @@ class CompositionScanner(Scanner):
impacted = True impacted = True
compo.total_characters += len(message.content) compo.total_characters += len(message.content)
emotes_found = emojis.regex.findall(message.content) emojis_found = emojis.regex.findall(message.content)
without_emote = message.content without_emoji = message.content
for name in emotes_found: for name in emojis_found:
if name in emojis.unicode_list or re.match( if name in emojis.unicode_list or re.match(
r"(<a?:[\w\-\~]+:\d+>|:[\w\\-\~]+:)", name r"(<a?:[\w\-\~]+:\d+>|:[\w\\-\~]+:)", name
): ):
compo.emotes[name] += 1 compo.emojis[name] += 1
i = without_emote.index(name) i = without_emoji.index(name)
without_emote = without_emote[:i] + without_emote[i + len(name) :] without_emoji = without_emoji[:i] + without_emoji[i + len(name) :]
if len(message.content.strip()) > 0 and len(without_emote.strip()) == 0: if len(message.content.strip()) > 0 and len(without_emoji.strip()) == 0:
compo.emote_only += 1 compo.emoji_only += 1
if len(emotes_found) > 0: if len(emojis_found) > 0:
compo.emote_msg += 1 compo.emoji_msg += 1
links_found = re.findall(r"https?:\/\/", message.content) links_found = re.findall(r"https?:\/\/", message.content)
compo.links += len(links_found) compo.links += len(links_found)
@@ -110,7 +102,7 @@ class CompositionScanner(Scanner):
compo.tts += 1 compo.tts += 1
if ( if (
len(emotes_found) == 0 len(emojis_found) == 0
and message.reference is None and message.reference is None
and not message.image and not message.image
and len(message.mentions) == 0 and len(message.mentions) == 0
@@ -1,44 +1,42 @@
from typing import Dict, List from typing import Dict, List
from collections import defaultdict
import discord import discord
# Custom libs # Custom libs
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from data_types import Emote, get_emote_dict from data_types import Emoji, get_emoji_dict
from .scanner import Scanner from .scanner import Scanner
from utils import emojis, COMMON_HELP_ARGS, plural, precise from utils import emojis, generate_help, plural, precise
class EmotesScanner(Scanner): class EmojisScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help(
"```\n" "emojis",
+ "%emojis: Rank emojis by their usage\n" "Rank emojis by their usage",
+ "arguments:\n" args=[
+ COMMON_HELP_ARGS "<n> - top <n> emojis, default is 20",
+ "* <n> - top <n> emojis, default is 20\n" "all - list all common emojis in addition to this guild's",
+ "* all - list all common emojis in addition to this guild's\n" "members - show top member for each emojis",
+ "* members - show top member for each emojis\n" "sort:usage/reaction - other sorting methods",
+ "* sort:usage/reaction - other sorting methods\n" "everyone - include bots",
+ "* everyone - include bots\n" ],
+ "Example: %emojis 10 all #mychannel1 #mychannel2 @user\n" example="10 all #mychannel1 #mychannel2 @user",
+ "```"
) )
def __init__(self): def __init__(self):
super().__init__( super().__init__(
has_digit_args=True, has_digit_args=True,
valid_args=["all", "members", "sort:usage", "sort:reaction", "everyone"], valid_args=["all", "members", "sort:usage", "sort:reaction", "everyone"],
help=EmotesScanner.help(), help=EmojisScanner.help(),
intro_context="Emoji usage", intro_context="Emoji usage",
) )
async def init(self, message: discord.Message, *args: str) -> bool: async def init(self, message: discord.Message, *args: str) -> bool:
guild = message.channel.guild guild = message.channel.guild
# get max emotes to view # get max emojis to view
self.top = 20 self.top = 20
for arg in args: for arg in args:
if arg.isdigit(): if arg.isdigit():
@@ -48,8 +46,8 @@ class EmotesScanner(Scanner):
self.show_members = "members" in args and ( self.show_members = "members" in args and (
len(self.members) == 0 or len(self.members) > 1 len(self.members) == 0 or len(self.members) > 1
) )
# Create emotes dict from custom emojis of the guild # Create emojis dict from custom emojis of the guild
self.emotes = get_emote_dict(guild) self.emojis = get_emoji_dict(guild)
self.sort = None self.sort = None
if "sort:usage" in args: if "sort:usage" in args:
self.sort = "usage" self.sort = "usage"
@@ -59,36 +57,36 @@ class EmotesScanner(Scanner):
return True return True
def compute_message(self, channel: ChannelLogs, message: MessageLog): def compute_message(self, channel: ChannelLogs, message: MessageLog):
return EmotesScanner.analyse_message( return EmojisScanner.analyse_message(
message, message,
self.emotes, self.emojis,
self.raw_members, self.raw_members,
all_emojis=self.all_emojis, all_emojis=self.all_emojis,
all_messages=self.all_messages, all_messages=self.all_messages,
) )
def get_results(self, intro: str) -> List[str]: def get_results(self, intro: str) -> List[str]:
names = [name for name in self.emotes] names = [name for name in self.emojis]
names.sort( names.sort(
key=lambda name: self.emotes[name].score( key=lambda name: self.emojis[name].score(
usage_weight=(0 if self.sort == "reaction" else 1), usage_weight=(0 if self.sort == "reaction" else 1),
react_weight=(0 if self.sort == "usage" else 1), react_weight=(0 if self.sort == "usage" else 1),
), ),
reverse=True, reverse=True,
) )
names = names[: self.top] names = names[: self.top]
# Get the total of all emotes used # Get the total of all emojis used
usage_count = 0 usage_count = 0
reaction_count = 0 reaction_count = 0
for name in self.emotes: for name in self.emojis:
usage_count += self.emotes[name].usages usage_count += self.emojis[name].usages
reaction_count += self.emotes[name].reactions reaction_count += self.emojis[name].reactions
res = [intro] res = [intro]
allow_unused = self.full and len(self.members) == 0 allow_unused = self.full and len(self.members) == 0
if self.sort is not None: if self.sort is not None:
res += [f"(Sorted by {self.sort})"] res += [f"(Sorted by {self.sort})"]
res += [ res += [
self.emotes[name].to_string( self.emojis[name].to_string(
names.index(name), names.index(name),
name, name,
total_usage=usage_count, total_usage=usage_count,
@@ -97,7 +95,7 @@ class EmotesScanner(Scanner):
show_members=self.show_members or len(self.raw_members) == 0, show_members=self.show_members or len(self.raw_members) == 0,
) )
for name in names for name in names
if allow_unused or self.emotes[name].used() if allow_unused or self.emojis[name].used()
] ]
res += [ res += [
f"Total: {plural(usage_count,'time')} ({precise(usage_count/self.msg_count)}/msg)" f"Total: {plural(usage_count,'time')} ({precise(usage_count/self.msg_count)}/msg)"
@@ -109,7 +107,7 @@ class EmotesScanner(Scanner):
@staticmethod @staticmethod
def analyse_message( def analyse_message(
message: MessageLog, message: MessageLog,
emotes: Dict[str, Emote], emojis_dict: Dict[str, Emoji],
raw_members: List[int], raw_members: List[int],
*, *,
all_emojis: bool, all_emojis: bool,
@@ -123,27 +121,29 @@ class EmotesScanner(Scanner):
or message.author in raw_members or message.author in raw_members
): ):
impacted = True impacted = True
# Find all emotes un the current message in the form "<:emoji:123456789>" # Find all emojis un the current message in the form "<:emoji:123456789>"
# Filter for known emotes # Filter for known emojis
found = emojis.regex.findall(message.content) found = emojis.regex.findall(message.content)
# For each emote, update its usage # For each emoji, update its usage
for name in found: for name in found:
if name not in emotes: if name not in emojis_dict:
if not all_emojis or name not in emojis.unicode_list: if not all_emojis or name not in emojis.unicode_list:
continue continue
emotes[name].usages += 1 emojis_dict[name].usages += 1
emotes[name].update_use(message.created_at, [message.author]) emojis_dict[name].update_use(message.created_at, [message.author])
# For each reaction of this message, test if known emote and update when it's the case # For each reaction of this message, test if known emoji and update when it's the case
for name in message.reactions: for name in message.reactions:
if name not in emotes: if name not in emojis_dict:
if not all_emojis or name not in emojis.unicode_list: if not all_emojis or name not in emojis.unicode_list:
continue continue
if len(raw_members) == 0: if len(raw_members) == 0:
emotes[name].reactions += len(message.reactions[name]) emojis_dict[name].reactions += len(message.reactions[name])
emotes[name].update_use(message.created_at, message.reactions[name]) emojis_dict[name].update_use(
message.created_at, message.reactions[name]
)
else: else:
for member in raw_members: for member in raw_members:
if member in message.reactions[name]: if member in message.reactions[name]:
emotes[name].reactions += 1 emojis_dict[name].reactions += 1
emotes[name].update_use(message.created_at, [member]) emojis_dict[name].update_use(message.created_at, [member])
return impacted return impacted
+134
View File
@@ -0,0 +1,134 @@
from typing import Dict, List, Optional, Tuple
from collections import defaultdict
import discord
import re
# Custom libs
from logs import ChannelLogs, MessageLog
from .scanner import Scanner
from data_types import Counter
from utils import (
generate_help,
plural,
precise,
mention,
escape_text,
)
class FindScanner(Scanner):
@staticmethod
def help() -> str:
return generate_help(
"find",
"Find specific words or phrases (you can use quotes to add spaces in queries, backticks define regexes)",
args=[
"top - rank users for these queries",
"all/everyone - include bots",
],
example='#mychannel1 #mychannel2 @user "I love you" "you too"',
)
def __init__(self):
super().__init__(
all_args=True,
valid_args=["all", "everyone", "top"],
help=FindScanner.help(),
intro_context="Matches",
)
async def init(self, message: discord.Message, *args: str) -> bool:
self.matches = defaultdict(Counter)
self.all_messages = "all" in args or "everyone" in args
self.top = "top" in args or len(self.other_args) == 1
if len(self.other_args) == 0:
await message.channel.send(
"You need to add a query to find (you can use quotes to add spaces in queries, backticks define regexes)",
reference=message,
)
return False
self.queries = [
(query, query.strip("`") if re.match(r"^`.*`$", query) else None)
for query in self.other_args
]
return True
def compute_message(self, channel: ChannelLogs, message: MessageLog):
return FindScanner.analyse_message(
message,
self.matches,
self.queries,
self.raw_members,
all_messages=self.all_messages,
top=self.top,
)
def get_results(self, intro: str) -> List[str]:
res = [intro]
matches = [match for match in self.matches]
matches.sort(key=lambda match: self.matches[match].score(), reverse=True)
usage_count = Counter.total(self.matches)
if self.top:
res += [
self.matches[match].to_string(
matches.index(match),
mention(match),
total_usage=usage_count,
)
for match in matches
]
else:
res += [
self.matches[match].to_string(
matches.index(match),
f'"{escape_text(match)}"'
if len(match.strip("`")) == len(match)
else match,
total_usage=self.msg_count,
ranking=False,
transform=lambda id: f" by {mention(id)}",
top=len(self.members) != 1,
)
for match in matches
]
if self.top or len(matches) > 1:
res += [
f"Total: {plural(usage_count,'time')} ({precise(usage_count/self.msg_count)}/msg)"
]
return res
special_cases = ["'s", "s"]
@staticmethod
def analyse_message(
message: MessageLog,
matches: Dict[str, Counter],
queries: List[Tuple[str, Optional[str]]],
raw_members: List[int],
*,
all_messages: bool,
top: bool,
) -> bool:
impacted = False
# If author is included in the selection (empty list is all)
if (
(not message.bot or all_messages)
and len(raw_members) == 0
or message.author in raw_members
):
impacted = True
content = message.content.lower()
for query in queries:
if query[1] is not None:
count = len(re.findall(query[1], message.content))
else:
count = content.count(query[0].lower())
if top:
if count > 0:
matches[message.author].update_use(count, message.created_at)
else:
matches[query[0]].update_use(
count, message.created_at, message.author
)
return impacted
+14 -3
View File
@@ -3,17 +3,28 @@ from typing import List
# Custom libs # Custom libs
from .history_scanner import HistoryScanner from .history_scanner import HistoryScanner
from utils import generate_help
class FirstScanner(HistoryScanner): class FirstScanner(HistoryScanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return super(FirstScanner, FirstScanner).help( return generate_help(
cmd="first", text="Read first message" "first",
"Read first message (add text to filter like %find)",
args=[
"image/gif - pull an image instead of a message",
"spoiler:allow/only - allow spoiler images",
],
) )
def __init__(self): def __init__(self):
super().__init__(help=FirstScanner.help()) super().__init__(help=FirstScanner.help())
def get_results(self, intro: str) -> List[str]: async def get_results(self, intro: str) -> List[str]:
if self.images_only:
return await self.history.to_string_image(
type="first", spoiler=self.spoiler, gif_only=self.gif_only
)
else:
return self.history.to_string(type="first") return self.history.to_string(type="first")
+15 -13
View File
@@ -8,25 +8,23 @@ import discord
from .scanner import Scanner from .scanner import Scanner
from data_types import Frequency from data_types import Frequency
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from utils import COMMON_HELP_ARGS from utils import generate_help
class FrequencyScanner(Scanner): class FrequencyScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help(
"```\n" "freq",
+ "%freq: Show frequency-related statistics\n" "(BETA) Show frequency-related statistics",
+ "arguments:\n" args=[
+ COMMON_HELP_ARGS "graph - plot hours of week",
+ "* all/everyone - include bots\n" ],
+ "Example: %freq #mychannel1 @user\n"
+ "```"
) )
def __init__(self): def __init__(self):
super().__init__( super().__init__(
valid_args=["all", "everyone"], valid_args=["all", "everyone", "graph"],
help=FrequencyScanner.help(), help=FrequencyScanner.help(),
intro_context="Frequency", intro_context="Frequency",
) )
@@ -34,6 +32,8 @@ class FrequencyScanner(Scanner):
async def init(self, message: discord.Message, *args: str) -> bool: async def init(self, message: discord.Message, *args: str) -> bool:
self.freq = Frequency() self.freq = Frequency()
self.all_messages = "all" in args or "everyone" in args self.all_messages = "all" in args or "everyone" in args
self.member_specific = len(self.members) > 0
self.to_graph = "graph" in args
return True return True
def compute_message(self, channel: ChannelLogs, message: MessageLog): def compute_message(self, channel: ChannelLogs, message: MessageLog):
@@ -43,6 +43,9 @@ class FrequencyScanner(Scanner):
def get_results(self, intro: str) -> List[str]: def get_results(self, intro: str) -> List[str]:
FrequencyScanner.compute_results(self.freq) FrequencyScanner.compute_results(self.freq)
if self.to_graph:
res = self.freq.to_graph()
else:
res = [intro] res = [intro]
res += self.freq.to_string( res += self.freq.to_string(
member_specific=self.member_specific, member_specific=self.member_specific,
@@ -55,7 +58,7 @@ class FrequencyScanner(Scanner):
freq: Frequency, freq: Frequency,
raw_members: List[int], raw_members: List[int],
*, *,
all_messages: bool all_messages: bool,
) -> bool: ) -> bool:
impacted = False impacted = False
# If author is included in the selection (empty list is all) # If author is included in the selection (empty list is all)
@@ -98,8 +101,7 @@ class FrequencyScanner(Scanner):
freq.longest_break_start = latest freq.longest_break_start = latest
latest = date latest = date
# calculate busiest weekday / hours # calculate busiest weekday / hours
freq.week[date.weekday()] += 1 freq.hours[date.weekday()][date.hour] += 1
freq.day[date.hour] += 1
# calculate busiest day ever # calculate busiest day ever
start_delta = date - freq.dates[0] start_delta = date - freq.dates[0]
if start_delta.days > current_day: if start_delta.days > current_day:
+5 -11
View File
@@ -5,24 +5,18 @@ import discord
# Custom libs # Custom libs
from .scanner import Scanner from .scanner import Scanner
from . import FrequencyScanner, CompositionScanner, PresenceScanner from .composition_scanner import CompositionScanner
from .frequency_scanner import FrequencyScanner
from .presence_scanner import PresenceScanner
from data_types import Frequency, Composition, Presence from data_types import Frequency, Composition, Presence
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from utils import COMMON_HELP_ARGS from utils import generate_help
class FullScanner(Scanner): class FullScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help("scan", "Show full statistics")
"```\n"
+ "%scan: Show full statistics\n"
+ "arguments:\n"
+ COMMON_HELP_ARGS
+ "* all/everyone - include bots\n"
+ "Example: %scan #mychannel1 @user\n"
+ "```"
)
def __init__(self): def __init__(self):
super().__init__( super().__init__(
+49 -16
View File
@@ -1,39 +1,56 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List from typing import List, Tuple, Optional
import discord import discord
import re
# Custom libs # Custom libs
from .scanner import Scanner from .scanner import Scanner
from data_types import History from data_types import History
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from utils import COMMON_HELP_ARGS from utils import FilterLevel
class HistoryScanner(Scanner, ABC): class HistoryScanner(Scanner, ABC):
@staticmethod
def help(*, cmd: str, text: str) -> str:
return (
"```\n"
+ f"%{cmd}: {text}\n"
+ "arguments:\n"
+ COMMON_HELP_ARGS
+ "* all/everyone - include bots\n"
+ "Example: %{cmd} #mychannel1 @user\n"
+ "```"
)
def __init__(self, *, help: str): def __init__(self, *, help: str):
super().__init__( super().__init__(
has_digit_args=True, has_digit_args=True,
valid_args=["all", "everyone"], valid_args=[
"all",
"everyone",
"spoiler",
"spoiler:allow",
"spoiler:only",
"image",
"img",
"gif",
],
help=help, help=help,
intro_context="", intro_context="",
all_args=True,
) )
async def init(self, message: discord.Message, *args: str) -> bool: async def init(self, message: discord.Message, *args: str) -> bool:
self.history = History() self.history = History()
self.all_messages = "all" in args or "everyone" in args self.all_messages = "all" in args or "everyone" in args
self.images_only = "image" in args or "img" in args or "gif" in args
self.gif_only = "gif" in args
if "spoiler" in args or "spoiler:allow" in args:
self.spoiler = FilterLevel.ALLOW
elif "spoiler:only" in args:
self.spoiler = FilterLevel.ONLY
else:
self.spoiler = FilterLevel.NONE
if not self.images_only:
self.queries = [
(
query.lower(),
query.strip("`") if re.match(r"^`.*`$", query) else None,
)
for query in self.other_args
]
else:
self.queries = []
return True return True
def compute_message(self, channel: ChannelLogs, message: MessageLog): def compute_message(self, channel: ChannelLogs, message: MessageLog):
@@ -43,6 +60,8 @@ class HistoryScanner(Scanner, ABC):
self.history, self.history,
self.raw_members, self.raw_members,
all_messages=self.all_messages, all_messages=self.all_messages,
queries=self.queries,
images_only=self.images_only,
) )
@abstractmethod @abstractmethod
@@ -57,14 +76,28 @@ class HistoryScanner(Scanner, ABC):
raw_members: List[int], raw_members: List[int],
*, *,
all_messages: bool, all_messages: bool,
queries: List[Tuple[str, Optional[str]]],
images_only: bool,
) -> bool: ) -> bool:
impacted = False impacted = False
# If author is included in the selection (empty list is all) # If author is included in the selection (empty list is all)
if ( if (
(
(not message.bot or all_messages) (not message.bot or all_messages)
and len(raw_members) == 0 and len(raw_members) == 0
or message.author in raw_members or message.author in raw_members
) and (message.content or message.attachment): )
and (message.content or message.attachment)
and (not images_only or message.image)
):
if not images_only:
content = message.content.lower()
for query in queries:
if query[1] is not None:
if not re.match(query[1], message.content):
return False
elif not query[0] in content:
return False
impacted = True impacted = True
history.messages += [message] history.messages += [message]
return impacted return impacted
+14 -3
View File
@@ -3,17 +3,28 @@ from typing import List
# Custom libs # Custom libs
from .history_scanner import HistoryScanner from .history_scanner import HistoryScanner
from utils import generate_help
class LastScanner(HistoryScanner): class LastScanner(HistoryScanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return super(LastScanner, LastScanner).help( return generate_help(
cmd="last", text="Read last message" "last",
"Read last message (add text to filter like %find)",
args=[
"image/gif - pull an image instead of a message",
"spoiler:allow/only - allow spoiler images",
],
) )
def __init__(self): def __init__(self):
super().__init__(help=LastScanner.help()) super().__init__(help=LastScanner.help())
def get_results(self, intro: str) -> List[str]: async def get_results(self, intro: str) -> List[str]:
if self.images_only:
return await self.history.to_string_image(
type="last", spoiler=self.spoiler, gif_only=self.gif_only
)
else:
return self.history.to_string(type="last") return self.history.to_string(type="last")
+11 -15
View File
@@ -8,22 +8,18 @@ import discord
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from .scanner import Scanner from .scanner import Scanner
from data_types import Counter from data_types import Counter
from utils import COMMON_HELP_ARGS, plural, precise, mention, alt_mention from utils import generate_help, plural, precise, mention, alt_mention
class MentionedScanner(Scanner): class MentionedScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help(
"```\n" "mentioned",
+ "%mentioned: Rank specific user's mentions by their usage\n" "Rank specific user's mentions by their usage",
+ "arguments:\n" args=["<n> - top <n>, default is 10", "all/everyone - include bots"],
+ "* @member/me - (required) one or more member\n" example="5 @user",
+ "\n".join(COMMON_HELP_ARGS.split("\n")[1:]) replace_args=[" @member/me - (required) one or more member"],
+ "* <n> - top <n> mentions, default is 10\n"
+ "* all - include bots mentions\n"
+ "Example: %mentioned 10 @user\n"
+ "```"
) )
def __init__(self): def __init__(self):
@@ -35,7 +31,6 @@ class MentionedScanner(Scanner):
) )
async def init(self, message: discord.Message, *args: str) -> bool: async def init(self, message: discord.Message, *args: str) -> bool:
# get max emotes to view
self.top = 10 self.top = 10
for arg in args: for arg in args:
if arg.isdigit(): if arg.isdigit():
@@ -45,7 +40,7 @@ class MentionedScanner(Scanner):
"You need to mention at least one member or use `me`", reference=message "You need to mention at least one member or use `me`", reference=message
) )
return False return False
self.all_mentions = "all" in args self.all_mentions = "all" in args or "everyone" in args
# Create mentions dict # Create mentions dict
self.mentions = defaultdict(Counter) self.mentions = defaultdict(Counter)
return True return True
@@ -59,7 +54,6 @@ class MentionedScanner(Scanner):
names = [name for name in self.mentions] names = [name for name in self.mentions]
names.sort(key=lambda name: self.mentions[name].score(), reverse=True) names.sort(key=lambda name: self.mentions[name].score(), reverse=True)
names = names[: self.top] names = names[: self.top]
# Get the total of all emotes used
usage_count = Counter.total(self.mentions) usage_count = Counter.total(self.mentions)
res = [intro] res = [intro]
res += [ res += [
@@ -67,6 +61,8 @@ class MentionedScanner(Scanner):
names.index(name), names.index(name),
name, name,
total_usage=usage_count, total_usage=usage_count,
transform=lambda id: f" for {mention(id)}",
top=len(self.members) != 1,
) )
for name in names for name in names
] ]
@@ -91,6 +87,6 @@ class MentionedScanner(Scanner):
mention(member_id) mention(member_id)
) + message.content.count(alt_mention(member_id)) ) + message.content.count(alt_mention(member_id))
mentions[mention(message.author)].update_use( mentions[mention(message.author)].update_use(
count, message.created_at count, message.created_at, member_id
) )
return impacted return impacted
+21 -18
View File
@@ -9,7 +9,7 @@ from logs import ChannelLogs, MessageLog
from .scanner import Scanner from .scanner import Scanner
from data_types import Counter from data_types import Counter
from utils import ( from utils import (
COMMON_HELP_ARGS, generate_help,
plural, plural,
precise, precise,
mention, mention,
@@ -22,16 +22,15 @@ from utils import (
class MentionsScanner(Scanner): class MentionsScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help(
"```\n" "mentions",
+ "%mentions: Rank mentions by their usage\n" "Rank mentions by their usage",
+ "arguments:\n" args=[
+ COMMON_HELP_ARGS "<n> - top <n>, default is 10",
+ "* <n> - top <n> mentions, default is 10\n" "all - show role/channel/everyone/here mentions",
+ "* all - show role/channel/everyone/here mentions\n" "everyone - include bots mentions",
+ "* everyone - include bots mentions\n" ],
+ "Example: %mentions 10 #mychannel1 #mychannel2 @user\n" example="10 #mychannel1 #mychannel2 @user",
+ "```"
) )
def __init__(self): def __init__(self):
@@ -43,7 +42,6 @@ class MentionsScanner(Scanner):
) )
async def init(self, message: discord.Message, *args: str) -> bool: async def init(self, message: discord.Message, *args: str) -> bool:
# get max emotes to view
self.top = 10 self.top = 10
for arg in args: for arg in args:
if arg.isdigit(): if arg.isdigit():
@@ -68,7 +66,6 @@ class MentionsScanner(Scanner):
names = [name for name in self.mentions] names = [name for name in self.mentions]
names.sort(key=lambda name: self.mentions[name].score(), reverse=True) names.sort(key=lambda name: self.mentions[name].score(), reverse=True)
names = names[: self.top] names = names[: self.top]
# Get the total of all emotes used
usage_count = Counter.total(self.mentions) usage_count = Counter.total(self.mentions)
res = [intro] res = [intro]
res += [ res += [
@@ -76,6 +73,8 @@ class MentionsScanner(Scanner):
names.index(name), names.index(name),
name, name,
total_usage=usage_count, total_usage=usage_count,
transform=lambda id: f" by {mention(id)}",
top=len(self.members) != 1,
) )
for name in names for name in names
] ]
@@ -106,24 +105,28 @@ class MentionsScanner(Scanner):
count = message.content.count(name) + message.content.count( count = message.content.count(name) + message.content.count(
alt_mention(member_id) alt_mention(member_id)
) )
mentions[name].update_use(count, message.created_at) mentions[name].update_use(count, message.created_at, message.author)
if all_mentions: if all_mentions:
for role_id in message.role_mentions: for role_id in message.role_mentions:
name = role_mention(role_id) name = role_mention(role_id)
mentions[name].update_use( mentions[name].update_use(
message.content.count(name), message.created_at message.content.count(name), message.created_at, message.author
) )
for channel_id in message.channel_mentions: for channel_id in message.channel_mentions:
name = channel_mention(channel_id) name = channel_mention(channel_id)
mentions[name].update_use( mentions[name].update_use(
message.content.count(name), message.created_at message.content.count(name), message.created_at, message.author
) )
if "@everyone" in message.content: if "@everyone" in message.content:
mentions["@\u200beveryone"].update_use( mentions["@\u200beveryone"].update_use(
message.content.count("@everyone"), message.created_at message.content.count("@everyone"),
message.created_at,
message.author,
) )
if "@here" in message.content: if "@here" in message.content:
mentions["@\u200bhere"].update_use( mentions["@\u200bhere"].update_use(
message.content.count("@here"), message.created_at message.content.count("@here"),
message.created_at,
message.author,
) )
return impacted return impacted
+7 -11
View File
@@ -8,21 +8,17 @@ import discord
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from .scanner import Scanner from .scanner import Scanner
from data_types import Counter from data_types import Counter
from utils import COMMON_HELP_ARGS, mention, channel_mention from utils import generate_help, mention, channel_mention
class MessagesScanner(Scanner): class MessagesScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help(
"```\n" "msg",
+ "%msg: Rank users by their messages\n" "Rank users by their messages",
+ "arguments:\n" args=["<n> - top <n>, default is 10", "all/everyone - include bots"],
+ COMMON_HELP_ARGS example="10 #channel",
+ "* <n> - top <n>, default is 10\n"
+ "* all/everyone - include bots\n"
+ "Example: %msg 10 #channel\n"
+ "```"
) )
def __init__(self): def __init__(self):
@@ -34,7 +30,6 @@ class MessagesScanner(Scanner):
) )
async def init(self, message: discord.Message, *args: str) -> bool: async def init(self, message: discord.Message, *args: str) -> bool:
# get max emotes to view
self.top = 10 self.top = 10
for arg in args: for arg in args:
if arg.isdigit(): if arg.isdigit():
@@ -66,6 +61,7 @@ class MessagesScanner(Scanner):
total_usage=usage_count, total_usage=usage_count,
counted="message", counted="message",
transform=lambda id: f" in {channel_mention(id)}", transform=lambda id: f" in {channel_mention(id)}",
top=self.channels != 1,
) )
for name in names for name in names
] ]
+2 -10
View File
@@ -7,21 +7,13 @@ import discord
from .scanner import Scanner from .scanner import Scanner
from data_types import Presence from data_types import Presence
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from utils import COMMON_HELP_ARGS from utils import generate_help
class PresenceScanner(Scanner): class PresenceScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help("pres", "Show presence statistics")
"```\n"
+ "%pres: Show presence statistics\n"
+ "arguments:\n"
+ COMMON_HELP_ARGS
+ "* all/everyone - include bots\n"
+ "Example: %pres #mychannel1 @user\n"
+ "```"
)
def __init__(self): def __init__(self):
super().__init__( super().__init__(
+14 -3
View File
@@ -3,17 +3,28 @@ from typing import List
# Custom libs # Custom libs
from .history_scanner import HistoryScanner from .history_scanner import HistoryScanner
from utils import generate_help
class RandomScanner(HistoryScanner): class RandomScanner(HistoryScanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return super(RandomScanner, RandomScanner).help( return generate_help(
cmd="rand", text="Read a random message" "rand",
"Read a random message (add text to filter like %find)",
args=[
"image/gif - pull an image instead of a message",
"spoiler:allow/only - allow spoiler images",
],
) )
def __init__(self): def __init__(self):
super().__init__(help=RandomScanner.help()) super().__init__(help=RandomScanner.help())
def get_results(self, intro: str) -> List[str]: async def get_results(self, intro: str) -> List[str]:
if self.images_only:
return await self.history.to_string_image(
type="random", spoiler=self.spoiler, gif_only=self.gif_only
)
else:
return self.history.to_string(type="random") return self.history.to_string(type="random")
+7 -10
View File
@@ -8,20 +8,17 @@ import discord
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from .scanner import Scanner from .scanner import Scanner
from data_types import Counter from data_types import Counter
from utils import COMMON_HELP_ARGS, mention, channel_mention from utils import generate_help, mention, channel_mention
class ReactionsScanner(Scanner): class ReactionsScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help(
"```\n" "react",
+ "%react: Rank users by their reactions\n" "Rank users by their reactions",
+ "arguments:\n" args=["<n> - top <n>, default is 10"],
+ COMMON_HELP_ARGS example="10 #channel",
+ "* <n> - top <n>, default is 10\n"
+ "Example: %react 10 #channel\n"
+ "```"
) )
def __init__(self): def __init__(self):
@@ -32,7 +29,6 @@ class ReactionsScanner(Scanner):
) )
async def init(self, message: discord.Message, *args: str) -> bool: async def init(self, message: discord.Message, *args: str) -> bool:
# get max emotes to view
self.top = 10 self.top = 10
for arg in args: for arg in args:
if arg.isdigit(): if arg.isdigit():
@@ -62,6 +58,7 @@ class ReactionsScanner(Scanner):
total_usage=usage_count, total_usage=usage_count,
counted="reaction", counted="reaction",
transform=lambda id: f" in {channel_mention(id)}", transform=lambda id: f" in {channel_mention(id)}",
top=self.channels != 1,
) )
for name in names for name in names
] ]
+184 -14
View File
@@ -4,12 +4,45 @@ from datetime import datetime
import logging import logging
import re import re
import discord import discord
import inspect
from utils import no_duplicate, get_intro, delta
from logs import GuildLogs, ChannelLogs, MessageLog, ALREADY_RUNNING, CANCELLED from utils import (
no_duplicate,
get_intro,
delta,
gdpr,
ISO8601_REGEX,
RELATIVE_REGEX,
parse_time,
command_cache,
FilterLevel,
SPLIT_TOKEN,
utc_now
)
from logs import (
GuildLogs,
ChannelLogs,
MessageLog,
ALREADY_RUNNING,
CANCELLED,
NO_FILE,
)
class Scanner(ABC): class Scanner(ABC):
VALID_ARGS = [
"me",
"here",
"fast",
"fresh",
"mobile",
"mention",
"nsfw",
"nsfw:allow",
"nsfw:only",
]
def __init__( def __init__(
self, self,
*, *,
@@ -17,12 +50,16 @@ class Scanner(ABC):
valid_args: List[str] = [], valid_args: List[str] = [],
help: str, help: str,
intro_context: str, intro_context: str,
all_args: bool = False,
): ):
self.has_digit_args = has_digit_args self.has_digit_args = has_digit_args
self.valid_args = valid_args self.valid_args = valid_args
self.all_args = all_args
self.help = help self.help = help
self.intro_context = intro_context self.intro_context = intro_context
self.other_args = []
self.members = [] self.members = []
self.raw_members = [] self.raw_members = []
self.full = False self.full = False
@@ -32,13 +69,19 @@ class Scanner(ABC):
self.chan_count = 0 self.chan_count = 0
async def compute( async def compute(
self, client: discord.client, message: discord.Message, *args: str self,
client: discord.client,
message: discord.Message,
*args: str,
other_mentions: List[str] = [],
): ):
args = list(args) args = list(args)
guild = message.guild guild = message.guild
progress = None
try:
with GuildLogs(guild) as logs: with GuildLogs(guild) as logs:
# If "%cmd help" redirect to "%help cmd" # If "%cmd help" redirect to "%help cmd"
if "help" in args: if len(args) > 1 and args[1] == "help":
await client.bot.help(client, message, "help", args[0]) await client.bot.help(client, message, "help", args[0])
return return
@@ -47,22 +90,54 @@ class Scanner(ABC):
str(channel.id) for channel in message.channel_mentions str(channel.id) for channel in message.channel_mentions
] ]
str_mentions = [str(member.id) for member in message.mentions] str_mentions = [str(member.id) for member in message.mentions]
dates = []
for i, arg in enumerate(args[1:]): for i, arg in enumerate(args[1:]):
if re.match(r"^<@!?\d+>$", arg): skip_check = False
if self.all_args and (
f"'{arg}'" in message.content or f'"{arg}"' in message.content
):
self.other_args += [arg]
elif re.match(r"^<@!?\d+>$", arg):
arg = arg[3:-1] if "!" in arg else arg[2:-1] arg = arg[3:-1] if "!" in arg else arg[2:-1]
elif re.match(r"^<#!?\d+>$", arg): elif re.match(r"^<#!?\d+>$", arg):
arg = arg[3:-1] if "!" in arg else arg[2:-1] arg = arg[3:-1] if "!" in arg else arg[2:-1]
elif re.match(ISO8601_REGEX, arg) or re.match(RELATIVE_REGEX, arg):
dates += [parse_time(arg)]
skip_check = True
if len(dates) > 2:
await message.channel.send(
f"Too many date arguments: `{arg}`", reference=message
)
return
if ( if (
arg not in self.valid_args + ["me", "here", "fast", "fresh"] arg not in self.valid_args + Scanner.VALID_ARGS
and (not arg.isdigit() or not self.has_digit_args) and (not arg.isdigit() or not self.has_digit_args)
and arg not in str_channel_mentions and arg not in str_channel_mentions
and arg not in str_mentions and arg not in str_mentions
and arg not in other_mentions
and not skip_check
and len(arg) > 0
): ):
if self.all_args:
self.other_args += [arg]
else:
await message.channel.send( await message.channel.send(
f"Unrecognized argument: `{arg}`", reference=message f"Unrecognized argument: `{arg}`", reference=message
) )
return return
for arg in self.other_args:
args.remove(arg)
self.start_date = None if len(dates) < 1 else min(dates)
self.stop_date = None if len(dates) < 2 else max(dates)
if self.start_date is not None and self.start_date > utc_now():
await message.channel.send(
f"Start date is after today", reference=message
)
return
# Get selected channels or all of them if no channel arguments # Get selected channels or all of them if no channel arguments
self.channels = no_duplicate(message.channel_mentions) self.channels = no_duplicate(message.channel_mentions)
@@ -83,6 +158,32 @@ class Scanner(ABC):
self.members += [message.author] self.members += [message.author]
self.raw_members += [message.author.id] self.raw_members += [message.author.id]
self.mention_users = "mention" in args or "mobile" in args
# nsfw filter
if "nsfw" in args or "nsfw:allow" in args:
self.nsfw = FilterLevel.ALLOW
elif "nsfw:only" in args:
self.nsfw = FilterLevel.ONLY
else:
self.nsfw = FilterLevel.NONE
# fix nsfw filter if channel specified
if not self.full and any(channel.nsfw for channel in self.channels):
self.nsfw = FilterLevel.ALLOW
elif all(channel.nsfw for channel in self.channels):
self.nsfw = FilterLevel.ONLY
# filter nsfw channels
if self.nsfw == FilterLevel.NONE:
self.channels = list(
filter(lambda channel: not channel.nsfw, self.channels)
)
elif self.nsfw == FilterLevel.ONLY:
self.channels = list(
filter(lambda channel: channel.nsfw, self.channels)
)
if not await self.init(message, *args): if not await self.init(message, *args):
return return
@@ -94,7 +195,12 @@ class Scanner(ABC):
allowed_mentions=discord.AllowedMentions.none(), allowed_mentions=discord.AllowedMentions.none(),
) )
total_msg, total_chan = await logs.load( total_msg, total_chan = await logs.load(
progress, self.channels, fast="fast" in args, fresh="fresh" in args progress,
self.channels,
self.start_date,
self.stop_date,
fast="fast" in args,
fresh="fresh" in args,
) )
if total_msg == CANCELLED: if total_msg == CANCELLED:
await message.channel.send( await message.channel.send(
@@ -106,7 +212,25 @@ class Scanner(ABC):
"An analysis is already running on this server, please be patient.", "An analysis is already running on this server, please be patient.",
reference=message, reference=message,
) )
elif total_msg == NO_FILE:
await message.channel.send(gdpr.TEXT)
else: else:
if self.start_date is not None and len(logs.channels) > 0:
self.start_date = max(
self.start_date,
min(
[
logs.channels[channel.id].start_date
for channel in self.channels
if channel.id in logs.channels
and logs.channels[channel.id].start_date
is not None
]
),
)
if self.stop_date is None:
self.stop_date = utc_now()
self.msg_count = 0 self.msg_count = 0
self.total_msg = 0 self.total_msg = 0
self.chan_count = 0 self.chan_count = 0
@@ -118,13 +242,21 @@ class Scanner(ABC):
[ [
self.compute_message(channel_logs, message_log) self.compute_message(channel_logs, message_log)
for message_log in channel_logs.messages for message_log in channel_logs.messages
if (
self.start_date is None
or message_log.created_at >= self.start_date
)
and (
self.stop_date is None
or message_log.created_at <= self.stop_date
)
] ]
) )
self.total_msg += len(channel_logs.messages) self.total_msg += len(channel_logs.messages)
self.msg_count += count self.msg_count += count
self.chan_count += 1 if count > 0 else 0 self.chan_count += 1 if count > 0 else 0
logging.info(f"scan {guild.id} > scanned in {delta(t0):,}ms") logging.info(f"scan {guild.id} > scanned in {delta(t0):,}ms")
if self.total_msg == 0: if self.msg_count == 0:
await message.channel.send( await message.channel.send(
"There are no messages found matching the filters", "There are no messages found matching the filters",
reference=message, reference=message,
@@ -133,37 +265,75 @@ class Scanner(ABC):
await progress.edit(content="```Computing results...```") await progress.edit(content="```Computing results...```")
# Display results # Display results
t0 = datetime.now() t0 = datetime.now()
results = self.get_results( intro = get_intro(
get_intro(
self.intro_context, self.intro_context,
self.full, self.full,
self.channels, self.channels,
self.members, self.members,
self.msg_count, self.msg_count,
self.chan_count, self.chan_count,
self.start_date,
self.stop_date,
) )
if inspect.iscoroutinefunction(self.get_results):
results = await self.get_results(intro)
else:
results = self.get_results(intro)
logging.info(
f"scan {guild.id} > results in {delta(t0):,}ms"
) )
logging.info(f"scan {guild.id} > results in {delta(t0):,}ms")
response = "" response = ""
first = True first = True
allowed_mentions = (
discord.AllowedMentions.all()
if self.mention_users
else discord.AllowedMentions.none()
)
file = None
for r in results: for r in results:
if r:
if isinstance(r, discord.File):
file = r
elif isinstance(r, int) and r == SPLIT_TOKEN:
await message.channel.send(
response,
reference=message if first else None,
allowed_mentions=allowed_mentions,
file=file,
)
first = False
file = None
response = ""
elif isinstance(r, str):
if len(response + "\n" + r) > 2000: if len(response + "\n" + r) > 2000:
await message.channel.send( await message.channel.send(
response, response,
reference=message if first else None, reference=message if first else None,
allowed_mentions=discord.AllowedMentions.none(), allowed_mentions=allowed_mentions,
file=file,
) )
first = False first = False
file = None
response = "" response = ""
response += "\n" + r response += "\n" + r
if len(response) > 0: if len(response) > 0 or file is not None:
await message.channel.send( await message.channel.send(
response, response,
reference=message if first else None, reference=message if first else None,
allowed_mentions=discord.AllowedMentions.none(), allowed_mentions=allowed_mentions,
file=file,
) )
command_cache.cache(self, message, args)
# Delete custom progress message # Delete custom progress message
await progress.delete() await progress.delete()
except Exception as error:
logging.exception(error)
await message.channel.send(
"An unexpected error happened while computing your command, we're sorry for the inconvenience.",
reference=message,
)
if progress is not None:
await progress.delete()
@abstractmethod @abstractmethod
async def init(self, message: discord.Message, *args: str) -> bool: async def init(self, message: discord.Message, *args: str) -> bool:
+18 -28
View File
@@ -8,26 +8,21 @@ import re
from logs import ChannelLogs, MessageLog from logs import ChannelLogs, MessageLog
from .scanner import Scanner from .scanner import Scanner
from data_types import Counter from data_types import Counter
from utils import ( from utils import generate_help, plural, precise, mention
COMMON_HELP_ARGS,
plural,
precise,
)
class WordsScanner(Scanner): class WordsScanner(Scanner):
@staticmethod @staticmethod
def help() -> str: def help() -> str:
return ( return generate_help(
"```\n" "words",
+ "%words: Rank words by their usage\n" "(BETA) Rank words by their usage",
+ "arguments:\n" args=[
+ COMMON_HELP_ARGS "<n> - words containings <n> or more letters, default is 3",
+ "* <n> - words containings <n> or more letters, default is 3\n" "<n2> - top <n2> words, default is 10",
+ "* <n2> - top <n2> words, default is 10\n" "all/everyone - include bots",
+ "* everyone - include bots\n" ],
+ "Example: %words 5 10 #mychannel1 #mychannel2 @user\n" example="5 10 #mychannel1 #mychannel2 @user",
+ "```"
) )
def __init__(self): def __init__(self):
@@ -68,15 +63,15 @@ class WordsScanner(Scanner):
words = [word for word in self.words] words = [word for word in self.words]
words.sort(key=lambda word: self.words[word].score(), reverse=True) words.sort(key=lambda word: self.words[word].score(), reverse=True)
words = words[: self.top] words = words[: self.top]
# Get the total of all emotes used
usage_count = Counter.total(self.words) usage_count = Counter.total(self.words)
print(len(self.words))
res = [intro.format(self.letters)] res = [intro.format(self.letters)]
res += [ res += [
self.words[word].to_string( self.words[word].to_string(
words.index(word), words.index(word),
f"`{word}`", f"`{word}`",
total_usage=usage_count, total_usage=usage_count,
transform=lambda id: f" by {mention(id)}",
top=len(self.members) != 1,
) )
for word in words for word in words
] ]
@@ -104,16 +99,13 @@ class WordsScanner(Scanner):
or message.author in raw_members or message.author in raw_members
): ):
impacted = True impacted = True
content = " ".join( content = message.content
[ content = re.sub(r"```.+```", "", content, flags=re.DOTALL)
block content = re.sub(r"`.+`", "", content, flags=re.DOTALL)
for block in message.content.split() content = re.sub(r"\w+:\/\/[^ ]+", "", content)
if not re.match(r"^\w+:\/\/", block)
]
)
for word in re.split("[^\w\-':]", content): for word in re.split("[^\w\-':]", content):
m = re.match( m = re.match(
r"(?!^:\w+:$)^[^\w]*((?![\d_])\w.*(?![\d_])\w)[^\w]*$", word r"(?!^:\w+:$)^[^\w]*((?![\d_])\w[\w\-']*(?![\d_])\w)[^\w]*$", word
) )
if m: if m:
word = m[1].lower() word = m[1].lower()
@@ -126,7 +118,5 @@ class WordsScanner(Scanner):
words[word] = words[word + case] words[word] = words[word + case]
del words[word + case] del words[word + case]
break break
words[word].update_use( words[word].update_use(1, message.created_at, message.author)
message.content.count(word), message.created_at
)
return impacted return impacted
+26
View File
@@ -0,0 +1,26 @@
import os
import os.path
from dotenv import load_dotenv
from cryptography.fernet import Fernet
load_dotenv()
LOG_DIR = os.getenv("LOG_DIR", "logs")
LOG_EXT = os.getenv("LOG_DIR", ".logz")
CRYPT_KEY = os.getenv("CRYPT_KEY", "")
fernet = Fernet(CRYPT_KEY)
for item in os.listdir(LOG_DIR):
if item.endswith(LOG_EXT):
path = os.path.join(LOG_DIR, item)
data = None
with open(path, mode="rb") as f:
data = f.read()
try:
fernet.decrypt(data)
print(f"{item} already encrypted")
except:
with open(path, mode="wb") as f:
f.write(fernet.encrypt(data))
print(f"{item} was encrypted")
+45
View File
@@ -0,0 +1,45 @@
from typing import List
import logging
import discord
from scanners import Scanner
command_cache = {}
def cache(scanner: Scanner, message: discord.Message, args: List[str]):
id = message.channel.id
command_cache[id] = (
type(scanner),
list(args),
[str(channel.id) for channel in message.channel_mentions]
+ [str(member.id) for member in message.mentions],
)
async def repeat(
client: discord.client,
message: discord.Message,
*args: str,
add_args: List[str] = [],
):
if len(args) > 1 and args[1] == "help":
await client.bot.help(client, message, "help", args[0])
return
id = message.channel.id
if id not in command_cache:
await message.channel.send(
"No command to repeat on this channel (type %help for more info)",
reference=message,
)
return
(
scannerType,
original_args,
original_mentions,
) = command_cache[id]
args = original_args + add_args + list(args[1:]) + ["fast"]
logging.info(f"repeating {args}")
await scannerType().compute(
client, message, *args, other_mentions=original_mentions
)
+65
View File
@@ -0,0 +1,65 @@
import discord
from logs import GuildLogs
HELP = """```
%gdpr: Displays GDPR information
arguments:
* agree - agree to GDPR
* revoke - remove this server's data
```"""
TEXT = """
__**About Analyst-bot's data usage**__
**TL;DR**
Analyst-bot collects text message information. It does not share collected data with any third-party and data is retained 90 days or until the bot is leaving the guild/server.
**Data collection**
Analyst-bot collects a Discord guild/server's history when asked to.
This includes:
- Visible text channel names
- Visible text messages: date and time of creation and edition, author, content, reactions and other available metadata (pinned, tts, etc.)
This does __not__ includes:
- Voice channels and not visible channels
- Not visible text messages
- Visible text messages' embedded content, images and other attachments
**Data processing**
Any data collected is only processed in order to produce a one-time report sent to the user immediately. No temporary data are retained.
**Data storage and retain policy**
Analyst-bot stores the collected data in files that are accessible by the software and its administrator only.
Any collected data are retained maximum 90 days until deletion or when the bot is leaving a guild/server.
**Data sharing**
Analyst-bot does not share the data collected with any third-party.
**Right to retract**
If you want to have your data removed, you can use the `%gdpr revoke` command or remove this bot from your guild/server.
**Terms agreement**
By agreeing to these terms, you ensure having the legal age if you are in a country that does have one and you also ensure having the consent of every member involved.
*If you want more information, please contact the creator of this bot: <https://github.com/Klemek/discord-analyst>.*
Type `%gdpr agree` to agree to these terms, `%gdpr revoke` to remove this guild/server's collected data or `%gdpr` to see this message again.
"""
AGREE_TEXT = "Thanks for agreeing for these terms, you can now run analysis on this guild/server."
REVOKE_TEXT = "This guild/server's data has been deleted. To run new analysis you must agree to the terms again."
async def process(client: discord.client, message: discord.Message, *args: str):
args = list(args)
if len(args) == 1:
await message.channel.send(TEXT)
elif args[1] == "help":
await client.bot.help(client, message, "help", args[0])
elif len(args) > 2:
await message.channel.send(f"Too many arguments", reference=message)
elif args[1] in ["agree", "accept"]:
GuildLogs.init_log(message.channel.guild)
await message.channel.send(AGREE_TEXT, reference=message)
elif args[1] in ["revoke", "cancel", "remove", "delete"]:
GuildLogs.remove_log(message.channel.guild)
await message.channel.send(REVOKE_TEXT, reference=message)
else:
await message.channel.send(
f"Unrecognized argument: `{args[1]}`", reference=message
)
+209 -44
View File
@@ -1,19 +1,47 @@
from typing import List, Dict, Union, Optional, Any from enum import IntEnum
from typing import Callable, List, Dict, Union, Optional, Any
import os import os
import logging import logging
import discord import discord
import math import math
from datetime import datetime from datetime import datetime, timedelta, timezone
import re
import time
import dateutil.parser
from dateutil.relativedelta import relativedelta
# OTHER # OTHER
COMMON_HELP_ARGS = ( COMMON_HELP_ARGS = [
"" "@member/me - filter for one or more member",
+ "* @member/me - filter for one or more member\n" "#channel/here - filter for one or more channel",
+ "* #channel/here - filter for one or more channel\n" "<date1> - filter after <date1>",
+ "* fast - only read cache\n" "<date2> - filter before <date2>",
+ "* fresh - does not read cache (long)\n" "fast - only read cache",
"fresh - does not read cache (long)",
"nsfw:allow/only - allow messages from nsfw channels",
"mobile/mention - mentions users (fix @invalid-user bug)",
]
def generate_help(
cmd: str,
info: str,
*,
args=["all/everyone - include bots"],
example="#mychannel1 @user",
replace_args=[],
):
arg_list = "* " + "\n* ".join(
args + replace_args + COMMON_HELP_ARGS[len(replace_args) :]
) )
return f"""```
%{cmd}: {info}
arguments:
{arg_list}
(Sample dates: 2020 / 2021-11 / 2021-06-28 / 2020-06-28T23:00 / today / week / 8days / 1y)
Example: %{cmd} {example}
```"""
def delta(t0: datetime): def delta(t0: datetime):
@@ -24,6 +52,35 @@ def deltas(t0: datetime):
return (datetime.now() - t0).total_seconds() return (datetime.now() - t0).total_seconds()
class FilterLevel(IntEnum):
NONE = 0
ALLOW = 1
ONLY = 2
SPLIT_TOKEN = 1152317803
# FILE
IMAGE_FORMAT = [".png", ".jpg", ".jpeg", ".bmp"]
EMBED_IMAGES = ["image"]
GIF_FORMAT = [".gif", ".gifv"]
EMBED_GIF = ["gifv"]
def is_extension(filepath: str, ext_list: List[str]) -> bool:
filename, file_extension = os.path.splitext(filepath.lower())
return file_extension in ext_list
def get_resource_path(filename: str) -> str:
return os.path.realpath(
os.path.join(os.path.dirname(__file__), "..", "resources", filename)
)
# DISCORD API # DISCORD API
@@ -55,22 +112,50 @@ def message_link(message: discord.Message) -> str:
return f"https://discord.com/channels/{message.channel.guild.id}/{message.channel.id}/{message.id}" return f"https://discord.com/channels/{message.channel.guild.id}/{message.channel.id}/{message.id}"
class FakeMessage: def escape_text(text: str) -> str:
return discord.utils.escape_markdown(discord.utils.escape_mentions(text))
class FakeMessage(discord.abc.Snowflake):
def __init__(self, id: int): def __init__(self, id: int):
self.id = id self.id = id
# FILE def has_image(message: discord.Message) -> bool:
for attachment in message.attachments:
if is_extension(attachment.filename, GIF_FORMAT + IMAGE_FORMAT):
return True
for embed in message.embeds:
if embed.type in (EMBED_IMAGES + EMBED_GIF):
return True
return False
def is_extension(filepath: str, ext_list: List[str]) -> bool: def is_image_spoiler(message: discord.Message) -> bool:
filename, file_extension = os.path.splitext(filepath.lower()) if len(message.attachments) > 0:
return file_extension in ext_list return message.attachments[0].is_spoiler()
elif len(message.embeds) > 0:
return re.match(r"\|\|[^|]*http[^|]\|\|", message.content.lower()) is not None
else:
return False
def get_resource_path(filename: str) -> str: def is_image_gif(message: discord.Message) -> bool:
return os.path.realpath( if len(message.attachments) > 0:
os.path.join(os.path.dirname(__file__), "..", "resources", filename) return is_extension(message.attachments[0].filename, GIF_FORMAT)
elif len(message.embeds) > 0:
return message.embeds[0].type in EMBED_GIF
else:
return False
def should_allow_spoiler(message: discord.Message, spoiler: FilterLevel) -> bool:
is_spoiler = is_image_spoiler(message)
return (
not is_spoiler
and spoiler <= FilterLevel.ONLY
or is_spoiler
and spoiler >= FilterLevel.ALLOW
) )
@@ -92,14 +177,37 @@ def no_duplicate(seq: list) -> list:
# DICTS # DICTS
def top_key(d: Dict[Union[str, int], int]) -> Union[str, int]: def top_key(
return sorted(d, key=lambda k: d[k])[-1] d: Dict[Union[str, int], int], key: Optional[Callable] = None, reverse: bool = False
) -> Union[str, int]:
if len(d) == 0:
return None
if key is None:
key = lambda k: d[k]
return sorted(d, key=key, reverse=reverse)[-1]
def val_sum(d: Dict[Any, int]) -> int: def val_sum(d: Dict[Any, int]) -> int:
if len(d) == 0:
return 0
return sum(d.values()) return sum(d.values())
def serialize(
obj: Any, *, not_serialized: List[str] = [], dates: List[str] = []
) -> Dict:
output = dict(obj.__dict__)
for key in not_serialized:
output.pop(key, None)
for key in dates:
if output[key]:
try:
output[key] = getattr(obj, key).isoformat()
except AttributeError:
pass
return output
# MESSAGE FORMATTING # MESSAGE FORMATTING
@@ -135,38 +243,93 @@ def precise(p: float, *, precision: int = 2) -> str:
# DATE FORMATTING # DATE FORMATTING
ISO8601_REGEX = r"^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$"
ISO8601_FULL = "0000-01-01T00:00:00"
def parse_iso_datetime(str_date: str) -> datetime:
if re.match(
"^\d{4}(-\d{2}(-\d{2}(T\d{2}(:\d{2}(:\d{2}(:\d{2})?)?)?)?)?)?$", str_date
):
str_date = str_date + "0000-01-01T00:00:00"[len(str_date) :]
return dateutil.parser.parse(str_date)
RELATIVE_REGEX = r"(yesterday|today|\d*hours?|\d+h(ours?)?|\d*days?|\d+d(ays?)?|\d*weeks?|\d+w(eeks?)?|\d*months?|\d+m(onths?)?|\d*years?|\d+y(ears?)?)"
def utc_now() -> datetime:
return datetime.now(tz=timezone.utc)
def utc_today() -> datetime:
today = utc_now().date()
return datetime(today.year, today.month, today.day, tzinfo=timezone.utc)
def parse_relative_time(src: str) -> datetime:
if src == "today":
return utc_today()
elif src == "yesterday":
return utc_today() - relativedelta(days=1)
else:
m = re.match("(\d*)(\w+)", src)
delta = None
value = int(m[1]) if m[1] else 1
unit = m[2][0]
if unit == "h":
delta = relativedelta(hours=value)
elif unit == "d":
delta = relativedelta(days=value)
elif unit == "w":
delta = relativedelta(weeks=value)
elif unit == "m":
delta = relativedelta(months=value)
elif unit == "y":
delta = relativedelta(years=value)
return utc_now() - delta
def parse_time(src: str) -> datetime:
if re.match(RELATIVE_REGEX, src):
return parse_relative_time(src)
else:
return parse_iso_datetime(src)
def str_date(date: datetime) -> str: def str_date(date: datetime) -> str:
return date.strftime("%d %b. %Y") # 12 Jun. 2018 return f"<t:{int(time.mktime(date.timetuple()))}:D>"
def str_datetime(date: datetime) -> str: def str_datetime(date: datetime) -> str:
return date.strftime("%H:%M, %d %b. %Y") # 12:05, 12 Jun. 2018 return f"<t:{int(time.mktime(date.timetuple()))}:f>"
def from_now(src: Optional[datetime]) -> str: def str_delta(delay: timedelta) -> str:
if src is None:
return "never"
delay = datetime.utcnow() - src
seconds = delay.seconds seconds = delay.seconds
minutes = seconds // 60 minutes = seconds // 60
hours = minutes // 60 hours = minutes // 60
if delay.days < 1: if delay.days < 1:
if hours < 1: if hours < 1:
if minutes == 0: if minutes == 0:
return "now" return "no time"
elif minutes == 1: elif minutes == 1:
return "a minute ago" return "a minute"
else: else:
return f"{minutes} minutes ago" return f"{minutes} minutes"
elif hours == 1: elif hours == 1:
return "an hour ago" return "an hour"
else: else:
return f"{hours} hours ago" return f"{hours} hours"
elif delay.days == 1: elif delay.days == 1:
return "yesterday" return "one day"
else: else:
return f"{delay.days:,} days ago" return f"{delay.days:,} days"
def from_now(src: Optional[datetime]) -> str:
if src is None:
return "never"
return f"<t:{int(time.mktime(src.timetuple()))}:R>"
# APP SPECIFIC # APP SPECIFIC
@@ -179,46 +342,48 @@ def get_intro(
members: List[discord.Member], members: List[discord.Member],
nmm: int, # number of messages impacted nmm: int, # number of messages impacted
nc: int, # number of impacted channels nc: int, # number of impacted channels
start_datetime: datetime,
stop_datetime: datetime,
) -> str: ) -> str:
""" """
Get the introduction sentence of the response Get the introduction sentence of the response
""" """
time_text = ""
if start_datetime is not None:
stop_datetime = utc_now() if stop_datetime is None else stop_datetime
time_text = f" (in {str_delta(stop_datetime - start_datetime)})"
# Show all data (members, channels) when it's less than 5 units # Show all data (members, channels) when it's less than 5 units
if len(members) == 0: if len(members) == 0:
# Full scan of the server # Full scan of the server
if full: if full:
return f"{subject} in this server ({nc} channels, {nmm:,} messages):" return f"{subject} in this server ({nc} channels, {nmm:,} messages){time_text}:"
elif len(channels) < 5: elif len(channels) < 5:
return f"{aggregate([c.mention for c in channels])} {subject.lower()} in {nmm:,} messages:" return f"{aggregate([c.mention for c in channels])} {subject.lower()} in {nmm:,} messages{time_text}:"
else: else:
return ( return f"These {len(channels)} channels {subject.lower()} in {nmm:,} messages{time_text}:"
f"These {len(channels)} channels {subject.lower()} in {nmm:,} messages:"
)
elif len(members) < 5: elif len(members) < 5:
if full: if full:
return f"{aggregate([m.mention for m in members])} {subject.lower()} in {nmm:,} messages:" return f"{aggregate([m.mention for m in members])} {subject.lower()} in {nmm:,} messages{time_text}:"
elif len(channels) < 5: elif len(channels) < 5:
return ( return (
f"{aggregate([m.mention for m in members])} on {aggregate([c.mention for c in channels])} " f"{aggregate([m.mention for m in members])} on {aggregate([c.mention for c in channels])} "
f"{subject.lower()} in {nmm:,} messages:" f"{subject.lower()} in {nmm:,} messages{time_text}:"
) )
else: else:
return ( return (
f"{aggregate([m.mention for m in members])} on these {len(channels)} channels " f"{aggregate([m.mention for m in members])} on these {len(channels)} channels "
f"{subject.lower()} in {nmm:,} messages:" f"{subject.lower()} in {nmm:,} messages{time_text}:"
) )
else: else:
if full: if full:
return ( return f"These {len(members)} members {subject.lower()} in {nmm:,} messages{time_text}:"
f"These {len(members)} members {subject.lower()} in {nmm:,} messages:"
)
elif len(channels) < 5: elif len(channels) < 5:
return ( return (
f"These {len(members)} members on {aggregate([c.mention for c in channels])} " f"These {len(members)} members on {aggregate([c.mention for c in channels])} "
f"{subject.lower()} in {nmm:,} messages:" f"{subject.lower()} in {nmm:,} messages{time_text}:"
) )
else: else:
return ( return (
f"These {len(members)} members on these {len(channels)} channels " f"These {len(members)} members on these {len(channels)} channels "
f"{subject.lower()} in {nmm:,} messages:" f"{subject.lower()} in {nmm:,} messages{time_text}:"
) )
+3
View File
@@ -0,0 +1,3 @@
pytest~=6.2.3
pytest-cov
coveralls
View File
View File
@@ -0,0 +1,90 @@
from unittest import TestCase
from unittest.mock import MagicMock
from src.scanners import FirstScanner
from datetime import datetime, timedelta
from tests.utils import AsyncTestCase, fake_message
class TestFirstScanner(AsyncTestCase):
def test_help(self):
self.assertGreater(len(FirstScanner.help()), 0)
self.assertIn("%first", FirstScanner.help())
def test_empty_no_messages(self):
scanner = FirstScanner()
command_msg = MagicMock()
self._await(scanner.init(command_msg, []))
results = self._await(scanner.get_results(""))
self.assertListEqual(["There was no messages matching your filters"], results)
def test_empty_filtered(self):
scanner = FirstScanner()
scanner.raw_members = [1]
self._await(scanner.init(fake_message(), []))
messages = [fake_message(author=2), fake_message(author=3)]
for msg in messages:
scanner.compute_message(msg.channel, msg)
results = self._await(scanner.get_results(""))
self.assertListEqual(["There was no messages matching your filters"], results)
def test_normal(self):
scanner = FirstScanner()
self._await(scanner.init(fake_message(), []))
messages = [
fake_message(id=1, created_at=timedelta(days=-2)),
fake_message(id=2, created_at=timedelta(days=-3)),
fake_message(id=3, created_at=timedelta(days=-1)),
]
for msg in messages:
scanner.compute_message(msg.channel, msg)
results = self._await(scanner.get_results(""))
expected = messages[1]
self.assertListEqual(
[
"First message out of 3",
f"{expected.created_at.strftime('%H:%M, %d %b. %Y')} (2 days ago) <@1> said:",
f"> {expected.content}",
"<https://discord.com/channels/1/1/2>",
],
results,
)
def test_filtered(self):
scanner = FirstScanner()
scanner.raw_members = [1]
self._await(scanner.init(fake_message(), []))
messages = [
fake_message(id=1, author=1, created_at=timedelta(days=-2)),
fake_message(id=2, author=2, created_at=timedelta(days=-3)),
fake_message(id=3, author=1, created_at=timedelta(days=-1)),
]
for msg in messages:
scanner.compute_message(msg.channel, msg)
results = self._await(scanner.get_results(""))
expected = messages[0]
self.assertListEqual(
[
"First message out of 2",
f"{expected.created_at.strftime('%H:%M, %d %b. %Y')} (yesterday) <@1> said:",
f"> {expected.content}",
"<https://discord.com/channels/1/1/1>",
],
results,
)
View File
+99
View File
@@ -0,0 +1,99 @@
from typing import List, Optional, Dict, Union
from unittest import TestCase
import asyncio
from datetime import datetime, timedelta
from unittest.mock import MagicMock
import random
import string
class AsyncTestCase(TestCase):
def setUp(self):
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(None)
def tearDown(self):
self.loop.close()
def _await(self, fn):
return self.loop.run_until_complete(fn)
RANDOM_TEXT_CHARS = string.ascii_letters + string.digits + string.punctuation
def random_text(min_len: int = 3, max_len: int = 45):
return "".join(
random.choice(RANDOM_TEXT_CHARS)
for _ in range(random.randrange(min_len, max_len))
)
def fake_guild(id: int = 1):
return MagicMock(id=id)
def fake_channel(id: int = 1, name: str = "fake-channel"):
return MagicMock(id=id, name=name, guild=fake_guild())
def fake_message(
id: int = 1,
channel_id: int = 1,
channel_name: str = "fake-channel",
created_at: Optional[Union[datetime, timedelta]] = None,
edited_at: Optional[datetime] = None,
author: int = 1,
pinned: bool = False,
mention_everyone: bool = False,
tts: bool = False,
bot: bool = False,
content: Optional[str] = None,
mentions: Optional[List[int]] = None,
reference: Optional[int] = None,
role_mentions: Optional[List[int]] = None,
channel_mentions: Optional[List[int]] = None,
image: bool = False,
attachment: bool = False,
embed: bool = False,
reactions: Optional[Dict[str, List[int]]] = None,
):
if created_at is None:
created_at = datetime.now() + timedelta(hours=random.randrange(-30 * 24, 0))
elif isinstance(created_at, timedelta):
created_at = datetime.now() + created_at
if isinstance(edited_at, timedelta):
edited_at = datetime.now() + edited_at
if content is None:
content = random_text()
if mentions is None:
mentions = []
if role_mentions is None:
role_mentions = []
if channel_mentions is None:
channel_mentions = []
if reactions is None:
reactions = {}
return MagicMock(
id=id,
channel=fake_channel(channel_id, channel_name),
created_at=created_at,
edited_at=edited_at,
author=author,
pinned=pinned,
mention_everyone=mention_everyone,
tts=tts,
bot=bot,
content=content,
mentions=mentions,
raw_mentions=mentions,
reference=reference,
role_mentions=role_mentions,
raw_role_mentions=role_mentions,
channel_mentions=channel_mentions,
raw_channel_mentions=channel_mentions,
image=image,
attachment=attachment,
embed=embed,
reactions=reactions,
)