229 Commits
staging ... dev

Author SHA1 Message Date
25dd9d38eb Merge pull request 'Update dependency isort to v8' (#114) from renovate/isort-8.x into dev
All checks were successful
safety-check / build (push) Successful in 24s
test / Build and Test (3.11) (push) Successful in 4m27s
test / Build and Test (3.12) (push) Successful in 4m37s
test / Build and Test (3.13) (push) Successful in 4m33s
Reviewed-on: #114
2026-02-22 23:06:41 +02:00
66529c70e9 Update dependency isort to v8
All checks were successful
safety-check / build (pull_request) Successful in 24s
test / Build and Test (3.11) (pull_request) Successful in 4m24s
test / Build and Test (3.12) (pull_request) Successful in 4m32s
test / Build and Test (3.13) (pull_request) Successful in 4m29s
2026-02-21 17:26:18 +02:00
1433e9dfd3 Merge pull request 'Update dependency tox to v4.44.0' (#112) from renovate/tox-4.x into dev
All checks were successful
safety-check / build (push) Successful in 24s
test / Build and Test (3.11) (push) Successful in 4m24s
test / Build and Test (3.12) (push) Successful in 4m34s
test / Build and Test (3.13) (push) Successful in 4m29s
Reviewed-on: #112
2026-02-21 14:53:23 +02:00
11e9ac12b3 Update dependency tox to v4.44.0
All checks were successful
safety-check / build (pull_request) Successful in 28s
test / Build and Test (3.11) (pull_request) Successful in 4m25s
test / Build and Test (3.12) (pull_request) Successful in 4m34s
test / Build and Test (3.13) (pull_request) Successful in 4m31s
2026-02-21 14:20:02 +02:00
d0b4c9cc55 Merge pull request 'Update dependency pylint to v4.0.5' (#111) from renovate/pylint-4.x into dev
All checks were successful
safety-check / build (push) Successful in 26s
test / Build and Test (3.11) (push) Successful in 4m25s
test / Build and Test (3.12) (push) Successful in 4m36s
test / Build and Test (3.13) (push) Successful in 4m34s
Reviewed-on: #111
2026-02-21 14:08:58 +02:00
752d902335 Update dependency pylint to v4.0.5
All checks were successful
safety-check / build (pull_request) Successful in 20s
test / Build and Test (3.11) (pull_request) Successful in 4m25s
test / Build and Test (3.12) (pull_request) Successful in 4m39s
test / Build and Test (3.13) (pull_request) Successful in 4m40s
2026-02-20 14:31:12 +02:00
7f9263689e Merge pull request 'Update dependency tox to v4.42.0' (#110) from renovate/tox-4.x into dev
All checks were successful
safety-check / build (push) Successful in 23s
test / Build and Test (3.11) (push) Successful in 4m22s
test / Build and Test (3.12) (push) Successful in 4m34s
test / Build and Test (3.13) (push) Successful in 4m30s
Reviewed-on: #110
2026-02-20 14:18:44 +02:00
8519ab6bdb Update dependency tox to v4.42.0
All checks were successful
safety-check / build (pull_request) Successful in 23s
test / Build and Test (3.11) (pull_request) Successful in 4m24s
test / Build and Test (3.12) (pull_request) Successful in 4m33s
test / Build and Test (3.13) (pull_request) Successful in 4m30s
2026-02-20 10:21:49 +02:00
f7ef3d1aa5 Merge pull request 'Update dependency tox to v4.40.0' (#109) from renovate/tox-4.x into dev
All checks were successful
safety-check / build (push) Successful in 22s
test / Build and Test (3.11) (push) Successful in 4m25s
test / Build and Test (3.12) (push) Successful in 4m34s
test / Build and Test (3.13) (push) Successful in 4m31s
Reviewed-on: #109
2026-02-19 10:13:31 +02:00
7a0befd13d Update dependency tox to v4.40.0
All checks were successful
safety-check / build (pull_request) Successful in 23s
test / Build and Test (3.11) (pull_request) Successful in 4m24s
test / Build and Test (3.12) (pull_request) Successful in 4m32s
test / Build and Test (3.13) (pull_request) Successful in 4m30s
2026-02-19 08:20:41 +02:00
19999577ee Fixed imports for the user router
All checks were successful
safety-check / build (push) Successful in 19s
test / Build and Test (3.11) (push) Successful in 4m34s
test / Build and Test (3.12) (push) Successful in 4m41s
test / Build and Test (3.13) (push) Successful in 4m38s
2026-02-18 17:53:31 +01:00
0e13891d3c Replaced imports with relative imports and formatted with black
Some checks failed
safety-check / build (push) Successful in 22s
test / Build and Test (3.12) (push) Has been cancelled
test / Build and Test (3.13) (push) Has been cancelled
test / Build and Test (3.11) (push) Has been cancelled
2026-02-18 17:52:58 +01:00
0897d5b6b4 Merge pull request 'Update dependency tox to v4.38.0' (#108) from renovate/tox-4.x into dev
All checks were successful
safety-check / build (push) Successful in 26s
test / Build and Test (3.11) (push) Successful in 4m23s
test / Build and Test (3.12) (push) Successful in 4m36s
test / Build and Test (3.13) (push) Successful in 4m32s
Reviewed-on: #108
2026-02-18 16:30:10 +02:00
fecf97abf5 Update dependency tox to v4.38.0
All checks were successful
safety-check / build (pull_request) Successful in 25s
test / Build and Test (3.11) (pull_request) Successful in 4m26s
test / Build and Test (3.12) (pull_request) Successful in 4m36s
test / Build and Test (3.13) (pull_request) Successful in 4m32s
2026-02-17 23:17:36 +02:00
a6fc19ff86 Merge pull request 'Update dependency tox to v4.36.1' (#107) from renovate/tox-4.x into dev
All checks were successful
safety-check / build (push) Successful in 24s
test / Build and Test (3.11) (push) Successful in 4m27s
test / Build and Test (3.12) (push) Successful in 4m36s
test / Build and Test (3.13) (push) Successful in 4m33s
Reviewed-on: #107
2026-02-17 15:35:47 +02:00
7b18078225 Update dependency tox to v4.36.1
All checks were successful
safety-check / build (pull_request) Successful in 25s
test / Build and Test (3.11) (pull_request) Successful in 4m26s
test / Build and Test (3.12) (pull_request) Successful in 4m36s
test / Build and Test (3.13) (pull_request) Successful in 4m32s
2026-02-17 03:39:40 +02:00
fa20da9189 Merge pull request 'Update dependency typer to ~=0.24.0' (#106) from renovate/typer-0.x into dev
All checks were successful
safety-check / build (push) Successful in 23s
test / Build and Test (3.11) (push) Successful in 4m29s
test / Build and Test (3.12) (push) Successful in 4m39s
test / Build and Test (3.13) (push) Successful in 4m34s
Reviewed-on: #106
2026-02-17 02:19:51 +02:00
b995b22e11 Merge pull request 'Update dependency tox to v4.36.0' (#105) from renovate/tox-4.x into dev
Some checks failed
test / Build and Test (3.11) (push) Has been cancelled
test / Build and Test (3.12) (push) Has been cancelled
test / Build and Test (3.13) (push) Has been cancelled
safety-check / build (push) Has been cancelled
Reviewed-on: #105
2026-02-17 02:19:43 +02:00
2f3d489ca2 Update dependency typer to ~=0.24.0
All checks were successful
safety-check / build (pull_request) Successful in 29s
test / Build and Test (3.11) (pull_request) Successful in 4m31s
test / Build and Test (3.12) (pull_request) Successful in 4m40s
test / Build and Test (3.13) (pull_request) Successful in 4m34s
2026-02-17 00:32:50 +02:00
55cd13157a Update dependency tox to v4.36.0
All checks were successful
test / Build and Test (3.11) (pull_request) Successful in 4m28s
test / Build and Test (3.12) (pull_request) Successful in 4m35s
test / Build and Test (3.13) (pull_request) Successful in 4m33s
safety-check / build (pull_request) Successful in 25s
2026-02-15 22:42:38 +02:00
867d7c5926 Merge pull request 'Update dependency typer to ~=0.23.0' (#102) from renovate/typer-0.x into dev
All checks were successful
safety-check / build (push) Successful in 23s
test / Build and Test (3.11) (push) Successful in 4m25s
test / Build and Test (3.12) (push) Successful in 4m35s
test / Build and Test (3.13) (push) Successful in 4m31s
Reviewed-on: #102
2026-02-13 11:55:49 +02:00
927941e1c2 Merge pull request 'Update dependency tox to v4.35.0' (#104) from renovate/tox-4.x into dev
Some checks failed
safety-check / build (push) Has been cancelled
test / Build and Test (3.11) (push) Has been cancelled
test / Build and Test (3.12) (push) Has been cancelled
test / Build and Test (3.13) (push) Has been cancelled
Reviewed-on: #104
2026-02-13 11:55:31 +02:00
9d8208f693 Update dependency tox to v4.35.0
All checks were successful
safety-check / build (pull_request) Successful in 26s
test / Build and Test (3.11) (pull_request) Successful in 4m29s
test / Build and Test (3.12) (pull_request) Successful in 4m34s
test / Build and Test (3.13) (pull_request) Successful in 4m28s
2026-02-13 01:37:04 +02:00
f79e5a31a4 Update dependency typer to ~=0.23.0
All checks were successful
safety-check / build (pull_request) Successful in 24s
test / Build and Test (3.11) (pull_request) Successful in 4m21s
test / Build and Test (3.12) (pull_request) Successful in 4m28s
test / Build and Test (3.13) (pull_request) Successful in 4m27s
2026-02-11 17:37:15 +02:00
7b3450aed0 Merge pull request 'Update dependency typer to ~=0.22.0' (#101) from renovate/typer-0.x into dev
All checks were successful
safety-check / build (push) Successful in 22s
test / Build and Test (3.11) (push) Successful in 4m16s
test / Build and Test (3.12) (push) Successful in 4m29s
test / Build and Test (3.13) (push) Successful in 4m26s
Reviewed-on: #101
2026-02-11 13:49:15 +02:00
7aec27dc12 Update dependency typer to ~=0.22.0
All checks were successful
safety-check / build (pull_request) Successful in 26s
test / Build and Test (3.11) (pull_request) Successful in 4m23s
test / Build and Test (3.12) (pull_request) Successful in 4m29s
test / Build and Test (3.13) (pull_request) Successful in 4m28s
2026-02-11 13:30:12 +02:00
2aaf929c53 Fixed broken router imports inside the package
All checks were successful
safety-check / build (push) Successful in 1m8s
test / Build and Test (3.11) (push) Successful in 4m21s
test / Build and Test (3.12) (push) Successful in 4m28s
test / Build and Test (3.13) (push) Successful in 4m26s
2026-02-10 08:40:04 +01:00
cd925128d4 WIP: Partially rebuilt how start/exit flow works
All checks were successful
safety-check / build (push) Successful in 45s
test / Build and Test (3.11) (push) Successful in 1m45s
test / Build and Test (3.12) (push) Successful in 1m58s
test / Build and Test (3.13) (push) Successful in 1m54s
2026-02-07 00:05:54 +01:00
7cdbe00432 Fixed integrations failing to initialize if no cogs directory exists 2026-02-07 00:04:55 +01:00
1d8fa8d6b7 Update dependency black to v26
All checks were successful
safety-check / build (pull_request) Successful in 25s
test / Build and Test (3.11) (pull_request) Successful in 1m28s
test / Build and Test (3.12) (pull_request) Successful in 1m39s
test / Build and Test (3.13) (pull_request) Successful in 1m31s
safety-check / build (push) Successful in 23s
test / Build and Test (3.11) (push) Successful in 1m26s
test / Build and Test (3.12) (push) Successful in 1m37s
test / Build and Test (3.13) (push) Successful in 1m30s
2026-01-18 07:04:29 +02:00
4187bb3492 Replaced PycordBot with Cog inside PycordGuild, added /emotes reset
All checks were successful
safety-check / build (push) Successful in 23s
test / Build and Test (3.11) (push) Successful in 1m24s
test / Build and Test (3.12) (push) Successful in 1m35s
test / Build and Test (3.13) (push) Successful in 1m29s
command
2026-01-11 13:22:01 +01:00
c4e9073496 Merge pull request '7TV integration' (#99) from feature/profitroll/7tv-integration into dev
All checks were successful
safety-check / build (push) Successful in 24s
test / Build and Test (3.11) (push) Successful in 1m25s
test / Build and Test (3.12) (push) Successful in 1m36s
test / Build and Test (3.13) (push) Successful in 1m32s
Reviewed-on: #99
2026-01-11 14:01:49 +02:00
a2d0806669 Added a requirement to enable the 7TV integration via the config
All checks were successful
safety-check / build (pull_request) Successful in 27s
test / Build and Test (3.11) (pull_request) Successful in 1m29s
test / Build and Test (3.12) (pull_request) Successful in 1m39s
test / Build and Test (3.13) (pull_request) Successful in 1m35s
2026-01-11 12:54:55 +01:00
9ae477369f Restructured the integration a bit to avoid setting things directly on
PycordBot
2026-01-11 12:41:51 +01:00
c3b12ad48b Implemented handling of emote updates 2026-01-11 12:11:50 +01:00
bc1720495a Added support for emote deletion 2026-01-10 22:38:51 +01:00
f068422aa0 Improved handling of emote image formats 2026-01-10 22:24:32 +01:00
54e32455d8 WIP: Partially implemented the guild emoji upload 2026-01-10 15:01:14 +01:00
7dbfa3280e Added 7TV's ToS and Privacy Policy 2026-01-10 14:19:23 +01:00
49e5a07c5f Redone the 7TV callback and shutdown handling 2026-01-10 11:44:48 +01:00
2fa658a174 Update dependency tox to v4.34.1
All checks were successful
safety-check / build (pull_request) Successful in 26s
test / Build and Test (3.11) (pull_request) Successful in 1m20s
test / Build and Test (3.12) (pull_request) Successful in 1m24s
test / Build and Test (3.13) (pull_request) Successful in 1m23s
safety-check / build (push) Successful in 25s
test / Build and Test (3.11) (push) Successful in 1m25s
test / Build and Test (3.12) (push) Successful in 1m26s
test / Build and Test (3.13) (push) Successful in 1m23s
2026-01-09 20:28:52 +02:00
48744f2cd8 WIP: Further extended 7TV support, added consent scope and 7TV command group, added emote_set_id to PycordGuild 2026-01-08 17:44:04 +01:00
a0dda1b47e Updated the lookup location for dependencies
All checks were successful
safety-check / build (push) Successful in 25s
test / Build and Test (3.11) (push) Successful in 1m26s
test / Build and Test (3.12) (push) Successful in 1m33s
test / Build and Test (3.13) (push) Successful in 1m22s
2026-01-08 09:14:46 +01:00
b56865b275 WIP: Added missing file content 2026-01-08 00:47:53 +01:00
d35aa4167a WIP: Added Emote7TV and Set7TV classes 2026-01-08 00:45:09 +01:00
4b31c9943a Divided installation sections into pip and pipx (pipx is still a TODO)
All checks were successful
safety-check / build (push) Successful in 25s
test / Build and Test (3.11) (push) Successful in 1m22s
test / Build and Test (3.12) (push) Successful in 1m25s
test / Build and Test (3.13) (push) Successful in 1m23s
2026-01-06 09:45:12 +01:00
b1c47fbc2b WIP: 7TV integration (#83) 2026-01-06 00:13:12 +01:00
da93b0ce87 WIP: Guild languages
All checks were successful
safety-check / build (push) Successful in 29s
test / Build and Test (3.11) (push) Successful in 1m32s
test / Build and Test (3.12) (push) Successful in 1m34s
test / Build and Test (3.13) (push) Successful in 1m30s
2026-01-05 21:14:32 +01:00
415cd771d6 Replaced command descriptions with i18n strings and added their stubs to Ukrainian and German locales
All checks were successful
safety-check / build (push) Successful in 27s
test / Build and Test (3.11) (push) Successful in 1m24s
test / Build and Test (3.12) (push) Successful in 1m27s
test / Build and Test (3.13) (push) Successful in 1m22s
2026-01-05 17:00:53 +01:00
bff0deef22 Replaced ubuntu-latest with ubuntu-24.04
All checks were successful
safety-check / build (push) Successful in 23s
test / Build and Test (3.11) (push) Successful in 1m23s
test / Build and Test (3.12) (push) Successful in 1m26s
test / Build and Test (3.13) (push) Successful in 1m22s
2026-01-04 22:48:26 +01:00
c1bdf30c77 Moved localization files from outside to inside the package
All checks were successful
safety-check / build (push) Successful in 21s
test / Build and Test (3.11) (push) Successful in 1m20s
test / Build and Test (3.12) (push) Successful in 1m25s
test / Build and Test (3.13) (push) Successful in 1m21s
2026-01-04 22:30:26 +01:00
65bbb3b693 Added Docker scripts to Makefile
All checks were successful
safety-check / build (push) Successful in 20s
test / Build and Test (3.11) (push) Successful in 1m24s
test / Build and Test (3.12) (push) Successful in 1m27s
test / Build and Test (3.13) (push) Successful in 1m22s
2026-01-04 21:49:45 +01:00
73c79d4d98 Reworked cog and plugin imports
Some checks failed
safety-check / build (push) Successful in 47s
test / Build and Test (3.12) (push) Has been cancelled
test / Build and Test (3.13) (push) Has been cancelled
test / Build and Test (3.11) (push) Has been cancelled
2026-01-04 21:48:28 +01:00
bd9f3ced76 Updated setuptools
All checks were successful
safety-check / build (push) Successful in 23s
test / Build and Test (3.11) (push) Successful in 1m29s
test / Build and Test (3.12) (push) Successful in 1m26s
test / Build and Test (3.13) (push) Successful in 1m25s
2026-01-04 16:49:30 +01:00
e028add504 Removed license file reference
Some checks failed
build-docker / docker (push) Has been cancelled
safety-check / build (push) Successful in 23s
test / Build and Test (3.12) (push) Successful in 1m32s
test / Build and Test (3.13) (push) Successful in 1m29s
test / Build and Test (3.11) (push) Failing after 1m2s
2026-01-04 16:16:15 +01:00
9f708eea8b Updated the lockfile
Some checks failed
safety-check / build (push) Has been cancelled
build-docker / docker (push) Has been cancelled
test / Build and Test (3.11) (push) Has been cancelled
test / Build and Test (3.12) (push) Has been cancelled
test / Build and Test (3.13) (push) Has been cancelled
test-uv / Build and Test (3.11) (push) Failing after 5s
test-uv / Build and Test (3.12) (push) Failing after 4s
test-uv / Build and Test (3.13) (push) Failing after 5s
2026-01-04 16:14:52 +01:00
7c6f93a86c Enabled tests for dev and staging branches
Some checks failed
build-docker / docker (push) Failing after 33s
safety-check / build (push) Successful in 22s
test-uv / Build and Test (3.11) (push) Failing after 31s
test-uv / Build and Test (3.12) (push) Failing after 5s
test-uv / Build and Test (3.13) (push) Failing after 5s
test / Build and Test (3.11) (push) Failing after 1m6s
test / Build and Test (3.13) (push) Has been cancelled
test / Build and Test (3.12) (push) Has been cancelled
2026-01-04 16:11:32 +01:00
543ce4e71b Updated dependencies and improved workflows
Some checks failed
build-docker / docker (push) Failing after 36s
safety-check / build (push) Successful in 23s
2026-01-04 16:05:39 +01:00
1dc2cc70de Merge pull request 'New package structure' (#94) from feature/profitroll/package-structure into dev
Some checks failed
build-docker / docker (push) Failing after 4m20s
Test / build (push) Successful in 23s
Reviewed-on: #94
2026-01-04 16:08:27 +02:00
b690d61bfb Restructured the package
All checks were successful
Test / build (pull_request) Successful in 25s
2026-01-04 14:53:37 +01:00
8bf0be0611 WIP: Changed package structure 2026-01-04 11:30:05 +01:00
3801992de7 Fixed the docker run command in the README
Some checks failed
build-docker / docker (push) Failing after 37s
Test / build (push) Successful in 26s
2026-01-02 00:08:07 +01:00
5f34c0f9a5 Replaced fastapi-discord to come from GitHub
Some checks failed
build-docker / docker (push) Failing after 30s
Test / build (push) Successful in 24s
2026-01-01 23:46:47 +01:00
7c4c1dfd04 Added experimental Docker CI
Some checks failed
build-docker / docker (push) Failing after 49s
Test / build (push) Successful in 25s
2026-01-01 23:34:21 +01:00
af80df540a Update dependency libbot to v4.4.1
All checks were successful
Test / build (pull_request) Successful in 25s
Test / build (push) Successful in 23s
2026-01-01 21:13:35 +02:00
e3241f3020 Update dependency fastapi to ~=0.128.0,<0.129.0
All checks were successful
Test / build (pull_request) Successful in 33s
Test / build (push) Successful in 42s
2025-12-27 17:29:16 +02:00
0da9c49c37 Update dependency typer to ~=0.21.0
All checks were successful
Test / build (pull_request) Successful in 30s
Test / build (push) Successful in 25s
2025-12-25 12:07:31 +02:00
f8156e11e2 Update dependency fastapi to ~=0.127.0,<0.128.0
All checks were successful
Test / build (pull_request) Successful in 32s
Test / build (push) Successful in 27s
2025-12-21 19:40:46 +02:00
6fc7f80b9f Update dependency fastapi to ~=0.126.0,<0.127.0
All checks were successful
Test / build (pull_request) Successful in 32s
Test / build (push) Successful in 25s
2025-12-20 18:46:52 +02:00
bb19662cf4 Update dependency fastapi to ~=0.125.0,<0.126.0
All checks were successful
Test / build (pull_request) Successful in 30s
Test / build (push) Successful in 24s
2025-12-18 00:19:13 +02:00
0c2421326a Update dependency fastapi to ~=0.124.0,<0.125.0
All checks were successful
Test / build (pull_request) Successful in 1m55s
Test / build (push) Successful in 24s
2025-12-06 15:45:14 +02:00
b8eb945d53 Update dependency fastapi to ~=0.123.0,<0.124.0
All checks were successful
Test / build (pull_request) Successful in 30s
Test / build (push) Successful in 26s
2025-11-30 17:50:30 +02:00
6993deb39c Update dependency fastapi to ~=0.122.0,<0.123.0
All checks were successful
Test / build (pull_request) Successful in 1m20s
Test / build (push) Successful in 24s
2025-11-24 22:10:29 +02:00
3351dc73c0 Update dependency fastapi to ~=0.121.0,<0.122.0
All checks were successful
Test / build (pull_request) Successful in 1m31s
Test / build (push) Successful in 22s
2025-11-03 13:20:15 +02:00
064aaee7c7 Update dependency fastapi to ~=0.120.0,<0.121.0
All checks were successful
Test / build (pull_request) Successful in 35s
Test / build (push) Successful in 27s
2025-10-24 00:47:35 +03:00
3197cc317a Update dependency typer to ~=0.20.0
All checks were successful
Test / build (pull_request) Successful in 30s
Test / build (push) Successful in 28s
2025-10-20 20:35:15 +03:00
56af31a9f9 Update dependency fastapi to ~=0.119.0,<0.120.0
All checks were successful
Test / build (pull_request) Successful in 29s
Test / build (push) Successful in 25s
2025-10-11 20:37:29 +03:00
8bfdd2479a Update python Docker tag to v3.14
All checks were successful
Test / build (pull_request) Successful in 41s
Test / build (push) Successful in 35s
2025-10-08 01:10:57 +03:00
48be2de7b3 Update dependency fastapi to ~=0.118.0,<0.119.0
All checks were successful
Test / build (pull_request) Successful in 1m24s
Test / build (push) Successful in 26s
2025-09-29 07:35:59 +03:00
37c2ce8604 Update dependency fastapi to ~=0.117.1,<0.118.0
All checks were successful
Test / build (pull_request) Successful in 30s
Test / build (push) Successful in 26s
2025-09-20 23:19:47 +03:00
88fa19c6f2 Update dependency typer to ~=0.19.0
All checks were successful
Test / build (pull_request) Successful in 30s
Test / build (push) Successful in 26s
2025-09-20 11:55:28 +03:00
7fdc0c38e5 Update dependency typer to ~=0.18.0
All checks were successful
Test / build (pull_request) Successful in 31s
Test / build (push) Successful in 24s
2025-09-19 22:26:31 +03:00
dc6223ab87 Update dependency fastapi to ~=0.116.2,<0.117.0
All checks were successful
Test / build (pull_request) Successful in 1m2s
Test / build (push) Successful in 28s
2025-09-16 21:56:59 +03:00
3a877631cf Update dependency pynacl to ~=1.6.0
All checks were successful
Test / build (pull_request) Successful in 33s
Test / build (push) Successful in 26s
2025-09-11 03:10:12 +03:00
5acddf3e94 Update dependency typer to ~=0.17.1
All checks were successful
Test / build (pull_request) Successful in 32s
Test / build (push) Successful in 26s
2025-08-30 14:57:29 +03:00
1153a9441f Fixed formatting with isort and black
All checks were successful
Test / build (push) Successful in 26s
2025-08-03 01:51:22 +02:00
63c815e748 Added a basic implementation of scheduled actions (will be used for #9 in the future) 2025-08-03 01:50:23 +02:00
8af1cfd689 Added very basic implementation of /clear
All checks were successful
Test / build (push) Successful in 27s
2025-08-02 01:42:09 +02:00
2936705be0 Fixed wrong method's name in cog_admin
All checks were successful
Test / build (push) Successful in 55s
2025-08-01 23:52:16 +02:00
8d10901467 Update python Docker tag to v3.13
All checks were successful
Test / build (pull_request) Successful in 36s
Test / build (push) Successful in 35s
2025-07-29 01:24:03 +03:00
6ef1e4be38 Added a small Dockerfile
All checks were successful
Test / build (push) Successful in 27s
2025-07-28 23:46:27 +02:00
5b5d6a9d88 Improved Makefile and removed run from it, updated uv.lock
All checks were successful
Test / build (push) Successful in 27s
2025-07-28 12:55:52 +02:00
47f770528a Merge pull request 'revert f30617b943e8ecad0f9e96108c54b8b3ce0534a1' (#67) from renovate/fastapi-0.x into dev
All checks were successful
Test / build (push) Successful in 22s
Reviewed-on: #67
2025-07-28 08:13:06 +03:00
57e0a0e085 revert f30617b943
All checks were successful
Test / build (pull_request) Successful in 25s
revert Update dependency fastapi to ~=0.116.1,<0.117.0
2025-07-28 08:12:18 +03:00
f30617b943 Update dependency fastapi to ~=0.116.1,<0.117.0
All checks were successful
Test / build (pull_request) Successful in 26s
Test / build (push) Successful in 25s
2025-07-28 07:54:22 +03:00
88f5921ea0 Removed useless migration script, fixed a call in main and added the uv.lock file to Git
All checks were successful
Test / build (push) Successful in 25s
2025-07-28 06:50:39 +02:00
12beb78131 Fixed broken routers
All checks were successful
Test / build (push) Successful in 26s
2025-07-28 06:33:58 +02:00
8f8b76df2c Improved CLI with Typer and fixed broken guild commands
All checks were successful
Test / build (push) Successful in 27s
2025-07-28 06:23:00 +02:00
7e3bb55bab Update dependency fastapi to v0.116.1
All checks were successful
Test / build (pull_request) Successful in 27s
Test / build (push) Successful in 25s
2025-07-28 02:46:19 +03:00
48f24c3a6b Moved project structure to javelina/
All checks were successful
Test / build (push) Successful in 27s
2025-07-28 01:32:38 +02:00
d804d6eb75 Merge pull request 'Added basic implementation for #51 and #62' (#64) from feature/profitroll/data-control into dev
All checks were successful
Test / build (push) Successful in 26s
Reviewed-on: #64
2025-07-28 02:13:46 +03:00
4cdb8fbd26 WIP: Implemented /consent give all, /consent withdraw all and /consent review (#51) 2025-07-28 01:12:35 +02:00
6f38ecb33d Added Makefile and development dependencies 2025-07-28 00:37:31 +02:00
cec35f10d7 Merge pull request 'Added basic analytics collector (#62)' (#63) from feature/profitroll/analytics into feature/profitroll/data-control
Reviewed-on: #63
2025-07-28 01:26:11 +03:00
7ef4372730 Added basic analytics collector (#62) 2025-07-28 00:23:36 +02:00
337c86d35f Fixes CVE-2024-23334
All checks were successful
Test / build (push) Successful in 26s
2025-07-25 12:31:05 +03:00
fe7d11092c Added safety scan with Safety
All checks were successful
Test / build (push) Successful in 52s
2025-07-25 12:28:13 +03:00
352f8c97ec WIP: Added consent durations and modified default embed colors (#51) 2025-07-24 19:52:12 +02:00
558b12bdbd WIP: Added basic implementation of consent withdrawal (#51) 2025-07-24 02:06:50 +02:00
6279bc4952 WIP: Added scope names to the localization and added a basic implementation of colors for data control (#51) 2025-07-24 00:40:08 +02:00
f61fa886d1 WIP: Added middleware for data control and changed the database index for consents (#51) 2025-07-23 21:55:43 +02:00
378473e453 WIP: Added scopes for commands and renamed consent scope "module_deepl" to "integration_deepl" 2025-07-22 01:31:58 +02:00
e0b2575d32 Added a minimal implementation for consent giving 2025-07-21 23:20:29 +02:00
0c2467209d Merge branch 'dev' into feature/profitroll/data-control
# Conflicts:
#	classes/__init__.py
#	classes/pycord_user.py
#	cogs/cog_admin.py
#	enums/__init__.py
#	enums/consent_scope.py
2025-07-21 22:43:55 +02:00
4be95428b5 Improved i18n in cog_admin, removed old wallet cog and added TODOs for consent durations 2025-07-21 22:38:32 +02:00
de2b04ca12 Made Wallet a child of BaseCacheable and improved caching 2025-07-21 00:24:06 +02:00
037e493bcc Implemented cache TTL 2025-07-21 00:23:06 +02:00
89307d8d0c Moved enums from classes/enums to enums 2025-07-21 00:22:31 +02:00
7565a643aa Merge pull request 'Update dependency libbot to v4.4.0' (#60) from renovate/libbot-4.x into dev
Reviewed-on: #60
2025-07-09 16:00:39 +03:00
d4474421e5 Update dependency libbot to v4.4.0 2025-07-09 16:00:19 +03:00
2684d9358e Merge pull request 'Update dependency libbot to v4.3.0' (#59) from renovate/libbot-4.x into dev
Reviewed-on: #59
2025-07-08 15:45:38 +03:00
a37827761b Update dependency libbot to v4.3.0 2025-07-08 02:41:39 +03:00
a553124e33 Merge pull request 'Update dependency fastapi to ~=0.116.0' (#58) from renovate/fastapi-0.x into dev
Reviewed-on: #58
2025-07-08 00:49:49 +03:00
cded34cb8a Update dependency fastapi to ~=0.116.0 2025-07-07 18:28:00 +03:00
71730362ef Closes #55 2025-06-07 21:06:16 +02:00
46edf5ea14 Removed old wallet cog and replaced "client" with "bot" in the new one 2025-06-07 00:43:47 +02:00
4ab7fb0630 WIP: Added config module and slash command stubs 2025-06-07 00:41:42 +02:00
9e10cf4fa4 Merge pull request 'Added stubs for Data and Consent cogs' (#54) from feature/data-control into feature/profitroll/data-control
Reviewed-on: #54
2025-06-07 00:21:27 +03:00
7b15480c30 WIP: Added stubs for Data and Consent cogs 2025-06-06 23:17:55 +02:00
996fe387df Improved health check and monitoring 2025-06-05 11:22:22 +02:00
fed2e0df07 Fixed database connection timeout not being handled during healthcheck 2025-06-04 09:58:01 +02:00
a109566738 Added missing FastAPI class 2025-06-04 09:42:39 +02:00
cbdfee63e4 WIP: Simple health check 2025-06-04 01:59:54 +02:00
54bfef981d WIP: Implemented basic methods for Consent and added necessary methods in PycordUser (#51) 2025-06-01 16:00:40 +02:00
1d8c29e73f Closes #12 2025-06-01 15:28:41 +02:00
4b4b9f5b0d WIP: Added stubs for #51 2025-06-01 01:06:26 +02:00
d08ea6240e Added simple Discord auth 2025-05-26 09:17:23 +02:00
ce86b95163 Slightly improved API extensions 2025-05-26 01:47:49 +02:00
296ef50a53 Removed "en" because it was already replaced by "en-US" 2025-05-25 22:57:51 +02:00
d5dc438601 WIP: #44, #43, #13, #12 2025-05-25 22:32:36 +02:00
62ee26b20f Replaced async_pymongo with default pymongo's async calls, fixed indexes 2025-05-19 00:16:51 +02:00
27ab68f6c5 Allowed empty wallet creation during transfers 2025-05-19 00:16:17 +02:00
32f19ee16b Added support for cache prefixes, improved logging and cached objects 2025-05-19 00:15:41 +02:00
b2fe8c516d Merge pull request 'Update dependency libbot to v4.2.0' (#48) from renovate/libbot-4.x into dev
Reviewed-on: Hessenuk/Javelina#48
2025-05-18 20:26:37 +03:00
df6ed8ac11 Update dependency libbot to v4.2.0 2025-05-18 19:29:06 +03:00
123f7e8e4f Update dependency pyrmv to v0.5.0 (#47) 2025-05-05 01:38:33 +03:00
08435f3dbb Update dependency pyrmv to v0.5.0 2025-05-05 01:28:58 +03:00
2cbe2a07e1 Update dependency deepl to v1.22.0 (#46) 2025-04-30 21:42:36 +03:00
9f99a2d507 Update dependency deepl to v1.22.0 2025-04-30 21:26:36 +03:00
187abbbbb4 Update dependency deepl to v1.21.1 (#45) 2025-03-12 20:18:21 +02:00
ab67e610d4 Update dependency deepl to v1.21.1 2025-03-12 19:41:36 +02:00
c6f971b39e WIP: Modified i18n usage 2025-02-24 21:36:01 +01:00
fcb09303ec WIP: Wallet Cog and i18n 2025-02-23 23:41:42 +01:00
1c8365b11f Added API version 2025-02-20 22:55:08 +01:00
bf6ca24eed WIP: Guilds and Wallets 2025-02-20 22:51:01 +01:00
65b0e30c75 WIP: Transactions 2025-02-20 14:39:19 +01:00
8e2003b7df WIP: Added stubs for Guilds and fixed formatting 2025-02-18 21:20:14 +01:00
kku
3ffea8b46b Added withdrawals and deposits 2025-02-18 20:25:57 +01:00
f3bb1ff79a WIP: Wallets, added missing changes 2025-02-18 20:19:09 +01:00
8883c8eda8 WIP: Wallets 2025-02-18 08:04:02 +01:00
654034491a Added stubs for custom channel, custom role and wallet 2025-02-16 22:36:18 +01:00
222a618591 Fixed a typo and removed an old class 2025-02-16 21:07:52 +01:00
a1bfbb537a Replaced built-in caching with the one from libbot 2025-02-16 20:38:41 +01:00
e0e307e35f Merge pull request 'Update dependency libbot to v4.1.0' (#41) from renovate/libbot-4.x into dev
Reviewed-on: Hessenuk/Javelina#41
2025-02-16 20:01:44 +02:00
e0564e150c Update dependency libbot to v4.1.0 2025-02-16 19:21:12 +02:00
4b401e878b Replaced UserNotFoundException with UserNotFoundError in a docstring 2025-02-16 14:09:32 +01:00
4ad79f1445 Improved type hints and added a placeholder for guilds 2025-02-16 13:41:23 +01:00
ffcfbbfc3b Added caching, updated libbot, refactored PycordUser 2025-02-16 13:11:48 +01:00
8154394539 Merge pull request 'Update dependency pytz to v2025' (#40) from renovate/pytz-2025.x into dev
Reviewed-on: Hessenuk/Javelina#40
2025-01-31 09:55:34 +02:00
e9ac435b40 Update dependency pytz to v2025 2025-01-31 04:27:32 +02:00
a5f18e9a4e Merge pull request 'Update dependency deepl to v1.21.0' (#39) from renovate/deepl-1.x into dev
Reviewed-on: Hessenuk/Javelina#39
2025-01-16 12:29:51 +02:00
faa0537c35 Update dependency deepl to v1.21.0 2025-01-15 20:52:46 +02:00
3794ad5aae Update requirements.txt 2024-12-27 01:54:13 +02:00
f952aa8c9d Merge pull request 'Update dependency libbot to v3.3.1' (#37) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#37
2024-12-17 00:03:28 +02:00
42293719e4 Update dependency libbot to v3.3.1 2024-12-17 00:00:45 +02:00
7f05cd79d9 Merge pull request 'Update dependency deepl to v1.20.0' (#35) from renovate/deepl-1.x into dev
Reviewed-on: Hessenuk/Javelina#35
2024-11-30 21:15:42 +02:00
8035610111 Update dependency deepl to v1.20.0 2024-11-24 22:57:57 +02:00
145357f487 Merge pull request 'Update dependency apscheduler to ~=3.11.0' (#36) from renovate/apscheduler-3.x into dev
Reviewed-on: Hessenuk/Javelina#36
2024-11-24 22:17:57 +02:00
c9a3943bca Update dependency apscheduler to ~=3.11.0 2024-11-24 21:55:07 +02:00
2b017c02d6 Merge pull request 'Update dependency async_pymongo to v0.1.11' (#34) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#34
2024-10-17 09:56:02 +03:00
d632201f65 Update dependency async_pymongo to v0.1.11 2024-10-16 20:14:07 +03:00
3b3f39a8f6 Merge pull request 'Update dependency async_pymongo to v0.1.10' (#33) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#33
2024-10-15 16:25:26 +03:00
dd1ce61cd1 Update dependency async_pymongo to v0.1.10 2024-10-15 13:09:26 +03:00
a4a95a61e2 Merge pull request 'Update dependency async_pymongo to v0.1.9' (#32) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#32
2024-10-08 16:49:41 +03:00
9b4df44564 Update dependency async_pymongo to v0.1.9 2024-10-08 16:29:37 +03:00
247c670b2e Merge pull request 'Update dependency async_pymongo to v0.1.8' (#31) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#31
2024-09-25 22:25:38 +03:00
00d6418c88 Update dependency async_pymongo to v0.1.8 2024-09-25 17:13:25 +03:00
a559f4c319 Merge pull request 'Update dependency async_pymongo to v0.1.7' (#30) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#30
2024-09-21 01:52:50 +03:00
91ad1baafa Update dependency async_pymongo to v0.1.7 2024-09-20 17:24:42 +03:00
8832ba89e4 Merge pull request 'Update dependency fastapi to ~=0.115.0' (#29) from renovate/fastapi-0.x into dev
Reviewed-on: Hessenuk/Javelina#29
2024-09-18 09:24:41 +03:00
7102ba5922 Update dependency fastapi to ~=0.115.0 2024-09-18 00:37:16 +03:00
c679af095d Merge pull request 'Update dependency deepl to v1.19.1' (#28) from renovate/deepl-1.x into dev
Reviewed-on: Hessenuk/Javelina#28
2024-09-18 00:01:50 +03:00
6f644b5236 Update dependency deepl to v1.19.1 2024-09-17 13:06:18 +03:00
c85140ee8b Update dependency pyrmv to v0.4.0 (#27) 2024-09-08 03:20:28 +03:00
63ac55d831 Update dependency pyrmv to v0.4.0 2024-09-08 03:05:11 +03:00
a2ebfe5867 Merge pull request 'Update dependency fastapi to ~=0.114.0' (#26) from renovate/fastapi-0.x into dev
Reviewed-on: Hessenuk/Javelina#26
2024-09-07 11:31:01 +03:00
76074a46b8 Update dependency fastapi to ~=0.114.0 2024-09-06 20:51:34 +03:00
3d1d7e2701 Merge pull request 'Update dependency fastapi to ~=0.112.0' (#25) from renovate/fastapi-0.x into dev
Reviewed-on: Hessenuk/Javelina#25
2024-08-03 00:58:30 +03:00
46bb3db995 Update dependency fastapi to ~=0.112.0 2024-08-02 09:57:19 +03:00
b6fb7e51b4 Merge pull request 'Update dependency libbot to v3.2.3' (#24) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#24
2024-07-10 08:12:41 +03:00
f91ed1fba4 Update dependency libbot to v3.2.3 2024-07-10 00:44:02 +03:00
dc63cbb563 Merge pull request 'Update dependency async_pymongo to v0.1.6' (#23) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#23
2024-06-23 14:33:54 +03:00
87c3a3cea2 Update dependency async_pymongo to v0.1.6 2024-06-23 13:30:25 +03:00
80b2f47403 Merge pull request 'Update dependency async_pymongo to v0.1.5' (#22) from renovate/async_pymongo-0.x into dev
Reviewed-on: Hessenuk/Javelina#22
2024-06-02 12:57:29 +03:00
9708fd6c2f Selected async_pymongo from PyPi 2024-06-02 12:56:30 +03:00
1bc84f0fcb Update dependency async_pymongo to v0.1.5 2024-06-01 15:32:29 +03:00
beb542b834 Merge pull request 'Update dependency libbot to v3.2.2' (#21) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#21
2024-05-26 23:58:34 +03:00
8f599c776a Update dependency libbot to v3.2.2 2024-05-26 23:13:04 +03:00
a352da2f3e Merge pull request 'Update dependency libbot to v3.2.0' (#20) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#20
2024-05-26 19:40:28 +03:00
2a7f582dd8 Update dependency libbot to v3.2.1 2024-05-26 19:01:56 +03:00
1e09ea7ec6 Merge pull request 'Update dependency libbot to v3.1.0' (#19) from renovate/libbot-3.x into dev
Reviewed-on: Hessenuk/Javelina#19
2024-05-24 22:46:52 +03:00
c6e048177e Update dependency libbot to v3.1.0 2024-05-24 22:42:35 +03:00
331184a7fd Update dependency mongodb-migrations to v1.3.1 (#17) 2024-05-14 01:48:11 +03:00
00642835bd Update dependency mongodb-migrations to v1.3.1 2024-05-14 01:26:11 +03:00
bbe72f2fdf Update dependency fastapi to ~=0.111.0 (#16) 2024-05-03 11:42:55 +03:00
c7c46060e8 Update dependency fastapi to ~=0.111.0 2024-05-03 04:06:30 +03:00
da969dad58 Update dependency deepl to v1.18.0 (#15) 2024-04-26 16:54:19 +03:00
dd368733d4 Update dependency deepl to v1.18.0 2024-04-26 14:00:04 +03:00
6bfc329666 Merge pull request 'Update dependency fastapi to ~=0.110.0' (#14) from renovate/fastapi-0.x into dev
Reviewed-on: Hessenuk/Javelina#14
2024-02-25 22:31:42 +02:00
edfd739023 Update dependency fastapi to ~=0.110.0 2024-02-25 02:13:58 +02:00
e2c05f3bf6 WIP: #9 2024-02-19 23:09:00 +01:00
fe4dcc4a92 Message events initialized 2024-02-19 23:08:41 +01:00
d5691c2bbb Fixed scheduler-related issues 2024-02-19 23:07:38 +01:00
40376d2e6d Merge pull request 'Update dependency deepl to v1.17.0' (#7) from renovate/deepl-1.x into dev
Reviewed-on: Hessenuk/Javelina#7
2024-02-13 19:05:55 +02:00
507c9dc9ed Update dependency deepl to v1.17.0 2024-02-07 13:53:18 +02:00
4d178bc3f2 Merge pull request 'Intial en locale addition' (#2) from i18n into dev
Reviewed-on: Hessenuk/Javelina#2
2024-02-04 02:13:29 +02:00
Weblate
00f907c09c Translated using Weblate (German)
Currently translated at 100.0% (0 of 0 strings)

Added translation using Weblate (German)

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Profitroll <vozhd.kk@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://weblate.end-play.xyz/projects/hessenuk/javelina/de/
Translation: Hessenuk/Javelina
2024-02-04 02:12:27 +02:00
Weblate
45573c48ae Translated using Weblate (English)
Currently translated at 73.0% (38 of 52 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://weblate.end-play.xyz/projects/hessenuk/javelina/en/
Translation: Hessenuk/Javelina
2024-02-04 02:12:07 +02:00
57a5b5b4b3 Fixed uk and added en 2024-02-04 01:11:47 +01:00
110 changed files with 7735 additions and 276 deletions

View File

@@ -0,0 +1,29 @@
name: build-docker-release
on:
release:
types: [ published ]
jobs:
docker:
runs-on: ubuntu-24.04
steps:
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DOCKER_REGISTRY_FQDN }}
username: ${{ vars.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:${{ gitea.REF_NAME }},${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:latest
secrets: |
GIT_AUTH_TOKEN=${{ secrets.DOCKER_REGISTRY_TOKEN }}

View File

@@ -0,0 +1,31 @@
name: build-docker
on:
push:
branches:
- staging
- dev
jobs:
docker:
runs-on: ubuntu-24.04
steps:
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DOCKER_REGISTRY_FQDN }}
username: ${{ vars.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ vars.DOCKER_REGISTRY_FQDN }}/${{ vars.DOCKER_IMAGE_USERNAME }}/${{ vars.DOCKER_IMAGE_NAME }}:${{ gitea.REF_NAME }}
secrets: |
GIT_AUTH_TOKEN=${{ secrets.DOCKER_REGISTRY_TOKEN }}

View File

@@ -0,0 +1,32 @@
name: safety-check
on:
push:
branches:
- main
- staging
- dev
tags-ignore:
- v*
pull_request:
branches:
- main
- staging
- dev
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run safety check
uses: pyupio/safety-action@v1
with:
api-key: ${{ secrets.SAFETY_API_KEY }}

View File

@@ -0,0 +1,43 @@
name: test-uv
on:
push:
branches:
- main
- staging
- dev
tags-ignore:
- v*
pull_request:
branches:
- main
- staging
- dev
jobs:
test:
name: Build and Test
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: [ "3.11", "3.12", "3.13" ]
steps:
- uses: actions/checkout@v3
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
env:
AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache
- name: Set up Python ${{ matrix.python-version }}
run: uv python install
- name: Install the project
run: uv sync --locked --all-extras --dev
- name: Test with tox
run: uv run tox
- name: Build
run: uv build
- uses: actions/upload-artifact@v3
with:
name: Artifacts
path: dist/*

44
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,44 @@
name: test
on:
push:
branches:
- main
- staging
- dev
tags-ignore:
- v*
pull_request:
branches:
- main
- staging
- dev
jobs:
test:
name: Build and Test
runs-on: ubuntu-24.04
strategy:
matrix:
python-version: [ "3.11", "3.12", "3.13" ]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: './requirements/*'
env:
AGENT_TOOLSDIRECTORY: /opt/hostedtoolcache
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions build
- name: Test with tox
run: tox
- name: Build
run: python -m build
- uses: actions/upload-artifact@v3
with:
name: Artifacts
path: dist/*

View File

@@ -6,6 +6,12 @@
"baseBranches": [
"dev"
],
"pip_requirements": {
"fileMatch": [
"requirements/.*\\.txt$"
],
"enabled": true
},
"packageRules": [
{
"matchUpdateTypes": [

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# WARNING: Docker deployment is not officially supported (yet)
FROM ghcr.io/astral-sh/uv:python3.13-trixie
WORKDIR /app
# Git is required because of git-based dependencies
RUN apt update && apt install -y git && rm -rf /var/lib/apt/lists/*
COPY . .
RUN uv sync --locked && uv pip install -e .
EXPOSE 8000
SHELL ["/bin/sh", "-c"]
ENTRYPOINT uv run javelina-cli migrate && uv run uvicorn javelina.asgi:app --host 0.0.0.0 --port 8000

35
Makefile Normal file
View File

@@ -0,0 +1,35 @@
.PHONY: setup setup-uv update update-uv dev-setup dev-setup-uv dev-update dev-update-uv
setup:
python3 -m venv .venv
.venv/bin/pip install .
setup-uv:
uv venv
uv sync
update:
.venv/bin/pip install --upgrade .
update-uv:
uv sync
build-docker:
sudo docker build -t javelina .
dev-setup:
python3 -m venv .venv
.venv/bin/pip install ."[dev]"
dev-setup-uv:
uv venv
uv sync --extra dev
dev-update:
.venv/bin/pip install --upgrade ."[dev]"
dev-update-uv:
uv sync --extra dev
dev-build-docker:
sudo docker build -t javelina-dev .

View File

@@ -14,3 +14,90 @@
<img alt="Discord" src="https://img.shields.io/discord/981251696208531466">
</a>
</p>
## Installing the bot
<details open>
<summary>Using pip</summary>
```shell
# Create a virtual environment
python -m venv .venv
# Active the virtual environment
source .venv/bin/activate
# Install the bot's package
pip install --index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple/ --extra-index-url https://pypi.org/simple javelina
```
</details>
<details>
<summary>Using pipx</summary>
```shell
# TODO
```
</details>
## Initializing the bot
<details open>
<summary>Using pip</summary>
```shell
# Activate the virtual environment
source .venv/bin/activate
# Trigger the initialization
javelina-cli init
```
</details>
<details>
<summary>Using pipx</summary>
```shell
# TODO
```
</details>
## Starting the bot
<details open>
<summary>Using pip</summary>
```shell
# Activate the virtual environment
source .venv/bin/activate
# Start the application
uvicorn javelina.asgi:app
```
</details>
<details>
<summary>Using pipx</summary>
```shell
# TODO
```
</details>
## Running in Docker [Experimental]
```shell
docker run -d --name javelina -p 8000:8000 -v /path/to/config.json:/app/config.json:ro git.end-play.xyz/javelina/javelina:latest
```

View File

@@ -1,3 +0,0 @@
from fastapi import FastAPI
app = FastAPI()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,8 +0,0 @@
from pathlib import Path
from api.app import app
from fastapi.responses import FileResponse
@app.get("/favicon.ico", response_class=FileResponse, include_in_schema=False)
async def favicon():
return FileResponse(Path("api/assets/favicon.ico"))

View File

@@ -1,45 +0,0 @@
from typing import Any, Union
from aiohttp import ClientSession
from discord import User
from libbot.pycord.classes import PycordBot as LibPycordBot
from classes.pycorduser import PycordUser
from modules.tracking.dhl import update_tracks_dhl
class PycordBot(LibPycordBot):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client_session = ClientSession()
# Scheduler job for DHL parcel tracking
self.scheduler.add_job(
update_tracks_dhl,
trigger="cron",
hour=self.config["modules"]["tracking"]["fetch_hours"],
args=[self, self.client_session],
)
async def find_user(self, user: Union[int, User]) -> PycordUser:
"""Find User by it's ID or User object.
### Args:
* user (`Union[int, User]`): ID or User object to extract ID from.
### Returns:
* `PycordUser`: User in database representation.
"""
return (
await PycordUser.find(user)
if isinstance(user, int)
else await PycordUser.find(user.id)
)
async def close(self, *args: Any, **kwargs: Any) -> None:
await self.client_session.close()
self.scheduler.shutdown()
await super().close(*args, **kwargs)

View File

@@ -1,42 +0,0 @@
import logging
from dataclasses import dataclass
from bson import ObjectId
from modules.database import col_users
logger = logging.getLogger(__name__)
@dataclass
class PycordUser:
"""Dataclass of DB entry of a user"""
__slots__ = ("_id", "id")
_id: ObjectId
id: int
@classmethod
async def find(cls, id: int):
"""Find user in database and create new record if user does not exist.
### Args:
* id (`int`): User's Discord ID
### Raises:
* `RuntimeError`: Raised when user entry after insertion could not be found.
### Returns:
* `PycordUser`: User with its database data.
"""
db_entry = await col_users.find_one({"id": id})
if db_entry is None:
inserted = await col_users.insert_one({"id": id})
db_entry = await col_users.find_one({"_id": inserted.inserted_id})
if db_entry is None:
raise RuntimeError("Could not find inserted user entry.")
return cls(**db_entry)

38
main.py
View File

@@ -1,38 +0,0 @@
import asyncio
import logging
from os import getpid
from libbot import sync
from classes.pycordbot import PycordBot
from modules.extensions_loader import dynamic_import_from_src
from modules.scheduler import scheduler
# Import required for uvicorn
from api.app import app
logging.basicConfig(
level=logging.DEBUG if sync.config_get("debug") else logging.INFO,
format="%(name)s.%(funcName)s | %(levelname)s | %(message)s",
datefmt="[%X]",
)
logger = logging.getLogger(__name__)
async def main():
bot = PycordBot(scheduler=scheduler)
bot.load_extension("cogs")
# Import API modules
dynamic_import_from_src("api.extensions", star_import=True)
try:
await bot.start(sync.config_get("bot_token", "bot"))
except KeyboardInterrupt:
logger.warning("Forcefully shutting down with PID %s...", getpid())
await bot.close()
asyncio.create_task(main())

View File

@@ -1,4 +0,0 @@
from modules.migrator import migrate_database
migrate_database()

View File

@@ -1,31 +0,0 @@
"""Module that provides all database collections"""
from typing import Any, Mapping
from async_pymongo import AsyncClient, AsyncCollection, AsyncDatabase
from libbot.sync import config_get
db_config: Mapping[str, Any] = config_get("database")
if db_config["user"] is not None and db_config["password"] is not None:
con_string = "mongodb://{0}:{1}@{2}:{3}/{4}".format(
db_config["user"],
db_config["password"],
db_config["host"],
db_config["port"],
db_config["name"],
)
else:
con_string = "mongodb://{0}:{1}/{2}".format(
db_config["host"], db_config["port"], db_config["name"]
)
db_client = AsyncClient(con_string)
db: AsyncDatabase = db_client.get_database(name=db_config["name"])
col_users: AsyncCollection = db.get_collection("users")
col_warnings: AsyncCollection = db.get_collection("warnings")
col_checkouts: AsyncCollection = db.get_collection("checkouts")
col_trackings: AsyncCollection = db.get_collection("trackings")
col_authorized: AsyncCollection = db.get_collection("authorized")
col_transactions: AsyncCollection = db.get_collection("transactions")

View File

@@ -1,76 +0,0 @@
from importlib.util import module_from_spec, spec_from_file_location
import logging
from os import getcwd, walk
from pathlib import Path
from types import ModuleType
from typing import List, Union
logger = logging.getLogger(__name__)
# Import functions
# Took from https://stackoverflow.com/a/57892961
def get_py_files(src: Union[str, Path]) -> List[str]:
cwd = getcwd() # Current Working directory
py_files = []
for root, dirs, files in walk(src):
py_files.extend(
Path(f"{cwd}/{root}/{file}") for file in files if file.endswith(".py")
)
return py_files
def dynamic_import(module_name: str, py_path: str) -> Union[ModuleType, None]:
try:
module_spec = spec_from_file_location(module_name, py_path)
if module_spec is None:
raise RuntimeError(
f"Module spec from module name {module_name} and path {py_path} is None"
)
module = module_from_spec(module_spec)
if module_spec.loader is None:
logger.warning(
"Could not load extension %s due to spec loader being None.",
module_name,
)
return
module_spec.loader.exec_module(module)
return module
except SyntaxError:
logger.warning(
"Could not load extension %s due to invalid syntax. Check logs/errors.log for details.",
module_name,
)
return
except Exception as exc:
logger.warning("Could not load extension %s due to %s", module_name, exc)
return
def dynamic_import_from_src(src: Union[str, Path], star_import=False) -> None:
my_py_files = get_py_files(src)
for py_file in my_py_files:
module_name = Path(py_file).stem
logger.debug("Importing %s extension...", module_name)
imported_module = dynamic_import(module_name, py_file)
if imported_module != None:
if star_import:
for obj in dir(imported_module):
globals()[obj] = imported_module.__dict__[obj]
else:
globals()[module_name] = imported_module
logger.info("Successfully loaded %s extension", module_name)
return

64
pyproject.toml Normal file
View File

@@ -0,0 +1,64 @@
[build-system]
requires = ["setuptools>=77.0.3,<80.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "javelina"
dynamic = ["version", "dependencies", "optional-dependencies"]
description = "Discord bot that manages the server and provides an additional RESTful API"
readme = "README.md"
license = "AGPL-3.0"
license-files = ["LICENSE"]
requires-python = ">=3.11"
authors = [
{ name = "Profitroll", email = "profitroll@end-play.xyz" }
]
maintainers = [
{ name = "Profitroll", email = "profitroll@end-play.xyz" }
]
classifiers = [
"Development Status :: 3 - Alpha",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
[project.scripts]
javelina-cli = "javelina.cli:main"
[tool.setuptools.dynamic]
version = { attr = "javelina.__version__" }
dependencies = { file = "requirements/_.txt" }
[tool.setuptools.dynamic.optional-dependencies]
dev = { file = "requirements/dev.txt" }
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
javelina = ["resources/*", "locale/*"]
[tool.black]
line-length = 96
target-version = ["py311", "py312", "py313"]
[tool.isort]
profile = "black"
[tool.mypy]
namespace_packages = true
install_types = true
strict = true
show_error_codes = true
[tool.pylint]
disable = ["line-too-long"]
[tool.pylint.main]
extension-pkg-whitelist = ["ujson"]
py-version = 3.11

View File

@@ -1,12 +0,0 @@
aiohttp>=3.6.0
apscheduler~=3.10.4
colorthief==0.2.1
deepl==1.16.1
fastapi[all]~=0.109.1
mongodb-migrations==1.3.0
pynacl~=1.5.0
pyrmv==0.3.5
pytz~=2024.1
--extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple
async_pymongo==0.1.4
libbot[speed,pycord]==3.0.0

17
requirements/_.txt Normal file
View File

@@ -0,0 +1,17 @@
aiohttp>=3.11.18
apscheduler~=3.11.2
eventapi @ git+https://git.end-play.xyz/Javelina/eventapi.git@master
fastapi[all]~=0.128.0,<0.129.0
fastapi_discord @ git+https://github.com/Tert0/fastapi-discord@master
libbot[speed,pycord,cache]==4.4.1
mongodb-migrations==1.3.1
pynacl~=1.6.0
pytz~=2025.1
tempora~=5.8.1
typer~=0.24.0
# Temporarily disabled because
# these are still unused for now
# colorthief==0.2.1
# deepl==1.22.0
# pyrmv==0.5.0

5
requirements/dev.txt Normal file
View File

@@ -0,0 +1,5 @@
black==26.1.0
isort==8.0.0
mypy==1.19.1
pylint==4.0.5
tox==4.44.0

8
src/javelina/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
from . import cli
from ._bot import app, main
__author__ = "Profitroll"
__version__ = "0.1.0"
__maintainer__ = "Profitroll"
__email__ = "profitroll@end-play.xyz"
__status__ = "Development"

97
src/javelina/_bot.py Normal file
View File

@@ -0,0 +1,97 @@
import contextlib
import logging.config
import sys
from asyncio import get_event_loop
from logging import Logger
from os import getpid, makedirs
from pathlib import Path
from sys import exit
from discord import LoginFailure
from eventapi import EventApi
from libbot.utils import config_get
# Import required for uvicorn
from .api.app import app # noqa
from .classes.pycord_bot import PycordBot
from .integrations.seven_tv import EventHandler7TV
from .modules.scheduler import scheduler
from .modules.utils import get_logger, get_logging_config
makedirs(Path("logs/"), exist_ok=True)
logging.config.dictConfig(get_logging_config())
logger: Logger = get_logger(__name__)
# Try to import the module that improves performance
# and ignore errors when module is not installed
with contextlib.suppress(ImportError):
import uvloop
uvloop.install()
async def main() -> None:
bot: PycordBot = PycordBot(scheduler=scheduler)
if config_get("enabled", "modules", "7tv"):
event_handler: EventHandler7TV = EventHandler7TV(bot, app)
try:
bot.load_cogs()
bot.load_integration_cogs()
bot.load_plugins()
except Exception as exc:
logger.error(
"An unexpected error has occurred while loading cogs: %s", exc, exc_info=exc
)
exit(1)
app.set_bot(bot)
if bot.is_integration_enabled("7tv") and bot.cogs.get("Cog7TV") is not None:
bot.cogs["Cog7TV"].set_event_handler(event_handler)
bot.cogs["Cog7TV"].set_event_api(EventApi(callback=event_handler.handle_event))
async def shutdown_cog_7tv() -> None:
if bot.is_integration_enabled("7tv") and (
bot.cogs.get("Cog7TV") is not None
and bot.cogs["Cog7TV"].emotes_event_api is not None
and not bot.cogs["Cog7TV"].emotes_event_api.closed
):
logger.debug("Closing the 7TV integration connection...")
await bot.cogs["Cog7TV"].emotes_event_api.close()
logger.debug("7TV integration closed.")
async def close_bot() -> None:
if not bot.is_closed():
logger.debug("Closing the bot...")
await bot.close()
logger.debug("Bot closed.")
try:
await bot.start(config_get("bot_token", "bot"))
except LoginFailure as exc:
logger.error("Provided bot token is invalid: %s", exc)
await shutdown_cog_7tv()
sys.exit(1)
# FIXME This block will not be reached because uvicorn handles SIGTERM instead
except KeyboardInterrupt:
logger.warning("Shutting down with PID %s...", getpid())
except Exception as exc:
logger.error("An unhandled exception was raised: %s", exc, exc_info=exc)
await shutdown_cog_7tv()
await close_bot()
sys.exit(1)
finally:
await shutdown_cog_7tv()
await close_bot()
logger.debug("Shutdown complete.")
# Should not be called anymore I guess...
if __name__ == "__main__":
event_loop = get_event_loop()
event_loop.run_until_complete(main())

54
src/javelina/api/app.py Normal file
View File

@@ -0,0 +1,54 @@
from logging import Logger
from urllib.parse import urljoin
from fastapi_discord import DiscordOAuthClient, RateLimited, Unauthorized
from fastapi_discord.exceptions import ClientSessionNotInitialized
from libbot.utils import config_get
from starlette.responses import JSONResponse
from ..classes.fastapi import FastAPI
from ..modules.utils import get_logger
from ..modules.utils.router_loader import run_router_setups
logger: Logger = get_logger(__name__)
discord_oauth: DiscordOAuthClient = DiscordOAuthClient(
config_get("client_id", "api", "oauth"),
config_get("client_secret", "api", "oauth"),
urljoin(config_get("public_url", "api"), "/v1/callback"),
("identify", "guilds"),
)
# TODO Add an integration for the contact information
app: FastAPI = FastAPI(
title="Javelina",
version="0.0.1",
)
run_router_setups(app, "javelina.api.routers")
# TODO Replace this with a FastAPI lifespan
@app.on_event("startup")
async def on_startup():
await discord_oauth.init()
@app.exception_handler(Unauthorized)
async def unauthorized_error_handler(_, __) -> JSONResponse:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
@app.exception_handler(RateLimited)
async def rate_limit_error_handler(_, exc: RateLimited) -> JSONResponse:
return JSONResponse(
{"error": "RateLimited", "retry": exc.retry_after, "message": exc.message},
status_code=429,
)
@app.exception_handler(ClientSessionNotInitialized)
async def client_session_error_handler(_, exc: ClientSessionNotInitialized) -> JSONResponse:
logger.error("Client session was not initialized: %s", exc, exc_info=exc)
return JSONResponse({"error": "Internal Error"}, status_code=500)

View File

@@ -0,0 +1 @@
from . import admin, auth, health, user

View File

@@ -0,0 +1 @@
from . import guilds, users, wallets

View File

@@ -0,0 +1,42 @@
from logging import Logger
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import JSONResponse
from starlette.requests import Request
from ....classes import PycordGuild
from ....classes.errors import GuildNotFoundError
from ....classes.fastapi import FastAPI
from ....modules.utils import get_logger
logger: Logger = get_logger(__name__)
router_v1: APIRouter = APIRouter(prefix="/v1/admin/guilds", tags=["Admin - Guilds"])
# TODO Implement this method
@router_v1.get("/", response_class=JSONResponse)
async def get_guilds_v1() -> JSONResponse:
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented")
@router_v1.get("/{guild_id}", response_class=JSONResponse)
async def get_guild_v1(request: Request, guild_id: int) -> JSONResponse:
try:
guild: PycordGuild = await PycordGuild.from_id(
guild_id, allow_creation=False, cache=request.app.bot.cache
)
except GuildNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Guild not found"
) from exc
except NotImplementedError as exc:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, detail="Not implemented"
) from exc
return JSONResponse(guild.to_dict(json_compatible=True))
def setup(app: FastAPI) -> None:
app.include_router(router_v1)

View File

@@ -0,0 +1,34 @@
from logging import Logger
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import JSONResponse
from starlette.requests import Request
from ....classes import PycordUser
from ....classes.errors import UserNotFoundError
from ....classes.fastapi import FastAPI
from ....modules.utils import get_logger
logger: Logger = get_logger(__name__)
router_v1: APIRouter = APIRouter(
prefix="/v1/admin/guilds/{guild_id}/users", tags=["Admin - Users"]
)
@router_v1.get("/{user_id}", response_class=JSONResponse)
async def get_guild_user_v1(request: Request, guild_id: int, user_id: int) -> JSONResponse:
try:
user: PycordUser = await PycordUser.from_id(
user_id, guild_id, allow_creation=False, cache=request.app.bot.cache
)
except UserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from exc
return JSONResponse(user.to_dict(json_compatible=True))
def setup(app: FastAPI) -> None:
app.include_router(router_v1)

View File

@@ -0,0 +1,34 @@
from logging import Logger
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import JSONResponse
from starlette.requests import Request
from ....classes.errors import WalletNotFoundError
from ....classes.fastapi import FastAPI
from ....classes.wallet import Wallet
from ....modules.utils import get_logger
logger: Logger = get_logger(__name__)
router_v1: APIRouter = APIRouter(
prefix="/v1/admin/guilds/{guild_id}/wallets", tags=["Admin - Wallets"]
)
@router_v1.get("/{user_id}", response_class=JSONResponse)
async def get_guild_wallet_v1(request: Request, guild_id: int, user_id: int) -> JSONResponse:
try:
wallet: Wallet = await Wallet.from_id(
user_id, guild_id, allow_creation=False, cache=request.app.bot.cache
)
except WalletNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Wallet not found"
) from exc
return JSONResponse(wallet.to_dict(json_compatible=True))
def setup(app: FastAPI) -> None:
app.include_router(router_v1)

View File

@@ -0,0 +1,27 @@
from logging import Logger
from fastapi import APIRouter
from starlette.responses import JSONResponse
from ...classes.fastapi import FastAPI
from ...modules.utils import get_logger
from ..app import discord_oauth
logger: Logger = get_logger(__name__)
router_v1: APIRouter = APIRouter(prefix="/v1", tags=["Auth"])
@router_v1.get("/login", response_class=JSONResponse)
async def login() -> JSONResponse:
return JSONResponse({"url": discord_oauth.oauth_login_url})
@router_v1.get("/callback", response_class=JSONResponse)
async def callback(code: str) -> JSONResponse:
token, refresh_token = await discord_oauth.get_access_token(code)
return JSONResponse({"access_token": token, "refresh_token": refresh_token})
def setup(app: FastAPI) -> None:
app.include_router(router_v1)

View File

@@ -0,0 +1,37 @@
from datetime import datetime, timedelta
from logging import Logger
from typing import Optional
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Request
from starlette.responses import JSONResponse
from ...classes import ApplicationHealth
from ...classes.fastapi import FastAPI
from ...modules.database import db
from ...modules.utils import get_logger
logger: Logger = get_logger(__name__)
router_v1: APIRouter = APIRouter(prefix="/v1", tags=["User"])
@router_v1.get("/status", response_class=JSONResponse)
async def get_status_v1() -> JSONResponse:
return JSONResponse({"status": "ok"})
@router_v1.get("/health", response_class=JSONResponse)
async def get_health_v1(request: Request, detailed: Optional[bool] = False) -> JSONResponse:
if request.app.status is None or request.app.status.get_last_update() < (
datetime.now(tz=ZoneInfo("UTC")) - timedelta(seconds=30)
):
request.app.update_status(await ApplicationHealth.from_data(request.app, db))
health: ApplicationHealth = request.app.status
return JSONResponse(health.to_json(detailed=detailed if detailed is not None else False))
def setup(app: FastAPI) -> None:
app.include_router(router_v1)

View File

@@ -0,0 +1,27 @@
from logging import Logger
from fastapi import APIRouter, Depends
from fastapi_discord import User
from starlette.responses import JSONResponse
from ...classes.fastapi import FastAPI
from ...modules.utils import get_logger
from ..app import discord_oauth
logger: Logger = get_logger(__name__)
router_v1: APIRouter = APIRouter(prefix="/v1", tags=["User"])
@router_v1.get(
"/me",
dependencies=[Depends(discord_oauth.requires_authorization)],
response_model=User,
response_class=JSONResponse,
)
async def get_me_v1(user: User = Depends(discord_oauth.user)) -> User:
return user
def setup(app: FastAPI) -> None:
app.include_router(router_v1)

5
src/javelina/asgi.py Normal file
View File

@@ -0,0 +1,5 @@
import asyncio
from ._bot import app, main # noqa
asyncio.create_task(main())

View File

@@ -0,0 +1,11 @@
from .application_health import ApplicationHealth
from .consent import Consent
from .guild_rules import GuildRules
from .pycord_guild import PycordGuild
from .pycord_guild_emote import PycordGuildEmote
from .pycord_user import PycordUser
from .scheduled_action import ScheduledAction
from .service_status import ServiceStatus
# from .pycord_guild_colors import PycordGuildColors
# from .wallet import Wallet

View File

@@ -0,0 +1 @@
from .cacheable import Cacheable

View File

@@ -0,0 +1,81 @@
from abc import ABC, abstractmethod
from typing import Any, ClassVar, Dict, Optional
from libbot.cache.classes import Cache
from pymongo.asynchronous.collection import AsyncCollection
class Cacheable(ABC):
"""Abstract class for cacheable"""
__short_name__: str
__collection__: ClassVar[AsyncCollection]
@classmethod
@abstractmethod
async def from_id(cls, *args: Any, cache: Optional[Cache] = None, **kwargs: Any) -> Any:
pass
@abstractmethod
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
pass
@abstractmethod
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
pass
@abstractmethod
def _get_cache_key(self) -> str:
pass
@abstractmethod
def _update_cache(self, cache: Optional[Cache] = None) -> None:
pass
@abstractmethod
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
pass
@staticmethod
@abstractmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
pass
@staticmethod
@abstractmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
pass
@abstractmethod
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
pass
@staticmethod
@abstractmethod
def get_defaults(**kwargs: Any) -> Dict[str, Any]:
pass
@staticmethod
@abstractmethod
def get_default_value(key: str) -> Any:
pass
@abstractmethod
async def update(
self,
cache: Optional[Cache] = None,
**kwargs: Any,
) -> None:
pass
@abstractmethod
async def reset(
self,
*args: str,
cache: Optional[Cache] = None,
) -> None:
pass
@abstractmethod
async def purge(self, cache: Optional[Cache] = None) -> None:
pass

View File

@@ -0,0 +1,157 @@
from dataclasses import dataclass
from datetime import datetime
from logging import Logger
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo
from libbot.cache.classes import Cache
from libbot.pycord.classes import PycordBot
from pymongo.asynchronous.database import AsyncDatabase
from pymongo.errors import ConnectionFailure
from ..classes.fastapi import FastAPI
from ..classes.service_status import ServiceStatus
from ..enums import HealthStatus
from ..modules.database import db_client
from ..modules.utils import get_logger
logger: Logger = get_logger(__name__)
@dataclass
class ApplicationHealth:
_last_update: datetime
api: ServiceStatus
bot: ServiceStatus
cache: ServiceStatus
database: ServiceStatus
@classmethod
async def from_data(cls, app: FastAPI, database: AsyncDatabase) -> "ApplicationHealth":
database_health: ServiceStatus = await ApplicationHealth.get_database_health(database)
cache_health: ServiceStatus = ApplicationHealth.get_cache_health(app.bot.cache)
data: Dict[str, Any] = {
"bot": ApplicationHealth.get_bot_health(app.bot, cache_health, database_health),
"cache": cache_health,
"database": database_health,
}
data["api"] = ApplicationHealth.get_api_health(
data["bot"], data["cache"], database_health
)
data["_last_update"] = datetime.now(tz=ZoneInfo("UTC"))
return cls(**data)
def update(self, app: FastAPI, database: AsyncDatabase) -> None:
raise NotImplementedError()
# TODO Fix the message
@staticmethod
def get_bot_health(
bot: PycordBot, cache_status: ServiceStatus, database_status: ServiceStatus
) -> ServiceStatus:
if not bot.is_ready():
return ServiceStatus(HealthStatus.FAILED, "discord connection has failed")
if database_status.status != HealthStatus.OPERATIONAL:
match database_status.status:
case HealthStatus.FAILED, HealthStatus.UNKNOWN:
return ServiceStatus(HealthStatus.FAILED, "database connection has failed")
case HealthStatus.DEGRADED:
return ServiceStatus(
HealthStatus.DEGRADED, "database connection is degraded"
)
if cache_status.status not in [HealthStatus.UNKNOWN, HealthStatus.OPERATIONAL]:
match cache_status.status:
case HealthStatus.FAILED:
return ServiceStatus(HealthStatus.DEGRADED, "cache connection has failed")
case HealthStatus.DEGRADED:
return ServiceStatus(HealthStatus.DEGRADED, "cache is degraded")
return ServiceStatus(HealthStatus.OPERATIONAL, None)
# TODO Fix the message
# TODO Implement this method
@staticmethod
def get_cache_health(cache: Cache) -> ServiceStatus:
return ServiceStatus(
HealthStatus.UNKNOWN,
None,
)
# TODO Fix the message
@staticmethod
async def get_database_health(database: AsyncDatabase) -> ServiceStatus:
try:
await db_client.admin.command("ping")
except ConnectionFailure as exc:
return ServiceStatus(HealthStatus.FAILED, str(exc))
return ServiceStatus(
HealthStatus.OPERATIONAL,
None,
)
@staticmethod
def get_api_health(
bot_status: ServiceStatus,
cache_status: ServiceStatus,
database_status: ServiceStatus,
) -> ServiceStatus:
if database_status.status != HealthStatus.OPERATIONAL:
match database_status.status:
case HealthStatus.FAILED, HealthStatus.UNKNOWN:
return ServiceStatus(
HealthStatus.FAILED,
"database connection has failed",
)
case HealthStatus.DEGRADED:
return ServiceStatus(
HealthStatus.DEGRADED,
"database connection is degraded",
)
if bot_status.status != HealthStatus.OPERATIONAL:
match bot_status.status:
case HealthStatus.FAILED, HealthStatus.UNKNOWN:
return ServiceStatus(
HealthStatus.DEGRADED,
"bot integration has failed",
)
case HealthStatus.DEGRADED:
return ServiceStatus(
HealthStatus.DEGRADED,
"bot integration is degraded",
)
if cache_status.status not in [HealthStatus.OPERATIONAL, HealthStatus.UNKNOWN]:
match cache_status.status:
case HealthStatus.FAILED:
return ServiceStatus(HealthStatus.DEGRADED, "cache connection has failed")
case HealthStatus.DEGRADED:
return ServiceStatus(HealthStatus.DEGRADED, "cache is degraded")
return ServiceStatus(
HealthStatus.OPERATIONAL,
None,
)
def get_last_update(self) -> datetime:
return self._last_update
def to_json(self, detailed: Optional[bool] = False) -> Dict[str, Dict[str, str | None]]:
output: Dict[str, Any] = {
"api": self.api.to_json(detailed),
"bot": self.bot.to_json(detailed),
}
if detailed:
output["cache"] = self.cache.to_json(detailed)
output["database"] = self.database.to_json(detailed)
return output

View File

@@ -0,0 +1 @@
from .base_cacheable import BaseCacheable

View File

@@ -0,0 +1,110 @@
from abc import ABC
from logging import Logger
from typing import Any, Dict, Optional
from bson import ObjectId
from libbot.cache.classes import Cache
from ...classes.abstract import Cacheable
from ...modules.utils import get_logger
logger: Logger = get_logger(__name__)
class BaseCacheable(Cacheable, ABC):
"""Base implementation of Cacheable used by all cachable classes."""
_id: ObjectId
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
for key, value in kwargs.items():
if not hasattr(self, key):
raise AttributeError()
setattr(self, key, value)
await self.__collection__.update_one({"_id": self._id}, {"$set": kwargs})
self._update_cache(cache)
logger.info("Set attributes of %s to %s", self._id, kwargs)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
attributes: Dict[str, Any] = {}
for key in args:
if not hasattr(self, key):
raise AttributeError()
default_value: Any = self.get_default_value(key)
setattr(self, key, default_value)
attributes[key] = default_value
await self.__collection__.update_one({"_id": self._id}, {"$set": attributes})
self._update_cache(cache)
logger.info("Reset attributes %s of %s to default values", args, self._id)
def _update_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
object_dict: Dict[str, Any] = self.to_dict(json_compatible=True)
if object_dict is not None:
cache.set_json(self._get_cache_key(), object_dict)
else:
self._delete_cache(cache)
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
if cache is None:
return
cache.delete(self._get_cache_key())
async def update(
self,
cache: Optional[Cache] = None,
**kwargs: Any,
) -> None:
"""Update attribute(s) on the object and save the updated entry into the database.
Args:
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
**kwargs (Any): Mapping of attributes in format `attribute_name=attribute_value` to update.
Raises:
AttributeError: Provided attribute does not exist in the class.
"""
await self._set(cache=cache, **kwargs)
async def reset(
self,
*args: str,
cache: Optional[Cache] = None,
) -> None:
"""Remove attribute(s) on the object, replace them with a default value and save the updated entry into the database.
Args:
*args (str): List of attributes to remove.
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
Raises:
AttributeError: Provided attribute does not exist in the class.
"""
await self._remove(*args, cache=cache)
async def purge(self, cache: Optional[Cache] = None) -> None:
"""Completely remove object data from database. Currently only removes the record from a respective collection.
Args:
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
"""
await self.__collection__.delete_one({"_id": self._id})
self._delete_cache(cache)
logger.info("Purged %s from the database", self._id)

View File

@@ -0,0 +1,247 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Optional
from zoneinfo import ZoneInfo
from bson import ObjectId
from libbot.cache.classes import Cache
from pymongo import DESCENDING
from pymongo.results import InsertOneResult, UpdateResult
from ..classes.base import BaseCacheable
from ..enums import ConsentScope
from ..modules.database import col_consents
from ..modules.utils import restore_from_cache
@dataclass
class Consent(BaseCacheable):
"""Dataclass of DB entry of a consent entry"""
__slots__ = (
"_id",
"user_id",
"guild_id",
"scope",
"consent_date",
"expiration_date",
"withdrawal_date",
)
__short_name__ = "consent"
__collection__ = col_consents
_id: ObjectId
user_id: int
guild_id: int
scope: ConsentScope
consent_date: datetime
expiration_date: datetime | None
withdrawal_date: datetime | None
@staticmethod
def get_cache_key(user_id: int, guild_id: int, scope: ConsentScope) -> str:
return f"{Consent.__short_name__}_{user_id}_{guild_id}_{scope.value}"
# TODO Implement this method
@classmethod
async def from_id(cls, id: ObjectId, cache: Optional[Cache] = None, **kwargs: Any) -> Any:
raise NotImplementedError()
# TODO Add documentation
@classmethod
async def from_combination(
cls,
user_id: int,
guild_id: int,
scope: ConsentScope,
cache: Optional[Cache] = None,
) -> Any:
cached_entry: Dict[str, Any] | None = restore_from_cache(
cls.__short_name__,
Consent.get_cache_key(user_id, guild_id, scope),
cache=cache,
)
if cached_entry is not None:
return cls(**cls._entry_from_cache(cached_entry))
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one(
{"user_id": user_id, "guild_id": guild_id, "scope": scope.value},
sort={"expiration_date": DESCENDING},
)
if db_entry is None:
# TODO Implement a unique exception
# raise ConsentNotFoundError(user_id, guild_id, scope)
raise RuntimeError(
f"Could not find a consent of user {user_id} from {guild_id} for scope '{scope.value}'"
)
if cache is not None:
cache.set_json(
Consent.get_cache_key(user_id, guild_id, scope),
cls._entry_to_cache(db_entry),
)
return cls(**db_entry)
@classmethod
def from_entry(cls, db_entry: Dict[str, Any]) -> "Consent":
db_entry["scope"] = ConsentScope(db_entry["scope"])
return cls(**db_entry)
# TODO Add documentation
@classmethod
async def give(
cls,
user_id: int,
guild_id: int,
scope: ConsentScope,
expiration_date: Optional[datetime] = None,
cache: Optional[Cache] = None,
) -> Any:
await cls.withdraw_scope_consents(user_id, guild_id, scope)
db_entry = Consent.get_defaults(user_id, guild_id, scope)
db_entry["scope"] = scope.value
db_entry["expiration_date"] = expiration_date
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
db_entry["scope"] = scope
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(
Consent.get_cache_key(user_id, guild_id, scope),
cls._entry_to_cache(db_entry),
)
return cls(**db_entry)
@staticmethod
async def withdraw_scope_consents(
user_id: int, guild_id: int, scope: ConsentScope
) -> UpdateResult:
"""Look up consents of a user in a guild with a specified scope and withdraw them.
Args:
user_id (int): Discord ID of a user.
guild_id (int): Discord ID of a guild.
scope (:obj:ConsentScope): Scope to look for.
Returns:
UpdateResult: Result object of all affected consents.
"""
return await Consent.__collection__.update_many(
{
"user_id": user_id,
"guild_id": guild_id,
"scope": scope.value,
"withdrawal_date": None,
"$or": [
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
{"expiration_date": None},
],
},
{"$set": {"withdrawal_date": datetime.now(tz=ZoneInfo("UTC"))}},
)
def _get_cache_key(self) -> str:
return self.get_cache_key(self.user_id, self.guild_id, self.scope)
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
cache_entry["scope"] = cache_entry["scope"].value
cache_entry["consent_date"] = cache_entry["consent_date"].isoformat()
cache_entry["expiration_date"] = (
None
if cache_entry["expiration_date"] is None
else cache_entry["expiration_date"].isoformat()
)
cache_entry["withdrawal_date"] = (
None
if cache_entry["withdrawal_date"] is None
else cache_entry["withdrawal_date"].isoformat()
)
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
cache_entry["scope"] = ConsentScope(cache_entry["scope"])
cache_entry["consent_date"] = datetime.fromisoformat(cache_entry["consent_date"])
cache_entry["expiration_date"] = (
None
if cache_entry["expiration_date"] is None
else datetime.fromisoformat(cache_entry["expiration_date"])
)
cache_entry["withdrawal_date"] = (
None
if cache_entry["withdrawal_date"] is None
else datetime.fromisoformat(cache_entry["withdrawal_date"])
)
return db_entry
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert Consent object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
Returns:
Dict[str, Any]: JSON representation of Consent
"""
return {
"_id": self._id if not json_compatible else str(self._id),
"user_id": self.user_id,
"guild_id": self.guild_id,
"scope": self.scope if not json_compatible else self.scope.value,
"consent_date": self.consent_date.isoformat(),
"expiration_date": (
None if self.expiration_date is None else self.expiration_date.isoformat()
),
"withdrawal_date": (
None if self.withdrawal_date is None else self.withdrawal_date.isoformat()
),
}
@staticmethod
def get_defaults(
user_id: Optional[int] = None,
guild_id: Optional[int] = None,
scope: Optional[ConsentScope] = None,
) -> Dict[str, Any]:
return {
"user_id": user_id,
"guild_id": guild_id,
"scope": scope,
"consent_date": datetime.now(tz=ZoneInfo("UTC")),
"expiration_date": None,
"withdrawal_date": None,
}
@staticmethod
def get_default_value(key: str) -> Any:
if key not in Consent.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in Consent")
return Consent.get_defaults()[key]
async def withdraw(self, cache: Optional[Cache] = None) -> None:
"""Withdraw consent now (in UTC timezone).
Args:
cache (:obj:`Cache`, optional): Cache engine that will be used to update the cache.
"""
await self.update(cache=cache, withdrawal_date=datetime.now(tz=ZoneInfo("UTC")))

View File

@@ -0,0 +1,108 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, Optional
from bson import ObjectId
from libbot.cache.classes import Cache
from ..classes.base import BaseCacheable
from ..modules.database import col_custom_channels
@dataclass
class CustomChannel(BaseCacheable):
"""Dataclass of DB entry of a custom channel"""
__slots__ = (
"_id",
"owner_id",
"guild_id",
"channel_id",
"allow_comments",
"allow_reactions",
"created",
"deleted",
)
__short_name__ = "channel"
__collection__ = col_custom_channels
_id: ObjectId
owner_id: int
guild_id: int
channel_id: int
allow_comments: bool
allow_reactions: bool
created: datetime
deleted: datetime | None
@classmethod
async def from_id(
cls,
user_id: int,
guild_id: int,
channel_id: Optional[int] = None,
cache: Optional[Cache] = None,
) -> "CustomChannel":
raise NotImplementedError()
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert PycordGuild object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
Returns:
Dict[str, Any]: JSON representation of PycordGuild
"""
return {
"_id": self._id if not json_compatible else str(self._id),
"id": self.id,
"locale": self.locale,
}
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
await super()._set(cache, **kwargs)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
await super()._remove(*args, cache=cache)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self.id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None:
super()._update_cache(cache)
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
super()._delete_cache(cache)
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
return db_entry
# TODO Add documentation
@staticmethod
def get_defaults(guild_id: Optional[int] = None) -> Dict[str, Any]:
return {
"id": guild_id,
"locale": None,
}
# TODO Add documentation
@staticmethod
def get_default_value(key: str) -> Any:
if key not in CustomChannel.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in CustomChannel")
return CustomChannel.get_defaults()[key]

View File

@@ -0,0 +1,14 @@
from dataclasses import dataclass
from datetime import datetime
from bson import ObjectId
@dataclass
class CustomRole:
_id: ObjectId
role_id: int
role_color: int
owner_id: int
created: datetime
deleted: datetime | None

View File

@@ -0,0 +1,8 @@
from .pycord_guild import GuildNotFoundError
from .pycord_user import UserNotFoundError
from .wallet import (
WalletBalanceLimitExceeded,
WalletInsufficientFunds,
WalletNotFoundError,
WalletOverdraftLimitExceeded,
)

View File

@@ -0,0 +1,7 @@
class GuildNotFoundError(Exception):
"""PycordGuild could not find guild with such an ID in the database"""
def __init__(self, guild_id: int) -> None:
self.guild_id: int = guild_id
super().__init__(f"Guild with id {self.guild_id} was not found")

View File

@@ -0,0 +1,8 @@
class UserNotFoundError(Exception):
"""PycordUser could not find user with such an ID in the database"""
def __init__(self, user_id: int, guild_id: int) -> None:
self.user_id: int = user_id
self.guild_id: int = guild_id
super().__init__(f"User with id {self.user_id} was not found in guild {self.guild_id}")

View File

@@ -0,0 +1,62 @@
class WalletNotFoundError(Exception):
"""Wallet could not find user with such an ID from a guild in the database"""
def __init__(self, owner_id: int, guild_id: int) -> None:
self.owner_id = owner_id
self.guild_id = guild_id
super().__init__(
f"Wallet of a user with id {self.owner_id} was not found for the guild with id {self.guild_id}"
)
class WalletInsufficientFunds(Exception):
"""Wallet's balance is not sufficient to perform the operation"""
def __init__(
self,
wallet: "Wallet",
amount: float,
) -> None:
self.wallet = wallet
self.amount = amount
super().__init__(
f"Wallet of a user with id {self.wallet.owner_id} for the guild with id {self.wallet.guild_id} does not have sufficient funds to perform the operation (balance: {self.wallet.balance}, requested: {self.amount})"
)
class WalletOverdraftLimitExceeded(Exception):
"""Wallet's overdraft limit is not sufficient to perform the operation"""
def __init__(
self,
wallet: "Wallet",
amount: float,
overdraft_limit: float,
) -> None:
self.wallet = wallet
self.amount = amount
self.overdraft_limit = overdraft_limit
super().__init__(
f"Wallet of a user with id {self.wallet.owner_id} for the guild with id {self.wallet.guild_id} does not have sufficient funds to perform the operation (balance: {self.wallet.balance}, requested: {self.amount}, overdraft limit: {self.overdraft_limit})"
)
class WalletBalanceLimitExceeded(Exception):
"""Wallet's balance limit is not high enough to perform the operation"""
def __init__(
self,
wallet: "Wallet",
amount: float,
balance_limit: float,
) -> None:
self.wallet = wallet
self.amount = amount
self.balance_limit = balance_limit
super().__init__(
f"Wallet of a user with id {self.wallet.owner_id} for the guild with id {self.wallet.guild_id} would have too much funds after the operation (balance: {self.wallet.balance}, deposited: {self.amount}, balance limit: {self.balance_limit})"
)

View File

@@ -0,0 +1,18 @@
from typing import Any, Optional
from fastapi import FastAPI as OriginalFastAPI
from libbot.pycord.classes import PycordBot
class FastAPI(OriginalFastAPI):
def __init__(self, *args, bot: Optional[PycordBot] = None, **kwargs) -> None:
self.bot: PycordBot | None = bot
self.status: Any | None = None
super().__init__(*args, **kwargs)
def set_bot(self, bot: PycordBot) -> None:
self.bot = bot
def update_status(self, status: Any) -> None:
self.status = status

View File

@@ -0,0 +1,39 @@
from dataclasses import dataclass
from typing import Any, Dict, List
from ..classes.guild_rules_section import GuildRulesSection
# Example JSON
# {
# "header": "These are our rules",
# "sections": [
# {
# "title": "1. First section",
# "description": "This sections contains some rules",
# "rules": [
# {
# "title": "Example rule",
# "content": "Do not wear sandals while in socks!",
# "punishment": 0,
# }
# ],
# }
# ],
# }
@dataclass
class GuildRules:
__slots__ = ("header", "sections")
header: str
sections: List[GuildRulesSection]
# TODO Implement this method
@classmethod
def from_json(cls, db_entry: Dict[str, Any]) -> "GuildRules":
raise NotImplementedError()
# TODO Implement this method
def to_dict(self) -> Dict[str, Any]:
raise NotImplementedError()

View File

@@ -0,0 +1,13 @@
from dataclasses import dataclass
from typing import Literal
from ..enums import Punishment
@dataclass
class GuildRulesRule:
__slots__ = ("title", "content", "punishment")
title: str
content: str
punishment: Literal[Punishment.WARNING, Punishment.MUTE, Punishment.KICK, Punishment.BAN]

View File

@@ -0,0 +1,13 @@
from dataclasses import dataclass
from typing import List
from ..classes.guild_rules_rule import GuildRulesRule
@dataclass
class GuildRulesSection:
__slots__ = ("title", "description", "rules")
title: str
description: str
rules: List[GuildRulesRule]

View File

@@ -0,0 +1,335 @@
import logging
from datetime import datetime, timedelta
from importlib import resources
from importlib.resources.abc import Traversable
from logging import Logger
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional
from zoneinfo import ZoneInfo
from aiohttp import ClientSession
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from discord import Activity, ActivityType, Colour, Embed, Guild, User
from libbot.cache.classes import CacheMemcached, CacheRedis
from libbot.cache.manager import create_cache_client
from libbot.i18n import BotLocale
from libbot.pycord.classes import PycordBot as LibPycordBot
from libbot.utils import json_read
from ..classes import PycordGuild, PycordUser, ScheduledAction
from ..enums import CacheTTL, EmbedColor
from ..modules.database import (
_update_database_indexes,
col_scheduled_actions,
)
from ..modules.utils.color_utils import hex_to_int
from ..utils.package_utils import get_locales_root
logger: Logger = logging.getLogger(__name__)
# from javelina.modules.tracking.dhl import update_tracks_dhl
class PycordBot(LibPycordBot):
started: datetime
cache: CacheMemcached | CacheRedis | None = None
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, locales_root=get_locales_root(), **kwargs)
self._set_cache_engine()
self.client_session: ClientSession = ClientSession()
# This replacement exists because of the different
# i18n formats than provided by libbot
self._ = self._modified_string_getter
if self.scheduler is None:
return
def _set_cache_engine(self) -> None:
cache_type: Literal["redis", "memcached"] | None = self.config["cache"]["type"]
if "cache" in self.config and cache_type is not None:
self.cache = create_cache_client(
self.config,
cache_type,
prefix=self.config["cache"][cache_type]["prefix"],
default_ttl_seconds=CacheTTL.NORMAL.value,
)
def _modified_string_getter(self, key: str, *args: str, locale: str | None = None) -> Any:
"""This method exists because of the different i18n formats than provided by libbot.
It splits "-" and takes the first part of the provided locale to make complex language codes
compatible with an easy libbot approach to i18n.
"""
return self.bot_locale._(
key, *args, locale=None if locale is None else locale.split("-")[0]
)
def load_cogs(self) -> None:
cog_files: Traversable = resources.files("javelina").joinpath("cogs")
cog_names: List[str] = []
for cog_file in cog_files.iterdir():
if cog_file.name.startswith("_") or not cog_file.name.endswith(".py"):
continue
cog_names.append(f".cogs.{cog_file.name.replace('.py', '')}")
self.load_extensions(*cog_names, package="javelina")
def load_integration_cogs(self) -> None:
integrations_dir: Traversable = resources.files("javelina").joinpath("integrations")
cog_names: List[str] = []
for integration_dir in integrations_dir.iterdir():
if not integration_dir.is_dir() or integration_dir.name.startswith("_"):
continue
cog_files: Traversable = integration_dir.joinpath("cogs")
if not cog_files.is_dir():
continue
for cog_file in cog_files.iterdir():
if cog_file.name.startswith("_") or not cog_file.name.endswith(".py"):
continue
integration_name: str = cog_file.name.replace("cog_", "").replace(".py", "")
if not self.is_integration_enabled(integration_name):
logger.info(
"Not loading the integration %s because it is not enabled",
integration_name,
)
continue
cog_names.append(
f".integrations.{integration_dir.name}.cogs.{cog_file.name.replace('.py', '')}"
)
self.load_extensions(*cog_names, package="javelina")
def load_plugins(self) -> None:
if not Path("plugins").exists():
return
cogs_loaded: Dict[str, Exception | bool] | List[str] | None = self.load_extension(
"plugins"
)
if cogs_loaded is None:
return
if isinstance(cogs_loaded, list):
if len(cogs_loaded) > 0:
logger.info("Loaded %s plugins: %s", len(cogs_loaded), ", ".join(cogs_loaded))
else:
logger.info("No user plugins were loaded.")
return
cogs_loaded_list: List[str] = []
for cog_name, cog_status in cogs_loaded.items():
if cog_status is True:
cogs_loaded_list.append(cog_name)
if len(cogs_loaded_list) > 0:
logger.info(
"Loaded %s plugins: %s",
len(cogs_loaded_list),
", ".join(cogs_loaded_list),
)
else:
logger.info("No user plugins were loaded.")
# TODO Add documentation
async def get_scheduled_actions(self) -> List[ScheduledAction]:
return [
ScheduledAction.from_entry(entry) async for entry in col_scheduled_actions.find()
]
# TODO Add rollback mechanism for recovery from broken config
# TODO Add documentation
def reload(self) -> None:
config_old: Dict[str, Any] = self.config.copy()
try:
self.config = json_read(Path("config.json"))
self.bot_locale = BotLocale(
default_locale=self.config["locale"],
locales_root=get_locales_root(),
)
self.default_locale = self.bot_locale.default
self.locales = self.bot_locale.locales
except Exception as exc:
logger.error(
"Could not reload the configuration, restoring old in-memory values due to: %s",
exc,
exc_info=exc,
)
self.config = config_old
raise exc
async def set_status(self) -> None:
activity_enabled: bool = self.config["bot"]["status"]["enabled"]
activity_id: int = self.config["bot"]["status"]["activity_type"]
activity_message: str = self.config["bot"]["status"]["activity_text"]
if not activity_enabled:
logger.info("Activity is disabled")
return
try:
activity_type: ActivityType = ActivityType(activity_id)
except Exception as exc:
logger.debug(
"Could not activity with ID %s to ActivityType due to: %s",
activity_id,
exc,
exc_info=exc,
)
logger.error("Activity type with ID %s is not supported", activity_id)
return
await self.change_presence(activity=Activity(type=activity_type, name=activity_message))
logger.info(
"Set activity type to %s (%s) with message '%s'",
activity_id,
activity_type.name,
activity_message,
)
async def find_user(self, user: int | User, guild_id: int) -> PycordUser:
"""Find User by its ID or User object.
Args:
user (int | User): ID or User object to extract ID from
guild_id (int): ID of the guild user is member of
Returns:
PycordUser: User object
Raises:
UserNotFoundException: User was not found and creation was not allowed
"""
return (
await PycordUser.from_id(user, guild_id, cache=self.cache)
if isinstance(user, int)
else await PycordUser.from_id(user.id, guild_id, cache=self.cache)
)
async def find_guild(self, guild: int | Guild) -> PycordGuild:
"""Find Guild by its ID or Guild object.
Args:
guild (int | Guild): ID or User object to extract ID from
Returns:
PycordGuild: Guild object
Raises:
GuildNotFoundException: Guild was not found and creation was not allowed
"""
return (
await PycordGuild.from_id(guild, cache=self.cache)
if isinstance(guild, int)
else await PycordGuild.from_id(guild.id, cache=self.cache)
)
async def start(self, *args: Any, **kwargs: Any) -> None:
await self._schedule_tasks()
await _update_database_indexes()
self.started = datetime.now(tz=ZoneInfo("UTC"))
await super().start(*args, **kwargs)
async def close(self, **kwargs) -> None:
await self.client_session.close()
await super().close(**kwargs)
def is_integration_enabled(self, integration_name: str) -> bool:
return self.config.get("modules", {}).get(
integration_name
) is not None and self.config.get("modules", {}).get(integration_name, {}).get(
"enabled", False
)
async def _schedule_tasks(self) -> None:
if self.scheduler is None:
return
for scheduled_action in await self.get_scheduled_actions():
match scheduled_action.action_type:
# case ScheduledActionType.WEATHER_REPORT:
# func: Callable | None = None
case _:
continue
self.scheduler.add_job(
func,
trigger=CronTrigger.from_crontab(scheduled_action.action_schedule),
args=[self, scheduled_action.action_data],
)
if scheduled_action.invoke_on_start:
self.scheduler.add_job(
func,
trigger=DateTrigger(datetime.now() + timedelta(seconds=5)),
args=[self, scheduled_action.action_data],
)
# Scheduler job for DHL parcel tracking
# self.scheduler.add_job(
# update_tracks_dhl,
# trigger="cron",
# hour=self.config["modules"]["tracking"]["fetch_hours"],
# args=[self, self.client_session],
# )
pass
# TODO Add support for guild colors
def create_embed(
self,
title: Optional[str] = None,
description: Optional[str] = None,
url: Optional[str] = None,
color: Optional[EmbedColor] = EmbedColor.PRIMARY,
) -> Embed:
return Embed(
title=title,
description=description,
url=url,
color=Colour(hex_to_int(self.config["colors"][color.value])),
)
def create_embed_error(
self,
title: Optional[str] = None,
description: Optional[str] = None,
url: Optional[str] = None,
) -> Embed:
return self.create_embed(
title=title, description=description, url=url, color=EmbedColor.ERROR
)
def create_embed_success(
self,
title: Optional[str] = None,
description: Optional[str] = None,
url: Optional[str] = None,
) -> Embed:
return self.create_embed(
title=title, description=description, url=url, color=EmbedColor.SUCCESS
)

View File

@@ -0,0 +1,181 @@
from dataclasses import dataclass
from typing import Any, Dict, Optional
from bson import ObjectId
from discord import Cog
from libbot.cache.classes import Cache
from libbot.pycord.classes import PycordBot as LibPycordBot
from pymongo.results import InsertOneResult
from ..classes import GuildRules
from ..classes.base import BaseCacheable
from ..classes.errors import GuildNotFoundError
from ..modules.database import col_guilds
from ..modules.utils import restore_from_cache
@dataclass
class PycordGuild(BaseCacheable):
"""Dataclass of DB entry of a guild"""
__slots__ = ("_id", "id", "locale", "rules")
__short_name__ = "guild"
__collection__ = col_guilds
_id: ObjectId
id: int
locale: str | None
rules: GuildRules | None
emote_set_id: str | None
@classmethod
async def from_id(
cls, guild_id: int, allow_creation: bool = True, cache: Optional[Cache] = None
) -> "PycordGuild":
"""Find the guild by its ID and construct PycordEventStage from database entry.
Args:
guild_id (int): ID of the guild to look up.
allow_creation (:obj:`bool`, optional): Create a new record if none found in the database.
cache (:obj:`Cache`, optional): Cache engine that will be used to fetch and update the cache.
Returns:
PycordGuild: Object of the found or newly created guild.
Raises:
GuildNotFoundError: Guild with such ID does not exist and creation was not allowed.
"""
cached_entry: Dict[str, Any] | None = restore_from_cache(
cls.__short_name__, guild_id, cache=cache
)
if cached_entry is not None:
return cls(**cls._entry_from_cache(cached_entry))
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one({"id": guild_id})
if db_entry is None:
if not allow_creation:
raise GuildNotFoundError(guild_id)
db_entry = PycordGuild.get_defaults(guild_id)
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{guild_id}", cls._entry_to_cache(db_entry))
db_entry["rules"] = (
None if db_entry["rules"] is None else GuildRules.from_json(db_entry["rules"])
)
return cls(**db_entry)
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert PycordGuild object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
Returns:
Dict[str, Any]: JSON representation of PycordGuild
"""
return {
"_id": self._id if not json_compatible else str(self._id),
"id": self.id,
"locale": self.locale,
"rules": None if self.rules is None else self.rules.to_dict(),
"emote_set_id": self.emote_set_id,
}
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
await super()._set(cache, **kwargs)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
await super()._remove(*args, cache=cache)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self.id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None:
super()._update_cache(cache)
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
super()._delete_cache(cache)
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
if cache_entry["rules"] is not None and isinstance(cache_entry["rules"], GuildRules):
cache_entry["rules"] = cache_entry["rules"].to_json()
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
db_entry["rules"] = (
None if db_entry["rules"] is None else GuildRules.from_json(db_entry["rules"])
)
return db_entry
# TODO Add documentation
@staticmethod
def get_defaults(guild_id: Optional[int] = None) -> Dict[str, Any]:
return {"id": guild_id, "locale": None, "rules": None, "emote_set_id": None}
# TODO Add documentation
@staticmethod
def get_default_value(key: str) -> Any:
if key not in PycordGuild.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in PycordGuild")
return PycordGuild.get_defaults()[key]
# TODO Add documentation
async def set_rules(self, rules: GuildRules, cache: Optional[Cache] = None) -> None:
await self.update(cache=cache, rules=rules.to_dict())
# TODO Add documentation
async def clear_rules(self, cache: Optional[Cache] = None) -> None:
await self.update(cache=cache, rules=None)
# TODO Add documentation
async def set_locale(self, locale: str, cache: Optional[Cache] = None) -> None:
await self.update(cache=cache, locale=locale)
# TODO Add documentation
async def reset_locale(self, cache: Optional[Cache] = None) -> None:
await self.update(cache=cache, locale=None)
# TODO Add documentation
async def set_emote_set_id(
self,
set_id: str,
cog: Optional[Cog] = None,
cache: Optional[Cache] = None,
) -> None:
if self.emote_set_id is not None and cog is not None:
await cog.unsubscribe_7tv_set(self.emote_set_id, self.id)
await self.update(cache=cache, emote_set_id=set_id)
if cog is not None:
await cog.subscribe_7tv_set(set_id, self.id)
# TODO Add documentation
async def reset_emote_set_id(
self, cog: Optional[Cog] = None, cache: Optional[Cache] = None
) -> None:
if cog is not None:
await cog.unsubscribe_7tv_set(self.emote_set_id, self.id)
await self.update(cache=cache, emote_set_id=None)

View File

@@ -0,0 +1,158 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from bson import ObjectId
from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult
from ..classes.base import BaseCacheable
from ..enums import GuildEmoteSource
from ..modules.database import col_guild_emotes
from ..modules.utils import restore_from_cache
@dataclass
class PycordGuildEmote(BaseCacheable):
"""Dataclass of DB entry of a guild emote"""
__slots__ = (
"_id",
"id",
"guild_id",
"name",
"source",
"source_id",
"source_set_id",
"created",
"modified",
)
__short_name__ = "guild_emote"
__collection__ = col_guild_emotes
_id: ObjectId
id: int
guild_id: int
name: str
source: GuildEmoteSource | None
source_id: str | None
source_set_id: str | None
created: datetime
modified: datetime
@classmethod
async def from_id(
cls,
id: int,
cache: Optional[Cache] = None,
) -> "PycordGuildEmote":
cached_entry: Dict[str, Any] | None = restore_from_cache(
cls.__short_name__, id, cache=cache
)
if cached_entry is not None:
return cls(**cls._entry_from_cache(cached_entry))
db_entry = await cls.__collection__.find_one({"id": id})
# TODO Replace with a real exception
if db_entry is None:
raise RuntimeError(f"Guild emote with ID {id} was not found")
# raise GuilEmoteNotFoundError(id)
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{id}", cls._entry_to_cache(db_entry))
return cls(**db_entry)
@classmethod
async def from_guild_and_source_id(
cls,
guild_id: int,
source_id: str,
) -> "PycordGuildEmote":
db_entry = await cls.__collection__.find_one(
{"guild_id": guild_id, "source_id": source_id}
)
# TODO Replace with a real exception
if db_entry is None:
raise RuntimeError(
f"Guild emote with source ID {source_id} in guild {guild_id} was not found"
)
# raise GuilEmoteNotFoundError(id)
return cls(**db_entry)
@classmethod
async def create(
cls,
id: int,
guild_id: int,
name: str,
source: Optional[GuildEmoteSource] = None,
source_id: Optional[str] = None,
source_set_id: Optional[str] = None,
cache: Optional[Cache] = None,
) -> "PycordGuildEmote":
db_entry: Dict[str, Any] = {
"id": id,
"guild_id": guild_id,
"name": name,
"source": None if source is None else source.value,
"source_id": source_id,
"source_set_id": source_set_id,
"created": datetime.now(tz=timezone.utc),
"modified": datetime.now(tz=timezone.utc),
}
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{id}", cls._entry_to_cache(db_entry))
return cls(**db_entry)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self.id}"
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError()
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError()
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert PycordEmote object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
Returns:
Dict[str, Any]: JSON representation of PycordEmote
"""
raise NotImplementedError()
@staticmethod
def get_defaults(
owner_id: Optional[int] = None, guild_id: Optional[int] = None
) -> Dict[str, Any]:
raise NotImplementedError()
@staticmethod
def get_default_value(key: str) -> Any:
if key not in PycordGuildEmote.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in PycordEmote")
return PycordGuildEmote.get_defaults()[key]
async def update_name(
self,
name: str,
cache: Optional[Cache] = None,
) -> None:
await self._set(cache=cache, name=name)

View File

@@ -0,0 +1,278 @@
import logging
from dataclasses import dataclass
from datetime import datetime
from logging import Logger
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
from bson import ObjectId
from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult
from ..classes import Consent
from ..classes.base import BaseCacheable
from ..classes.errors.pycord_user import UserNotFoundError
from ..classes.wallet import Wallet
from ..enums import ConsentScope
from ..modules.database import col_users
from ..modules.utils import restore_from_cache
logger: Logger = logging.getLogger(__name__)
@dataclass
class PycordUser(BaseCacheable):
"""Dataclass of DB entry of a user"""
__slots__ = ("_id", "id", "guild_id")
__short_name__ = "user"
__collection__ = col_users
_id: ObjectId
id: int
guild_id: int
@classmethod
async def from_id(
cls,
user_id: int,
guild_id: int,
allow_creation: bool = True,
cache: Optional[Cache] = None,
) -> "PycordUser":
"""Find user in database and create new record if user does not exist.
Args:
user_id (int): User's Discord ID
guild_id (int): User's guild Discord ID
allow_creation (:obj:`bool`, optional): Create new user record if none found in the database
cache (:obj:`Cache`, optional): Cache engine to get the cache from
Returns:
PycordUser: User object
Raises:
UserNotFoundError: User was not found and creation was not allowed
"""
cached_entry: Dict[str, Any] | None = restore_from_cache(
cls.__short_name__, f"{user_id}_{guild_id}", cache=cache
)
if cached_entry is not None:
return cls(**cls._entry_from_cache(cached_entry))
db_entry: Dict[str, Any] | None = await cls.__collection__.find_one(
{"id": user_id, "guild_id": guild_id}
)
if db_entry is None:
if not allow_creation:
raise UserNotFoundError(user_id, guild_id)
db_entry = PycordUser.get_defaults(user_id, guild_id)
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(
f"{cls.__short_name__}_{user_id}_{guild_id}",
cls._entry_to_cache(db_entry),
)
return cls(**db_entry)
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert PycordUser object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
Returns:
Dict[str, Any]: JSON representation of PycordUser
"""
return {
"_id": self._id if not json_compatible else str(self._id),
"id": self.id,
"guild_id": self.guild_id,
}
async def _set(self, cache: Optional[Cache] = None, **kwargs: Any) -> None:
await super()._set(cache, **kwargs)
async def _remove(self, *args: str, cache: Optional[Cache] = None) -> None:
await super()._remove(*args, cache=cache)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self.id}_{self.guild_id}"
def _update_cache(self, cache: Optional[Cache] = None) -> None:
super()._update_cache(cache)
def _delete_cache(self, cache: Optional[Cache] = None) -> None:
super()._delete_cache(cache)
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
return db_entry
# TODO Add documentation
@staticmethod
def get_defaults(
user_id: Optional[int] = None, guild_id: Optional[int] = None
) -> Dict[str, Any]:
return {
"id": user_id,
"guild_id": guild_id,
}
# TODO Add documentation
@staticmethod
def get_default_value(key: str) -> Any:
if key not in PycordUser.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in PycordUser")
return PycordUser.get_defaults()[key]
async def update(
self,
cache: Optional[Cache] = None,
**kwargs: Any,
) -> None:
await super().update(cache=cache, **kwargs)
async def reset(
self,
*args: str,
cache: Optional[Cache] = None,
) -> None:
await super().reset(*args, cache=cache)
async def purge(self, cache: Optional[Cache] = None) -> None:
await super().purge(cache)
async def get_wallet(self, guild_id: int, cache: Optional[Cache] = None) -> Wallet:
"""Get wallet of the user.
Args:
guild_id (int): Guild ID of the wallet
cache (:obj:`Cache`, optional): Cache engine to get the cache from
Returns:
Wallet: Wallet object of the user
"""
return await Wallet.from_id(self.id, guild_id, cache=cache)
# TODO Add documentation
async def has_active_consent(self, scope: ConsentScope) -> bool:
# TODO Test this query
consent: Dict[str, Any] | None = await Consent.__collection__.find_one(
{
"user_id": self.id,
"guild_id": self.guild_id,
"scope": scope.value,
"withdrawal_date": None,
"$or": [
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
{"expiration_date": None},
],
}
)
return consent is not None
# TODO Add documentation
async def give_consent(
self,
scope: ConsentScope,
expiration_date: Optional[datetime] = None,
cache: Optional[Cache] = None,
) -> None:
await Consent.give(self.id, self.guild_id, scope, expiration_date, cache=cache)
# TODO Add documentation
async def withdraw_consent(
self,
scope: ConsentScope,
cache: Optional[Cache] = None,
) -> None:
# TODO Test this query
await Consent.__collection__.update_many(
{
"user_id": self.id,
"guild_id": self.guild_id,
"scope": scope.value,
"withdrawal_date": None,
"$or": [
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
{"expiration_date": None},
],
},
{"$set": {"withdrawal_date": datetime.now(tz=ZoneInfo("UTC"))}},
)
if cache is not None:
cache.delete(Consent.get_cache_key(self.id, self.guild_id, scope))
# TODO Add documentation
async def withdraw_all_consents(
self,
cache: Optional[Cache] = None,
) -> None:
filter: Dict[str, Any] = {
"user_id": self.id,
"guild_id": self.guild_id,
"withdrawal_date": None,
"$or": [
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
{"expiration_date": None},
],
}
consents: List[Dict[str, Any]] | None = await Consent.__collection__.find(
filter
).to_list()
await Consent.__collection__.update_many(
filter,
{"$set": {"withdrawal_date": datetime.now(tz=ZoneInfo("UTC"))}},
)
if cache is not None and consents is not None:
for consent in consents:
cache.delete(
Consent.get_cache_key(
self.id, self.guild_id, ConsentScope(consent["scope"])
)
)
async def get_consents(self) -> List[Consent]:
consents: List[Consent] = []
async for consent in Consent.__collection__.find(
{
"user_id": self.id,
"guild_id": self.guild_id,
"withdrawal_date": None,
"$or": [
{"expiration_date": {"$gte": datetime.now(tz=ZoneInfo("UTC"))}},
{"expiration_date": None},
],
}
):
consents.append(Consent.from_entry(consent))
return consents

View File

@@ -0,0 +1,32 @@
from dataclasses import dataclass
from typing import Any, Dict
from bson import ObjectId
from ..enums import ScheduledActionType
@dataclass
class ScheduledAction:
"""Dataclass of DB entry of a scheduled action"""
__slots__ = (
"_id",
"action_type",
"action_schedule",
"action_data",
"invoke_on_start",
)
_id: ObjectId
action_type: ScheduledActionType
action_schedule: str
action_data: Dict[str, Any] | None
invoke_on_start: bool
# TODO Add documentation
@classmethod
def from_entry(cls, db_entry: Dict[str, Any]) -> "ScheduledAction":
db_entry["action_type"] = ScheduledActionType(db_entry["action_type"])
return cls(**db_entry)

View File

@@ -0,0 +1,23 @@
from dataclasses import dataclass
from typing import Dict, Literal, Optional
from ..enums import HealthStatus
@dataclass
class ServiceStatus:
status: Literal[
HealthStatus.OPERATIONAL,
HealthStatus.DEGRADED,
HealthStatus.FAILED,
HealthStatus.UNKNOWN,
]
message: str | None
def to_json(self, detailed: Optional[bool] = False) -> Dict[str, str | None]:
output: Dict[str, str | None] = {"status": self.status.value}
if detailed:
output["message"] = self.message
return output

View File

@@ -0,0 +1,216 @@
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from logging import Logger
from typing import Any, Dict, Optional
from bson import ObjectId
from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult
from ..classes.base import BaseCacheable
from ..classes.errors.wallet import (
WalletBalanceLimitExceeded,
WalletInsufficientFunds,
WalletNotFoundError,
WalletOverdraftLimitExceeded,
)
from ..modules.database import col_wallets
from ..modules.utils import restore_from_cache
logger: Logger = logging.getLogger(__name__)
@dataclass
class Wallet(BaseCacheable):
"""Dataclass of DB entry of a wallet"""
__slots__ = ("_id", "owner_id", "guild_id", "balance", "is_frozen", "created")
__short_name__ = "wallet"
__collection__ = col_wallets
_id: ObjectId
owner_id: int
guild_id: int
balance: float
is_frozen: bool
created: datetime
# TODO Write a docstring
@classmethod
async def from_id(
cls,
owner_id: int,
guild_id: int,
allow_creation: bool = True,
cache: Optional[Cache] = None,
) -> "Wallet":
cached_entry: Dict[str, Any] | None = restore_from_cache(
cls.__short_name__, f"{owner_id}_{guild_id}", cache=cache
)
if cached_entry is not None:
return cls(**cls._entry_from_cache(cached_entry))
db_entry = await col_wallets.find_one({"owner_id": owner_id, "guild_id": guild_id})
if db_entry is None:
if not allow_creation:
raise WalletNotFoundError(owner_id, guild_id)
db_entry = Wallet.get_defaults(owner_id, guild_id)
insert_result: InsertOneResult = await col_wallets.insert_one(db_entry)
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(
f"{cls.__short_name__}_{owner_id}_{guild_id}",
cls._entry_to_cache(db_entry),
)
return cls(**db_entry)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self.owner_id}_{self.guild_id}"
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
cache_entry: Dict[str, Any] = db_entry.copy()
cache_entry["_id"] = str(cache_entry["_id"])
cache_entry["created"] = cache_entry["created"].isoformat()
return cache_entry
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
db_entry: Dict[str, Any] = cache_entry.copy()
db_entry["_id"] = ObjectId(db_entry["_id"])
db_entry["created"] = datetime.fromisoformat(db_entry["created"])
return db_entry
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert Wallet object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
Returns:
Dict[str, Any]: JSON representation of Wallet
"""
return {
"_id": self._id if not json_compatible else str(self._id),
"owner_id": self.owner_id,
"guild_id": self.guild_id,
"balance": self.balance,
"is_frozen": self.is_frozen,
"created": self.created if not json_compatible else self.created.isoformat(),
}
@staticmethod
def get_defaults(
owner_id: Optional[int] = None, guild_id: Optional[int] = None
) -> Dict[str, Any]:
return {
"owner_id": owner_id,
"guild_id": guild_id,
"balance": 0.0,
"is_frozen": False,
"created": datetime.now(tz=timezone.utc),
}
@staticmethod
def get_default_value(key: str) -> Any:
if key not in Wallet.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in Wallet")
return Wallet.get_defaults()[key]
# TODO Write a docstring
async def freeze(
self,
cache: Optional[Cache] = None,
) -> None:
await self.update(cache, is_frozen=True)
# TODO Write a docstring
async def unfreeze(
self,
cache: Optional[Cache] = None,
) -> None:
await self.update(cache, is_frozen=False)
# TODO Write a docstring
async def deposit(
self,
amount: float,
balance_limit: Optional[float] = None,
cache: Optional[Cache] = None,
) -> float:
new_balance: float = round(self.balance + amount, 2)
if balance_limit is not None and new_balance > balance_limit:
raise WalletBalanceLimitExceeded(self, amount, balance_limit)
await self.update(cache, balance=new_balance)
return new_balance
# TODO Write a docstring
async def withdraw(
self,
amount: float,
allow_overdraft: bool = False,
overdraft_limit: Optional[float] = None,
cache: Optional[Cache] = None,
) -> float:
if amount > self.balance:
if not allow_overdraft or overdraft_limit is None:
raise WalletInsufficientFunds(self, amount)
if allow_overdraft and amount > overdraft_limit:
raise WalletOverdraftLimitExceeded(self, amount, overdraft_limit)
new_balance: float = round(self.balance - amount, 2)
await self.update(cache, balance=new_balance)
return new_balance
async def transfer(
self,
wallet_owner_id: int,
wallet_guild_id: int,
amount: float,
balance_limit: Optional[float] = None,
allow_overdraft: bool = False,
overdraft_limit: Optional[float] = None,
cache: Optional[Cache] = None,
) -> None:
# TODO Replace with a concrete exception
if amount < 0:
raise ValueError()
# allow_creation might need to be set to False in the future
# if users will be able to opt out from having a wallet
wallet: Wallet = await self.from_id(
wallet_owner_id, wallet_guild_id, allow_creation=True, cache=cache
)
if balance_limit is not None and amount + wallet.balance > balance_limit:
raise WalletBalanceLimitExceeded(wallet, amount, balance_limit)
if amount > self.balance:
if not allow_overdraft or overdraft_limit is None:
raise WalletInsufficientFunds(self, amount)
if allow_overdraft and amount > overdraft_limit:
raise WalletOverdraftLimitExceeded(self, amount, overdraft_limit)
# TODO Make a sanity check to revert the transaction if anything goes wrong
await self.withdraw(amount, allow_overdraft, overdraft_limit, cache=cache)
await wallet.deposit(amount, balance_limit, cache=cache)

42
src/javelina/cli.py Normal file
View File

@@ -0,0 +1,42 @@
from importlib import resources
from pathlib import Path
from typer import Option, Typer, echo
from .modules.migrator import migrate_database
cli: Typer = Typer()
@cli.command()
def init(
destination: Path = Option(
"config.json", help="File to write the default configuration to"
),
overwrite: bool = Option(False, help="Overwrite config if already exists"),
) -> None:
if destination.exists() and not overwrite:
raise FileExistsError(
f"File at {destination} already exists. Pass --overwrite to overwrite it"
)
with open(destination, "w", encoding="utf-8") as config_file:
config_file.write(resources.read_text("javelina.resources", "config_example.json"))
echo(f"Copied default config to {destination}")
@cli.command()
def migrate(
config: Path = Option("config.json", help="Config file"),
) -> None:
echo("Performing migrations...")
with resources.path("javelina.migrations", "__init__.py") as init_file:
migrations_path: Path = init_file.parent
migrate_database(config_file=config, migrations_path=migrations_path)
def main() -> None:
cli()

View File

@@ -0,0 +1,124 @@
from typing import Callable, List
from discord import (
ApplicationContext,
Cog,
Forbidden,
InteractionContextType,
Message,
User,
default_permissions,
option,
slash_command,
)
from libbot.i18n import _, in_every_locale
from ..classes.pycord_bot import PycordBot
from ..utils.package_utils import get_locales_root
class CogAdmin(Cog):
"""Cog with the guessing command."""
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
@slash_command(
name="reload",
description=_("description", "commands", "reload", locales_root=get_locales_root()),
description_localizations=in_every_locale(
"description", "commands", "reload", locales_root=get_locales_root()
),
contexts={InteractionContextType.bot_dm},
)
async def command_reload(self, ctx: ApplicationContext) -> None:
if ctx.user.id not in self.bot.owner_ids:
await ctx.respond(
embed=self.bot.create_embed_error(
self.bot._(
"permission_denied_title",
"messages",
"general",
locale=ctx.locale,
),
self.bot._("permission_denied", "messages", "general", locale=ctx.locale),
),
ephemeral=True,
)
return
try:
self.bot.reload()
await self.bot.set_status()
await ctx.respond(
embed=self.bot.create_embed_success(
self.bot._("success_title", "messages", "general", locale=ctx.locale),
self.bot._("config_reload_success", "messages", "admin", locale=ctx.locale),
)
)
except Exception as exc:
await ctx.respond(
embed=self.bot.create_embed_error(
self.bot._("error_title", "messages", "general", locale=ctx.locale),
self.bot._(
"config_reload_error", "messages", "admin", locale=ctx.locale
).format(error=exc),
),
ephemeral=True,
)
# TODO Implement i18n
# TODO Account for rate limits
@slash_command(
name="clear",
description=_("description", "commands", "clear", locales_root=get_locales_root()),
description_localizations=in_every_locale(
"description", "commands", "clear", locales_root=get_locales_root()
),
contexts={InteractionContextType.guild},
)
@option("amount", description="How many messages to delete", min_value=1, max_value=100)
@option(
"user",
description="User whose balance to check (if not your own)",
required=False,
)
@default_permissions(manage_messages=True)
async def command_clear(
self, ctx: ApplicationContext, amount: int, user: User = None
) -> None:
try:
check: Callable[[Message], bool] = (
(lambda msg: True) if user is None else (lambda msg: msg.author.id == user.id)
)
purged_messages: List[Message] = await ctx.channel.purge(limit=amount, check=check)
await ctx.respond(
embed=self.bot.create_embed(
description=f"Deleted {len(purged_messages)} message(s)."
),
ephemeral=True,
delete_after=3.0,
)
except Forbidden:
await ctx.respond(
embed=self.bot.create_embed_error(
"Missing permissions",
description="The bot does not have permission to delete messages. Please, allow the bot to delete messages and try again.",
),
ephemeral=True,
)
except Exception as exc:
await ctx.respond(
embed=self.bot.create_embed_error(
"Something went wrong",
description=f"Could not delete messages:\n```\n{exc}\n```",
),
ephemeral=True,
)
def setup(bot: PycordBot) -> None:
bot.add_cog(CogAdmin(bot))

View File

@@ -0,0 +1,99 @@
from datetime import datetime
from logging import Logger
from zoneinfo import ZoneInfo
from discord import Cog, Message
from ..classes.pycord_bot import PycordBot
from ..enums import AnalyticsEventType
from ..modules.database import col_analytics
from ..modules.utils import get_logger
logger: Logger = get_logger(__name__)
class CogAnalytics(Cog):
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
@Cog.listener()
async def on_message(self, message: Message) -> None:
if (
message.guild is None
or message.channel is None
or message.author is None
or message.author.bot
):
return
await col_analytics.insert_one(
{
"event_type": AnalyticsEventType.GUILD_MESSAGE_SENT.value,
"event_date": message.created_at,
"guild_id": message.guild.id,
"channel_id": message.channel.id,
"message_id": message.id,
"user_id": message.author.id,
"is_deleted": False,
}
)
@Cog.listener()
async def on_message_edit(self, before: Message, after: Message) -> None:
if (
after.guild is None
or after.channel is None
or after.author is None
or after.author.bot
):
return
await col_analytics.insert_one(
{
"event_type": AnalyticsEventType.GUILD_MESSAGE_EDITED.value,
"event_date": after.edited_at,
"guild_id": after.guild.id,
"channel_id": after.channel.id,
"message_id": after.id,
"user_id": after.author.id,
}
)
@Cog.listener()
async def on_message_delete(self, message: Message) -> None:
if (
message.guild is None
or message.channel is None
or message.author is None
or message.author.bot
):
return
await col_analytics.insert_one(
{
"event_type": AnalyticsEventType.GUILD_MESSAGE_DELETED.value,
"event_date": datetime.now(tz=ZoneInfo("UTC")),
"guild_id": message.guild.id,
"channel_id": message.channel.id,
"message_id": message.id,
"user_id": message.author.id,
}
)
await col_analytics.update_one(
{
"event_type": AnalyticsEventType.GUILD_MESSAGE_SENT.value,
"guild_id": message.guild.id,
"channel_id": message.channel.id,
"message_id": message.id,
},
{
"$set": {
"is_deleted": True,
}
},
)
def setup(bot: PycordBot) -> None:
bot.add_cog(CogAnalytics(bot))

View File

@@ -0,0 +1,410 @@
import logging
from datetime import datetime
from logging import Logger
from typing import Any, Dict, List
from zoneinfo import ZoneInfo
from discord import (
ApplicationContext,
Interaction,
InteractionContextType,
OptionChoice,
SlashCommandGroup,
option,
)
from discord.ext import commands
from libbot.i18n import _, in_every_locale
from tempora import parse_timedelta
from ..classes import Consent, PycordUser
from ..classes.pycord_bot import PycordBot
from ..enums import ConsentDuration, ConsentScope
from ..utils.package_utils import get_locales_root
logger: Logger = logging.getLogger(__name__)
class CogConsent(commands.Cog):
def __init__(self, client: PycordBot):
self.bot: PycordBot = client
command_group: SlashCommandGroup = SlashCommandGroup(
"consent",
description=_("description", "commands", "consent", locales_root=get_locales_root()),
description_localizations=in_every_locale(
"description", "commands", "consent", locales_root=get_locales_root()
),
contexts={InteractionContextType.guild},
)
@staticmethod
def _get_scope_choices() -> List[OptionChoice]:
choices: List[OptionChoice] = []
for scope in ConsentScope._member_map_.values():
scope_value: str = scope.value
choices.append(
OptionChoice(
_(
"name",
"data_control",
"scopes",
scope_value,
locales_root=get_locales_root(),
),
scope_value,
in_every_locale(
"name",
"data_control",
"scopes",
scope_value,
locales_root=get_locales_root(),
),
)
)
return choices
@staticmethod
def _get_consent_durations() -> List[OptionChoice]:
choices: List[OptionChoice] = []
for duration in ConsentDuration._member_map_.values():
duration_value: str = duration.value
choices.append(
OptionChoice(
_(
duration_value,
"data_control",
"consent_durations",
locales_root=get_locales_root(),
),
duration_value,
in_every_locale(
duration_value,
"data_control",
"consent_durations",
locales_root=get_locales_root(),
),
)
)
return choices
# /consent terms <scope>
# Will provide information about terms
# TODO Implement i18n
# TODO Implement consent duration
@command_group.command(
name="terms",
description=_(
"description", "commands", "consent_terms", locales_root=get_locales_root()
),
description_localizations=in_every_locale(
"description", "commands", "consent_terms", locales_root=get_locales_root()
),
)
@option(
"scope",
description="Scope of the consent",
choices=_get_scope_choices(),
)
async def command_consent_terms(self, ctx: ApplicationContext, scope: str) -> None:
scopes_config: Dict[str, Dict[str, Any]] = self.bot.config["modules"]["consent"][
"scopes"
]
if scope not in scopes_config:
await ctx.respond(
embed=self.bot.create_embed_error(
self.bot._("error_title", "messages", "admin", locale=ctx.locale),
"Scope does not exist in the config!",
),
ephemeral=True,
)
return
scope_config: Dict[str, Any] = scopes_config[scope]
interaction: Interaction = await ctx.respond(
embed=self.bot.create_embed(
"Terms for {third_party_notice}**{scope_name}**".format(
third_party_notice=(
"" if not scope_config["is_third_party"] else "a third-party scope "
),
scope_name=self.bot._(
"name", "data_control", "scopes", scope, locale=ctx.locale
),
),
"Terms of use: {terms_url}\nPrivacy policy: {privacy_url}\n\nNote: Any consent given on this Discord server will be valid only for this server.".format(
terms_url=scope_config["terms_url"],
privacy_url=scope_config["privacy_url"],
),
),
ephemeral=True,
)
# /consent give <scope> [<duration>]
# Will provide information about terms and a button to confirm
# TODO Implement i18n
@command_group.command(
name="give",
description=_(
"description", "commands", "consent_give", locales_root=get_locales_root()
),
description_localizations=in_every_locale(
"description", "commands", "consent_give", locales_root=get_locales_root()
),
)
@option(
"scope",
description="Scope of the consent",
choices=_get_scope_choices(),
)
@option(
"duration",
description="Duration of the consent",
choices=_get_consent_durations(),
)
async def command_consent_give(
self,
ctx: ApplicationContext,
scope: str,
duration: str = ConsentDuration.NORMAL.value,
) -> None:
scopes_config: Dict[str, Dict[str, Any]] = self.bot.config["modules"]["consent"][
"scopes"
]
if scope not in scopes_config:
await ctx.respond(
embed=self.bot.create_embed_error(
self.bot._("error_title", "messages", "admin", locale=ctx.locale),
"Scope does not exist in the config!",
),
ephemeral=True,
)
return
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
is_scope_third_party: bool = self.bot.config["modules"]["consent"]["scopes"][scope][
"is_third_party"
]
expiration_date: datetime = datetime.now(tz=ZoneInfo("UTC")) + parse_timedelta(
self.bot.config["modules"]["consent"]["durations"][
"third_party" if is_scope_third_party else "first_party"
][duration]
)
try:
await user.give_consent(ConsentScope(scope), expiration_date, cache=self.bot.cache)
await ctx.respond(
embed=self.bot.create_embed_success("Success", "Consent has been given."),
ephemeral=True,
)
except Exception as exc:
logger.error("Could not give consent due to: %s", exc, exc_info=exc)
await ctx.respond(
embed=self.bot.create_embed_error("Error", "Something went wrong!"),
ephemeral=True,
)
# /consent withdraw <scope>
# Will directly withdraw consent if confirmation is provided
# TODO Implement i18n
# TODO Implement the command
@command_group.command(
name="withdraw",
description=_(
"description",
"commands",
"consent_withdraw",
locales_root=get_locales_root(),
),
description_localizations=in_every_locale(
"description",
"commands",
"consent_withdraw",
locales_root=get_locales_root(),
),
)
@option(
"scope",
description="Scope of the consent",
choices=_get_scope_choices(),
)
async def command_consent_withdraw(self, ctx: ApplicationContext, scope: str) -> None:
scopes_config: Dict[str, Dict[str, Any]] = self.bot.config["modules"]["consent"][
"scopes"
]
if scope not in scopes_config:
await ctx.respond(
embed=self.bot.create_embed_error(
self.bot._("error_title", "messages", "admin", locale=ctx.locale),
"Scope does not exist in the config!",
),
ephemeral=True,
)
return
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
await user.withdraw_consent(ConsentScope(scope), cache=self.bot.cache)
await ctx.respond(
embed=self.bot.create_embed("Done", "The consent has been withdrawn."),
ephemeral=True,
)
# /consent give_all [<duration>] [<confirm>]
# Will inform about necessity to review all scopes and a button to confirm
# TODO Implement i18n
@command_group.command(
name="give_all",
description=_(
"description",
"commands",
"consent_give_all",
locales_root=get_locales_root(),
),
description_localizations=in_every_locale(
"description",
"commands",
"consent_give_all",
locales_root=get_locales_root(),
),
)
@option(
"duration",
description="Duration of the consent",
choices=_get_consent_durations(),
)
@option(
"confirm",
description="Confirmation of the action",
required=False,
)
async def command_consent_give_all(
self,
ctx: ApplicationContext,
confirm: bool = False,
duration: str = ConsentDuration.NORMAL.value,
) -> None:
if not confirm:
await ctx.respond("Operation not confirmed!", ephemeral=True)
return
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
try:
for scope in ConsentScope._member_map_.values():
is_scope_third_party: bool = self.bot.config["modules"]["consent"]["scopes"][
scope.value
]["is_third_party"]
expiration_date: datetime = datetime.now(tz=ZoneInfo("UTC")) + parse_timedelta(
self.bot.config["modules"]["consent"]["durations"][
"third_party" if is_scope_third_party else "first_party"
][duration]
)
await user.give_consent(
ConsentScope(scope), expiration_date, cache=self.bot.cache
)
except Exception as exc:
logger.error("Could not give consent due to: %s", exc, exc_info=exc)
await ctx.respond(
embed=self.bot.create_embed_error("Error", "Something went wrong!"),
ephemeral=True,
)
return
await ctx.respond(
embed=self.bot.create_embed_success("Success", "Consent has been given."),
ephemeral=True,
)
# /consent withdraw_all [<confirm>]
# Will directly withdraw all consents if confirmation is provided
# TODO Implement i18n
@command_group.command(
name="withdraw_all",
description=_(
"description",
"commands",
"consent_withdraw_all",
locales_root=get_locales_root(),
),
description_localizations=in_every_locale(
"description",
"commands",
"consent_withdraw_all",
locales_root=get_locales_root(),
),
)
@option(
"confirm",
description="Confirmation of the action",
required=False,
)
async def command_consent_withdraw_all(
self, ctx: ApplicationContext, confirm: bool = False
) -> None:
if not confirm:
await ctx.respond("Operation not confirmed!", ephemeral=True)
return
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
await user.withdraw_all_consents(cache=self.bot.cache)
await ctx.respond(
embed=self.bot.create_embed("Done", "All consents have been withdrawn."),
ephemeral=True,
)
# /consent review
# Will show all consents provided by the user, including scopes and expiration dates
# TODO Implement i18n
@command_group.command(
name="review",
description=_(
"description", "commands", "consent_review", locales_root=get_locales_root()
),
description_localizations=in_every_locale(
"description", "commands", "consent_review", locales_root=get_locales_root()
),
)
async def command_consent_review(self, ctx: ApplicationContext) -> None:
user: PycordUser = await self.bot.find_user(ctx.user, ctx.guild_id)
consents: List[Consent] = await user.get_consents()
if len(consents) == 0:
await ctx.respond(
embed=self.bot.create_embed("Consents", "You have no active consents."),
ephemeral=True,
)
return
joined_consents: str = "\n".join(
[
f"`{consent.scope.value}` - Expires <t:{int(consent.expiration_date.timestamp())}>"
for consent in consents
]
)
await ctx.respond(
embed=self.bot.create_embed("Consents", f"Active consents:\n{joined_consents}"),
ephemeral=True,
)
def setup(client: PycordBot) -> None:
client.add_cog(CogConsent(client))

View File

@@ -0,0 +1,73 @@
import logging
from logging import Logger
from discord import (
ApplicationContext,
InteractionContextType,
SlashCommandGroup,
option,
)
from discord.ext import commands
from libbot.i18n import _, in_every_locale
from ..classes.pycord_bot import PycordBot
from ..utils.package_utils import get_locales_root
logger: Logger = logging.getLogger(__name__)
class CogData(commands.Cog):
def __init__(self, client: PycordBot):
self.bot: PycordBot = client
command_group: SlashCommandGroup = SlashCommandGroup(
"data",
description=_("description", "commands", "data", locales_root=get_locales_root()),
description_localizations=in_every_locale(
"description", "commands", "data", locales_root=get_locales_root()
),
contexts={InteractionContextType.guild},
)
# /data checkout
# Export all user data in a ZIP archive
# TODO Implement i18n
# TODO Implement the command
@command_group.command(
name="checkout",
description=_(
"description", "commands", "data_checkout", locales_root=get_locales_root()
),
description_localizations=in_every_locale(
"description", "commands", "data_checkout", locales_root=get_locales_root()
),
)
async def command_data_checkout(self, ctx: ApplicationContext) -> None:
await ctx.respond("Command is not implemented!", ephemeral=True)
# /data purge [<confirm>]
# Soft-delete all user data
# TODO Implement i18n
# TODO Implement the command
@command_group.command(
name="purge",
description=_("description", "commands", "data_purge", locales_root=get_locales_root()),
description_localizations=in_every_locale(
"description", "commands", "data_purge", locales_root=get_locales_root()
),
)
@option(
"confirm",
description="Confirmation of the action",
required=False,
)
async def command_data_purge(self, ctx: ApplicationContext, confirm: bool = False) -> None:
if not confirm:
await ctx.respond("Operation not confirmed!", ephemeral=True)
return
await ctx.respond("Command is not implemented!", ephemeral=True)
def setup(client: PycordBot) -> None:
client.add_cog(CogData(client))

View File

@@ -0,0 +1,14 @@
from discord import Cog
from ..classes.pycord_bot import PycordBot
class CogOrganizational(Cog):
"""Cog with the guessing command."""
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
def setup(bot: PycordBot) -> None:
bot.add_cog(CogOrganizational(bot))

View File

@@ -0,0 +1,47 @@
from logging import Logger
from discord import ApplicationContext, Cog, DiscordException
from discord.ext.commands import CheckFailure
from ..classes.pycord_bot import PycordBot
from ..enums import EmbedColor
from ..modules.utils import get_logger
logger: Logger = get_logger(__name__)
class CogUtility(Cog):
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
@Cog.listener()
async def on_ready(self) -> None:
"""Listener for the event when bot connects to Discord and becomes "ready"."""
logger.info("Logged in as %s", self.bot.user)
await self.bot.set_status()
# TODO Should probably also add an error message displayed to users
@Cog.listener()
async def on_application_command_error(
self, ctx: ApplicationContext, error: DiscordException
) -> None:
if isinstance(error, CheckFailure):
await ctx.respond(
embed=self.bot.create_embed(
"Something went wrong", str(error), color=EmbedColor.ERROR
),
ephemeral=True,
)
return
logger.error(
"An error has occurred during execution of a command %s: %s",
ctx.command,
error,
exc_info=error,
)
def setup(bot: PycordBot) -> None:
bot.add_cog(CogUtility(bot))

View File

@@ -0,0 +1,125 @@
import logging
from logging import Logger
from discord import (
ApplicationContext,
InteractionContextType,
SlashCommandGroup,
User,
option,
)
from discord.ext import commands
from libbot.i18n import _, in_every_locale
from ..classes.errors import WalletInsufficientFunds
from ..classes.pycord_bot import PycordBot
from ..classes.wallet import Wallet
from ..enums import ConsentScope
from ..modules.middleware import user_consent_required
from ..utils.package_utils import get_locales_root
logger: Logger = logging.getLogger(__name__)
class CogWallet(commands.Cog):
def __init__(self, client: PycordBot):
self.bot: PycordBot = client
command_group: SlashCommandGroup = SlashCommandGroup(
"wallet",
description=_("description", "commands", "wallet", locales_root=get_locales_root()),
description_localizations=in_every_locale(
"description", "commands", "wallet", locales_root=get_locales_root()
),
contexts={InteractionContextType.guild},
)
@command_group.command(
name="balance",
description=_(
"description", "commands", "wallet_balance", locales_root=get_locales_root()
),
description_localizations=in_every_locale(
"description", "commands", "wallet_balance", locales_root=get_locales_root()
),
)
@option(
"user",
description="User whose balance to check (if not your own)",
required=False,
)
@user_consent_required(ConsentScope.GENERAL)
async def command_wallet_balance(self, ctx: ApplicationContext, user: User = None) -> None:
wallet: Wallet = await Wallet.from_id(
ctx.user.id if not user else user.id, ctx.guild_id, cache=self.bot.cache
)
await ctx.respond(
embed=self.bot.create_embed(
self.bot._("balance_title", "messages", "wallet", locale=ctx.locale),
(
self.bot._("balance_own", "messages", "wallet", locale=ctx.locale).format(
balance=wallet.balance
)
if user is None
else self.bot._(
"balance_user", "messages", "wallet", locale=ctx.locale
).format(balance=wallet.balance, user=user.display_name)
),
)
)
@command_group.command(
name="transfer",
description=_(
"description",
"commands",
"wallet_transfer",
locales_root=get_locales_root(),
),
description_localizations=in_every_locale(
"description",
"commands",
"wallet_transfer",
locales_root=get_locales_root(),
),
)
@option("user", description="Recipient")
@option("amount", description="Amount", min_value=0.01)
async def command_wallet_transfer(
self, ctx: ApplicationContext, user: User, amount: float
) -> None:
amount = round(amount, 2)
# Guild will be needed for overdraft options
# guild: PycordGuild = await PycordGuild.from_id(ctx.guild_id)
wallet: Wallet = await Wallet.from_id(ctx.user.id, ctx.guild_id, cache=self.bot.cache)
try:
await wallet.transfer(user.id, ctx.guild_id, amount)
except WalletInsufficientFunds:
await ctx.respond(
embed=self.bot.create_embed_error(
self.bot._("error_title", "messages", "general", locale=ctx.locale),
self.bot._(
"transfer_insufficient_funds",
"messages",
"wallet",
locale=ctx.locale,
).format(amount=round(abs(wallet.balance - amount), 2)),
)
)
return
await ctx.respond(
embed=self.bot.create_embed_success(
self.bot._("success_title", "messages", "general", locale=ctx.locale),
self.bot._("transfer_success", "messages", "wallet", locale=ctx.locale).format(
amount=amount, recipient=user.display_name
),
)
)
def setup(client: PycordBot) -> None:
client.add_cog(CogWallet(client))

View File

@@ -0,0 +1,10 @@
from .analytics_event_type import AnalyticsEventType
from .cache_ttl import CacheTTL
from .consent_duration import ConsentDuration
from .consent_scope import ConsentScope
from .embed_color import EmbedColor
from .guild_emote_source import GuildEmoteSource
from .health_status import HealthStatus
from .message_events import MessageEvents
from .punishment import Punishment
from .scheduled_action_type import ScheduledActionType

View File

@@ -0,0 +1,7 @@
from enum import Enum
class AnalyticsEventType(Enum):
GUILD_MESSAGE_SENT = "guild_message_sent"
GUILD_MESSAGE_EDITED = "guild_message_edited"
GUILD_MESSAGE_DELETED = "guild_message_deleted"

View File

@@ -0,0 +1,7 @@
from enum import Enum
class CacheTTL(Enum):
SHORT = 300
NORMAL = 3600
LONG = 86400

View File

@@ -0,0 +1,7 @@
from enum import Enum
class ConsentDuration(Enum):
SHORT = "short"
NORMAL = "normal"
LONG = "long"

View File

@@ -0,0 +1,7 @@
from enum import Enum
class ConsentScope(Enum):
GENERAL = "general"
INTEGRATION_DEEPL = "integration_deepl"
INTEGRATION_7TV = "integration_7tv"

View File

@@ -0,0 +1,9 @@
from enum import Enum
class EmbedColor(Enum):
PRIMARY = "primary"
SECONDARY = "secondary"
SUCCESS = "success"
WARNING = "warning"
ERROR = "error"

View File

@@ -0,0 +1,5 @@
from enum import StrEnum, auto
class GuildEmoteSource(StrEnum):
SEVEN_TV = auto()

View File

@@ -0,0 +1,8 @@
from enum import Enum
class HealthStatus(Enum):
OPERATIONAL = "operational"
DEGRADED = "degraded"
FAILED = "failed"
UNKNOWN = "unknown"

View File

@@ -0,0 +1,5 @@
from enum import Enum
class MessageEvents(Enum):
WEATHER_FORECAST = 0

View File

@@ -0,0 +1,8 @@
from enum import Enum
class Punishment(Enum):
WARNING = 0
MUTE = 1
KICK = 2
BAN = 3

View File

@@ -0,0 +1,5 @@
from enum import Enum
class ScheduledActionType(Enum):
WEATHER_REPORT = 0

View File

@@ -0,0 +1 @@
from . import seven_tv

View File

@@ -0,0 +1 @@
from .classes import EventHandler7TV # noqa

View File

@@ -0,0 +1,4 @@
from .emote_7tv import Emote7TV
from .event_emote import EventEmote
from .event_handler_7tv import EventHandler7TV
from .set_7tv import Set7TV

View File

@@ -0,0 +1,126 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from bson import ObjectId
from libbot.cache.classes import Cache
from pymongo.results import InsertOneResult
from ....classes.base import BaseCacheable
from ....modules.database import col_int_7tv_emotes
from ....modules.utils import restore_from_cache
@dataclass
class Emote7TV(BaseCacheable):
"""Dataclass of DB entry of a 7TV emote"""
__slots__ = (
"_id",
"id",
"emote_name",
"emote_set_id",
"created",
"modified",
)
__short_name__ = "7tv_emote"
__collection__ = col_int_7tv_emotes
_id: ObjectId
id: int
emote_name: str
emote_set_id: str
created: datetime
modified: datetime
@classmethod
async def from_id(
cls,
id: str,
cache: Optional[Cache] = None,
) -> "Emote7TV":
cached_entry: Dict[str, Any] | None = restore_from_cache(
cls.__short_name__, id, cache=cache
)
if cached_entry is not None:
return cls(**cls._entry_from_cache(cached_entry))
db_entry = await cls.__collection__.find_one({"id": id})
# TODO Replace with a real exception
if db_entry is None:
raise RuntimeError(f"7TV emote with ID {id} was not found")
# raise EmoteNotFoundError(id)
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{id}", cls._entry_to_cache(db_entry))
return cls(**db_entry)
@classmethod
async def create(
cls,
id: str,
emote_name: str,
emote_set_id: str,
cache: Optional[Cache] = None,
) -> "Emote7TV":
db_entry: Dict[str, Any] = {
"id": id,
"emote_name": emote_name,
"emote_set_id": emote_set_id,
"created": datetime.now(tz=timezone.utc),
"modified": datetime.now(tz=timezone.utc),
}
insert_result: InsertOneResult = await cls.__collection__.insert_one(db_entry)
db_entry["_id"] = insert_result.inserted_id
if cache is not None:
cache.set_json(f"{cls.__short_name__}_{id}", cls._entry_to_cache(db_entry))
return cls(**db_entry)
def _get_cache_key(self) -> str:
return f"{self.__short_name__}_{self.id}"
@staticmethod
def _entry_to_cache(db_entry: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError()
@staticmethod
def _entry_from_cache(cache_entry: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError()
def to_dict(self, json_compatible: bool = False) -> Dict[str, Any]:
"""Convert Emote7TV object to a JSON representation.
Args:
json_compatible (bool): Whether the JSON-incompatible objects like ObjectId need to be converted
Returns:
Dict[str, Any]: JSON representation of Emote7TV
"""
raise NotImplementedError()
@staticmethod
def get_defaults(
owner_id: Optional[int] = None, guild_id: Optional[int] = None
) -> Dict[str, Any]:
raise NotImplementedError()
@staticmethod
def get_default_value(key: str) -> Any:
if key not in Emote7TV.get_defaults():
raise KeyError(f"There's no default value for key '{key}' in Emote7TV")
return Emote7TV.get_defaults()[key]
async def update_name(
self,
name: str,
cache: Optional[Cache] = None,
) -> None:
await self._set(cache=cache, emote_name=name)

View File

@@ -0,0 +1,84 @@
import logging
from dataclasses import dataclass
from logging import Logger
from typing import Any, Dict, List, Optional
from aiohttp import ClientSession
logger: Logger = logging.getLogger(__name__)
@dataclass
class EventEmote:
id: str
name: str
set_id: str
_data: Dict[str, Any]
@classmethod
def from_dict(cls, data: Dict[str, Any], set_id: str) -> "EventEmote":
return cls(
id=data["id"],
name=data["name"],
set_id=set_id,
_data=data.get("data", {}),
)
def get_best_image_name(self) -> Optional[str]:
files: List[Dict[str, Any]] = self._data.get("host", {}).get("files", [])
is_animated: bool = self._data.get("animated", False)
# Priority order for formats (higher index = higher priority)
format_priority: Dict[str, int] = {"GIF": 3, "PNG": 2} if is_animated else {"PNG": 2}
# Scale multipliers ordered from smallest to largest
scale_order: List[str] = ["1x", "2x", "3x", "4x"]
scale_index: Dict[str, int] = {scale: idx for idx, scale in enumerate(scale_order)}
# Find the best format based on priority
best_image_name: str | None = None
best_format_priority: int = -1
best_scale_index: int = -1
for file in files:
image_name: str = file.get("name", "")
image_size: int = file.get("size", 0)
format_name: str = file.get("format", "")
scale_name: str = file.get("name", "").split(".")[0] # Extract scale like
if format_name not in format_priority:
continue
current_priority: int = format_priority[format_name]
current_scale_idx: int = scale_index.get(scale_name, -1)
# Update best match: higher priority wins, or same priority with larger scale
if (
current_priority > best_format_priority
or (
current_priority == best_format_priority
and current_scale_idx > best_scale_index
)
) and image_size <= 262144:
best_image_name = image_name
best_format_priority = current_priority
best_scale_index = current_scale_idx
logger.debug(f"Found the best format for the emote {self.id}: {best_image_name}")
return best_image_name
# TODO Review whether this is sufficient for Discord emoji requirements
async def get_url(self) -> str:
image_name: str | None = self.get_best_image_name()
if image_name is None:
raise ValueError("No supported format is available.")
return f"https:{self._data['host']['url']}/{image_name}"
async def fetch_image(self) -> bytes:
image_url: str = await self.get_url()
async with ClientSession() as session:
return await (await session.get(image_url)).read()

View File

@@ -0,0 +1,94 @@
import logging
from logging import Logger
from eventapi import Dispatch, EventType, ResponseTypes
from fastapi import FastAPI
from ....classes.pycord_bot import PycordBot
from ..integration import (
add_emote_to_guilds,
delete_emote_in_guilds,
update_emote_in_guilds,
)
from . import Emote7TV, EventEmote
logger: Logger = logging.getLogger(__name__)
class EventHandler7TV:
def __init__(self, bot: PycordBot, app: FastAPI):
self.bot: PycordBot = bot
self.app: FastAPI = app
async def handle_event(self, data: ResponseTypes) -> None:
if not isinstance(data, Dispatch):
return
if data.type != EventType.EMOTE_SET_UPDATE:
return
emote_set_id: str = data.body.id
logger.debug("Got an event for the 7TV set %s, processing...", emote_set_id)
if data.body.pulled:
for pulled in data.body.pulled:
if pulled.old_value is None:
continue
emote: EventEmote = EventEmote.from_dict(pulled.old_value, emote_set_id)
try:
int_emote: Emote7TV = await Emote7TV.from_id(pulled.old_value["id"])
except RuntimeError:
return
await int_emote.purge()
# Process emote for each guild
await delete_emote_in_guilds(self.bot, emote, emote_set_id)
if data.body.pushed:
for pushed in data.body.pushed:
if pushed.value is None or isinstance(pushed.value, list):
continue
emote: EventEmote = EventEmote.from_dict(pushed.value, emote_set_id)
await Emote7TV.create(
pushed.value["id"],
pushed.value["name"],
emote_set_id,
)
await add_emote_to_guilds(self.bot, emote, emote_set_id)
if data.body.updated:
for updated in data.body.updated:
if (
updated.old_value is None
or updated.value is None
or isinstance(updated.value, list)
):
continue
emote_old: EventEmote = EventEmote.from_dict(updated.old_value, emote_set_id)
emote_new: EventEmote = EventEmote.from_dict(updated.value, emote_set_id)
# TODO Review possibility of image changes
if emote_old.name == emote_new.name:
continue
try:
int_emote: Emote7TV = await Emote7TV.from_id(emote_old.id)
except RuntimeError:
await Emote7TV.create(
emote_old.id,
emote_new.name,
emote_set_id,
)
return
await int_emote.update_name(emote_new.name, cache=self.bot.cache)
await update_emote_in_guilds(self.bot, emote_old, emote_new, emote_set_id)

View File

@@ -0,0 +1,20 @@
from dataclasses import dataclass
from bson import ObjectId
from ....classes.base import BaseCacheable
from ....enums import GuildEmoteSource
from ....modules.database import col_int_7tv_sets
@dataclass
class Set7TV(BaseCacheable):
"""Dataclass of DB entry of a 7TV emote set"""
__slots__ = ("_id", "id")
__short_name__ = "7tv_set"
__collection__ = col_int_7tv_sets
_id: ObjectId
id: str
source: GuildEmoteSource

View File

@@ -0,0 +1,182 @@
from logging import Logger
from typing import Dict, List
from discord import (
ApplicationContext,
Cog,
InteractionContextType,
SlashCommandGroup,
option,
)
from eventapi import EventApi, EventType, SubscriptionCondition, SubscriptionData
from ....classes import PycordGuild
from ....classes.pycord_bot import PycordBot
from ....enums import ConsentScope
from ....modules.database import col_guilds
from ....modules.middleware import user_consent_required
from ....modules.utils import get_logger
from ..classes.event_handler_7tv import EventHandler7TV
logger: Logger = get_logger(__name__)
class Cog7TV(Cog):
"""Cog with the guessing command."""
def __init__(self, bot: PycordBot):
self.bot: PycordBot = bot
self.event_handler: EventHandler7TV
self.emotes_event_api: EventApi
self.subscribed_7tv_sets: Dict[str, List[int]] = {}
def set_event_handler(self, event_handler: EventHandler7TV) -> None:
self.event_handler = event_handler
def set_event_api(self, event_api: EventApi) -> None:
self.emotes_event_api = event_api
# TODO Implement i18n
command_group: SlashCommandGroup = SlashCommandGroup(
"emotes",
description="Guild's emote set management",
contexts={InteractionContextType.guild},
)
@Cog.listener()
async def on_ready(self) -> None:
if not self.bot.is_integration_enabled("7tv"):
return
logger.debug("Initializing the 7TV integration...")
await self.init_7tv_integration()
logger.info("Initialized the 7TV integration")
def _add_7tv_subscription(self, emote_set_id: str, guild_id: int) -> None:
if emote_set_id not in self.subscribed_7tv_sets.keys():
self.subscribed_7tv_sets[emote_set_id] = []
self.subscribed_7tv_sets[emote_set_id].append(guild_id)
def _remove_7tv_subscription(self, emote_set_id: str, guild_id: int) -> None:
if emote_set_id not in self.subscribed_7tv_sets.keys():
return
self.subscribed_7tv_sets[emote_set_id].remove(guild_id)
if len(self.subscribed_7tv_sets[emote_set_id]) == 0:
self.subscribed_7tv_sets.pop(emote_set_id, None)
async def subscribe_7tv_set(self, emote_set_id: str, guild_id: int) -> None:
if self.emotes_event_api is None:
return
self._add_7tv_subscription(emote_set_id, guild_id)
subscription_condition: SubscriptionCondition = SubscriptionCondition(
object_id=emote_set_id
)
subscription_data: SubscriptionData = SubscriptionData(
subscription_type=EventType.EMOTE_SET_ALL, condition=subscription_condition
)
await self.emotes_event_api.subscribe(subscription_data=subscription_data)
async def unsubscribe_7tv_set(self, emote_set_id: str, guild_id: int) -> None:
if self.emotes_event_api is None:
return
self._remove_7tv_subscription(emote_set_id, guild_id)
if emote_set_id not in self.subscribed_7tv_sets.keys():
subscription_condition: SubscriptionCondition = SubscriptionCondition(
object_id=emote_set_id
)
subscription_data: SubscriptionData = SubscriptionData(
subscription_type=EventType.EMOTE_SET_ALL,
condition=subscription_condition,
)
await self.emotes_event_api.unsubscribe(subscription_data)
async def init_7tv_integration(self) -> None:
if self.emotes_event_api is None:
return
await self.emotes_event_api.connect()
# This entire block has to be replaced with something that would
# allow the bot to only subscribe to emote sets that
# guilds actually need to function properly
async for guild in col_guilds.find(
{"emote_set_id": {"$ne": None}}, {"_id": 0, "id": 1, "emote_set_id": 1}
):
logger.info(
"Guild %s is using 7TV emote set %s, adding to subscriptions...",
guild["id"],
guild["emote_set_id"],
)
self._add_7tv_subscription(guild["emote_set_id"], guild["id"])
for emote_set_id in self.subscribed_7tv_sets.keys():
logger.info("Subscribing to the 7TV set %s", emote_set_id)
subscription_condition: SubscriptionCondition = SubscriptionCondition(
object_id=emote_set_id
)
subscription_data: SubscriptionData = SubscriptionData(
subscription_type=EventType.EMOTE_SET_ALL,
condition=subscription_condition,
)
await self.emotes_event_api.subscribe(subscription_data=subscription_data)
# TODO Implement i18n
@command_group.command(
name="set",
description="Set a 7TV emotes set to use in the guild",
)
@option("set_id", description="ID of the 7TV emotes set", required=False)
@user_consent_required(ConsentScope.INTEGRATION_7TV)
async def command_emotes_set(self, ctx: ApplicationContext, set_id: str) -> None:
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
if guild.emote_set_id == set_id:
await ctx.respond(
"You can't provide the same set ID as is already in use.",
ephemeral=True,
)
return
await guild.set_emote_set_id(set_id, self, self.bot.cache)
await ctx.respond(f"Subscribed to the emote set {set_id}")
# TODO Implement i18n
@command_group.command(
name="reset",
description="Reset a 7TV emotes set used in the guild",
)
@option(
"confirm",
description="Confirmation of the action",
required=False,
)
@user_consent_required(ConsentScope.INTEGRATION_7TV)
async def command_emotes_reset(
self, ctx: ApplicationContext, confirm: bool = False
) -> None:
if not confirm:
await ctx.respond("Operation not confirmed!", ephemeral=True)
return
guild: PycordGuild = await self.bot.find_guild(ctx.guild.id)
await guild.reset_emote_set_id(self, self.bot.cache)
await ctx.respond("Unsubscribed from the emote set")
def setup(bot: PycordBot) -> None:
bot.add_cog(Cog7TV(bot))

View File

@@ -0,0 +1,224 @@
import logging
from logging import Logger
from typing import List
from discord import Guild, GuildEmoji
from discord.errors import Forbidden, HTTPException
from ...classes import PycordGuildEmote
from ...classes.pycord_bot import PycordBot
from ...enums.guild_emote_source import GuildEmoteSource
from ...modules.database import col_guilds
from .classes import EventEmote
logger: Logger = logging.getLogger(__name__)
async def get_guild_ids_using_set(emote_set_id: str) -> List[int]:
guild_ids: List[int] = []
async for guild in col_guilds.find({"emote_set_id": emote_set_id}, {"_id": 0, "id": 1}):
guild_ids.append(guild["id"])
return guild_ids
async def add_emote_to_guilds(bot: PycordBot, emote: EventEmote, emote_set_id: str) -> None:
guild_ids: List[int] = await get_guild_ids_using_set(emote_set_id)
for guild_id in guild_ids:
logging.debug(
"Adding a 7TV emote %s from the set %s to the guild %s",
emote.id,
emote_set_id,
guild_id,
)
guild: Guild | None = bot.get_guild(guild_id)
if guild is None:
logger.error(
"Could not add 7TV emote %s from the set %s to the guild %s because the bot could not fetch the guild",
emote.id,
emote_set_id,
guild_id,
)
continue
if len(guild.emojis) >= guild.emoji_limit:
logger.error(
"Could not add 7TV emote %s from the set %s to the guild %s because the emoji limit was reached",
emote.id,
emote_set_id,
guild_id,
)
continue
try:
discord_emoji: GuildEmoji = await guild.create_custom_emoji(
name=emote.name, image=await emote.fetch_image()
)
except ValueError:
logger.error(
"Could not add 7TV emote %s from the set %s to the guild %s because the emote does not contain a valid URL",
emote.id,
emote_set_id,
guild_id,
)
continue
except Forbidden as exc:
logger.error(
"Could not add 7TV emote %s from the set %s to the guild %s because the bot is missing permissions",
emote.id,
emote_set_id,
guild_id,
exc_info=exc,
)
continue
except HTTPException as exc:
logger.error(
"Could not add 7TV emote %s from the set %s to the guild %s due to: %s",
emote.id,
emote_set_id,
guild_id,
exc,
exc_info=exc,
)
continue
await PycordGuildEmote.create(
discord_emoji.id,
guild_id,
emote.name,
GuildEmoteSource.SEVEN_TV,
emote.id,
emote_set_id,
cache=bot.cache,
)
logger.info(
f"Added a 7TV emote {emote.id} ({emote.name}) from the set {emote_set_id} to the guild {guild_id}"
)
async def update_emote_in_guilds(
bot: PycordBot, emote_old: EventEmote, emote_new: EventEmote, emote_set_id: str
) -> None:
guild_ids: List[int] = await get_guild_ids_using_set(emote_set_id)
for guild_id in guild_ids:
try:
guild_emote: PycordGuildEmote = await PycordGuildEmote.from_guild_and_source_id(
guild_id, emote_old.id
)
except RuntimeError as exc:
logger.error(
"Could not update 7TV emote %s from the set %s in the guild %s due to: %s",
emote_old.id,
emote_set_id,
guild_id,
exc,
exc_info=exc,
)
continue
guild: Guild | None = bot.get_guild(guild_id)
if guild is None:
logger.error(
"Could not update 7TV emote %s from the set %s in the guild %s because the bot could not fetch the guild",
emote_old.id,
emote_set_id,
guild_id,
)
continue
await guild_emote.update_name(emote_new.name, cache=bot.cache)
discord_emoji: GuildEmoji | None = guild.get_emoji(guild_emote.id)
if discord_emoji is None:
logger.error(
"Could not update 7TV emote %s from the set %s in the guild %s because the bot could not find the emoji",
emote_old.id,
emote_set_id,
guild_id,
)
continue
try:
await discord_emoji.edit(name=emote_new.name)
except Forbidden as exc:
logger.error(
"Could not update 7TV emote %s from the set %s in the guild %s because the bot is missing permissions",
emote_old.id,
emote_set_id,
guild_id,
exc_info=exc,
)
continue
except HTTPException as exc:
logger.error(
"Could not update 7TV emote %s from the set %s in the guild %s due to: %s",
emote_old.id,
emote_set_id,
guild_id,
exc,
exc_info=exc,
)
async def delete_emote_in_guilds(bot: PycordBot, emote: EventEmote, emote_set_id: str) -> None:
guild_ids: List[int] = await get_guild_ids_using_set(emote_set_id)
for guild_id in guild_ids:
guild_emote: PycordGuildEmote = await PycordGuildEmote.from_guild_and_source_id(
guild_id, emote.id
)
guild: Guild | None = bot.get_guild(guild_id)
if guild is None:
logger.error(
"Could not delete 7TV emote %s from the set %s from the guild %s because the bot could not fetch the guild",
emote.id,
emote_set_id,
guild_id,
)
await guild_emote.purge(cache=bot.cache)
continue
discord_emoji: GuildEmoji | None = guild.get_emoji(guild_emote.id)
if discord_emoji is None:
logger.error(
"Could not delete 7TV emote %s from the set %s from the guild %s because the bot could not find the emoji",
emote.id,
emote_set_id,
guild_id,
)
await guild_emote.purge(cache=bot.cache)
continue
try:
await discord_emoji.delete()
except Forbidden as exc:
logger.error(
"Could not delete 7TV emote %s from the set %s from the guild %s because the bot is missing permissions",
emote.id,
emote_set_id,
guild_id,
exc_info=exc,
)
continue
except HTTPException as exc:
logger.error(
"Could not delete 7TV emote %s from the set %s from the guild %s due to: %s",
emote.id,
emote_set_id,
guild_id,
exc,
exc_info=exc,
)
await guild_emote.purge(cache=bot.cache)

189
src/javelina/locale/de.json Normal file
View File

@@ -0,0 +1,189 @@
{
"meta": {
"name": "German",
"native_name": "Deutsch"
},
"messages": {
"general": {
"permission_denied": "You're not allowed to perform this operation.",
"permission_denied_title": "Permission denied",
"error_title": "Something went wrong",
"success_title": "Success",
"warning_title": "Warning"
},
"admin": {
"config_reload_success": "Bot's configuration has been reloaded.",
"config_reload_error": "Could not reload the bot's configuration:\n```\n{error}\n```"
},
"data_control": {
"consent_required": "Consent to following scope(s) is required to access the command: {scopes}\n\nYou can use following commands to manage your consents:\n`/consent terms <scope>` - Review the terms for a consent\n`/consent give <scope>` - Give consent for a scope"
},
"wallet": {
"balance_title": "Balance",
"balance_own": "Your balance is `{balance}`.",
"balance_user": "**{user}**'s balance is `{balance}`.",
"transfer_success": "You have transferred `{amount}` to **{recipient}**.",
"transfer_insufficient_funds": "Insufficient funds. `{amount}` more is needed for this transaction."
},
"reload": {
"reload_success": "Configuration has been successfully reloaded.",
"reload_failure": "Could not reload the configuration:\n```\n{exception}\n```"
},
"welcome": {
"morning": [
"{0} Добрий ранок та ласкаво просимо! {1}"
],
"midday": [
"{0} Добрий день! Ласкаво просимо! {1}"
],
"evening": [
"{0} Добрий вечір! Ласкаво просимо! {1}"
],
"night": [
"{0} Доброї ночі! Ласкаво просимо! {1}"
],
"unknown": [
"{0} Вітаннячко! Ласкаво просимо! {1}"
]
}
},
"data_control": {
"scopes": {
"general": {
"name": "General",
"description": ""
},
"integration_deepl": {
"name": "Integration: DeepL",
"description": ""
},
"integration_7tv": {
"name": "Integration: 7TV",
"description": ""
}
},
"consent_durations": {
"short": "Short",
"normal": "Normal",
"long": "Long"
}
},
"commands": {
"reload": {
"description": "Reload bot's configuration"
},
"clear": {
"description": "Delete some messages in the current channel"
},
"consent": {
"description": "Consent management"
},
"consent_terms": {
"description": "View terms for the consent scope"
},
"consent_give": {
"description": "Give consent to the scope"
},
"consent_withdraw": {
"description": "Withdraw consent to the scope"
},
"consent_give_all": {
"description": "Give consent to all scopes"
},
"consent_withdraw_all": {
"description": "Withdraw consent to all scopes"
},
"consent_review": {
"description": "Review all given consents"
},
"data": {
"description": "Data management"
},
"data_checkout": {
"description": "Checkout all user data"
},
"data_purge": {
"description": "Delete all user data"
},
"wallet": {
"description": "Wallet management"
},
"wallet_balance": {
"description": "View wallet's balance"
},
"wallet_transfer": {
"description": "Transfer money from the wallet"
}
},
"actions": {
"bite": {
"name": "Вкусити",
"text": "**{user_name}** робить кусь **{target_name}**"
},
"poke": {
"name": "Тикнути",
"text": "**{user_name}** тикає в **{target_name}**"
},
"hug": {
"name": "Обійняти",
"text": "**{user_name}** обіймає **{target_name}**"
},
"kiss": {
"name": "Поцілувати",
"text": "**{user_name}** цілує **{target_name}**"
},
"lick": {
"name": "Лизнути",
"text": "**{user_name}** лиже **{target_name}**"
},
"pat": {
"name": "Погладити",
"text": "**{user_name}** гладить **{target_name}**"
},
"wave": {
"name": "Помахати",
"text": "**{user_name}** махає **{target_name}**"
},
"wink": {
"name": "Підморгнути",
"text": "**{user_name}** підморгує **{target_name}**"
}
},
"tracking": {
"dhl": {
"statuses": {
"AA": "Departed from the hub",
"AE": "Pickup successful",
"AN": "Pickup not successful",
"BV": "Exception occurred",
"DD": "Data service",
"EE": "Arrived at the hub",
"ES": "First processed by DHL",
"GT": "Money transfer",
"LA": "In storage",
"NB": "Processing during transit",
"PO": "In delivery",
"VA": "Electronic pre-advise",
"ZF": "Delivery",
"ZN": "Delivery not successful",
"ZO": "Customs clearance",
"ZU": "Delivery successful"
},
"messages": {
"DHL PAKET (parcel)": "DHL PAKET (посилка)",
"The shipment was prepared for onward transport.": "Вантаж підготовлено до подальшого транспортування.",
"Warenpost (Merchandise Shipment)": "Варенпост (відвантаження товарів)",
"The shipment has been processed in the parcel center": "Відправлення пройшло обробку в посилковому центрі",
"Unfortunately, the shipment could not be delivered today due to a strike action.": "На жаль, сьогодні вантаж не вдалося доставити через страйк.",
"Die Sendung wurde von DHL abgeholt.": "Відправлення забрала компанія DHL.",
"The shipment has been successfully delivered": "Відправлення успішно доставлено",
"The shipment could not be delivered, and the recipient has been notified": "Відправлення не вдалося доставити, одержувача було повідомлено про це",
"The shipment has been loaded onto the delivery vehicle": "Вантаж завантажено на транспортний засіб доставки",
"The shipment arrived in the region of recipient and will be transported to the delivery base in the next step.": "Відправлення прибуло в регіон одержувача і буде доставлено на базу доставки на наступному кроці",
"The shipment has been processed in the parcel center of origin": "Відправлення оброблено в центрі відправлення посилок",
"The shipment has been posted by the sender at the retail outlet": "Відправлення відправлено відправником у торговій точці",
"The instruction data for this shipment have been provided by the sender to DHL electronically": "Дані інструкцій для цього відправлення були надані DHL відправником в електронному вигляді"
}
}
}
}

View File

@@ -0,0 +1,189 @@
{
"meta": {
"name": "English, US",
"native_name": "English, US"
},
"messages": {
"general": {
"permission_denied": "You're not allowed to perform this operation.",
"permission_denied_title": "Permission denied",
"error_title": "Something went wrong",
"success_title": "Success",
"warning_title": "Warning"
},
"admin": {
"config_reload_success": "Bot's configuration has been reloaded.",
"config_reload_error": "Could not reload the bot's configuration:\n```\n{error}\n```"
},
"data_control": {
"consent_required": "Consent to following scope(s) is required to access the command: {scopes}\n\nYou can use following commands to manage your consents:\n`/consent terms <scope>` - Review the terms for a consent\n`/consent give <scope>` - Give consent for a scope"
},
"wallet": {
"balance_title": "Balance",
"balance_own": "Your balance is `{balance}`.",
"balance_user": "**{user}**'s balance is `{balance}`.",
"transfer_success": "You have transferred `{amount}` to **{recipient}**.",
"transfer_insufficient_funds": "Insufficient funds. `{amount}` more is needed for this transaction."
},
"reload": {
"reload_success": "Configuration has been successfully reloaded.",
"reload_failure": "Could not reload the configuration:\n```\n{exception}\n```"
},
"welcome": {
"morning": [
"{0} Good morning and welcome! {1}"
],
"midday": [
"{0} Good day and welcome! {1}"
],
"evening": [
"{0} Good evening and welcome! {1}"
],
"night": [
"{0} Good night and welcome! {1}"
],
"unknown": [
"{0} Hello and welcome! {1}"
]
}
},
"data_control": {
"scopes": {
"general": {
"name": "General",
"description": ""
},
"integration_deepl": {
"name": "Integration: DeepL",
"description": ""
},
"integration_7tv": {
"name": "Integration: 7TV",
"description": ""
}
},
"consent_durations": {
"short": "Short",
"normal": "Normal",
"long": "Long"
}
},
"commands": {
"reload": {
"description": "Reload bot's configuration"
},
"clear": {
"description": "Delete some messages in the current channel"
},
"consent": {
"description": "Consent management"
},
"consent_terms": {
"description": "View terms for the consent scope"
},
"consent_give": {
"description": "Give consent to the scope"
},
"consent_withdraw": {
"description": "Withdraw consent to the scope"
},
"consent_give_all": {
"description": "Give consent to all scopes"
},
"consent_withdraw_all": {
"description": "Withdraw consent to all scopes"
},
"consent_review": {
"description": "Review all given consents"
},
"data": {
"description": "Data management"
},
"data_checkout": {
"description": "Checkout all user data"
},
"data_purge": {
"description": "Delete all user data"
},
"wallet": {
"description": "Wallet management"
},
"wallet_balance": {
"description": "View wallet's balance"
},
"wallet_transfer": {
"description": "Transfer money from the wallet"
}
},
"actions": {
"bite": {
"name": "Bite",
"text": "**{user_name}** bites **{target_name}**"
},
"hug": {
"name": "Hug",
"text": "**{user_name}** hugs **{target_name}**"
},
"kiss": {
"name": "Kiss",
"text": "**{user_name}** kisses **{target_name}**"
},
"lick": {
"name": "Lick",
"text": "**{user_name}** licks **{target_name}**"
},
"pat": {
"name": "Pat",
"text": "**{user_name}** pats **{target_name}**"
},
"poke": {
"name": "Poke",
"text": "**{user_name}** pokes **{target_name}**"
},
"wave": {
"name": "Wave",
"text": "**{user_name}** waves **{target_name}**"
},
"wink": {
"name": "Wink",
"text": "**{user_name}** winks **{target_name}**"
}
},
"tracking": {
"dhl": {
"statuses": {
"AA": "Departed from the hub",
"AE": "Pickup successful",
"AN": "Pickup not successful",
"BV": "Exception occurred",
"DD": "Data service",
"EE": "Arrived at the hub",
"ES": "First processed by DHL",
"GT": "Money transfer",
"LA": "In storage",
"NB": "Processing during transit",
"PO": "In delivery",
"VA": "Electronic pre-advise",
"ZF": "Delivery",
"ZN": "Delivery not successful",
"ZO": "Customs clearance",
"ZU": "Delivery successful"
},
"messages": {
"DHL PAKET (parcel)": "DHL PAKET (parcel)",
"The shipment was prepared for onward transport.": "The shipment was prepared for onward transport.",
"Warenpost (Merchandise Shipment)": "Warenpost (Merchandise Shipment)",
"The shipment has been processed in the parcel center": "The shipment has been processed in the parcel center",
"Unfortunately, the shipment could not be delivered today due to a strike action.": "Unfortunately, the shipment could not be delivered today due to a strike action.",
"Die Sendung wurde von DHL abgeholt.": "The shipment has been picked up by DHL.",
"The shipment has been successfully delivered": "The shipment has been successfully delivered",
"The shipment could not be delivered, and the recipient has been notified": "The shipment could not be delivered, and the recipient has been notified",
"The shipment has been loaded onto the delivery vehicle": "The shipment has been loaded onto the delivery vehicle",
"The shipment arrived in the region of recipient and will be transported to the delivery base in the next step.": "The shipment arrived in the region of recipient and will be transported to the delivery base in the next step.",
"The shipment has been processed in the parcel center of origin": "The shipment has been processed in the parcel center of origin",
"The shipment has been posted by the sender at the retail outlet": "The shipment has been posted by the sender at the retail outlet",
"The instruction data for this shipment have been provided by the sender to DHL electronically": "The instruction data for this shipment have been provided by the sender to DHL electronically"
}
}
}
}

View File

@@ -1,5 +1,34 @@
{
"meta": {
"name": "Ukrainian",
"native_name": "Українська"
},
"messages": {
"general": {
"permission_denied": "Ви не маєте права виконувати цю операцію.",
"permission_denied_title": "Permission denied",
"error_title": "Something went wrong",
"success_title": "Success",
"warning_title": "Warning"
},
"admin": {
"config_reload_success": "Конфігурацію бота було перезавантажено.",
"config_reload_error": "Не вдалося перезавантажити конфігурацію бота:\n```\n{error}\n```"
},
"data_control": {
"consent_required": "Для доступу до команди необхідна згода на наступні сфери застосування: {scopes}\n\nВи можете використовувати наступні команди для управління своїми згодами:\n`/consent terms <scope>` - Переглянути умови згоди\n`/consent give <scope>` - Надати згоду на певну сферу застосування"
},
"wallet": {
"balance_title": "Balance",
"balance_own": "Ваш баланс складає `{balance}`.",
"balance_user": "Баланс **{user}** складає `{balance}`.",
"transfer_success": "Ви перевели `{amount}` на рахунок **{recipient}**.",
"transfer_insufficient_funds": "Недостатньо коштів. Потрібно ще `{amount}` для цієї транзакції."
},
"reload": {
"reload_success": "Конфігурацію було успішно перезавантажено.",
"reload_failure": "Не вдалось перезавантажити конфігурацію:\n```\n{exception}\n```"
},
"welcome": {
"morning": [
"{0} Добрий ранок та ласкаво просимо! {1}",
@@ -32,13 +61,81 @@
]
}
},
"data_control": {
"scopes": {
"general": {
"name": "Загальне",
"description": ""
},
"integration_deepl": {
"name": "Інтеграція: DeepL",
"description": ""
},
"integration_7tv": {
"name": "Інтеграція: 7TV",
"description": ""
}
},
"consent_durations": {
"short": "Короткий",
"normal": "Звичаний",
"long": "Довгий"
}
},
"commands": {
"reload": {
"description": "Перезавантажити конфігурацію бота"
},
"clear": {
"description": "Delete some messages in the current channel"
},
"consent": {
"description": "Consent management"
},
"consent_terms": {
"description": "View terms for the consent scope"
},
"consent_give": {
"description": "Give consent to the scope"
},
"consent_withdraw": {
"description": "Withdraw consent to the scope"
},
"consent_give_all": {
"description": "Give consent to all scopes"
},
"consent_withdraw_all": {
"description": "Withdraw consent to all scopes"
},
"consent_review": {
"description": "Review all given consents"
},
"data": {
"description": "Data management"
},
"data_checkout": {
"description": "Checkout all user data"
},
"data_purge": {
"description": "Delete all user data"
},
"wallet": {
"description": "Wallet management"
},
"wallet_balance": {
"description": "View wallet's balance"
},
"wallet_transfer": {
"description": "Transfer money from the wallet"
}
},
"actions": {
"bite": {
"name": "Вкусити",
"text": "**{user_name}** робить кусь **{target_name}**"
},
"hug": {
"name": "",
"name": "Обійняти",
"text": "**{user_name}** обіймає **{target_name}**"
},
"kiss": {
@@ -69,10 +166,22 @@
"tracking": {
"dhl": {
"statuses": {
"delivered": "Доставлено",
"transit": "Транзит",
"pre-transit": "Пре-транзит",
"failure": "Невдача"
"AA": "Departed from the hub",
"AE": "Pickup successful",
"AN": "Pickup not successful",
"BV": "Exception occurred",
"DD": "Data service",
"EE": "Arrived at the hub",
"ES": "First processed by DHL",
"GT": "Money transfer",
"LA": "In storage",
"NB": "Processing during transit",
"PO": "In delivery",
"VA": "Electronic pre-advise",
"ZF": "Delivery",
"ZN": "Delivery not successful",
"ZO": "Customs clearance",
"ZU": "Delivery successful"
},
"messages": {
"DHL PAKET (parcel)": "DHL PAKET (посилка)",

View File

View File

@@ -0,0 +1 @@
from . import database, migrator, scheduler, utils

View File

@@ -0,0 +1,65 @@
"""Module that provides all database collections"""
from typing import Any, Mapping
from libbot.utils import config_get
from pymongo import AsyncMongoClient
from pymongo.asynchronous.collection import AsyncCollection
from pymongo.asynchronous.database import AsyncDatabase
db_config: Mapping[str, Any] = config_get("database")
if db_config["user"] is not None and db_config["password"] is not None:
con_string = "mongodb://{0}:{1}@{2}:{3}/{4}".format(
db_config["user"],
db_config["password"],
db_config["host"],
db_config["port"],
db_config["name"],
)
else:
con_string = "mongodb://{0}:{1}/{2}".format(
db_config["host"], db_config["port"], db_config["name"]
)
# Async declarations
db_client = AsyncMongoClient(con_string, connectTimeoutMS=3000)
db: AsyncDatabase = db_client.get_database(name=db_config["name"])
col_users: AsyncCollection = db.get_collection("users")
col_guilds: AsyncCollection = db.get_collection("guilds")
col_wallets: AsyncCollection = db.get_collection("wallets")
col_consents: AsyncCollection = db.get_collection("consents")
col_analytics: AsyncCollection = db.get_collection("analytics")
col_guild_emotes: AsyncCollection = db.get_collection("guild_emotes")
col_custom_channels: AsyncCollection = db.get_collection("custom_channels")
col_scheduled_actions: AsyncCollection = db.get_collection("scheduled_actions")
col_int_7tv_sets: AsyncCollection = db.get_collection("int_7tv_sets")
col_int_7tv_emotes: AsyncCollection = db.get_collection("int_7tv_emotes")
# col_messages: AsyncCollection = db.get_collection("messages")
# col_warnings: AsyncCollection = db.get_collection("warnings")
# col_checkouts: AsyncCollection = db.get_collection("checkouts")
# col_trackings: AsyncCollection = db.get_collection("trackings")
# col_authorized: AsyncCollection = db.get_collection("authorized")
# col_transactions: AsyncCollection = db.get_collection("transactions")
# Update indexes
async def _update_database_indexes() -> None:
await col_users.create_index(["id", "guild_id"], name="user_id-guild_id", unique=True)
await col_guilds.create_index("guild_id", name="guild_id", unique=True)
await col_guild_emotes.create_index(
["id", "guild_id"], name="emote_id-guild_id", unique=True
)
await col_wallets.create_index(
["owner_id", "guild_id"], name="owner_id-guild_id", unique=True
)
await col_consents.create_index(
["owner_id", "guild_id", "scope"], name="owner_id-guild_id-scope", unique=False
)
await col_custom_channels.create_index(
["owner_id", "guild_id", "channel_id"], name="owner_id-guild_id-channel_id", unique=True
)