Compare commits

..

180 commits

Author SHA1 Message Date
7526a82bb7
feat: kill the service if it persists longer than 9 min 2025-09-04 23:11:24 +01:00
3149a5f99f Bump 2 2025-08-31 11:55:35 +00:00
061b73378a Bump the bot 2025-08-31 11:51:57 +00:00
a225c14b4f
fix: was ddossing the poor database
guild_members_chunk is triggered for each chunk for each server it is on, and the bot is currently in 10 servers so it was runnign teh same thigns 10 times, clogging up conenctions
2025-07-21 04:29:03 +01:00
095ff6f2ce
fix: better handling of returning teh committees 2025-07-21 02:52:59 +01:00
18fd45d39b Merge pull request '#40_Improve_Preformance' (#41) from #40_Improve_Preformance into main
Reviewed-on: Skynet/discord-bot#41

Closes #40
2025-07-21 01:06:27 +00:00
854e946a8f
ci: improve the pipeline to test for the full suite
ci: dont pull in the lfs for teh PR pipeline build
ci: checkout without LFS
ci: see if using the ref directly will help
ci: test a slight modification
ci: see if new get ldfs script works
ci: test using teh new v8
2025-07-21 01:59:36 +01:00
d0726169ee
clippy: changes from nightly clippy
all of this is embeding teh var into teh format macro
2025-07-21 00:50:20 +01:00
9d409e3692
fmt: clippy and nightly fmt 2025-07-21 00:38:59 +01:00
57d4947edf
fix: no longer needint to wait until the cache in teh main program is filled 2025-07-20 23:48:05 +01:00
6d08312f48
fix: these were not using teh cache to access teh member/role data
*was executing before teh cache was built (``cache_ready`` is only when teh cache has been init, not when it is populated)
2025-07-20 23:46:30 +01:00
bd9d0cd43f
fix: these do not need to use teh cache 2025-07-20 23:44:13 +01:00
1af7f28a45
feat: restore teh original services, giving up the logging and control was too much 2025-07-20 23:40:34 +01:00
feff293043
feat: moved the update server icon to the main thread 2025-07-20 23:12:02 +01:00
227db8a741
feat: moved the update data to the main thread 2025-07-20 22:54:34 +01:00
13eb230754
fix: updating the wolves (user data) should not trigger a server update (directly).
That should always be triggered separately.
This was a holdover from a  time when updating teh users was expensive (timewise)
2025-07-20 22:48:04 +01:00
eb88216740
feat: removed the cronjob for updating the committee server 2025-07-20 22:33:56 +01:00
96eb81293b
feat: got the committee update running smoothly (using cache and far far faster due to fixing some logic issues) 2025-07-20 22:32:55 +01:00
5815cde38b
fmt: better wording for logs 2025-07-20 22:28:59 +01:00
1729ec0a54
feat: drastically speed up the committee server script, it no longer tries to remove the committee role from those who dont have it. 2025-07-20 22:26:51 +01:00
43ef787d59
fix: no need to pass in teh full object, a reference will do 2025-07-20 22:25:31 +01:00
a8bed0bacc
fix: was calling the wrong ctx 2025-07-20 20:43:46 +01:00
3dd81a5c54
feat: remove the update_users service 2025-07-20 20:40:15 +01:00
04aa0e63d4
feat: setup update users to be every 5 min while using the cache 2025-07-20 20:37:28 +01:00
2b2dfc2531
feat: cleaned up array that was used to count members/changes to a struct 2025-07-20 20:32:43 +01:00
e901f3ed74
feat: add caching to everything, should make all member interacts faster 2025-07-07 22:22:25 +01:00
3abbb8d485
feat: added script to clean up the committee server if it got flooded with extra channels 2025-07-07 22:18:04 +01:00
b8ffd42184
feat: the bot wasnt using any caching, this should make many operations far faster now 2025-07-07 21:29:37 +01:00
764e8cd620 Fix LFS on discord bot (#39)
Reviewed-on: Skynet/discord-bot#39

Thanks @esy
Co-authored-by: Daragh <esy@skynet.ie>
Co-committed-by: Daragh <esy@skynet.ie>
2025-07-06 23:28:51 +00:00
esy
76f8aa2712 Update README.md 2025-07-06 18:12:04 +00:00
c4da3e9109
fix: only allow image files to be chosen 2025-07-05 15:31:53 +01:00
d27befdac6 Merge pull request 'Add command for link to documentation' (#38) from #37_add-documentation-command into main
Reviewed-on: Skynet/discord-bot#38
2025-06-23 23:16:41 +00:00
7403f531eb
feat: the backend is pretty simple, just pull the rep link from teh config_toml and add on the path to the docs.
Then its just linking it all up.

Closes #37
2025-06-24 00:13:29 +01:00
1dc5c105df
fix: needed to add git and git lfs to teh path of the service 2025-06-18 03:57:04 +01:00
3a56d7bba5
feat: lock the `nix build` to using the repo rust version 2025-06-17 16:21:57 +01:00
327ff99b69
fix: the pipeline got caught on a lint
This isnt in the 1.87 rustfmt but its stilla  good catch
2025-06-17 16:21:15 +01:00
dedf8c3644 Merge pull request '#35_remove-hardcoded-servers' (#36) from #35_remove-hardcided-servers into main
Reviewed-on: Skynet/discord-bot#36

Closes #35
2025-06-17 14:56:57 +00:00
a6eff75e39
feat: use values from teh env file to dictate the servers 2025-06-17 15:38:15 +01:00
72226cc59b
feat: add support for passing teh compsoc server id via env 2025-06-17 15:38:15 +01:00
f841039c53
fix: was pulling in the wrong env var 2025-06-17 15:38:15 +01:00
87dd04e12f Merge pull request 'Automatically change server icon daily' (#33) from #32_rotating-server-icon into main
Reviewed-on: Skynet/discord-bot#33

Closes #32
2025-06-17 14:35:43 +00:00
9134feee4e
feat: cleaned up remaining unwraps, and then clippy+fmt 2025-06-16 21:56:06 +01:00
652dd6ff1c
ci: add workflow to check for lfs status 2025-06-16 21:56:06 +01:00
cae383a186
feat: set up the systemd timer for teh binary 2025-06-16 21:56:06 +01:00
b4cadffdb5
fmt: for whenever it gets stabised (or we use nightly) this would be really good for cleaning up imports 2025-06-16 21:56:06 +01:00
721c8246ac
todo: add a todo where teh mc commands get moved in under committee 2025-06-16 21:56:06 +01:00
0f4524ea63
feat: tidied up the command outouts 2025-06-16 21:56:06 +01:00
86f54aec6d
feat: got the commands mostly working, will need some further fine tuning 2025-06-16 21:56:05 +01:00
86a3af2a65
feat: pull the config for the festivals locally, using teh imported repo 2025-06-16 21:56:05 +01:00
6d5ad8e418
feat: split out the functions so they can be shared with commands 2025-06-16 21:56:05 +01:00
9d50efb757
fmt: cargo+clippy 2025-06-16 21:56:05 +01:00
51bc2f177f
feat: save the selected image to teh library 2025-06-16 21:56:05 +01:00
3523dac46e
fix: properly filter icon based on the festival 2025-06-16 21:56:05 +01:00
51d5904ffd
feat: allow for overlapping festivals 2025-06-16 21:56:05 +01:00
1555a94656
fix: give a reference where it needed to be 2025-06-16 21:56:05 +01:00
7bcf30fb3a
feat: can now set the server icon programmatically 2025-06-16 21:56:05 +01:00
4f96c9087f
feat: get a random image 2025-06-16 21:56:05 +01:00
1ff993d236
feat: only need to keep whatever ones are in teh current season (if at all) 2025-06-16 21:56:05 +01:00
acdfe4b423
fix: only convert if it hasnt already been converted 2025-06-16 21:56:05 +01:00
537fdfd40c
feat: put the converted files into a subfolder 2025-06-16 21:56:05 +01:00
ffd6e40d0b
fix: was being too strict in matching the year 2025-06-16 21:56:05 +01:00
a7423959dc
fix: use a struct for clarity 2025-06-16 21:56:05 +01:00
b4f6835704
feat: got the logos, and converted them if needs be 2025-06-16 21:56:05 +01:00
0034bd34d6
feat: code borrowed from https://github.com/MCorange99/svg2colored-png/tree/main in order to convert from svg to png 2025-06-16 21:56:05 +01:00
725bfa41cc
feat: initial tests of new function to handle changing the logo in discord server 2025-06-16 21:56:05 +01:00
fcfcfb8409
feat: added libraries needed to run the new feature 2025-06-16 21:56:05 +01:00
e449204863
feat: need some new inputs to get this to build 2025-06-16 21:56:05 +01:00
f1dbbec32d
feat: update teh base rust version 2025-06-16 21:56:05 +01:00
esy
8560ed6de5 Merge pull request 'correct command for linking wolves acc' (#31) from update-dm into main
Reviewed-on: Skynet/discord-bot#31
Reviewed-by: silver <silver@skynet.ie>
2025-06-16 13:07:53 +00:00
esy
d28a56f255
fix: out of date command 2025-04-23 12:40:33 +01:00
esy
22ff91b152 correct command for linking wolves acc 2025-04-23 11:30:22 +00:00
e7425588a6
fix: hardcode teh inter-committee server 2025-04-09 00:06:20 +01:00
7f7e7ac598
fix: output looks strange when teh committee value is 0 2025-03-14 04:59:31 +00:00
a907243986
fix: further improvements to teh count command 2025-03-14 04:44:32 +00:00
b44518c467
feat: improved the count command 2025-03-14 04:03:20 +00:00
421d425f5d
feat: lock down to using the specific rust version 2025-03-10 20:17:55 +00:00
76cddde36d
doc: updated images for documentation 2025-03-06 22:54:57 +00:00
f307fcea43
feat: made the minecraft command fall under Wolves 2025-03-06 21:42:27 +00:00
df032f2d7b
feat: updated the core committee commands 2025-03-06 21:35:17 +00:00
0e6a5d3455
fmt: re-organised it o better reflect what teh commands in teh file are for 2025-03-06 20:43:36 +00:00
052f6aecb2
doc: updated the docs for the suer sub-commands 2025-03-06 20:32:39 +00:00
aa58c97fcf
feat: made the other user wolves commands sub_commands 2025-03-06 20:32:26 +00:00
3a39084f40
feat: unlink is now a subcommand of wolves 2025-03-06 19:21:17 +00:00
058f8a7a7d
fmt: feedback from clippy 2025-02-28 10:58:44 +00:00
b43f760fb1
fmt: feedback from clippy 2025-02-28 00:05:57 +00:00
a11ba15f4a
fix: limit the count committee to committee members and above 2025-02-27 23:58:35 +00:00
7d7afcd00c
feat: new command to count the server info, available on the computer soc server 2025-02-27 23:56:48 +00:00
934842cbc9
feat: will now properly re-order channels whenever a new club/soc is added 2025-02-27 22:37:40 +00:00
143483d3b3
feat: new command to see how many of each club/soc are on teh server 2025-02-27 03:02:35 +00:00
0bedf96da5
fix: properly update teh channel name 2025-02-27 01:24:44 +00:00
555e38ee26
feat: now store the roles and channel ids in teh database 2025-02-27 01:19:40 +00:00
6a5f651ba2
doc: update to reflect that ye no longer need admin to setup and modify the config 2025-02-26 17:06:58 +00:00
f046410cdc
fix: feedback on teh email users get sent when verifying their discord accounts 2025-02-26 17:02:47 +00:00
44bb40d96d
fix: removing some deadweight 2025-02-26 16:20:28 +00:00
7406f0e620
feat: change commands to use the "Manage Server" permission instead of just admin.
This measn that the commands only show up if you can manage teh server
2025-02-26 15:55:42 +00:00
09ce45f70f
feat: improved the email 2025-02-26 14:44:42 +00:00
b67894fc6e
fixL had bad query 2025-02-25 17:34:33 +00:00
6481fcb89f
feat: updated teh bot to use the new wolves_id for clubs/socs 2025-02-24 17:07:26 +00:00
9ce5b8136b
fix: bump to use the new fieldname for teh API get_members 2025-02-24 16:44:22 +00:00
348020ecfe
feat: will check if a person needs a committee role on teh committee server 2025-02-19 22:36:39 +00:00
8645a9b3ce
fix: clippy and fmt 2025-02-19 12:38:40 +00:00
0eba54b6f2
feat: split up the committee refresh from teh regular user refresh 2025-02-19 12:29:53 +00:00
25fcc04287 Merge pull request '#17-automate-onboarding-mk-ii' (#30) from #17-automate-onboarding-mk-ii into main
Reviewed-on: Skynet/discord-bot#30
2025-02-19 10:30:54 +00:00
c79944bd6e
fix: actually enabled the option to get a persons ID if they arent on one of teh servers with teh bot enabled 2025-02-19 01:18:28 +00:00
262eb0c991
fix: ensure that all committee members get teh right role 2025-02-19 00:51:40 +00:00
30287466cb
fix: cannot have an ID of 0 for any role, channel or server 2025-02-19 00:42:26 +00:00
86f71f0fa4
feat: updated more dependencies 2025-02-19 00:30:15 +00:00
6b84f33d2e
feat: bumped serenity to the latest version
Lots of changes to how it runs
2025-02-19 00:17:02 +00:00
a8c1cc9cf1
fix: slight duplicate removal 2025-02-18 22:46:58 +00:00
8c81fb864a
fix: slight duplicate removal 2025-02-18 22:44:32 +00:00
b6cffd8a22
fix: trimmed down teh duplicates 2025-02-18 21:41:28 +00:00
1aef86ad01
feat: now properly sets and removes roles for committee members 2025-02-18 21:14:05 +00:00
4eeb7f2135
fix: grab the committee name since we can use that to match up against teh suers 2025-02-18 14:00:21 +00:00
cab04a068f
feat: fixed up the changes 2025-02-18 13:36:08 +00:00
c79113921d
Merge branch 'main' into #17-automate-onboarding-mk-ii
# Conflicts:
#	Cargo.lock
#	src/commands/link_email.rs
#	src/lib.rs
2025-02-17 17:30:25 +00:00
5fcc24a867
feat: run teh data update every 10 min 2025-01-26 20:06:46 +00:00
0a4f5281a5
fix: typo, always took users as being java 2025-01-03 21:01:27 +00:00
a9d3af024e
feat: added some minor logging 2025-01-03 19:58:00 +00:00
b7d36de976
fix: argument names and some logic 2024-11-30 13:49:30 +00:00
68ffa55dc5
fix: improve wording 2024-11-30 01:21:40 +00:00
9b3c71e321
fix: had not intednded to leave the test config inthe cargo.toml 2024-11-30 01:05:19 +00:00
0478f634fa
feat; modified the command that interacts with teh server to accomodate bedrock players
For #26
2024-11-30 00:55:23 +00:00
ee0c8f0987
feat; can now handle bedrock in the command
For #26
2024-11-30 00:44:36 +00:00
b55650b221
db: add another col to store the bedrock id
For #26
2024-11-29 23:13:28 +00:00
4691869ae9
doc: split out the documentation 2024-11-29 22:16:31 +00:00
68d7b53905
dbg: excessive logging - moar 2024-11-25 18:07:20 +00:00
bf55dfe31e
dbg: excessive logging 2024-11-25 17:45:04 +00:00
ad94b197ae
fix: pretty solid chance that this was due to using teh wrong base url 2024-11-24 00:14:42 +00:00
1f3c33458e
dbg: excessive logging 2024-11-23 23:50:25 +00:00
bab6e4fdec
ci: use the nix compatable version 2024-11-23 22:27:20 +00:00
f00db7ef5d
ci: update teh actions to take into account git-lfs 2024-11-23 22:24:29 +00:00
37ea38f516
feat: backport changes from the #17-automate-onboarding-mk-ii branch 2024-11-23 22:17:57 +00:00
ca55a78447
feat: switched over to using a library to interact with teh wolves API 2024-11-23 21:53:30 +00:00
93359698f0
doc: feedback 2024-11-23 00:26:00 +00:00
dda05d7ca1
doc: resized images using html tags 2024-11-23 00:25:56 +00:00
5dee9acbaa
doc: added images to suer signup 2024-11-23 00:25:51 +00:00
96a61e6fc8
git: finally added a gitattributes 2024-11-23 00:24:48 +00:00
94292fa388
fix: style was causing issues 2024-11-18 16:09:43 +00:00
77a7b7b81d
fix: slight change in env var used to get teh base URL 2024-11-09 16:53:26 +00:00
2f75dc41c8
feat: will properly re-order the channels created
Also focuses on anything in teh right category
2024-11-09 16:47:48 +00:00
c98baa9d72
feat: will now create a channel for any new club/soc 2024-11-09 16:23:03 +00:00
e4a8cce725
feat: new env var for teh specific channel that the general chat stuff will be under 2024-11-09 16:17:43 +00:00
5b22f699d6
fix: getting teh server config needs to happen after checking for committee 2024-11-09 14:59:05 +00:00
6739c7e068
feat: now use env vars to get teh server and roles for committee 2024-11-09 14:55:26 +00:00
d673dce6fa
fix: handle the just in case the user alrady exists as a different person 2024-11-09 12:53:53 +00:00
015f23b922
feat: no longer using teh "hardcoded" api key 2024-11-09 12:51:41 +00:00
7a6421469c
feat: now able to get the memebr_id from just email 2024-11-09 02:23:46 +00:00
733827c3e6
feat: added support for teh new api key 2024-11-09 01:17:43 +00:00
2daa010d25
doc: updated committee instructions 2024-11-04 12:43:09 +00:00
da4d006bc0
doc: update user docs 2024-10-29 14:00:43 +00:00
344d6d3585
fmt: formatting and clippy 2024-10-28 21:53:04 +00:00
b7161e2614
todo: added note 2024-10-28 21:51:14 +00:00
32249364ff
feat: new committee member joins the committee server they automagically get roles 2024-10-28 21:40:48 +00:00
f1138a3c81
fix: moved the methods that changes role into their own module folder 2024-10-28 21:34:21 +00:00
61e76db8dd
feat: this should be able to update teh roles of commettee members on teh discord server 2024-10-28 21:17:44 +00:00
3e6dc9d560
feat: actually get the data for teh committees and pop it in the database 2024-10-28 14:20:36 +00:00
aff6299ac7
feat: added the committee request from wolves 2024-10-28 12:49:35 +00:00
bd80bda22f
feat: added rough code to get an individuals member_id 2024-10-28 02:06:24 +00:00
fe5aa5b252
prep: rough format for requesting data for an indivual and committee 2024-10-28 01:34:23 +00:00
273c58d035
fmt: re-organise the regular data request 2024-10-28 01:31:36 +00:00
3927734083
feat: split out minecraft 2024-10-28 01:06:21 +00:00
41407ecefb
feat: split out all the databse interactions into their own file 2024-10-28 00:59:04 +00:00
79f880daea
feat: splitting up lib.rs into subfiles, starting with anythign taht interacts with teh api 2024-10-28 00:51:39 +00:00
ceade9b972
sql: slight reordering of the migrations 2024-10-28 00:03:52 +00:00
b2d8238c17
Merge branch 'main' into #17-automate-onboarding-mk-ii
# Conflicts:
#	.gitignore
#	src/commands/committee.rs
2024-10-27 23:56:48 +00:00
a7e8f5698e
git: expand out the .gitignore 2024-10-27 23:21:30 +00:00
80c9191eee
fmt: more clippy that got missed 2024-09-30 00:19:58 +01:00
1c3ccbecf5
fmt: not sure how this one slipped by 2024-09-30 00:12:48 +01:00
d1af8a7c1f Merge pull request '#22_Role-Joiner' (#24) from #22_Role-Joiner into main
Reviewed-on: Skynet/discord-bot#24
2024-09-29 23:10:21 +00:00
0d9ce2de7f
fmt: fmt and clippy 2024-09-30 00:09:29 +01:00
5e7964ae26
feat: some cleanup in messages and added some handrails so folks wont add stupid combos 2024-09-30 00:03:03 +01:00
32292a3c0b
feat: tested out command and gt rid of the last few kinks 2024-09-29 23:47:33 +01:00
ffce78a10d
feat: command config for setting it up
(lsit command can come later to see the active ones)
2024-09-29 22:19:58 +01:00
7980739627
feat: new struct to mirror the databse 2024-09-29 21:39:27 +01:00
2aad895bb3
feat: new table for the role adder 2024-09-29 21:25:58 +01:00
ec74dc0aa7
prep: skeleton to handle roles changing from other means 2024-09-29 21:04:08 +01:00
42f301455a Merge pull request '#21_Normalise-email-inputs' (#23) from #21_Normalise-email-inputs into main
Reviewed-on: Skynet/discord-bot#23
2024-09-29 19:07:38 +00:00
0e1a7d56b6
feat: remove teh temp setup 2024-09-17 11:02:26 +01:00
fd32adb138
db: delete teh old table and recreate a new one with teh right fields 2024-09-17 11:02:26 +01:00
04a487cd8f
fix: rename `get_wolves` to be just for clubs/socs 2024-09-17 11:02:26 +01:00
47 changed files with 5893 additions and 2186 deletions

View file

@ -0,0 +1,10 @@
on:
- push
- workflow_dispatch
jobs:
check_lfs:
# nix/docker
runs-on: nix
steps:
- uses: https://github.com/MPLew-is/lfs-check-action@1

View file

@ -0,0 +1,51 @@
on:
- pull_request
jobs:
check_lfs:
# nix/docker
runs-on: nix
steps:
- uses: https://github.com/MPLew-is/lfs-check-action@1
# rust code must be formatted for standardisation
lint_fmt:
# build it using teh base nixos system, helps with caching
runs-on: nix
steps:
# get the repo first
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://forgejo.skynet.ie/Skynet/actions/get_lfs/nix@v8
with:
server_url: ${{ gitea.server_url }}
repository: ${{ gitea.repository }}
- run: nix build .#fmt --verbose
# clippy is incredibly useful for making yer code better
lint_clippy:
# build it using teh base nixos system, helps with caching
runs-on: nix
permissions:
checks: write
steps:
# get the repo first
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://forgejo.skynet.ie/Skynet/actions/get_lfs/nix@v8
with:
server_url: ${{ gitea.server_url }}
repository: ${{ gitea.repository }}
- run: nix build .#clippy --verbose
build:
# build it using teh base nixos system, helps with caching
runs-on: nix
needs: [ lint_fmt, lint_clippy ]
steps:
# get the repo first
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://forgejo.skynet.ie/Skynet/actions/get_lfs/nix@v8
with:
server_url: ${{ gitea.server_url }}
repository: ${{ gitea.repository }}
- name: "Build it locally"
run: nix build --verbose

View file

@ -19,6 +19,10 @@ jobs:
steps:
# get the repo first
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://forgejo.skynet.ie/Skynet/actions/get_lfs@v3
with:
repository: ${{ gitea.repository }}
ref_name: ${{ gitea.ref_name }}
- run: nix build .#fmt --verbose
# clippy is incredibly useful for making yer code better
@ -30,6 +34,10 @@ jobs:
steps:
# get the repo first
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://forgejo.skynet.ie/Skynet/actions/get_lfs@v3
with:
repository: ${{ gitea.repository }}
ref_name: ${{ gitea.ref_name }}
- run: nix build .#clippy --verbose
build:
@ -39,6 +47,10 @@ jobs:
steps:
# get the repo first
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://forgejo.skynet.ie/Skynet/actions/get_lfs@v3
with:
repository: ${{ gitea.repository }}
ref_name: ${{ gitea.ref_name }}
- name: "Build it locally"
run: nix build --verbose
@ -49,7 +61,7 @@ jobs:
needs: [ build ]
steps:
- name: "Deploy to Skynet"
uses: https://forgejo.skynet.ie/Skynet/actions-deploy-to-skynet@v2
uses: https://forgejo.skynet.ie/Skynet/actions/deploy@v3
with:
input: 'skynet_discord_bot'
token: ${{ secrets.API_TOKEN_FORGEJO }}

37
.gitattributes vendored Normal file
View file

@ -0,0 +1,37 @@
# Git config here
* text eol=lf
#############################################
# Git lfs stuff
# Documents
*.pdf filter=lfs diff=lfs merge=lfs -text
*.doc filter=lfs diff=lfs merge=lfs -text
*.docx filter=lfs diff=lfs merge=lfs -text
# Excel
*.xls filter=lfs diff=lfs merge=lfs -text
*.xlsx filter=lfs diff=lfs merge=lfs -text
*.xlsm filter=lfs diff=lfs merge=lfs -text
# Powerpoints
*.ppt filter=lfs diff=lfs merge=lfs -text
*.pptx filter=lfs diff=lfs merge=lfs -text
*.ppsx filter=lfs diff=lfs merge=lfs -text
# Images
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
# Video
*.mkv filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.wmv filter=lfs diff=lfs merge=lfs -text
# Misc
*.zip filter=lfs diff=lfs merge=lfs -text
# ET4011
*.cbe filter=lfs diff=lfs merge=lfs -text
*.pbs filter=lfs diff=lfs merge=lfs -text
# Open/Libre office
# from https://www.libreoffice.org/discover/what-is-opendocument/
*.odt filter=lfs diff=lfs merge=lfs -text
*.ods filter=lfs diff=lfs merge=lfs -text
*.odp filter=lfs diff=lfs merge=lfs -text
*.odg filter=lfs diff=lfs merge=lfs -text
# QT
*.ui filter=lfs diff=lfs merge=lfs -text

View file

@ -6,4 +6,5 @@ fn_params_layout = "Compressed"
#brace_style = "PreferSameLine"
struct_lit_width = 0
tab_spaces = 2
use_small_heuristics = "Max"
use_small_heuristics = "Max"
imports_granularity = "Crate"

6
.server-icons.toml Normal file
View file

@ -0,0 +1,6 @@
# this file controls the
[source]
repo = "https://forgejo.skynet.ie/Computer_Society/open-goverance"
directory = "Resources/Logo_Variants"
file = "_festivals.toml"

1896
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,37 +4,56 @@ version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
name = "update_data"
[[bin]]
name = "update_users"
name = "update_committee"
[[bin]]
name = "update_minecraft"
[[bin]]
name = "update_server-icon"
[[bin]]
name = "cleanup_committee"
[dependencies]
# discord library
serenity = { version = "0.11.6", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "cache"] }
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }
serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "cache"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "full"] }
# wolves api
wolves_oxidised = { git = "https://forgejo.skynet.ie/Skynet/wolves-oxidised.git", features = ["unstable"] }
# wolves_oxidised = { path = "../wolves-oxidised", features = ["unstable"] }
# to make the http requests
surf = "2.3.2"
surf = "2.3"
dotenvy = "0.15.7"
dotenvy = "0.15"
# For sqlite
sqlx = { version = "0.7.1", features = [ "runtime-tokio", "sqlite", "migrate" ] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] }
serde_json = { version = "1.0", features = ["raw_value"] }
# create random strings
rand = "0.8.5"
rand = "0.9"
# fancy time stuff
chrono = "0.4.26"
chrono = "0.4"
# for email
lettre = "0.10.4"
maud = "0.25.0"
lettre = "0.11"
maud = "0.27"
serde = "1.0.188"
toml = "0.8.23"
serde = "1.0"
# for image conversion
eyre = "0.6.8"
color-eyre = "0.6.2"
usvg-text-layout = "0.29.0"
usvg = "0.29.0"
resvg = "0.29.0"
tiny-skia = "0.8.3"

View file

@ -1,70 +1,10 @@
# Skynet Discord Bot
This bots core purpose is to give members roles based on their status on <ulwolves.ie>.
It uses an api key provided by wolves to get member lists.
The Skynet bot is designed to manage users on Discord.
It allows users to link their UL Wolves account with Wolves in a GDPR compliant manner.
Skynet (bot) is hosted by the Computer Society on Skynet (computer cluster).
Users are able to link their wolves account to the bot and that works across discord servers.
For example is a user links on the CompSoc Discord then they will also get their roles (automagically) on Games Dev if they are a member there.
## Documentation
We have split up the documentation into different segments depending on who the user is.
## Commands - Admin
You need admin access to run any of the commands in this section.
Either the server owner or a suer with the ``Administrator`` permission
### Getting the Skynet Discord bot
1. Email ``keith@assurememberships.com`` from committee email and say ye want an api key for ``193.1.99.74``
2. Create a role for current members (maybe call it ``current-member`` ?)
3. (Optional) create a role for all past and current members (ye can use the existing ``member`` role for this, )
4. Invite the bot https://discord.com/api/oauth2/authorize?client_id=1145761669256069270&permissions=139855185984&scope=bot
5. Make sure the bot role ``@skynet`` is above these two roles (so it can manage them)
6. Make sure that you have a role that gives ye administrator powers
7. Use the command ``/add`` and insert the api key, role current and role all (desktop recommended)
The reason for both roles is ye have one for active members while the second is for all current and past members.
### Minecraft
The bot is able to manage the whitelist of a Minecraft server managed by the Computer Society.
Talk to us to get a server.
#### Add
This links a minecraft server with your club/society.
``/minecraft_add SERVER_ID``
#### List
List the servers linked to your club/society.
It is possible to have more than one minecraft server
``/minecraft_list``
#### Delete
This unlinks a minecraft server from your club/society.
``/minecraft_delete SERVER_ID``
## Commands - User
### Setup
* Start the process using ``/link_wolves WOLVES_EMAIL``
* The email that is in the Contact Email here: <https://ulwolves.ie/memberships/profile>
* An email will be sent to them that they need to verify using ``/verify CODE``
* This will only have to be done once.
* If the user is an active member on wolves
* If they are in any servers with teh Skynet Bot
* They will get relevant roles.
* If they Join a server with teh bot enabled.
* They will be granted the roles automatically
* If the user is **not** an active member on wolves.
* If they have no Roles
* No change
* If they have Past Member Role
* No change
* If they have both Roles
* The current-member role will be removed from them
* Past Member role will remain unchanged
### Minecraft
Users can link their Minecraft username to grant them access to any servers where teh whitelist is managed by teh bot.
``/link_minecraft MINECRAFT_USERNAME``
* [Committees](./doc/Committee.md)
* [Member](./doc/User.md)

View file

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS committee_roles (
id_wolves integer PRIMARY KEY,
id_role integer DEFAULT 1,
id_channel integer DEFAULT 1,
-- not strictly required but for readability and debugging
name_role text NOT NULL DEFAULT '',
name_channel text NOT NULL DEFAULT '',
count integer DEFAULT 0
);

View file

@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS server_icons (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
date TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS index_name ON server_icons (name);

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS roles_adder (
server integer not null,
role_a integer not null,
role_b integer not null,
role_c integer not null,
PRIMARY KEY(server,role_a,role_b,role_c)
);
CREATE INDEX IF NOT EXISTS index_roles_adder_server ON roles_adder (server);
CREATE INDEX IF NOT EXISTS index_roles_adder_from ON roles_adder (role_a,role_b);
CREATE INDEX IF NOT EXISTS index_roles_adder_to ON roles_adder (role_c);
CREATE INDEX IF NOT EXISTS index_roles_adder_search ON roles_adder (server,role_a,role_b);

View file

@ -0,0 +1,3 @@
-- Using this col we will be able to see if someone has a bedrock account (as well as a java one)
ALTER TABLE wolves ADD COLUMN minecraft_uid TEXT;

View file

@ -0,0 +1,14 @@
-- No longer using the previous committee table
DROP TABLE committee;
-- new table pulling from teh api
CREATE TABLE IF NOT EXISTS committees (
id integer PRIMARY KEY,
name_profile text not null,
name_plain text not null,
name_full text not null,
link text not null,
committee text not null
);
ALTER TABLE servers DROP COLUMN wolves_link;

View file

@ -0,0 +1,6 @@
-- No need for this col since it is goign to be in "committees" anyways
ALTER TABLE servers DROP COLUMN server_name;
-- we do care about teh ID of the club/soc though
ALTER TABLE servers ADD COLUMN wolves_id integer DEFAULT 0;

71
doc/Committee.md Normal file
View file

@ -0,0 +1,71 @@
# Skynet Discord Bot
This bots core purpose is to give members roles based on their status on <https://ulwolves.ie>.
It uses an api key provided by wolves to get member lists.
Users are able to link their wolves account to the bot and that works across discord servers.
For example is a user links on the CompSoc Discord then they will also get their roles (automagically) on Games Dev if they are a member there.
## Setup - Committee
You need admin access to run any of the commands in this section.
Either the server owner or a user with the ``Manage Server`` permission.
### Get the API Key
The ``api_key`` is used by the Bot in order to request information, it will be used later in the process.
1. Email ``keith@assurememberships.com`` from committee email and say you want an ``api_key`` for ``193.1.99.74``
* The committee email is the one here: <https://cp.ulwolves.ie/mailbox/>
* This may take up to a week to get the key.
### Setup Server
The Bot reason for existing is being able to give members Roles.
So we have to create those.
1. Create a role for Current Members.
* You can call it whatever you want.
* ``member-current`` is a good choice.
* This should be a new role
2. **Optional**: you can create a role that is given to folks who were but no longer a member.
* ``member`` would be a good choice for this
* If you have an existing member role this is also a good fit.
The reason for both roles is ye have one for active members while the second is for all current and past members.
### Invite Bot
1. Invite the bot https://discord.com/api/oauth2/authorize?client_id=1145761669256069270&permissions=139855185984&scope=bot
2. Make sure the bot role ``@skynet`` is above these two roles created in the previous step
* This is so it can manage the roles (give and remove them from users)
### Setup Bot
This is where the bot is configured.
You will need the ``api_key`` from the start of the process.
You (personally) will need a role with ``Manage Server`` permission to be able to do this.
1. Use the command ``/committee add`` and a list of options will pop up.
2. ``api_key`` is the key you got from Keith earlier.
3. ``role_current`` is the ``member-current`` that you created earlier.
4. ``role_past`` (optional) is the role for all current and past members.
5. ``bot_channel`` is a channel that folks are recommended to use the bot.
* You can have it so folks cannot see message history
At this point the bot is set up and no further action is required.
### Minecraft
The bot is able to manage the whitelist of a Minecraft server managed by the Computer Society.
Talk to us to get a server.
#### Add
This links a minecraft server with your club/society.
``/minecraft_add SERVER_ID``
#### List
List the servers linked to your club/society.
It is possible to have more than one minecraft server
``/minecraft_list``
#### Delete
This unlinks a minecraft server from your club/society.
``/minecraft_delete SERVER_ID``

26
doc/User.md Normal file
View file

@ -0,0 +1,26 @@
# Skynet Discord Bot
The Skynet bot is designed to make it easy to verify that you are a member of a Club/Society.
The bot will be able to give you member roles for any partnered servers.
It also provides secondary manifests such as granting access to minecraft servers managed by teh Computer Society.
## Setup
This is to link your Discord account with your UL Wolves account.
**You will only need to do this once**.
### Setup
1. In a Discord server with the Skynet Bot enter ``/wolves link YOUR_WOLVES_CONTACT_EMAIL``
<img src="../media/setup_user_01.png" alt="link process start" width="50%" height="50%">
* Your ``YOUR_WOLVES_CONTACT_EMAIL`` is the email in the Contact Email here: <https://ulwolves.ie/memberships/profile>
* This is most likely your student mail
2. An email will be sent to you with a verification code.
<img src="../media/setup_user_02.png" alt="signup email" width="50%" height="50%">
3. Verify the code using ``/wolves verify CODE_FROM_EMAIL`` in Discord.
<img src="../media/setup_user_03.png" alt="verify in discord" width="50%" height="50%">
4. Once complete your Wolves and Discord accounts will be linked.
You will get member roles on any Discord that is using the bot that you are a member of.
### Minecraft
You can link your Minecraft username to grant you access to any Minecraft server run by UL Computer Society.
``/wolves link_minecraft MINECRAFT_USERNAME``

17
flake.lock generated
View file

@ -32,6 +32,22 @@
"type": "indirect"
}
},
"nixpkgs-mozilla": {
"flake": false,
"locked": {
"lastModified": 1744624473,
"narHash": "sha256-S6zT/w5SyAkJ//dYdjbrXgm+6Vkd/k7qqUl4WgZ6jjk=",
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"rev": "2292d4b35aa854e312ad2e95c4bb5c293656f21a",
"type": "github"
},
"original": {
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1722995383,
@ -51,6 +67,7 @@
"inputs": {
"naersk": "naersk",
"nixpkgs": "nixpkgs_2",
"nixpkgs-mozilla": "nixpkgs-mozilla",
"utils": "utils"
}
},

View file

@ -4,6 +4,10 @@
inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable";
naersk.url = "github:nix-community/naersk";
nixpkgs-mozilla = {
url = "github:mozilla/nixpkgs-mozilla";
flake = false;
};
utils.url = "github:numtide/flake-utils";
};
@ -17,15 +21,33 @@
nixpkgs,
utils,
naersk,
nixpkgs-mozilla,
}:
utils.lib.eachDefaultSystem (
system: let
pkgs = (import nixpkgs) {inherit system;};
naersk' = pkgs.callPackage naersk {};
overrides = (builtins.fromTOML (builtins.readFile ./rust-toolchain.toml));
pkgs = (import nixpkgs) {
inherit system;
overlays = [
(import nixpkgs-mozilla)
];
};
toolchain = (pkgs.rustChannelOf {
rustToolchain = ./rust-toolchain.toml;
sha256 = "sha256-KUm16pHj+cRedf8vxs/Hd2YWxpOrWZ7UOrwhILdSJBU=";
}).rust;
naersk' = pkgs.callPackage naersk {
cargo = toolchain;
rustc = toolchain;
};
package_name = "skynet_discord_bot";
desc = "Skynet Discord Bot";
buildInputs = with pkgs; [
openssl
glib
gdk-pixbuf
pkg-config
rustfmt
];
@ -36,6 +58,10 @@
pname = "${package_name}";
src = ./.;
buildInputs = buildInputs;
postInstall = ''
mkdir $out/config
cp .server-icons.toml $out/config
'';
};
# Run `nix build .#fmt` to run tests
fmt = naersk'.buildPackage {
@ -62,7 +88,15 @@
# `nix develop`
devShell = pkgs.mkShell {
nativeBuildInputs = with pkgs; [rustc cargo pkg-config openssl rustfmt];
nativeBuildInputs = with pkgs; [rustup rustPlatform.bindgenHook];
# libraries here
buildInputs = buildInputs;
RUSTC_VERSION = overrides.toolchain.channel;
# https://github.com/rust-lang/rust-bindgen#environment-variables
shellHook = ''
export PATH="''${CARGO_HOME:-~/.cargo}/bin":"$PATH"
export PATH="''${RUSTUP_HOME:-~/.rustup}/toolchains/$RUSTC_VERSION-${pkgs.stdenv.hostPlatform.rust.rustcTarget}/bin":"$PATH"
'';
};
nixosModule = {
@ -88,12 +122,14 @@
wantedBy = [];
after = ["network-online.target"];
environment = environment_config;
path = with pkgs; [ git git-lfs ];
serviceConfig = {
Type = "oneshot";
User = "${cfg.user}";
Group = "${cfg.user}";
ExecStart = "${self.defaultPackage."${system}"}/bin/${script}";
# kill each service if its ran for 9 min
TimeoutStartSec=540;
EnvironmentFile = [
"${cfg.env.discord}"
"${cfg.env.mail}"
@ -118,12 +154,17 @@
# modify these
scripts = {
# every 20 min
"update_data" = "*:0,20,40";
# every 10 min
"update_data" = "*:0,10,20,30,40,50";
# groups are updated every hour, offset from teh ldap
"update_users" = "*:05:00";
"update_users" = "*:5,15,25,35,45,55";
# Committee server has its own timer
"update_committee" = "*:5,15,25,35,45,55";
# minecraft stuff is updated at 5am
# this service does not depend on teh discord cache
"update_minecraft" = "5:10:00";
# server icon gets updated daily at midnight
"update_server-icon" = "0:01:00";
};
in {
options.services."${package_name}" = {
@ -183,6 +224,7 @@
after = ["network-online.target"];
wants = [];
environment = environment_config;
path = with pkgs; [ git git-lfs ];
serviceConfig = {
User = "${cfg.user}";

BIN
media/setup_user_01.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
media/setup_user_02.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
media/setup_user_03.png (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -1,2 +1,2 @@
[toolchain]
channel = "1.80"
channel = "1.87"

View file

@ -0,0 +1,139 @@
use serenity::{
all::{ChunkGuildFilter, GuildId, GuildMembersChunkEvent},
async_trait,
client::{Context, EventHandler},
model::gateway::GatewayIntents,
Client,
};
use skynet_discord_bot::{
common::{
database::{db_init, DataBase},
set_roles::committee::db_roles_get,
},
get_config, Config,
};
use sqlx::{Pool, Sqlite};
use std::{process, sync::Arc};
use tokio::sync::RwLock;
/// Cleanup teh Committee server
///
/// This removes any invalid roles/channels which ay have been set up accidentally
/// DO NOT run this locally unless you have a fresh copy of teh live database handy.
#[tokio::main]
async fn main() {
let config = get_config();
let db = match db_init(&config).await {
Ok(x) => x,
Err(_) => return,
};
// Intents are a bitflag, bitwise operations can be used to dictate which intents to use
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILD_MEMBERS;
// Build our client.
let mut client = Client::builder(&config.discord_token, intents)
.event_handler(Handler {})
.cache_settings(serenity::cache::Settings::default())
.await
.expect("Error creating client");
{
let mut data = client.data.write().await;
data.insert::<Config>(Arc::new(RwLock::new(config)));
data.insert::<DataBase>(Arc::new(RwLock::new(db)));
}
if let Err(why) = client.start().await {
println!("Client error: {why:?}");
}
}
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn cache_ready(&self, ctx: Context, _guilds: Vec<GuildId>) {
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config_global = config_lock.read().await;
let server = config_global.committee_server;
ctx.shard.chunk_guild(server, Some(2000), false, ChunkGuildFilter::None, None);
println!("Cache loaded");
}
async fn guild_members_chunk(&self, ctx: Context, chunk: GuildMembersChunkEvent) {
if (chunk.chunk_index + 1) == chunk.chunk_count {
println!("Cache built successfully!");
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Config in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
cleanup(&db, &ctx, &config).await;
// finish up
process::exit(0);
}
}
}
async fn cleanup(db: &Pool<Sqlite>, ctx: &Context, config: &Config) {
let server = config.committee_server;
let committees = db_roles_get(db).await;
if let Ok(channels) = server.channels(ctx).await {
for (id, channel) in &channels {
let name = &channel.name;
let committee_tmp = committees.iter().filter(|x| &x.name_channel == name).collect::<Vec<_>>();
let committee = match committee_tmp.first() {
// if there are no committees which match then this is not a channelw e care about
None => {
continue;
}
Some(x) => x,
};
// if the id of the channel does not match then remove it
if id != &committee.id_channel {
println!("Deleting Channel - ID: {} Name: {}", id, &channel.name);
if let Err(e) = channel.delete(ctx).await {
dbg!(e);
}
}
}
}
if let Ok(mut roles) = server.roles(ctx).await {
for (id, role) in &mut roles {
let name = &role.name;
let committee_tmp = committees.iter().filter(|x| &x.name_role == name).collect::<Vec<_>>();
let committee = match committee_tmp.first() {
// if there are no committees which match then this is not a channelw e care about
None => {
continue;
}
Some(x) => x,
};
// if the id of the role does not match then remove it
if id != &committee.id_role {
println!("Deleting Role - ID: {} Name: {}", id, &role.name);
if let Err(e) = role.delete(ctx).await {
dbg!(e);
}
}
}
}
}

View file

@ -0,0 +1,74 @@
use serenity::{
all::{ChunkGuildFilter, GuildId, GuildMembersChunkEvent},
async_trait,
client::{Context, EventHandler},
model::gateway::GatewayIntents,
Client,
};
use skynet_discord_bot::{
common::{
database::{db_init, DataBase},
set_roles::committee,
},
get_config, Config,
};
use std::{process, sync::Arc};
use tokio::sync::RwLock;
#[tokio::main]
async fn main() {
let config = get_config();
let db = match db_init(&config).await {
Ok(x) => x,
Err(_) => return,
};
// Intents are a bitflag, bitwise operations can be used to dictate which intents to use
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILD_MEMBERS;
// Build our client.
let mut client = Client::builder(&config.discord_token, intents)
.event_handler(Handler {})
.cache_settings(serenity::cache::Settings::default())
.await
.expect("Error creating client");
{
let mut data = client.data.write().await;
data.insert::<Config>(Arc::new(RwLock::new(config)));
data.insert::<DataBase>(Arc::new(RwLock::new(db)));
}
if let Err(why) = client.start().await {
println!("Client error: {why:?}");
}
}
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn cache_ready(&self, ctx: Context, _guilds: Vec<GuildId>) {
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config_global = config_lock.read().await;
let server = config_global.committee_server;
ctx.shard.chunk_guild(server, Some(2000), false, ChunkGuildFilter::None, None);
println!("Cache loaded");
}
async fn guild_members_chunk(&self, ctx: Context, chunk: GuildMembersChunkEvent) {
if (chunk.chunk_index + 1) == chunk.chunk_count {
println!("Cache built successfully!");
// u[date committee server
committee::check_committee(&ctx).await;
// finish up
process::exit(0);
}
}
}

View file

@ -4,7 +4,13 @@ use serenity::{
model::gateway::{GatewayIntents, Ready},
Client,
};
use skynet_discord_bot::{db_init, get_config, get_data::get_wolves, Config, DataBase};
use skynet_discord_bot::{
common::{
database::{db_init, DataBase},
wolves::{cns::get_wolves, committees::get_cns},
},
get_config, Config,
};
use std::{process, sync::Arc};
use tokio::sync::RwLock;
@ -13,7 +19,10 @@ async fn main() {
let config = get_config();
let db = match db_init(&config).await {
Ok(x) => x,
Err(_) => return,
Err(e) => {
dbg!(e);
return;
}
};
// Intents are a bitflag, bitwise operations can be used to dictate which intents to use
@ -21,6 +30,7 @@ async fn main() {
// Build our client.
let mut client = Client::builder(&config.discord_token, intents)
.event_handler(Handler {})
.cache_settings(serenity::cache::Settings::default())
.await
.expect("Error creating client");
@ -32,7 +42,7 @@ async fn main() {
}
if let Err(why) = client.start().await {
println!("Client error: {:?}", why);
println!("Client error: {why:?}");
}
}
@ -43,8 +53,12 @@ impl EventHandler for Handler {
let ctx = Arc::new(ctx);
println!("{} is connected!", ready.user.name);
// get the data for each individual club/soc
get_wolves(&ctx).await;
// get teh data for the clubs/socs committees
get_cns(&ctx).await;
// finish up
process::exit(0);
}

View file

@ -1,4 +1,10 @@
use skynet_discord_bot::{db_init, get_config, get_minecraft_config, update_server, whitelist_wipe};
use skynet_discord_bot::{
common::{
database::db_init,
minecraft::{get_minecraft_config, update_server, whitelist_wipe},
},
get_config,
};
use std::collections::HashSet;
#[tokio::main]

View file

@ -0,0 +1,72 @@
use serenity::{
async_trait,
client::{Context, EventHandler},
model::gateway::{GatewayIntents, Ready},
Client,
};
use skynet_discord_bot::{
common::{
database::{db_init, DataBase},
server_icon::{get_config_icons, update_icon},
},
get_config, Config,
};
use std::{process, sync::Arc};
use tokio::sync::RwLock;
#[tokio::main]
async fn main() {
let config = get_config();
let db = match db_init(&config).await {
Ok(x) => x,
Err(_) => return,
};
// Intents are a bitflag, bitwise operations can be used to dictate which intents to use
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILD_MEMBERS;
// Build our client.
let mut client = Client::builder(&config.discord_token, intents)
.event_handler(Handler {})
.cache_settings(serenity::cache::Settings::default())
.await
.expect("Error creating client");
{
let mut data = client.data.write().await;
data.insert::<Config>(Arc::new(RwLock::new(config)));
data.insert::<DataBase>(Arc::new(RwLock::new(db)));
}
if let Err(why) = client.start().await {
println!("Client error: {why:?}");
}
}
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, ready: Ready) {
let ctx = Arc::new(ctx);
println!("{} is connected!", ready.user.name);
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Config in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config_global = config_lock.read().await;
let config_toml = get_config_icons::minimal();
update_icon::update_icon_main(&ctx, &db, &config_global, &config_toml).await;
// finish up
process::exit(0);
}
}

View file

@ -1,10 +1,18 @@
use serenity::{
all::{ChunkGuildFilter, GuildId, GuildMembersChunkEvent},
async_trait,
client::{Context, EventHandler},
model::gateway::{GatewayIntents, Ready},
model::gateway::GatewayIntents,
Client,
};
use skynet_discord_bot::{db_init, get_config, get_server_config_bulk, set_roles, Config, DataBase};
use skynet_discord_bot::{
common::{
database::{db_init, get_server_config_bulk, DataBase},
set_roles::normal,
},
get_config, Config,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::{process, sync::Arc};
use tokio::sync::RwLock;
@ -20,7 +28,11 @@ async fn main() {
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILD_MEMBERS;
// Build our client.
let mut client = Client::builder(&config.discord_token, intents)
.event_handler(Handler {})
.event_handler(Handler {
server_count: Default::default(),
server_cached: Default::default(),
})
.cache_settings(serenity::cache::Settings::default())
.await
.expect("Error creating client");
@ -32,25 +44,41 @@ async fn main() {
}
if let Err(why) = client.start().await {
println!("Client error: {:?}", why);
println!("Client error: {why:?}");
}
}
struct Handler;
struct Handler {
server_count: AtomicUsize,
server_cached: AtomicUsize,
}
#[async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, ready: Ready) {
let ctx = Arc::new(ctx);
println!("{} is connected!", ready.user.name);
async fn cache_ready(&self, ctx: Context, guilds: Vec<GuildId>) {
self.server_count.swap(guilds.len(), Ordering::SeqCst);
for guild in guilds {
ctx.shard.chunk_guild(guild, Some(2000), false, ChunkGuildFilter::None, None);
}
println!("Cache loaded {}", &self.server_count.load(Ordering::SeqCst));
}
bulk_check(Arc::clone(&ctx)).await;
async fn guild_members_chunk(&self, ctx: Context, chunk: GuildMembersChunkEvent) {
if (chunk.chunk_index + 1) == chunk.chunk_count {
self.server_cached.fetch_add(1, Ordering::SeqCst);
if (self.server_cached.load(Ordering::SeqCst) + 1) == self.server_count.load(Ordering::SeqCst) {
println!("Cache built successfully!");
// finish up
process::exit(0);
// this goes into each server and sets roles for each wolves member
check_bulk(&ctx).await;
// finish up
process::exit(0);
}
}
}
}
async fn bulk_check(ctx: Arc<Context>) {
async fn check_bulk(ctx: &Context) {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Config in TypeMap.").clone()
@ -59,6 +87,6 @@ async fn bulk_check(ctx: Arc<Context>) {
let db = db_lock.read().await;
for server_config in get_server_config_bulk(&db).await {
set_roles::update_server(&ctx, &server_config, &[], &[]).await;
normal::update_server(ctx, &server_config, &[], &[]).await;
}
}

View file

@ -1,98 +1,61 @@
use serenity::{
builder::CreateApplicationCommand,
all::{CommandDataOption, CommandDataOptionValue, CommandInteraction},
client::Context,
model::{
application::interaction::application_command::ApplicationCommandInteraction,
prelude::{command::CommandOptionType, interaction::application_command::CommandDataOptionValue},
},
};
use skynet_discord_bot::get_data::get_wolves;
use skynet_discord_bot::{get_server_config, is_admin, set_roles::update_server, DataBase, Servers};
use skynet_discord_bot::common::{
database::{get_server_config, DataBase, Servers},
set_roles::normal::update_server,
wolves::cns::get_wolves,
};
use sqlx::{Error, Pool, Sqlite};
pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String {
// check if user has high enough permisssions
if let Some(msg) = is_admin(command, ctx).await {
return msg;
}
let api_key = if let CommandDataOptionValue::String(key) = command
.data
.options
.first()
.expect("Expected user option")
.resolved
.as_ref()
.expect("Expected user object")
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let sub_options = if let Some(CommandDataOption {
value: CommandDataOptionValue::SubCommand(options),
..
}) = command.data.options.first()
{
key
options
} else {
return "Please provide sub options".to_string();
};
let wolves_api = if let Some(x) = sub_options.first() {
match &x.value {
CommandDataOptionValue::String(key) => key.to_string(),
_ => return "Please provide a wolves API key".to_string(),
}
} else {
return "Please provide a wolves API key".to_string();
};
let role_current = if let CommandDataOptionValue::Role(role) = command
.data
.options
.get(1)
.expect("Expected role option")
.resolved
.as_ref()
.expect("Expected role object")
{
role.id.to_owned()
let role_current = if let Some(x) = sub_options.get(1) {
match &x.value {
CommandDataOptionValue::Role(role) => role.to_owned(),
_ => return "Please provide a valid role for ``Role Current``".to_string(),
}
} else {
return "Please provide a valid role for ``Role Current``".to_string();
};
let mut role_past = None;
if let Some(x) = command.data.options.get(5) {
if let Some(CommandDataOptionValue::Role(role)) = &x.resolved {
role_past = Some(role.id.to_owned());
let role_past = if let Some(x) = sub_options.get(5) {
match &x.value {
CommandDataOptionValue::Role(role) => Some(role.to_owned()),
_ => None,
}
} else {
None
};
let bot_channel_id = if let CommandDataOptionValue::Channel(channel) = command
.data
.options
.get(2)
.expect("Expected channel option")
.resolved
.as_ref()
.expect("Expected channel object")
{
channel.id.to_owned()
let bot_channel_id = if let Some(x) = sub_options.get(2) {
match &x.value {
CommandDataOptionValue::Channel(channel) => channel.to_owned(),
_ => return "Please provide a valid channel for ``Bot Channel``".to_string(),
}
} else {
return "Please provide a valid channel for ``Bot Channel``".to_string();
};
let server_name = if let CommandDataOptionValue::String(name) = command
.data
.options
.get(3)
.expect("Expected Server Name option")
.resolved
.as_ref()
.expect("Expected Server Name object")
{
name
} else {
&"UL Computer Society".to_string()
};
let wolves_link = if let CommandDataOptionValue::String(wolves) = command
.data
.options
.get(4)
.expect("Expected Wolves Link option")
.resolved
.as_ref()
.expect("Expected Server Name object")
{
wolves
} else {
&"https://ulwolves.ie/society/computer".to_string()
};
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
@ -101,92 +64,41 @@ pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> Stri
let server_data = Servers {
server: command.guild_id.unwrap_or_default(),
wolves_api: api_key.to_owned(),
wolves_api,
wolves_id: 0,
role_past,
role_current,
member_past: 0,
member_current: 0,
bot_channel_id,
server_name: server_name.to_owned(),
wolves_link: wolves_link.to_string(),
};
match add_server(&db, ctx, &server_data).await {
Ok(_) => {}
Err(e) => {
println!("{:?}", e);
return format!("Failure to insert into Servers {:?}", server_data);
println!("{e:?}");
return format!("Failure to insert into Servers {server_data:?}");
}
}
"Added/Updated server info".to_string()
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name("add")
.description("Enable the bot for this discord")
.create_option(|option| {
option
.name("api_key")
.description("UL Wolves API Key")
.kind(CommandOptionType::String)
.required(true)
})
.create_option(|option| {
option
.name("role_current")
.description("Role for Current members")
.kind(CommandOptionType::Role)
.required(true)
})
.create_option(|option| {
option
.name("bot_channel")
.description("Safe space for folks to use the bot commands.")
.kind(CommandOptionType::Channel)
.required(true)
})
.create_option(|option| {
option
.name("server_name")
.description("Name of the Discord Server.")
.kind(CommandOptionType::String)
.required(true)
})
.create_option(|option| {
option
.name("wolves_link")
.description("Link to the Club/Society on UL Wolves.")
.kind(CommandOptionType::String)
.required(true)
})
.create_option(|option| {
option
.name("role_past")
.description("Role for Past members")
.kind(CommandOptionType::Role)
.required(false)
})
}
async fn add_server(db: &Pool<Sqlite>, ctx: &Context, server: &Servers) -> Result<Option<Servers>, Error> {
let existing = get_server_config(db, &server.server).await;
let role_past = server.role_past.map(|x| *x.as_u64() as i64);
let role_past = server.role_past.map(|x| x.get() as i64);
let insert = sqlx::query_as::<_, Servers>(
"
INSERT OR REPLACE INTO servers (server, wolves_api, role_past, role_current, bot_channel_id, server_name, wolves_link)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
INSERT OR REPLACE INTO servers (server, wolves_api, role_past, role_current, bot_channel_id)
VALUES (?1, ?2, ?3, ?4, ?5)
",
)
.bind(*server.server.as_u64() as i64)
.bind(server.server.get() as i64)
.bind(&server.wolves_api)
.bind(role_past)
.bind(*server.role_current.as_u64() as i64)
.bind(*server.bot_channel_id.as_u64() as i64)
.bind(&server.server_name)
.bind(&server.wolves_link)
.bind(server.role_current.get() as i64)
.bind(server.bot_channel_id.get() as i64)
.fetch_optional(db)
.await;

View file

@ -1,311 +1,27 @@
use lettre::{
message::{header, MultiPart, SinglePart},
transport::smtp::{self, authentication::Credentials},
Message, SmtpTransport, Transport,
};
use maud::html;
use serenity::{
builder::CreateApplicationCommand,
client::Context,
model::{
application::interaction::application_command::ApplicationCommandInteraction,
id::UserId,
prelude::{command::CommandOptionType, interaction::application_command::CommandDataOptionValue},
},
};
use skynet_discord_bot::{random_string, Config, DataBase};
use sqlx::{Pool, Sqlite};
use serenity::all::{CommandOptionType, CreateCommand, CreateCommandOption};
pub mod link {
use super::*;
use serenity::model::id::GuildId;
use skynet_discord_bot::Committee;
pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String {
let committee_server = GuildId(1220150752656363520);
match command.guild_id {
None => {
return "Not in correct discord server.".to_string();
}
Some(x) => {
if x != committee_server {
return "Not in correct discord server.".to_string();
}
}
}
let option = command
.data
.options
.first()
.expect("Expected email option")
.resolved
.as_ref()
.expect("Expected email object");
let email = if let CommandDataOptionValue::String(email) = option {
email.trim()
} else {
return "Please provide a valid committee email.".to_string();
};
// fail early
if !email.ends_with("@ulwolves.ie") {
return "Please use a @ulwolves.ie address you have access to.".to_string();
}
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
if get_server_member_discord(&db, &command.user.id).await.is_some() {
return "Already linked".to_string();
}
if get_verify_from_db(&db, &command.user.id).await.is_some() {
return "Linking already in process, please check email.".to_string();
}
// generate a auth key
let auth = random_string(20);
match send_mail(&config, email, &auth, &command.user.name) {
Ok(_) => match save_to_db(&db, email, &auth, &command.user.id).await {
Ok(_) => {}
Err(e) => {
return format!("Unable to save to db {} {e:?}", email);
}
},
Err(e) => {
return format!("Unable to send mail to {} {e:?}", email);
}
}
format!("Verification email sent to {}, it may take up to 15 min for it to arrive. If it takes longer check the Junk folder.", email)
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name("link_committee")
.description("Verify you are a committee member")
.create_option(|option| {
option
.name("email")
.description("UL Wolves Committee Email")
.kind(CommandOptionType::String)
.required(true)
})
}
pub async fn get_server_member_discord(db: &Pool<Sqlite>, user: &UserId) -> Option<Committee> {
sqlx::query_as::<_, Committee>(
r#"
SELECT *
FROM committee
WHERE discord = ?
"#,
pub fn register() -> CreateCommand {
CreateCommand::new("committee")
.description("Commands related to what committees can do")
.default_member_permissions(serenity::model::Permissions::MANAGE_GUILD)
.add_option(
CreateCommandOption::new(CommandOptionType::SubCommand, "add", "Enable the bot for this discord")
.add_sub_option(CreateCommandOption::new(CommandOptionType::String, "api_key", "UL Wolves API Key").required(true))
.add_sub_option(CreateCommandOption::new(CommandOptionType::Role, "role_current", "Role for Current members").required(true))
.add_sub_option(
CreateCommandOption::new(CommandOptionType::Channel, "bot_channel", "Safe space for folks to use the bot commands.").required(true),
)
.add_sub_option(CreateCommandOption::new(CommandOptionType::Role, "role_past", "Role for Past members").required(false)),
)
.bind(*user.as_u64() as i64)
.fetch_one(db)
.await
.ok()
}
fn send_mail(config: &Config, mail: &str, auth: &str, user: &str) -> Result<smtp::response::Response, smtp::Error> {
let sender = format!("UL Computer Society <{}>", &config.mail_user);
// Create the html we want to send.
let html = html! {
head {
title { "Hello from Skynet!" }
style type="text/css" {
"h2, h4 { font-family: Arial, Helvetica, sans-serif; }"
}
}
div style="display: flex; flex-direction: column; align-items: center;" {
h2 { "Hello from Skynet!" }
// Substitute in the name of our recipient.
p { "Hi " (user) "," }
p {
"Please use " pre { "/verify_committee code: " (auth)} " to verify your discord account."
}
p {
"Skynet Team"
br;
"UL Computer Society"
}
}
};
let body_text = format!(
r#"
Hi {user}
Please use "/verify_committee code: {auth}" to verify your discord account.
Skynet Team
UL Computer Society
"#
);
// Build the message.
let email = Message::builder()
.from(sender.parse().unwrap())
.to(mail.parse().unwrap())
.subject("Skynet-Discord: Link Committee.")
.multipart(
// This is composed of two parts.
// also helps not trip spam settings (uneven number of url's
MultiPart::alternative()
.singlepart(SinglePart::builder().header(header::ContentType::TEXT_PLAIN).body(body_text))
.singlepart(SinglePart::builder().header(header::ContentType::TEXT_HTML).body(html.into_string())),
)
.expect("failed to build email");
let creds = Credentials::new(config.mail_user.clone(), config.mail_pass.clone());
// Open a remote connection to gmail using STARTTLS
let mailer = SmtpTransport::starttls_relay(&config.mail_smtp)?.credentials(creds).build();
// Send the email
mailer.send(&email)
}
pub async fn get_verify_from_db(db: &Pool<Sqlite>, user: &UserId) -> Option<Committee> {
sqlx::query_as::<_, Committee>(
r#"
SELECT *
FROM committee
WHERE discord = ?
"#,
.add_option(
CreateCommandOption::new(CommandOptionType::SubCommand, "roles_adder", "Combine roles together to an new one")
.add_sub_option(CreateCommandOption::new(CommandOptionType::Role, "role_a", "A role you want to add to Role B").required(true))
.add_sub_option(CreateCommandOption::new(CommandOptionType::Role, "role_b", "A role you want to add to Role A").required(true))
.add_sub_option(CreateCommandOption::new(CommandOptionType::Role, "role_c", "Sum of A and B").required(true))
.add_sub_option(CreateCommandOption::new(CommandOptionType::Boolean, "delete", "Delete this entry.").required(false)),
)
.bind(*user.as_u64() as i64)
.fetch_one(db)
.await
.ok()
}
async fn save_to_db(db: &Pool<Sqlite>, email: &str, auth: &str, user: &UserId) -> Result<Option<Committee>, sqlx::Error> {
sqlx::query_as::<_, Committee>(
"
INSERT INTO committee (email, discord, auth_code)
VALUES (?1, ?2, ?3)
",
.add_option(
CreateCommandOption::new(CommandOptionType::SubCommandGroup, "icon", "Committee commands for the server icon")
.add_sub_option(CreateCommandOption::new(CommandOptionType::SubCommand, "change", "Change the server icon.")),
)
.bind(email.to_owned())
.bind(*user.as_u64() as i64)
.bind(auth.to_owned())
.fetch_optional(db)
.await
}
}
pub mod verify {
use super::*;
use crate::commands::committee::link::get_verify_from_db;
use serenity::model::id::{GuildId, RoleId};
use serenity::model::user::User;
use skynet_discord_bot::Committee;
use sqlx::Error;
pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String {
let committee_server = GuildId(1220150752656363520);
match command.guild_id {
None => {
return "Not in correct discord server.".to_string();
}
Some(x) => {
if x != committee_server {
return "Not in correct discord server.".to_string();
}
}
}
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Database in TypeMap.").clone()
};
let db = db_lock.read().await;
// check if user has used /link_committee
let details = if let Some(x) = get_verify_from_db(&db, &command.user.id).await {
x
} else {
return "Please use /link_committee first".to_string();
};
let option = command
.data
.options
.first()
.expect("Expected code option")
.resolved
.as_ref()
.expect("Expected code object");
let code = if let CommandDataOptionValue::String(code) = option {
code
} else {
return "Please provide a verification code".to_string();
};
if &details.auth_code != code {
return "Invalid verification code".to_string();
}
match set_discord(&db, &command.user.id).await {
Ok(_) => {
// get teh right roles for the user
set_server_roles(&command.user, ctx).await;
"Discord username linked to Wolves for committee".to_string()
}
Err(e) => {
println!("{:?}", e);
"Failed to save, please try /link_committee again".to_string()
}
}
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name("verify_committee")
.description("Verify Wolves Committee Email")
.create_option(|option| {
option
.name("code")
.description("Code from verification email")
.kind(CommandOptionType::String)
.required(true)
})
}
async fn set_discord(db: &Pool<Sqlite>, discord: &UserId) -> Result<Option<Committee>, Error> {
sqlx::query_as::<_, Committee>(
"
UPDATE committee
SET committee = 1
WHERE discord = ?
",
)
.bind(*discord.as_u64() as i64)
.fetch_optional(db)
.await
}
async fn set_server_roles(discord: &User, ctx: &Context) {
let committee_server = GuildId(1220150752656363520);
if let Ok(mut member) = committee_server.member(&ctx.http, &discord.id).await {
let committee_member = RoleId(1226602779968274573);
if let Err(e) = member.add_role(&ctx, committee_member).await {
println!("{:?}", e);
}
}
}
}

261
src/commands/count.rs Normal file
View file

@ -0,0 +1,261 @@
pub mod committee {
// get the list of all the current clubs/socs members
use serenity::all::{
CommandDataOption, CommandDataOptionValue, CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption,
};
use skynet_discord_bot::common::{database::DataBase, set_roles::committee::db_roles_get};
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let sub_options = if let Some(CommandDataOption {
value: CommandDataOptionValue::SubCommand(key),
..
}) = command.data.options.first()
{
key
} else {
return "Please provide a wolves API key".to_string();
};
let all = if let Some(x) = sub_options.first() {
match x.value {
CommandDataOptionValue::Boolean(y) => y,
_ => false,
}
} else {
false
};
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
let mut cs = vec![];
// pull it from a DB
for committee in db_roles_get(&db).await {
if !all && committee.count == 0 {
continue;
}
cs.push((committee.count, committee.name_role.to_owned()));
}
cs.sort_by_key(|(count, _)| *count);
cs.reverse();
// msg can be a max 2000 chars long
let mut limit = 2000 - 3;
let mut response = vec!["```".to_string()];
for (count, name) in cs {
let leading = if count < 10 { " " } else { "" };
let line = format!("{leading}{count} {name}");
let length = line.len() + 1;
if length < (limit + 3) {
response.push(line);
limit -= length;
} else {
break;
}
}
response.push("```".to_string());
response.join("\n")
}
pub fn register() -> CreateCommand {
CreateCommand::new("count")
.description("Count Committee Members")
// All Committee members are able to add reactions to posts
.default_member_permissions(serenity::model::Permissions::ADD_REACTIONS)
.add_option(
CreateCommandOption::new(CommandOptionType::SubCommand, "committee", "List out the Committee Roles Numbers")
.add_sub_option(CreateCommandOption::new(CommandOptionType::Boolean, "all", "List out all the Committee Roles Numbers").required(false)),
)
}
}
pub mod servers {
// get the list of all the current clubs/socs
use serde::{Deserialize, Serialize};
use serenity::all::{CommandInteraction, CommandOptionType, Context, CreateCommand, CreateCommandOption};
use skynet_discord_bot::{
common::{
database::{get_server_config_bulk, DataBase},
set_roles::committee::get_committees,
},
get_now_iso,
};
use sqlx::{Pool, Sqlite};
use std::collections::HashMap;
pub async fn run(_command: &CommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
let mut committees = HashMap::new();
if let Some(x) = get_committees(&db).await {
for committee in x {
committees.insert(committee.id, committee.to_owned());
}
}
let mut cs = vec![];
// pull it from a DB
for server_config in get_server_config_bulk(&db).await {
if let Some(x) = committees.get(&server_config.wolves_id) {
cs.push((server_config.member_current, server_config.member_past, x.name_full.to_owned()));
}
}
// get all members
let (wolves_current, wolves_past, total) = get_wolves_total(&db).await;
cs.push((total, total, String::from("Skynet Network")));
cs.push((wolves_current, wolves_past, String::from("Clubs/Socs Servers")));
// treat teh committee server as its own thing
let committee_current = get_wolves_committee(&db).await;
cs.push((committee_current, committee_current, String::from("Committee Server")));
cs.sort_by_key(|(current, _, _)| *current);
cs.reverse();
// msg can be a max 2000 chars long
let mut limit = 2000 - 3;
let mut response = vec!["```".to_string()];
for (current, past, name) in cs {
let current_leading = if current < 10 {
" "
} else if current < 100 {
" "
} else {
""
};
let past_leading = if past < 10 {
" "
} else if past < 100 {
" "
} else {
""
};
let line = format!("{current_leading}{current} {past_leading}{past} {name}");
let length = line.len() + 1;
// +3 is to account for the closing fense
if length < (limit + 3) {
response.push(line);
limit -= length;
} else {
break;
}
}
response.push("```".to_string());
response.join("\n")
}
#[derive(Debug, Clone, Deserialize, Serialize, sqlx::FromRow)]
pub struct Count {
pub count: i64,
}
async fn get_wolves_total(db: &Pool<Sqlite>) -> (i64, i64, i64) {
let current = match sqlx::query_as::<_, Count>(
r#"
SELECT COUNT(DISTINCT id_wolves) as count
FROM server_members
JOIN wolves USING (id_wolves)
WHERE (
wolves.discord IS NOT NULL
AND server_members.expiry > ?
)
"#,
)
.bind(get_now_iso(true))
.fetch_one(db)
.await
{
Ok(res) => res.count,
Err(e) => {
dbg!(e);
0
}
};
let cns = match sqlx::query_as::<_, Count>(
r#"
SELECT COUNT(DISTINCT id_wolves) as count
FROM server_members
JOIN wolves USING (id_wolves)
WHERE wolves.discord IS NOT NULL
"#,
)
.bind(get_now_iso(true))
.fetch_one(db)
.await
{
Ok(res) => res.count,
Err(e) => {
dbg!(e);
0
}
};
let total = match sqlx::query_as::<_, Count>(
r#"
SELECT COUNT(DISTINCT id_wolves) as count
FROM wolves
WHERE discord IS NOT NULL
"#,
)
.fetch_one(db)
.await
{
Ok(res) => res.count,
Err(e) => {
dbg!(e);
0
}
};
(current, cns, total)
}
async fn get_wolves_committee(db: &Pool<Sqlite>) -> i64 {
// expiry
match sqlx::query_as::<_, Count>(
"
SELECT count
FROM committee_roles
WHERE id_wolves = '0'
",
)
.fetch_one(db)
.await
{
Ok(res) => res.count,
Err(e) => {
dbg!(e);
0
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("count")
.description("Count the servers")
.default_member_permissions(serenity::model::Permissions::MANAGE_GUILD)
.add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "servers", "List out all servers using the skynet bot"))
}
}

View file

@ -1,381 +0,0 @@
use lettre::{
message::{header, MultiPart, SinglePart},
transport::smtp::{self, authentication::Credentials},
Message, SmtpTransport, Transport,
};
use maud::html;
use serenity::{
builder::CreateApplicationCommand,
client::Context,
model::{
application::interaction::application_command::ApplicationCommandInteraction,
id::UserId,
prelude::{command::CommandOptionType, interaction::application_command::CommandDataOptionValue},
},
};
use skynet_discord_bot::{get_now_iso, random_string, Config, DataBase, Wolves, WolvesVerify};
use sqlx::{Pool, Sqlite};
pub mod link {
use super::*;
pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
if get_server_member_discord(&db, &command.user.id).await.is_some() {
return "Already linked".to_string();
}
db_pending_clear_expired(&db).await;
if get_verify_from_db(&db, &command.user.id).await.is_some() {
return "Linking already in process, please check email.".to_string();
}
let option = command
.data
.options
.first()
.expect("Expected email option")
.resolved
.as_ref()
.expect("Expected email object");
let email = if let CommandDataOptionValue::String(email) = option {
email.trim()
} else {
return "Please provide a valid user".to_string();
};
// check if email exists
let details = match get_server_member_email(&db, email).await {
None => {
return "Please check it matches (including case) your preferred contact on https://ulwolves.ie/memberships/profile and that you are fully paid up.".to_string()
}
Some(x) => x,
};
if details.discord.is_some() {
return "Email already verified".to_string();
}
// generate a auth key
let auth = random_string(20);
match send_mail(&config, &details, &auth, &command.user.name) {
Ok(_) => match save_to_db(&db, &details, &auth, &command.user.id).await {
Ok(_) => {}
Err(e) => {
return format!("Unable to save to db {} {e:?}", &details.email);
}
},
Err(e) => {
return format!("Unable to send mail to {} {e:?}", &details.email);
}
}
format!("Verification email sent to {}, it may take up to 15 min for it to arrive. If it takes longer check the Junk folder.", email)
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command
.name("link_wolves")
.description("Set Wolves Email")
.create_option(|option| option.name("email").description("UL Wolves Email").kind(CommandOptionType::String).required(true))
}
pub async fn get_server_member_discord(db: &Pool<Sqlite>, user: &UserId) -> Option<Wolves> {
sqlx::query_as::<_, Wolves>(
r#"
SELECT *
FROM wolves
WHERE discord = ?
"#,
)
.bind(*user.as_u64() as i64)
.fetch_one(db)
.await
.ok()
}
async fn get_server_member_email(db: &Pool<Sqlite>, email: &str) -> Option<Wolves> {
sqlx::query_as::<_, Wolves>(
r#"
SELECT *
FROM wolves
WHERE email = ?
"#,
)
.bind(email)
.fetch_one(db)
.await
.ok()
}
fn send_mail(config: &Config, email: &Wolves, auth: &str, user: &str) -> Result<smtp::response::Response, smtp::Error> {
let mail = &email.email;
let discord = "https://discord.skynet.ie";
let sender = format!("UL Computer Society <{}>", &config.mail_user);
// Create the html we want to send.
let html = html! {
head {
title { "Hello from Skynet!" }
style type="text/css" {
"h2, h4 { font-family: Arial, Helvetica, sans-serif; }"
}
}
div style="display: flex; flex-direction: column; align-items: center;" {
h2 { "Hello from Skynet!" }
// Substitute in the name of our recipient.
p { "Hi " (user) "," }
p {
"Please use " pre { "/verify code: " (auth)} " to verify your discord account."
}
p {
"If you have issues please refer to our Discord server:"
br;
a href=(discord) { (discord) }
}
p {
"Skynet Team"
br;
"UL Computer Society"
}
}
};
let body_text = format!(
r#"
Hi {user}
Please use "/verify code: {auth}" to verify your discord account.
If you have issues please refer to our Discord server:
{discord}
Skynet Team
UL Computer Society
"#
);
// Build the message.
let email = Message::builder()
.from(sender.parse().unwrap())
.to(mail.parse().unwrap())
.subject("Skynet-Discord: Link Wolves.")
.multipart(
// This is composed of two parts.
// also helps not trip spam settings (uneven number of url's
MultiPart::alternative()
.singlepart(SinglePart::builder().header(header::ContentType::TEXT_PLAIN).body(body_text))
.singlepart(SinglePart::builder().header(header::ContentType::TEXT_HTML).body(html.into_string())),
)
.expect("failed to build email");
let creds = Credentials::new(config.mail_user.clone(), config.mail_pass.clone());
// Open a remote connection to gmail using STARTTLS
let mailer = SmtpTransport::starttls_relay(&config.mail_smtp)?.credentials(creds).build();
// Send the email
mailer.send(&email)
}
pub async fn db_pending_clear_expired(pool: &Pool<Sqlite>) -> Option<WolvesVerify> {
sqlx::query_as::<_, WolvesVerify>(
r#"
DELETE
FROM wolves_verify
WHERE date_expiry < ?
"#,
)
.bind(get_now_iso(true))
.fetch_one(pool)
.await
.ok()
}
pub async fn get_verify_from_db(db: &Pool<Sqlite>, user: &UserId) -> Option<WolvesVerify> {
sqlx::query_as::<_, WolvesVerify>(
r#"
SELECT *
FROM wolves_verify
WHERE discord = ?
"#,
)
.bind(*user.as_u64() as i64)
.fetch_one(db)
.await
.ok()
}
async fn save_to_db(db: &Pool<Sqlite>, record: &Wolves, auth: &str, user: &UserId) -> Result<Option<WolvesVerify>, sqlx::Error> {
sqlx::query_as::<_, WolvesVerify>(
"
INSERT INTO wolves_verify (email, discord, auth_code, date_expiry)
VALUES (?1, ?2, ?3, ?4)
",
)
.bind(record.email.to_owned())
.bind(*user.as_u64() as i64)
.bind(auth.to_owned())
.bind(get_now_iso(false))
.fetch_optional(db)
.await
}
}
pub mod verify {
use super::*;
use crate::commands::link_email::link::{db_pending_clear_expired, get_verify_from_db};
use serenity::model::user::User;
use skynet_discord_bot::{get_server_config, ServerMembersWolves, Servers};
use sqlx::Error;
pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
// check if user has used /link_wolves
let details = if let Some(x) = get_verify_from_db(&db, &command.user.id).await {
x
} else {
return "Please use /link_wolves first".to_string();
};
let option = command
.data
.options
.first()
.expect("Expected code option")
.resolved
.as_ref()
.expect("Expected code object");
let code = if let CommandDataOptionValue::String(code) = option {
code
} else {
return "Please provide a verification code".to_string();
};
db_pending_clear_expired(&db).await;
if &details.auth_code != code {
return "Invalid verification code".to_string();
}
match db_pending_clear_successful(&db, &command.user.id).await {
Ok(_) => {
return match set_discord(&db, &command.user.id, &details.email).await {
Ok(_) => {
// get teh right roles for the user
set_server_roles(&db, &command.user, ctx).await;
"Discord username linked to Wolves".to_string()
}
Err(e) => {
println!("{:?}", e);
"Failed to save, please try /link_wolves again".to_string()
}
};
}
Err(e) => println!("{:?}", e),
}
"Failed to verify".to_string()
}
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command.name("verify").description("Verify Wolves Email").create_option(|option| {
option
.name("code")
.description("Code from verification email")
.kind(CommandOptionType::String)
.required(true)
})
}
async fn db_pending_clear_successful(pool: &Pool<Sqlite>, user: &UserId) -> Result<Option<WolvesVerify>, Error> {
sqlx::query_as::<_, WolvesVerify>(
r#"
DELETE
FROM wolves_verify
WHERE discord = ?
"#,
)
.bind(*user.as_u64() as i64)
.fetch_optional(pool)
.await
}
async fn set_discord(db: &Pool<Sqlite>, discord: &UserId, email: &str) -> Result<Option<Wolves>, Error> {
sqlx::query_as::<_, Wolves>(
"
UPDATE wolves
SET discord = ?
WHERE email = ?
",
)
.bind(*discord.as_u64() as i64)
.bind(email)
.fetch_optional(db)
.await
}
async fn set_server_roles(db: &Pool<Sqlite>, discord: &User, ctx: &Context) {
if let Ok(servers) = get_servers(db, &discord.id).await {
for server in servers {
if let Ok(mut member) = server.server.member(&ctx.http, &discord.id).await {
if let Some(config) = get_server_config(db, &server.server).await {
let Servers {
role_past,
role_current,
..
} = config;
let mut roles = vec![];
if let Some(role) = &role_past {
if !member.roles.contains(role) {
roles.push(role.to_owned());
}
}
if !member.roles.contains(&role_current) {
roles.push(role_current.to_owned());
}
if let Err(e) = member.add_roles(&ctx, &roles).await {
println!("{:?}", e);
}
}
}
}
}
}
async fn get_servers(db: &Pool<Sqlite>, discord: &UserId) -> Result<Vec<ServerMembersWolves>, Error> {
sqlx::query_as::<_, ServerMembersWolves>(
"
SELECT *
FROM server_members
JOIN wolves USING (id_wolves)
WHERE discord = ?
",
)
.bind(*discord.as_u64() as i64)
.fetch_all(db)
.await
}
}

View file

@ -1,35 +1,28 @@
use serenity::{
builder::CreateApplicationCommand,
client::Context,
model::{
application::interaction::application_command::ApplicationCommandInteraction,
prelude::{command::CommandOptionType, interaction::application_command::CommandDataOptionValue},
},
};
use serenity::client::Context;
use skynet_discord_bot::DataBase;
use skynet_discord_bot::common::database::DataBase;
use sqlx::{Pool, Sqlite};
pub(crate) mod user {
use super::*;
pub(crate) mod add {
use super::*;
use crate::commands::link_email::link::get_server_member_discord;
use serenity::model::id::UserId;
use skynet_discord_bot::{whitelist_update, Config, Minecraft, Wolves};
use crate::commands::wolves::link::get_server_member_discord;
use serde::{Deserialize, Serialize};
use serenity::{
all::{CommandDataOption, CommandDataOptionValue, CommandInteraction},
model::id::UserId,
};
use skynet_discord_bot::{
common::{
database::Wolves,
minecraft::{whitelist_update, Minecraft},
},
Config,
};
use sqlx::Error;
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command.name("link_minecraft").description("Link your minecraft account").create_option(|option| {
option
.name("minecraft-username")
.description("Your Minecraft username")
.kind(CommandOptionType::String)
.required(true)
})
}
pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String {
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
@ -44,36 +37,71 @@ pub(crate) mod user {
// user has to have previously linked with wolves
if get_server_member_discord(&db, &command.user.id).await.is_none() {
return "Not linked with wolves, please use ``/link_wolves`` with your wolves email.".to_string();
return "Not linked with wolves, please use ``/wolves link`` with your wolves email.".to_string();
}
let username = if let CommandDataOptionValue::String(username) = command
.data
.options
.first()
.expect("Expected username option")
.resolved
.as_ref()
.expect("Expected username object")
let sub_options = if let Some(CommandDataOption {
value: CommandDataOptionValue::SubCommand(options),
..
}) = command.data.options.first()
{
username.trim()
options
} else {
return "Please provide sub options".to_string();
};
let username = if let Some(x) = sub_options.first() {
match &x.value {
CommandDataOptionValue::String(username) => username.trim(),
_ => return "Please provide a valid username".to_string(),
}
} else {
return "Please provide a valid username".to_string();
};
// insert the username into the database
match add_minecraft(&db, &command.user.id, username).await {
Ok(_) => {}
Err(e) => {
dbg!("{:?}", e);
return format!("Failure to minecraft username {:?}", username);
let java = if let Some(x) = sub_options.get(1) {
match &x.value {
CommandDataOptionValue::Boolean(z) => !z,
_ => true,
}
} else {
true
};
let username_mc;
if java {
// insert the username into the database
match add_minecraft(&db, &command.user.id, username).await {
Ok(_) => {}
Err(e) => {
dbg!("{:?}", e);
return format!("Failure to minecraft username {username:?}");
}
}
username_mc = username.to_string();
} else {
match get_minecraft_bedrock(username, &config.minecraft_mcprofile).await {
None => {
return format!("No UID found for {username:?}");
}
Some(x) => {
match add_minecraft_bedrock(&db, &command.user.id, &x.floodgateuid).await {
Ok(_) => {}
Err(e) => {
dbg!("{:?}", e);
return format!("Failure to minecraft UID {:?}", &x.floodgateuid);
}
}
username_mc = x.floodgateuid;
}
}
}
// get a list of servers that the user is a member of
if let Ok(servers) = get_servers(&db, &command.user.id).await {
for server in servers {
whitelist_update(&vec![username.to_string()], &server.minecraft, &config.discord_token_minecraft).await;
whitelist_update(&vec![(username_mc.to_owned(), java)], &server.minecraft, &config.discord_token_minecraft).await;
}
}
@ -88,7 +116,52 @@ pub(crate) mod user {
WHERE discord = ?1;
",
)
.bind(*user.as_u64() as i64)
.bind(user.get() as i64)
.bind(minecraft)
.fetch_optional(db)
.await
}
#[derive(Serialize, Deserialize, Debug)]
struct BedrockDetails {
pub gamertag: String,
pub xuid: String,
pub floodgateuid: String,
pub icon: String,
pub gamescore: String,
pub accounttier: String,
pub textureid: String,
pub skin: String,
pub linked: bool,
pub java_uuid: String,
pub java_name: String,
}
async fn get_minecraft_bedrock(username: &str, api_key: &str) -> Option<BedrockDetails> {
let url = format!("https://mcprofile.io/api/v1/bedrock/gamertag/{username}/");
match surf::get(url)
.header("x-api-key", api_key)
.header("User-Agent", "UL Computer Society")
.recv_json()
.await
{
Ok(res) => Some(res),
Err(e) => {
dbg!(e);
None
}
}
}
async fn add_minecraft_bedrock(db: &Pool<Sqlite>, user: &UserId, minecraft: &str) -> Result<Option<Wolves>, Error> {
sqlx::query_as::<_, Wolves>(
"
UPDATE wolves
SET minecraft_uid = ?2
WHERE discord = ?1;
",
)
.bind(user.get() as i64)
.bind(minecraft)
.fetch_optional(db)
.await
@ -107,7 +180,7 @@ pub(crate) mod user {
) sub on minecraft.server_discord = sub.server
",
)
.bind(*discord.as_u64() as i64)
.bind(discord.get() as i64)
.fetch_all(db)
.await
}
@ -118,42 +191,40 @@ pub(crate) mod server {
use super::*;
pub(crate) mod add {
use serenity::model::id::GuildId;
use serenity::{
all::{CommandDataOption, CommandDataOptionValue, CommandInteraction, CommandOptionType, CreateCommand, CreateCommandOption},
model::id::GuildId,
};
use sqlx::Error;
// this is to managfe the server side of commands related to minecraft
use super::*;
use skynet_discord_bot::{is_admin, update_server, Config, Minecraft};
use skynet_discord_bot::{
common::minecraft::{update_server, Minecraft},
Config,
};
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command.name("minecraft_add").description("Add a minecraft server").create_option(|option| {
option
.name("server_id")
.description("ID of the Minecraft server hosted by the Computer Society")
.kind(CommandOptionType::String)
.required(true)
})
pub fn register() -> CreateCommand {
CreateCommand::new("minecraft_add")
.description("Add a minecraft server")
.default_member_permissions(serenity::model::Permissions::MANAGE_GUILD)
.add_option(
CreateCommandOption::new(CommandOptionType::String, "server_id", "ID of the Minecraft server hosted by the Computer Society")
.required(true),
)
}
pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String {
// check if user has high enough permisssions
if let Some(msg) = is_admin(command, ctx).await {
return msg;
}
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let g_id = match command.guild_id {
None => return "Not in a server".to_string(),
Some(x) => x,
};
let server_minecraft = if let CommandDataOptionValue::String(id) = command
.data
.options
.first()
.expect("Expected server_id option")
.resolved
.as_ref()
.expect("Expected server_id object")
let server_minecraft = if let Some(CommandDataOption {
value: CommandDataOptionValue::String(id),
..
}) = command.data.options.first()
{
id.to_owned()
id.to_string()
} else {
return String::from("Expected Server ID");
};
@ -167,7 +238,7 @@ pub(crate) mod server {
match add_server(&db, &g_id, &server_minecraft).await {
Ok(_) => {}
Err(e) => {
println!("{:?}", e);
println!("{e:?}");
return format!("Failure to insert into Minecraft {} {}", &g_id, &server_minecraft);
}
}
@ -190,7 +261,7 @@ pub(crate) mod server {
VALUES (?1, ?2)
",
)
.bind(*discord.as_u64() as i64)
.bind(discord.get() as i64)
.bind(minecraft)
.fetch_optional(db)
.await
@ -198,19 +269,22 @@ pub(crate) mod server {
}
pub(crate) mod list {
use serenity::builder::CreateApplicationCommand;
use serenity::client::Context;
use serenity::model::prelude::application_command::ApplicationCommandInteraction;
use skynet_discord_bot::{get_minecraft_config_server, is_admin, server_information, Config, DataBase};
use serenity::{all::CommandInteraction, builder::CreateCommand, client::Context};
use skynet_discord_bot::{
common::{
database::DataBase,
minecraft::{get_minecraft_config_server, server_information},
},
Config,
};
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command.name("minecraft_list").description("List your minecraft servers")
pub fn register() -> CreateCommand {
CreateCommand::new("minecraft_list")
.default_member_permissions(serenity::model::Permissions::MANAGE_GUILD)
.description("List your minecraft servers")
}
pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String {
if let Some(msg) = is_admin(command, ctx).await {
return msg;
}
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let g_id = match command.guild_id {
None => return "Not in a server".to_string(),
Some(x) => x,
@ -243,7 +317,7 @@ pub(crate) mod server {
ID: {id}
Online: {online}
Info: {description}
Link: <http://panel.games.skynet.ie/server/{id}>
Link: <https://panel.games.skynet.ie/server/{id}>
"#,
name = &x.attributes.name,
online = !x.attributes.is_suspended,
@ -257,44 +331,37 @@ pub(crate) mod server {
}
pub(crate) mod delete {
use serenity::builder::CreateApplicationCommand;
use serenity::client::Context;
use serenity::model::application::command::CommandOptionType;
use serenity::model::id::GuildId;
use serenity::model::prelude::application_command::{ApplicationCommandInteraction, CommandDataOptionValue};
use skynet_discord_bot::{is_admin, DataBase, Minecraft};
use serenity::{
all::{CommandDataOption, CommandDataOptionValue, CommandInteraction, CommandOptionType, CreateCommandOption},
builder::CreateCommand,
client::Context,
model::id::GuildId,
};
use skynet_discord_bot::common::{database::DataBase, minecraft::Minecraft};
use sqlx::{Error, Pool, Sqlite};
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
command.name("minecraft_delete").description("Delete a minecraft server").create_option(|option| {
option
.name("server_id")
.description("ID of the Minecraft server hosted by the Computer Society")
.kind(CommandOptionType::String)
.required(true)
})
pub fn register() -> CreateCommand {
CreateCommand::new("minecraft_delete")
.description("Delete a minecraft server")
.default_member_permissions(serenity::model::Permissions::MANAGE_GUILD)
.add_option(
CreateCommandOption::new(CommandOptionType::String, "server_id", "ID of the Minecraft server hosted by the Computer Society")
.required(true),
)
}
pub async fn run(command: &ApplicationCommandInteraction, ctx: &Context) -> String {
// check if user has high enough permisssions
if let Some(msg) = is_admin(command, ctx).await {
return msg;
}
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let g_id = match command.guild_id {
None => return "Not in a server".to_string(),
Some(x) => x,
};
let server_minecraft = if let CommandDataOptionValue::String(id) = command
.data
.options
.first()
.expect("Expected server_id option")
.resolved
.as_ref()
.expect("Expected server_id object")
let server_minecraft = if let Some(CommandDataOption {
value: CommandDataOptionValue::String(id),
..
}) = command.data.options.first()
{
id.to_owned()
id.to_string()
} else {
return String::from("Expected Server ID");
};
@ -308,7 +375,7 @@ pub(crate) mod server {
match server_remove(&db, &g_id, &server_minecraft).await {
Ok(_) => {}
Err(e) => {
println!("{:?}", e);
println!("{e:?}");
return format!("Failure to insert into Minecraft {} {}", &g_id, &server_minecraft);
}
}
@ -325,7 +392,7 @@ pub(crate) mod server {
WHERE server_discord = ?1 AND server_minecraft = ?2
",
)
.bind(*discord.as_u64() as i64)
.bind(discord.get() as i64)
.bind(minecraft)
.fetch_optional(db)
.await

View file

@ -1,4 +1,7 @@
pub mod add_server;
pub mod committee;
pub mod link_email;
pub mod count;
pub mod minecraft;
pub mod role_adder;
pub mod server_icon;
pub mod wolves;

193
src/commands/role_adder.rs Normal file
View file

@ -0,0 +1,193 @@
use serenity::client::Context;
use skynet_discord_bot::common::database::{DataBase, RoleAdder};
use sqlx::{Error, Pool, Sqlite};
pub mod edit {
use super::*;
use serenity::all::{CommandDataOption, CommandDataOptionValue, CommandInteraction};
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let sub_options = if let Some(CommandDataOption {
value: CommandDataOptionValue::SubCommand(options),
..
}) = command.data.options.first()
{
options
} else {
return "Please provide sub options".to_string();
};
let role_a = if let Some(x) = sub_options.first() {
match &x.value {
CommandDataOptionValue::Role(role) => role.to_owned(),
_ => return "Please provide a valid role for ``Role A``".to_string(),
}
} else {
return "Please provide a valid role for ``Role A``".to_string();
};
let role_b = if let Some(x) = sub_options.get(1) {
match &x.value {
CommandDataOptionValue::Role(role) => role.to_owned(),
_ => return "Please provide a valid role for ``Role B``".to_string(),
}
} else {
return "Please provide a valid role for ``Role B``".to_string();
};
let role_c = if let Some(x) = sub_options.get(2) {
match &x.value {
CommandDataOptionValue::Role(role) => role.to_owned(),
_ => return "Please provide a valid role for ``Role C``".to_string(),
}
} else {
return "Please provide a valid role for ``Role C``".to_string();
};
if role_a == role_b {
return "Roles A and B must be different".to_string();
}
if (role_c == role_a) || (role_c == role_b) {
return "Role C cannot be same as A or B".to_string();
}
let delete = if let Some(x) = sub_options.get(3) {
match &x.value {
CommandDataOptionValue::Boolean(z) => *z,
_ => false,
}
} else {
false
};
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
let server = command.guild_id.unwrap_or_default();
let server_data = RoleAdder {
server,
role_a,
role_b,
role_c,
};
match add_server(&db, &server_data, delete).await {
Ok(_) => {}
Err(e) => {
println!("{e:?}");
return format!("Failure to insert into Servers {server_data:?}");
}
}
let mut role_a_name = String::new();
let mut role_b_name = String::new();
let mut role_c_name = String::new();
if let Ok(x) = server.roles(&ctx).await {
if let Some(y) = x.get(&role_a) {
role_a_name = y.to_owned().name;
}
if let Some(y) = x.get(&role_b) {
role_b_name = y.to_owned().name;
}
if let Some(y) = x.get(&role_b) {
role_c_name = y.to_owned().name;
}
}
if delete {
format!("Removed {role_a_name} + {role_b_name} = {role_c_name}")
} else {
format!("Added {role_a_name} + {role_b_name} = {role_c_name}")
}
}
async fn add_server(db: &Pool<Sqlite>, server: &RoleAdder, delete: bool) -> Result<Option<RoleAdder>, Error> {
if delete {
sqlx::query_as::<_, RoleAdder>(
"
DELETE FROM roles_adder
WHERE server = ?1 AND role_a = ?2 AND role_b = ?3 AND role_c = ?4
",
)
.bind(server.server.get() as i64)
.bind(server.role_a.get() as i64)
.bind(server.role_b.get() as i64)
.bind(server.role_c.get() as i64)
.fetch_optional(db)
.await
} else {
sqlx::query_as::<_, RoleAdder>(
"
INSERT OR REPLACE INTO roles_adder (server, role_a, role_b, role_c)
VALUES (?1, ?2, ?3, ?4)
",
)
.bind(server.server.get() as i64)
.bind(server.role_a.get() as i64)
.bind(server.role_b.get() as i64)
.bind(server.role_c.get() as i64)
.fetch_optional(db)
.await
}
}
}
// TODO
pub mod list {}
pub mod tools {
use serenity::{client::Context, model::guild::Member};
use skynet_discord_bot::common::database::RoleAdder;
use sqlx::{Pool, Sqlite};
pub async fn on_role_change(db: &Pool<Sqlite>, ctx: &Context, new_data: Member) {
// check if the role changed is part of the oens for this server
if let Ok(role_adders) = sqlx::query_as::<_, RoleAdder>(
r#"
SELECT *
FROM roles_adder
WHERE server = ?
"#,
)
.bind(new_data.guild_id.get() as i64)
.fetch_all(db)
.await
{
let mut roles_add = vec![];
let mut roles_remove = vec![];
for role_adder in role_adders {
// if the user has both A dnd B give them C
if new_data.roles.contains(&role_adder.role_a) && new_data.roles.contains(&role_adder.role_b) && !new_data.roles.contains(&role_adder.role_c)
{
roles_add.push(role_adder.role_c);
}
// If the suer has C but not A or B remove C
if new_data.roles.contains(&role_adder.role_c)
&& (!new_data.roles.contains(&role_adder.role_a) || !new_data.roles.contains(&role_adder.role_b))
{
roles_remove.push(role_adder.role_c);
}
}
if !roles_add.is_empty() {
if let Err(e) = new_data.add_roles(&ctx, &roles_add).await {
println!("{e:?}");
}
}
if !roles_remove.is_empty() {
if let Err(e) = new_data.remove_roles(&ctx, &roles_remove).await {
println!("{e:?}");
}
}
}
}
}

226
src/commands/server_icon.rs Normal file
View file

@ -0,0 +1,226 @@
use serenity::all::{CommandInteraction, Context};
use skynet_discord_bot::{
common::{
database::DataBase,
server_icon::{get_config_icons, update_icon::update_icon_main, ServerIcons},
},
Config,
};
use serenity::all::{CommandOptionType, CreateCommand, CreateCommandOption};
// commands that server mods are able to use
pub(crate) mod admin {
use super::*;
// Moderators can force a icon change
pub(crate) mod change {
use super::*;
pub async fn run(_command: &CommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config_global = config_lock.read().await;
let config_toml = get_config_icons::minimal();
update_icon_main(ctx, &db, &config_global, &config_toml).await;
"Changed server Icon".to_string()
}
}
}
// commands for general users
pub(crate) mod user {
use super::*;
use skynet_discord_bot::common::server_icon::get_config_icons::ConfigTomlLocal;
pub fn register() -> CreateCommand {
CreateCommand::new("icon")
.description("Commands related to the Server Icon")
.add_option(
CreateCommandOption::new(CommandOptionType::SubCommandGroup, "current", "Information on current items.")
.add_sub_option(CreateCommandOption::new(CommandOptionType::SubCommand, "icon", "Information on current icon."))
.add_sub_option(CreateCommandOption::new(CommandOptionType::SubCommand, "festival", "Information on current festival.")),
)
.add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "stats", "How many times the particular icon has been used"))
}
fn get_logo_url(config_toml: &ConfigTomlLocal, logo_name: &str) -> String {
format!("{}/src/branch/main/{}/{}", &config_toml.source.repo, &config_toml.source.directory, logo_name)
}
/// Regular users can get teh link to teh current icon
pub(crate) mod current {
use super::*;
pub(crate) mod icon {
use super::*;
use serenity::all::{CreateAttachment, EditInteractionResponse};
use sqlx::{Pool, Sqlite};
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_toml = get_config_icons::minimal();
if let Some(logo) = get_current_icon(&db).await {
if let Ok(attachment) = CreateAttachment::path(&logo.path).await {
match command.edit_response(&ctx.http, EditInteractionResponse::new().new_attachment(attachment)).await {
Ok(_) => {}
Err(e) => {
dbg!(e);
}
}
}
format!("[{}]({})", &logo.name, get_logo_url(&config_toml, &logo.name))
} else {
"Could not find current icon".to_string()
}
}
pub async fn get_current_icon(db: &Pool<Sqlite>) -> Option<ServerIcons> {
match sqlx::query_as::<_, ServerIcons>(
"
SELECT * from server_icons ORDER BY id DESC LIMIT 1
",
)
.fetch_one(db)
.await
{
Ok(res) => Some(res),
Err(e) => {
dbg!(e);
None
}
}
}
}
pub(crate) mod festival {
use serenity::all::{CommandInteraction, Context};
use skynet_discord_bot::{
common::server_icon::{get_config_icons, update_icon::get_festival},
Config,
};
// use this to return what current festivals are active?
pub async fn run(_command: &CommandInteraction, ctx: &Context) -> String {
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
let config_toml = get_config_icons::full(&config);
let response = get_festival(&config_toml).current;
if response.is_empty() {
"No festival currently active".to_string()
} else {
format!("Festivals active: {}", response.join(", "))
}
}
}
}
/// Get the statistics of the icons
pub(crate) mod stats {
use super::*;
use sqlx::{Pool, Sqlite};
pub async fn run(_command: &CommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_toml = get_config_icons::minimal();
let totals = get_totals(&db).await;
fmt_msg(&config_toml, &totals)
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct CountResult {
pub name: String,
pub times: i64,
}
async fn get_totals(db: &Pool<Sqlite>) -> Vec<CountResult> {
sqlx::query_as::<_, CountResult>(
"
SELECT
DISTINCT name,
COUNT(*) OVER(PARTITION BY name) AS times
FROM server_icons
",
)
.fetch_all(db)
.await
.unwrap_or_else(|e| {
dbg!(e);
vec![]
})
}
fn fmt_msg(config_toml: &ConfigTomlLocal, totals: &[CountResult]) -> String {
let mut totals_local = totals.to_owned();
totals_local.sort_by_key(|x| x.times);
totals_local.reverse();
// msg can be a max 2000 chars long
let mut limit = 2000 - 3;
let mut response = vec![];
for CountResult {
name,
times,
} in &totals_local
{
let current_leading = if times < &10 {
"00"
} else if times < &100 {
"0"
} else {
""
};
let url = get_logo_url(config_toml, name);
// the `` is so that the numbers will be rendered in monospaced font
// the <> is to suppress the URL embed
let line = format!("``{current_leading}{times}`` [{name}](<{url}>)");
let length = line.len() + 1;
// +3 is to account for the closing fense
if length < (limit + 3) {
response.push(line);
limit -= length;
} else {
break;
}
}
response.join("\n")
}
}
}

550
src/commands/wolves.rs Normal file
View file

@ -0,0 +1,550 @@
use lettre::{
message::{header, MultiPart, SinglePart},
transport::smtp::{self, authentication::Credentials},
Message, SmtpTransport, Transport,
};
use maud::html;
use serenity::{
all::CommandOptionType,
builder::{CreateCommand, CreateCommandOption},
client::Context,
model::id::UserId,
};
use skynet_discord_bot::{
common::database::{DataBase, Wolves, WolvesVerify},
get_now_iso, random_string, Config,
};
use sqlx::{Pool, Sqlite};
pub mod link {
use super::*;
use serenity::all::{CommandDataOption, CommandDataOptionValue, CommandInteraction};
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
if get_server_member_discord(&db, &command.user.id).await.is_some() {
return "Already linked".to_string();
}
db_pending_clear_expired(&db).await;
if get_verify_from_db(&db, &command.user.id).await.is_some() {
return "Linking already in process, please check email.".to_string();
}
let sub_options = if let Some(CommandDataOption {
value: CommandDataOptionValue::SubCommand(options),
..
}) = command.data.options.first()
{
options
} else {
return "Please provide sub options".to_string();
};
let email = if let Some(x) = sub_options.first() {
match &x.value {
CommandDataOptionValue::String(email) => email.trim(),
_ => return "Please provide a valid email".to_string(),
}
} else {
return "Please provide a valid email".to_string();
};
// check if email exists
let details = match get_server_member_email(&db, email).await {
None => {
let invalid_user = "Please check it matches (including case) your preferred contact on https://ulwolves.ie/memberships/profile and that you are fully paid up.".to_string();
let wolves = wolves_oxidised::Client::new(&config.wolves_url, Some(&config.wolves_api));
// see if the user actually exists
let id = match wolves.get_member(email).await {
None => {
return invalid_user;
}
Some(x) => x,
};
// save teh user id and email to teh db
match save_to_db_user(&db, id, email).await {
Ok(x) => x,
Err(x) => {
dbg!(x);
return "Error: unable to save user to teh database, contact Computer Society".to_string();
}
};
// pull it back out (technically could do it in previous step but more explicit)
match get_server_member_email(&db, email).await {
None => {
return "Error: failed to read user from database.".to_string();
}
Some(x) => x,
}
}
Some(x) => x,
};
if details.discord.is_some() {
return "Email already verified".to_string();
}
// generate a auth key
let auth = random_string(20);
match send_mail(&config, &details.email, &auth, &command.user.name) {
Ok(_) => match save_to_db(&db, &details, &auth, &command.user.id).await {
Ok(_) => {}
Err(e) => {
return format!("Unable to save to db {} {e:?}", &details.email);
}
},
Err(e) => {
return format!("Unable to send mail to {} {e:?}", &details.email);
}
}
format!("Verification email sent to {email}, it may take up to 15 min for it to arrive. If it takes longer check the Junk folder.")
}
pub async fn get_server_member_discord(db: &Pool<Sqlite>, user: &UserId) -> Option<Wolves> {
sqlx::query_as::<_, Wolves>(
r#"
SELECT *
FROM wolves
WHERE discord = ?
"#,
)
.bind(user.get() as i64)
.fetch_one(db)
.await
.ok()
}
async fn get_server_member_email(db: &Pool<Sqlite>, email: &str) -> Option<Wolves> {
sqlx::query_as::<_, Wolves>(
r#"
SELECT *
FROM wolves
WHERE email = ?
"#,
)
.bind(email)
.fetch_one(db)
.await
.ok()
}
fn send_mail(config: &Config, mail: &str, auth: &str, user: &str) -> Result<smtp::response::Response, smtp::Error> {
let discord = "https://computer.discord.skynet.ie";
let sender = format!("UL Computer Society <{}>", &config.mail_user);
// Create the html we want to send.
let html = html! {
head {
title { "UL Wolves Discord Linker" }
style type="text/css" {
"h2, h4 { font-family: Arial, Helvetica, sans-serif; }"
}
}
div {
h2 { "UL Wolves Discord Linker" }
h3 { "Link your UL Wolves Account to Discord" }
// Substitute in the name of our recipient.
p { "Hi " (user) "," }
p {
"Please paste this line into Discord (and press enter) to verify your discord account:"
br;
pre { "/wolves verify code: " (auth)}
}
hr;
h3 { "Help & Support" }
p {
"If you have issues please refer to our Computer Society Discord Server:"
br;
a href=(discord) { (discord) }
br;
"UL Computer Society"
}
}
};
let body_text = format!(
r#"
UL Wolves Discord Linker
Link your UL Wolves Account to Discord
Link your Account
Hi {user},
Please paste this line into Discord (and press enter) to verify your Discord account:
/wolves verify code: {auth}
-------------------------------------------------------------------------
Help & Support
If you have issues please refer to our Computer Society Discord Server:
{discord}
UL Computer Society
"#
);
// Build the message.
let email = Message::builder()
.from(sender.parse().unwrap())
.to(mail.parse().unwrap())
.subject("Skynet: Link Discord to Wolves.")
.multipart(
// This is composed of two parts.
// also helps not trip spam settings (uneven number of url's
MultiPart::alternative()
.singlepart(SinglePart::builder().header(header::ContentType::TEXT_PLAIN).body(body_text))
.singlepart(SinglePart::builder().header(header::ContentType::TEXT_HTML).body(html.into_string())),
)
.expect("failed to build email");
let creds = Credentials::new(config.mail_user.clone(), config.mail_pass.clone());
// Open a remote connection to gmail using STARTTLS
let mailer = SmtpTransport::starttls_relay(&config.mail_smtp)?.credentials(creds).build();
// Send the email
mailer.send(&email)
}
pub async fn db_pending_clear_expired(pool: &Pool<Sqlite>) -> Option<WolvesVerify> {
sqlx::query_as::<_, WolvesVerify>(
r#"
DELETE
FROM wolves_verify
WHERE date_expiry < ?
"#,
)
.bind(get_now_iso(true))
.fetch_one(pool)
.await
.ok()
}
pub async fn get_verify_from_db(db: &Pool<Sqlite>, user: &UserId) -> Option<WolvesVerify> {
sqlx::query_as::<_, WolvesVerify>(
r#"
SELECT *
FROM wolves_verify
WHERE discord = ?
"#,
)
.bind(user.get() as i64)
.fetch_one(db)
.await
.ok()
}
async fn save_to_db(db: &Pool<Sqlite>, record: &Wolves, auth: &str, user: &UserId) -> Result<Option<WolvesVerify>, sqlx::Error> {
sqlx::query_as::<_, WolvesVerify>(
"
INSERT INTO wolves_verify (email, discord, auth_code, date_expiry)
VALUES (?1, ?2, ?3, ?4)
",
)
.bind(record.email.to_owned())
.bind(user.get() as i64)
.bind(auth.to_owned())
.bind(get_now_iso(false))
.fetch_optional(db)
.await
}
async fn save_to_db_user(db: &Pool<Sqlite>, id_wolves: i64, email: &str) -> Result<Option<Wolves>, sqlx::Error> {
sqlx::query_as::<_, Wolves>(
"
INSERT INTO wolves (id_wolves, email)
VALUES ($1, $2)
ON CONFLICT(id_wolves) DO UPDATE SET email = $2
",
)
.bind(id_wolves)
.bind(email)
.fetch_optional(db)
.await
}
}
pub mod link_docs {
use super::*;
pub mod users {
use super::*;
use serenity::all::CommandInteraction;
pub async fn run(_command: &CommandInteraction, _ctx: &Context) -> String {
"https://forgejo.skynet.ie/Skynet/discord-bot/src/branch/main/doc/User.md".to_string()
}
}
// pub mod committee {
//
// }
}
pub mod verify {
use super::*;
use crate::commands::wolves::link::{db_pending_clear_expired, get_server_member_discord, get_verify_from_db};
use serenity::{
all::{CommandDataOption, CommandDataOptionValue, CommandInteraction},
model::user::User,
};
use skynet_discord_bot::common::{
database::{get_server_config, ServerMembersWolves, Servers},
wolves::committees::Committees,
};
use sqlx::Error;
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Database in TypeMap.").clone()
};
let db = db_lock.read().await;
// check if user has used /link_wolves
let details = if let Some(x) = get_verify_from_db(&db, &command.user.id).await {
x
} else {
return "Please use ''/wolves link'' first".to_string();
};
let sub_options = if let Some(CommandDataOption {
value: CommandDataOptionValue::SubCommand(options),
..
}) = command.data.options.first()
{
options
} else {
return "Please provide sub options".to_string();
};
let code = if let Some(x) = sub_options.first() {
match &x.value {
CommandDataOptionValue::String(y) => y.trim(),
_ => return "Please provide a verification code".to_string(),
}
} else {
return "Please provide a verification code".to_string();
};
db_pending_clear_expired(&db).await;
if details.auth_code != code {
return "Invalid verification code".to_string();
}
match db_pending_clear_successful(&db, &command.user.id).await {
Ok(_) => {
return match set_discord(&db, &command.user.id, &details.email).await {
Ok(_) => {
// get teh right roles for the user
set_server_roles(&db, &command.user, ctx).await;
// check if they are a committee member, and on that server
set_server_roles_committee(&db, &command.user, ctx).await;
"Discord username linked to Wolves".to_string()
}
Err(e) => {
println!("{e:?}");
"Failed to save, please try /link_wolves again".to_string()
}
};
}
Err(e) => println!("{e:?}"),
}
"Failed to verify".to_string()
}
async fn db_pending_clear_successful(pool: &Pool<Sqlite>, user: &UserId) -> Result<Option<WolvesVerify>, Error> {
sqlx::query_as::<_, WolvesVerify>(
r#"
DELETE
FROM wolves_verify
WHERE discord = ?
"#,
)
.bind(user.get() as i64)
.fetch_optional(pool)
.await
}
async fn set_discord(db: &Pool<Sqlite>, discord: &UserId, email: &str) -> Result<Option<Wolves>, Error> {
sqlx::query_as::<_, Wolves>(
"
UPDATE wolves
SET discord = ?
WHERE email = ?
",
)
.bind(discord.get() as i64)
.bind(email)
.fetch_optional(db)
.await
}
async fn set_server_roles(db: &Pool<Sqlite>, discord: &User, ctx: &Context) {
if let Ok(servers) = get_servers(db, &discord.id).await {
for server in servers {
if let Ok(member) = server.server.member(&ctx.http, &discord.id).await {
if let Some(config) = get_server_config(db, &server.server).await {
let Servers {
role_past,
role_current,
..
} = config;
let mut roles = vec![];
if let Some(role) = &role_past {
if !member.roles.contains(role) {
roles.push(role.to_owned());
}
}
if !member.roles.contains(&role_current) {
roles.push(role_current.to_owned());
}
if let Err(e) = member.add_roles(&ctx, &roles).await {
println!("{e:?}");
}
}
}
}
}
}
async fn get_committees_id(db: &Pool<Sqlite>, wolves_id: i64) -> Vec<Committees> {
sqlx::query_as::<_, Committees>(
r#"
SELECT *
FROM committees
WHERE committee LIKE ?1
"#,
)
.bind(format!("%{wolves_id}%"))
.fetch_all(db)
.await
.unwrap_or_else(|e| {
dbg!(e);
vec![]
})
}
async fn set_server_roles_committee(db: &Pool<Sqlite>, discord: &User, ctx: &Context) {
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
if let Some(x) = get_server_member_discord(db, &discord.id).await {
// if they are a member of one or more committees, and in teh committee server then give the teh general committee role
// they will get teh more specific vanity role later
if !get_committees_id(db, x.id_wolves).await.is_empty() {
let server = config.committee_server;
let committee_member = config.committee_role;
if let Ok(member) = server.member(ctx, &discord.id).await {
member.add_roles(&ctx, &[committee_member]).await.unwrap_or_default();
}
}
}
}
async fn get_servers(db: &Pool<Sqlite>, discord: &UserId) -> Result<Vec<ServerMembersWolves>, Error> {
sqlx::query_as::<_, ServerMembersWolves>(
"
SELECT *
FROM server_members
JOIN wolves USING (id_wolves)
WHERE discord = ?
",
)
.bind(discord.get() as i64)
.fetch_all(db)
.await
}
}
pub mod unlink {
use serenity::all::{CommandInteraction, Context, UserId};
use skynet_discord_bot::common::database::{DataBase, Wolves};
use sqlx::{Pool, Sqlite};
pub async fn run(command: &CommandInteraction, ctx: &Context) -> String {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Databse in TypeMap.").clone()
};
let db = db_lock.read().await;
// dosent matter if there is one or not, it will be removed regardless
delete_link(&db, &command.user.id).await;
"Discord link removed".to_string()
}
async fn delete_link(db: &Pool<Sqlite>, user: &UserId) {
match sqlx::query_as::<_, Wolves>(
"
UPDATE wolves
SET discord = NULL
WHERE discord = ?1;
",
)
.bind(user.get() as i64)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
dbg!(e);
}
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("wolves")
.description("Commands related to UL Wolves")
// link
.add_option(
CreateCommandOption::new(CommandOptionType::SubCommand, "link", "Link your Wolves account to your Discord")
.add_sub_option(CreateCommandOption::new(CommandOptionType::String, "email", "UL Wolves Email").required(true)),
)
// verify
.add_option(
CreateCommandOption::new(CommandOptionType::SubCommand, "verify", "Verify Wolves Email")
.add_sub_option(CreateCommandOption::new(CommandOptionType::String, "code", "Code from verification email").required(true)),
)
// unlink
.add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "unlink", "Unlink your Wolves account from your Discord"))
.add_option(
CreateCommandOption::new(CommandOptionType::SubCommand, "link_minecraft", "Link your minecraft account")
.add_sub_option(CreateCommandOption::new(CommandOptionType::String, "minecraft_username", "Your Minecraft username").required(true))
.add_sub_option(CreateCommandOption::new(CommandOptionType::Boolean, "bedrock_account", "Is this a Bedrock account?").required(false)),
)
.add_option(CreateCommandOption::new(CommandOptionType::SubCommand, "docs", "Link to where the documentation can be found."))
}

279
src/common/database.rs Normal file
View file

@ -0,0 +1,279 @@
use crate::Config;
use serde::{Deserialize, Serialize};
use serenity::{
model::{
guild,
id::{ChannelId, GuildId, RoleId, UserId},
},
prelude::TypeMapKey,
};
use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteRow},
Error, FromRow, Pool, Row, Sqlite,
};
use std::{str::FromStr, sync::Arc};
use tokio::sync::RwLock;
pub struct DataBase;
impl TypeMapKey for DataBase {
type Value = Arc<RwLock<Pool<Sqlite>>>;
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerMembers {
pub server: GuildId,
pub id_wolves: i64,
pub expiry: String,
}
impl<'r> FromRow<'r, SqliteRow> for ServerMembers {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let server_tmp: i64 = row.try_get("server")?;
let server = GuildId::from(server_tmp as u64);
Ok(Self {
server,
id_wolves: row.try_get("id_wolves")?,
expiry: row.try_get("expiry")?,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerMembersWolves {
pub server: GuildId,
pub id_wolves: i64,
pub expiry: String,
pub email: String,
pub discord: Option<UserId>,
pub minecraft: Option<String>,
pub minecraft_uid: Option<String>,
}
impl<'r> FromRow<'r, SqliteRow> for ServerMembersWolves {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let server_tmp: i64 = row.try_get("server")?;
let server = GuildId::from(server_tmp as u64);
Ok(Self {
server,
id_wolves: row.try_get("id_wolves")?,
expiry: row.try_get("expiry")?,
email: row.try_get("email")?,
discord: get_discord_from_row(row),
minecraft: row.try_get("minecraft")?,
minecraft_uid: row.try_get("minecraft_uid")?,
})
}
}
fn get_discord_from_row(row: &SqliteRow) -> Option<UserId> {
match row.try_get("discord") {
Ok(x) => {
let tmp: i64 = x;
if tmp == 0 {
None
} else {
Some(UserId::from(tmp as u64))
}
}
_ => None,
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Wolves {
pub id_wolves: i64,
pub email: String,
pub discord: Option<UserId>,
pub minecraft: Option<String>,
}
impl<'r> FromRow<'r, SqliteRow> for Wolves {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
Ok(Self {
id_wolves: row.try_get("id_wolves")?,
email: row.try_get("email")?,
discord: get_discord_from_row(row),
minecraft: row.try_get("minecraft")?,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WolvesVerify {
pub email: String,
pub discord: UserId,
pub auth_code: String,
pub date_expiry: String,
}
impl<'r> FromRow<'r, SqliteRow> for WolvesVerify {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let user_tmp: i64 = row.try_get("discord")?;
let discord = UserId::from(user_tmp as u64);
Ok(Self {
email: row.try_get("email")?,
discord,
auth_code: row.try_get("auth_code")?,
date_expiry: row.try_get("date_expiry")?,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Servers {
pub server: GuildId,
pub wolves_api: String,
pub wolves_id: i64,
pub role_past: Option<RoleId>,
pub role_current: RoleId,
pub member_past: i64,
pub member_current: i64,
pub bot_channel_id: ChannelId,
}
impl<'r> FromRow<'r, SqliteRow> for Servers {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let server_tmp: i64 = row.try_get("server")?;
let server = GuildId::from(server_tmp as u64);
let role_past = match row.try_get("role_past") {
Ok(x) => {
let tmp: i64 = x;
if tmp == 0 {
None
} else {
Some(RoleId::from(tmp as u64))
}
}
_ => None,
};
let role_current = match row.try_get("role_current") {
Ok(x) => {
let tmp: i64 = x;
RoleId::from(tmp as u64)
}
_ => RoleId::from(0u64),
};
let bot_channel_tmp: i64 = row.try_get("bot_channel_id")?;
let bot_channel_id = ChannelId::from(bot_channel_tmp as u64);
Ok(Self {
server,
wolves_api: row.try_get("wolves_api")?,
wolves_id: row.try_get("wolves_id").unwrap_or(0),
role_past,
role_current,
member_past: row.try_get("member_past")?,
member_current: row.try_get("member_current")?,
bot_channel_id,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RoleAdder {
pub server: GuildId,
pub role_a: RoleId,
pub role_b: RoleId,
pub role_c: RoleId,
}
impl<'r> FromRow<'r, SqliteRow> for RoleAdder {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let server_tmp: i64 = row.try_get("server")?;
let server = GuildId::from(server_tmp as u64);
Ok(Self {
server,
role_a: get_role_from_row(row, "role_a"),
role_b: get_role_from_row(row, "role_b"),
role_c: get_role_from_row(row, "role_c"),
})
}
}
pub(crate) fn get_role_from_row(row: &SqliteRow, col: &str) -> RoleId {
let id = match row.try_get(col) {
Ok(x) => {
let tmp: i64 = x;
tmp as u64
}
_ => 0,
};
RoleId::from(id)
}
pub(crate) fn get_channel_from_row(row: &SqliteRow, col: &str) -> ChannelId {
let id = match row.try_get(col) {
Ok(x) => {
let tmp: i64 = x;
tmp as u64
}
_ => 0,
};
ChannelId::from(id)
}
pub async fn db_init(config: &Config) -> Result<Pool<Sqlite>, Error> {
let database = format!("{}/{}", &config.home, &config.database);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::from_str(&format!("sqlite://{database}"))?
.foreign_keys(true)
.create_if_missing(true),
)
.await?;
// migrations are amazing!
sqlx::migrate!("./db/migrations").run(&pool).await?;
Ok(pool)
}
pub async fn get_server_config(db: &Pool<Sqlite>, server: &GuildId) -> Option<Servers> {
sqlx::query_as::<_, Servers>(
r#"
SELECT *
FROM servers
WHERE server = ?
"#,
)
.bind(server.get() as i64)
.fetch_one(db)
.await
.ok()
}
pub async fn get_server_member(db: &Pool<Sqlite>, server: &GuildId, member: &guild::Member) -> Result<ServerMembersWolves, Error> {
sqlx::query_as::<_, ServerMembersWolves>(
r#"
SELECT *
FROM server_members
JOIN wolves USING (id_wolves)
WHERE server = ? AND discord = ?
"#,
)
.bind(server.get() as i64)
.bind(member.user.id.get() as i64)
.fetch_one(db)
.await
}
pub async fn get_server_config_bulk(db: &Pool<Sqlite>) -> Vec<Servers> {
sqlx::query_as::<_, Servers>(
r#"
SELECT *
FROM servers
"#,
)
.fetch_all(db)
.await
.unwrap_or_default()
}

171
src/common/minecraft.rs Normal file
View file

@ -0,0 +1,171 @@
use crate::{common::set_roles::normal::get_server_member_bulk, Config};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serenity::model::id::GuildId;
use sqlx::{sqlite::SqliteRow, Error, FromRow, Pool, Row, Sqlite};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Minecraft {
pub discord: GuildId,
pub minecraft: String,
}
impl<'r> FromRow<'r, SqliteRow> for Minecraft {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let server_tmp: i64 = row.try_get("server_discord")?;
let discord = GuildId::from(server_tmp as u64);
Ok(Self {
discord,
minecraft: row.try_get("server_minecraft")?,
})
}
}
/**
loop through all members of server
get a list of folks with mc accounts that are members
and a list that arent members
*/
pub async fn update_server(server_id: &str, db: &Pool<Sqlite>, g_id: &GuildId, config: &Config) {
let mut usernames = vec![];
for member in get_server_member_bulk(db, g_id).await {
if let Some(x) = member.minecraft {
usernames.push((x, true));
}
if let Some(x) = member.minecraft_uid {
usernames.push((x, false));
}
}
if !usernames.is_empty() {
whitelist_update(&usernames, server_id, &config.discord_token_minecraft).await;
}
}
pub async fn post<T: Serialize>(url: &str, bearer: &str, data: &T) {
match surf::post(url)
.header("Authorization", bearer)
.header("Content-Type", "application/json")
.header("Accept", "Application/vnd.pterodactyl.v1+json")
.body_json(&data)
{
Ok(req) => {
req.await.ok();
}
Err(e) => {
dbg!(e);
}
}
}
#[derive(Deserialize, Serialize, Debug)]
pub struct ServerDetailsResSub {
pub identifier: String,
pub name: String,
pub description: String,
pub is_suspended: bool,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct ServerDetailsRes {
pub attributes: ServerDetailsResSub,
}
async fn get<T: Serialize + DeserializeOwned>(url: &str, bearer: &str) -> Option<T> {
match surf::get(url)
.header("Authorization", bearer)
.header("Content-Type", "application/json")
.header("Accept", "Application/vnd.pterodactyl.v1+json")
.recv_json()
.await
{
Ok(res) => Some(res),
Err(e) => {
dbg!(e);
None
}
}
}
#[derive(Deserialize, Serialize, Debug)]
struct BodyCommand {
command: String,
}
#[derive(Deserialize, Serialize, Debug)]
struct BodyDelete {
root: String,
files: Vec<String>,
}
pub async fn whitelist_wipe(server: &str, token: &str) {
let url_base = format!("https://panel.games.skynet.ie/api/client/servers/{server}");
let bearer = format!("Bearer {token}");
// delete whitelist
let deletion = BodyDelete {
root: "/".to_string(),
files: vec!["whitelist.json".to_string()],
};
post(&format!("{url_base}/files/delete"), &bearer, &deletion).await;
// recreate teh file, passing in the type here so the compiler knows what type of vec it is
post::<Vec<&str>>(&format!("{url_base}/files/write?file=%2Fwhitelist.json"), &bearer, &vec![]).await;
// reload the whitelist
let data = BodyCommand {
command: "whitelist reload".to_string(),
};
post(&format!("{url_base}/command"), &bearer, &data).await;
}
pub async fn server_information(server: &str, token: &str) -> Option<ServerDetailsRes> {
let url_base = format!("https://panel.games.skynet.ie/api/client/servers/{server}");
let bearer = format!("Bearer {token}");
get::<ServerDetailsRes>(&format!("{url_base}/"), &bearer).await
}
pub async fn get_minecraft_config(db: &Pool<Sqlite>) -> Vec<Minecraft> {
sqlx::query_as::<_, Minecraft>(
r#"
SELECT *
FROM minecraft
"#,
)
.fetch_all(db)
.await
.unwrap_or_default()
}
pub async fn get_minecraft_config_server(db: &Pool<Sqlite>, g_id: GuildId) -> Vec<Minecraft> {
sqlx::query_as::<_, Minecraft>(
r#"
SELECT *
FROM minecraft
WHERE server_discord = ?1
"#,
)
.bind(g_id.get() as i64)
.fetch_all(db)
.await
.unwrap_or_default()
}
pub async fn whitelist_update(add: &Vec<(String, bool)>, server: &str, token: &str) {
println!("Update whitelist for {server}");
let url_base = format!("https://panel.games.skynet.ie/api/client/servers/{server}");
let bearer = format!("Bearer {token}");
for (name, java) in add {
let data = if *java {
BodyCommand {
command: format!("whitelist add {name}"),
}
} else {
BodyCommand {
command: format!("fwhitelist add {name}"),
}
};
post(&format!("{url_base}/command"), &bearer, &data).await;
}
}

7
src/common/mod.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod database;
pub mod minecraft;
pub mod set_roles;
pub mod wolves;
pub mod renderer;
pub mod server_icon;

194
src/common/renderer.rs Normal file
View file

@ -0,0 +1,194 @@
// this code is taken from https://github.com/MCorange99/svg2colored-png/tree/main
// I was unable to figure out how to use usvg myself so younked it from here.
use std::{
ffi::OsStr,
path::{Path, PathBuf},
};
// use clap::builder::OsStr;
use color_eyre::{eyre::bail, Result};
use usvg_text_layout::TreeTextToPath;
#[derive(Debug, Clone)]
pub struct Args {
pub input: PathBuf,
/// Output folder where the PNG's will be placed
pub output: PathBuf,
/// Comma seperated colors that will be used in HEX Eg. 000000,ffffff
/// Can be like an object: black:000000,white:ffffff
pub colors: String,
/// Width of the generated PNG's
pub width: u32,
/// Height of the generated PNG's
pub height: u32,
}
#[derive(Debug, Clone)]
enum ColorType {
Array(Vec<String>),
Object(Vec<(String, String)>),
None,
}
#[derive(Debug, Clone)]
pub struct Renderer {
fontdb: usvg_text_layout::fontdb::Database,
colors: ColorType,
size: (u32, u32),
pub count: u64,
}
impl Renderer {
pub fn new(args: &Args) -> Result<Self> {
let mut db = usvg_text_layout::fontdb::Database::new();
db.load_system_fonts();
let mut this = Self {
fontdb: db,
colors: ColorType::None,
size: (args.width, args.height),
count: 0,
};
let colors = if args.colors.contains(':') {
//? object
let obj = args
.colors
.split(',')
.map(|s| {
let s = s.split(':').collect::<Vec<&str>>();
if s.len() < 2 {
dbg!("Invalid color object, try checking help");
return None;
}
Some((s[0].to_string(), s[1].to_string()))
})
.collect::<Vec<Option<(String, String)>>>();
let mut colors = Vec::new();
for c in obj.into_iter().flatten() {
std::fs::create_dir_all(args.output.join(&c.0))?;
colors.push(c);
}
ColorType::Object(colors)
} else {
//? list
// let colors = args.colors.split(",").map(|s| {
// s.to_string()
// })
// .collect::<Vec<String>>();
let mut colors = Vec::new();
for color in args.colors.split(',') {
std::fs::create_dir_all(args.output.join(color))?;
colors.push(color.to_string())
}
ColorType::Array(colors)
};
this.colors = colors;
Ok(this)
}
pub fn render(&mut self, fi: &Path, args: &Args) -> Result<()> {
match fi.extension() {
Some(e) if e.to_str() == Some("svg") => {}
Some(_) | None => {
dbg!("Filer {:?} is not of type SVG", fi);
// util::logger::warning(format!("File '{}' is not of SVG type", fi.clone().to_str().unwrap()));
bail!("Failed to render");
}
};
match self.colors.clone() {
ColorType::Array(c) => {
for color in c {
// log::info!("Rendering the color {color:?}");
let fo = self.get_out_file(fi, &color, args);
self.render_one(fi, &fo, &color)?;
}
}
ColorType::Object(c) => {
for o in c {
// log::info!("Rendering the color {:?}", o);
let fo = self.get_out_file(fi, &o.0, args);
self.render_one(fi, &fo, &o.1)?;
}
}
ColorType::None => unreachable!(),
}
Ok(())
}
fn render_one(&mut self, fi: &Path, fo: &Path, color: &String) -> Result<()> {
if fo.exists() {
dbg!("File {fo:?} exists, skipping");
return Ok(());
}
let svg = self.set_color(&self.get_svg_data(fi)?, color);
let opt = usvg::Options {
// Get file's absolute directory.
resources_dir: std::fs::canonicalize(fi).ok().and_then(|p| p.parent().map(|p| p.to_path_buf())),
..Default::default()
};
let mut tree = match usvg::Tree::from_data(svg.as_bytes(), &opt) {
Ok(v) => v,
Err(_) => {
dbg!("Failed to parse {fi:?}");
bail!("");
}
};
tree.convert_text(&self.fontdb);
let mut pixmap = tiny_skia::Pixmap::new(self.size.0, self.size.1).unwrap();
// log::info!("Rendering {fo:?}");
//? maybe handle this and possibly throw error if its none
let _ = resvg::render(&tree, usvg::FitTo::Size(self.size.0, self.size.1), tiny_skia::Transform::default(), pixmap.as_mut());
pixmap.save_png(fo)?;
self.count += 1;
Ok(())
}
#[inline]
fn get_out_file(&mut self, fi: &Path, _sub_folder: &str, args: &Args) -> PathBuf {
let mut fo: std::path::PathBuf = args.output.clone();
// fo.push(sub_folder);
fo.push(fi.file_name().unwrap_or(OsStr::new("default")).to_str().unwrap_or("default").replace(".svg", ""));
fo.set_extension("png");
fo
}
fn set_color(&self, svg: &str, color: &String) -> String {
svg.replace("fill=\"currentColor\"", &format!("fill=\"#{color}\""))
}
fn get_svg_data(&self, fi: &Path) -> Result<String> {
match std::fs::read_to_string(fi) {
Ok(d) => Ok(d),
Err(_) => {
dbg!("File {fi:?} does not exist");
bail!("File {fi:?} does not exist");
}
}
}
}

404
src/common/server_icon.rs Normal file
View file

@ -0,0 +1,404 @@
use serde::Deserialize;
use std::{ffi::OsString, fs, path::PathBuf};
pub mod get_config_icons {
use super::*;
use crate::Config;
#[derive(Deserialize)]
pub struct ConfigToml {
pub source: ConfigTomlSource,
pub festivals: Vec<ConfigTomlFestivals>,
}
#[derive(Deserialize)]
pub struct ConfigTomlLocal {
pub source: ConfigTomlSource,
}
#[derive(Deserialize)]
pub struct ConfigTomlRemote {
pub festivals: Vec<ConfigTomlFestivals>,
}
#[derive(Deserialize, Debug)]
pub struct ConfigTomlSource {
pub repo: String,
pub directory: String,
pub file: String,
}
#[derive(Deserialize, Debug)]
pub struct ConfigTomlFestivals {
pub name: String,
pub all_year: bool,
pub start: ConfigTomlFestivalsTime,
pub end: ConfigTomlFestivalsTime,
}
#[derive(Deserialize, Debug)]
pub struct ConfigTomlFestivalsTime {
pub day: u32,
pub month: u32,
pub year: i32,
}
pub fn minimal() -> ConfigTomlLocal {
let toml_raw_min = include_str!("../../.server-icons.toml");
toml::from_str::<ConfigTomlLocal>(toml_raw_min).unwrap_or_else(|e| {
dbg!(e);
ConfigTomlLocal {
source: ConfigTomlSource {
repo: "".to_string(),
directory: "".to_string(),
file: "".to_string(),
},
}
})
}
// since a copy of the festival file is in the repo we just need to get to it
pub fn full(config: &Config) -> ConfigToml {
let config_source = minimal();
let file_path = format!("{}/open-governance/{}/{}", &config.home, &config_source.source.directory, &config_source.source.file);
let contents = fs::read_to_string(file_path).unwrap_or_else(|e| {
dbg!(e);
"".to_string()
});
let festivals = match toml::from_str::<ConfigTomlRemote>(&contents) {
Ok(config_festivals) => config_festivals.festivals,
Err(e) => {
dbg!(e);
vec![]
}
};
ConfigToml {
source: config_source.source,
festivals,
}
}
}
#[derive(Debug)]
pub struct LogoData {
pub name: OsString,
pub path: PathBuf,
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct ServerIcons {
pub id: i64,
pub name: String,
pub path: String,
pub date: String,
}
pub mod update_icon {
use super::*;
use crate::{
common::{
renderer::{Args, Renderer},
server_icon::get_config_icons::{self, ConfigToml, ConfigTomlLocal},
},
get_now_iso, Config,
};
use chrono::{Datelike, Utc};
use rand::{rngs::SmallRng, seq::IndexedRandom, SeedableRng};
use serenity::{
all::GuildId,
builder::{CreateAttachment, EditGuild},
client::Context,
};
use sqlx::{Pool, Sqlite};
use std::process::Command;
/// Update the server icon, pulling from open governance.
pub async fn update_icon_main(ctx: &Context, db: &Pool<Sqlite>, config_global: &Config, config_toml_local: &ConfigTomlLocal) {
let server = config_global.compsoc_server;
// clone repo into local folder
clone_repo(config_global, config_toml_local);
// now the repo has been downloaded/updated we can now access the festivals
let config_toml = get_config_icons::full(config_global);
// see if there is a current festival
let festival_data = get_festival(&config_toml);
// get a list of all the graphics files
let logos = get_logos(config_global, &config_toml);
// filter them so only the current season (if any) are active
let logos_filtered = logos_filter(&festival_data, logos);
let mut rng = SmallRng::from_os_rng();
let logo_selected = logos_filtered.choose(&mut rng).unwrap();
logo_set(ctx, db, &server, logo_selected).await;
}
#[derive(Debug)]
pub struct FestivalData {
pub current: Vec<String>,
exclusions: Vec<String>,
}
pub fn get_festival(config_toml: &ConfigToml) -> FestivalData {
let today = Utc::now();
let day = today.day();
let month = today.month();
let year = today.year();
let mut result = FestivalData {
current: vec![],
exclusions: vec![],
};
for festival in &config_toml.festivals {
if (day >= festival.start.day && day <= festival.end.day) && (month >= festival.start.month && month <= festival.end.month) {
if festival.start.year == 0 || festival.end.year == 0 || (year >= festival.start.year && year <= festival.end.year) {
result.current.push(festival.name.to_owned());
}
} else if !festival.all_year {
result.exclusions.push(festival.name.to_owned());
}
}
result
}
fn clone_repo(config: &Config, config_toml: &ConfigTomlLocal) {
let url = &config_toml.source.repo;
let folder = format!("{}/open-governance", &config.home);
if let Err(e) = Command::new("git")
// clone the repo, gracefully "fails"
.arg("clone")
.arg(url)
.arg(&folder)
.output()
{
dbg!(e);
}
if let Err(e) = Command::new("git")
// Update the repo
.arg("pull")
.arg("origin")
.arg("main")
.current_dir(&folder)
.output()
{
dbg!(e);
}
if let Err(e) = Command::new("git")
// Install LFS for the repo
.arg("lfs")
.arg("install")
.current_dir(&folder)
.output()
{
dbg!(e);
}
if let Err(e) = Command::new("git")
// clone the repo, gracefully "fails"
.arg("lfs")
.arg("pull")
.arg("origin")
.arg("main")
.current_dir(&folder)
.output()
{
dbg!(e);
}
}
fn get_logos(config: &Config, config_toml: &ConfigToml) -> Vec<LogoData> {
let folder = format!("{}/open-governance/{}", &config.home, &config_toml.source.directory);
let folder_path = PathBuf::from(&folder);
let mut folder_output = folder_path.clone();
folder_output.push("converted");
let paths = match fs::read_dir(folder) {
Ok(x) => x,
Err(e) => {
dbg!(e);
return vec![];
}
};
let args = Args {
input: folder_path.clone(),
output: folder_output,
colors: String::from(""),
width: 1024,
height: 1024,
};
let mut r = match Renderer::new(&args) {
Ok(x) => x,
Err(e) => {
let _ = dbg!(e);
return vec![];
}
};
let mut logos = vec![];
for tmp in paths.flatten() {
let path_local = tmp.path().to_owned();
let path_local2 = tmp.path().to_owned();
let name = match path_local2.file_name() {
None => {
dbg!(path_local2);
continue;
}
Some(x) => x.to_owned(),
};
let mut path = tmp.path();
if path.is_dir() {
continue;
}
match tmp.path().extension() {
None => {}
Some(ext) => {
if ext == "svg" {
let mut path_new = path_local.clone();
path_new.set_extension("png");
let filename_tmp = path_new.clone();
let filename = match filename_tmp.file_name() {
None => {
dbg!(filename_tmp);
continue;
}
Some(x) => x,
};
path_new.pop();
path_new.push("converted");
path_new.push(filename);
// check if exists
if !path_new.exists() {
// convert if it hasnt been converted already
match r.render(&path_local, &args) {
Ok(_) => {}
Err(_e) => {
dbg!("Failed to render {path_local:?}: {}");
}
}
}
path = path_new;
}
}
};
logos.push(LogoData {
name,
path,
});
// println!("Name: {}", &tmp.path().display());
}
logos
}
fn logos_filter(festival_data: &FestivalData, existing: Vec<LogoData>) -> Vec<LogoData> {
let mut filtered: Vec<LogoData> = vec![];
let allowed_files = vec![".png", ".jpeg", ".gif", ".svg"];
'outer: for logo in existing {
let name_lowercase0 = logo.name.to_ascii_lowercase();
let name_lowercase = name_lowercase0.to_str().unwrap_or_default();
let mut allowed = false;
for allowed_type in &allowed_files {
if name_lowercase.ends_with(allowed_type) {
allowed = true;
}
}
if !allowed {
continue;
}
if !festival_data.current.is_empty() {
// if its a current festival filter based on it
for festival in &festival_data.current {
if name_lowercase.contains(festival) {
filtered.push(logo);
continue 'outer;
}
}
} else {
// else filter using the excluded ones
let mut excluded = false;
for festival in &festival_data.exclusions {
if name_lowercase.contains(festival) {
excluded = true;
}
}
if !excluded {
filtered.push(logo);
}
}
}
filtered
}
async fn logo_set(ctx: &Context, db: &Pool<Sqlite>, server: &GuildId, logo_selected: &LogoData) {
// add to teh database
if !logo_set_db(db, logo_selected).await {
// something went wrong
return;
}
if let Some(logo_path) = logo_selected.path.to_str() {
match CreateAttachment::path(logo_path).await {
Ok(icon) => {
// assuming a `guild` has already been bound
let builder = EditGuild::new().icon(Some(&icon));
if let Err(e) = server.edit(ctx, builder).await {
dbg!(e);
}
}
Err(e) => {
dbg!(e);
}
}
}
}
async fn logo_set_db(db: &Pool<Sqlite>, logo_selected: &LogoData) -> bool {
let name = match logo_selected.name.to_str() {
None => return false,
Some(x) => x,
};
let path = match logo_selected.path.to_str() {
None => return false,
Some(x) => x,
};
match sqlx::query_as::<_, ServerIcons>(
"
INSERT OR REPLACE INTO server_icons (name, date, path)
VALUES (?1, ?2, ?3)
",
)
.bind(name)
.bind(get_now_iso(false))
.bind(path)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
dbg!(e);
return false;
}
}
true
}
}

537
src/common/set_roles.rs Normal file
View file

@ -0,0 +1,537 @@
pub mod normal {
use crate::{
common::database::{DataBase, ServerMembersWolves, Servers, Wolves},
get_now_iso,
};
use serenity::{
client::Context,
model::id::{GuildId, RoleId, UserId},
};
use sqlx::{Pool, Sqlite};
struct RolesChange {
total: i32,
new: i32,
current_add: i32,
current_rem: i32,
}
pub async fn update_server(ctx: &Context, server: &Servers, remove_roles: &[Option<RoleId>], members_changed: &[UserId]) {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Database in TypeMap.").clone()
};
let db = db_lock.read().await;
let Servers {
server,
role_past,
role_current,
..
} = server;
let mut roles_set = RolesChange {
total: 0,
new: 0,
current_add: 0,
current_rem: 0,
};
let mut members = vec![];
for member in get_server_member_bulk(&db, server).await {
if let Some(x) = member.discord {
members.push(x);
}
}
let mut members_all = members.len();
if let Ok(x) = server.members(ctx, None, None).await {
for member in x {
// members_changed acts as an override to only deal with teh users in it
if !members_changed.is_empty() && !members_changed.contains(&member.user.id) {
continue;
}
if members.contains(&member.user.id) {
roles_set.total += 1;
let mut roles = vec![];
if let Some(role) = &role_past {
if !member.roles.contains(role) {
roles_set.new += 1;
roles.push(role.to_owned());
}
}
if !member.roles.contains(role_current) {
roles_set.current_add += 1;
roles.push(role_current.to_owned());
}
if let Err(e) = member.add_roles(ctx, &roles).await {
println!("{e:?}");
}
} else {
// old and never
if let Some(role) = &role_past {
if member.roles.contains(role) {
members_all += 1;
}
}
if member.roles.contains(role_current) {
roles_set.current_rem += 1;
// if theya re not a current member and have the role then remove it
if let Err(e) = member.remove_role(ctx, role_current).await {
println!("{e:?}");
}
}
}
for role in remove_roles.iter().flatten() {
if let Err(e) = member.remove_role(ctx, role).await {
println!("{e:?}");
}
}
}
}
set_server_numbers(&db, server, members_all as i64, members.len() as i64).await;
// small bit of logging to note changes over time
println!(
"{:?} Total: {} Changes: New: +{}, Current: +{}/-{}",
server.get(),
roles_set.total,
roles_set.new,
roles_set.current_add,
roles_set.current_rem
);
}
pub async fn get_server_member_bulk(db: &Pool<Sqlite>, server: &GuildId) -> Vec<ServerMembersWolves> {
sqlx::query_as::<_, ServerMembersWolves>(
r#"
SELECT *
FROM server_members
JOIN wolves USING (id_wolves)
WHERE (
server = ?
AND discord IS NOT NULL
AND expiry > ?
)
"#,
)
.bind(server.get() as i64)
.bind(get_now_iso(true))
.fetch_all(db)
.await
.unwrap_or_default()
}
async fn set_server_numbers(db: &Pool<Sqlite>, server: &GuildId, past: i64, current: i64) {
match sqlx::query_as::<_, Wolves>(
"
UPDATE servers
SET member_past = ?, member_current = ?
WHERE server = ?
",
)
.bind(past)
.bind(current)
.bind(server.get() as i64)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
println!("Failure to insert into {}", server.get());
println!("{e:?}");
}
}
}
}
// for updating committee members
pub mod committee {
use crate::{
common::{
database::{get_channel_from_row, get_role_from_row, DataBase, Wolves},
wolves::committees::Committees,
},
Config,
};
use serde::{Deserialize, Serialize};
use serenity::{
all::EditRole,
builder::CreateChannel,
client::Context,
model::{channel::ChannelType, guild::Member, id::ChannelId, prelude::RoleId},
};
use sqlx::{sqlite::SqliteRow, Error, FromRow, Pool, Row, Sqlite};
use std::collections::HashMap;
pub async fn check_committee(ctx: &Context) {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Config in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config_global = config_lock.read().await;
let server = config_global.committee_server;
// because to use it to update a single user we need to pre-get the members of teh server
let mut members = server.members(&ctx, None, None).await.unwrap_or_default();
update_committees(&db, ctx, &config_global, &mut members).await;
}
/**
This function can take a vec of members (or just one) and gives tehm the appropiate roles on teh committee server
*/
pub async fn update_committees(db: &Pool<Sqlite>, ctx: &Context, config: &Config, members: &mut Vec<Member>) {
let server = config.committee_server;
let committee_member = config.committee_role;
let committees = match get_committees(db).await {
None => {
return;
}
Some(x) => x,
};
let categories = config.committee_category.clone();
// information about the server
let mut roles_db = HashMap::new();
for role in db_roles_get(db).await {
roles_db.insert(
role.id_wolves,
CommitteeRoles {
id_wolves: role.id_wolves,
id_role: role.id_role,
id_channel: role.id_channel,
name_role: role.name_role,
name_channel: role.name_channel,
// always start at 0
count: 0,
},
);
}
let mut channels = server.channels(&ctx).await.unwrap_or_default();
// a map of users and the roles they are goign to be getting
let mut users_roles = HashMap::new();
let mut re_order = false;
// we need to create roles and channels if tehy dont already exist
let mut category_index = 0;
let mut i = 0;
loop {
if i >= committees.len() {
break;
}
let committee = &committees[i];
// if a club/soc ever changes their name
if let Some(x) = roles_db.get_mut(&committee.id) {
committee.name_full.clone_into(&mut x.name_role);
committee.name_profile.clone_into(&mut x.name_channel);
}
// handle new clubs/socs
if let std::collections::hash_map::Entry::Vacant(e) = roles_db.entry(committee.id) {
// create channel
// channel is first as the categories can only contain 50 channels
let channel = match server
.create_channel(
&ctx,
CreateChannel::new(&committee.name_profile)
.kind(ChannelType::Text)
.category(categories[category_index]),
)
.await
{
Ok(x) => {
println!("Created channel: {}", &committee.name_profile);
x.id
}
Err(x) => {
let tmp = x.to_string();
dbg!("Unable to create channel: ", &tmp, &tmp.contains("Maximum number of channels in category reached (50)"));
if x.to_string().contains("Maximum number of channels in category reached (50)") {
category_index += 1;
continue;
}
ChannelId::new(1)
}
};
// create role
let role = match server
.create_role(&ctx, EditRole::new().name(&committee.name_full).hoist(false).mentionable(true))
.await
{
Ok(x) => x.id,
Err(_) => RoleId::new(1),
};
let tmp = CommitteeRoles {
id_wolves: committee.id,
id_role: role,
id_channel: channel,
name_role: committee.name_full.to_owned(),
name_channel: committee.name_profile.to_owned(),
count: 0,
};
// save it to the db in case of crash or error
db_role_set(db, &tmp).await;
// insert it into teh local cache
e.insert(tmp);
re_order = true;
}
i += 1;
}
for committee in &committees {
let r = if let Some(x) = roles_db.get(&committee.id) {
x.id_role
} else {
continue;
};
for id_wolves in &committee.committee {
// ID in this is the wolves ID, so we need to get a matching discord ID (if one exists)
if let Some(x) = get_server_member_discord(db, id_wolves).await {
if let Some(member_tmp) = x.discord {
if server.member(ctx, &member_tmp).await.is_ok() {
let values = users_roles.entry(member_tmp).or_insert(vec![]);
values.push(r);
if let Some(x) = roles_db.get_mut(&committee.id) {
x.count += 1;
}
}
}
}
}
}
// now we have a map of all users that should get roles time to go through all the folks on teh server
for member in members {
// if member.user.id != 136522490632601600 {
// continue;
// }
//
let roles_current = member.roles(ctx).unwrap_or_default();
let roles_required = match users_roles.get(&member.user.id) {
None => {
vec![]
}
Some(x) => x.to_owned(),
};
let on_committee = !roles_required.is_empty();
let mut roles_rem = vec![];
let mut roles_add = vec![];
// get a list of all the roles to remove from someone
let mut roles_current_id = vec![];
for role in &roles_current {
roles_current_id.push(role.id.to_owned());
if !roles_required.contains(&role.id) {
if role.id == committee_member && on_committee {
continue;
}
roles_rem.push(role.id.to_owned());
}
}
let has_committee_role = roles_current_id.contains(&committee_member);
if on_committee && !has_committee_role {
// if there are committee roles then give the general purporse role
roles_add.push(committee_member);
}
if !on_committee && has_committee_role {
roles_rem.push(committee_member);
}
if !roles_required.is_empty() {
if let Some(x) = roles_db.get_mut(&0) {
x.count += 1;
}
}
for role in &roles_required {
if !roles_current_id.contains(role) {
roles_add.push(role.to_owned());
}
}
if !roles_rem.is_empty() {
member.remove_roles(&ctx, &roles_rem).await.unwrap_or_default();
}
if !roles_add.is_empty() {
// these roles are flavor roles, only there to make folks mentionable
member.add_roles(&ctx, &roles_add).await.unwrap_or_default();
}
}
let mut channel_names = vec![];
let mut positions = vec![];
for role in roles_db.values() {
// save these to db
db_role_set(db, role).await;
if re_order {
let channel_id = role.id_channel.to_owned();
if let Some(channel) = channels.get_mut(&channel_id) {
// record the position of each of teh C&S channels
positions.push(channel.position);
// pull out teh channel names
channel_names.push((role.name_channel.to_owned(), channel_id));
}
}
}
if re_order {
// sort by the position and name
positions.sort();
channel_names.sort_by_key(|(name, _)| name.to_owned());
let mut new_positions = vec![];
for (i, (_, id)) in channel_names.iter().enumerate() {
new_positions.push((id.to_owned(), positions[i] as u64));
}
if !new_positions.is_empty() {
match server.reorder_channels(&ctx, new_positions).await {
Ok(_) => {
println!("Successfully re-orderd the committee category");
}
Err(e) => {
dbg!("Failed to re-order ", e);
}
}
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CommitteeRoles {
id_wolves: i64,
pub id_role: RoleId,
pub id_channel: ChannelId,
pub name_role: String,
pub name_channel: String,
pub count: i64,
}
impl<'r> FromRow<'r, SqliteRow> for CommitteeRoles {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
Ok(Self {
id_wolves: row.try_get("id_wolves")?,
id_role: get_role_from_row(row, "id_role"),
id_channel: get_channel_from_row(row, "id_channel"),
name_role: row.try_get("name_role")?,
name_channel: row.try_get("name_channel")?,
count: row.try_get("count")?,
})
}
}
async fn db_role_set(db: &Pool<Sqlite>, role: &CommitteeRoles) {
// expiry
match sqlx::query_as::<_, CommitteeRoles>(
"
INSERT INTO committee_roles (id_wolves, id_role, id_channel, name_role, name_channel, count)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(id_wolves) DO UPDATE SET name_role = $4, name_channel = $5, count = $6
",
)
.bind(role.id_wolves)
.bind(role.id_role.get() as i64)
.bind(role.id_channel.get() as i64)
.bind(&role.name_role)
.bind(&role.name_channel)
.bind(role.count)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
println!("Failure to insert into Wolves {role:?}");
println!("{e:?}");
}
}
}
pub async fn db_roles_get(db: &Pool<Sqlite>) -> Vec<CommitteeRoles> {
// expiry
sqlx::query_as::<_, CommitteeRoles>(
"
SELECT *
FROM committee_roles
",
)
.fetch_all(db)
.await
.unwrap_or_else(|e| {
println!("Failure to get Roles from committee_roles");
println!("{e:?}");
vec![]
})
}
pub async fn get_committees(db: &Pool<Sqlite>) -> Option<Vec<Committees>> {
match sqlx::query_as::<_, Committees>(
r#"
SELECT *
FROM committees
"#,
)
.fetch_all(db)
.await
{
Ok(x) => Some(x),
Err(e) => {
dbg!(e);
None
}
}
}
async fn get_server_member_discord(db: &Pool<Sqlite>, user: &i64) -> Option<Wolves> {
sqlx::query_as::<_, Wolves>(
r#"
SELECT *
FROM wolves
WHERE id_wolves = ?
"#,
)
.bind(user)
.fetch_one(db)
.await
.ok()
}
}

271
src/common/wolves.rs Normal file
View file

@ -0,0 +1,271 @@
use crate::common::database::Wolves;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
/**
This file relates to anything that directly interacts with teh wolves API
*/
#[derive(Deserialize, Serialize, Debug)]
struct WolvesResultUserMin {
// committee: String,
member_id: String,
// first_name: String,
// last_name: String,
contact_email: String,
// opt_in_email: String,
// student_id: Option<String>,
// note: Option<String>,
// expiry: String,
// requested: String,
// approved: String,
// sitename: String,
// domain: String,
}
async fn add_users_wolves(db: &Pool<Sqlite>, user: &WolvesResultUserMin) {
// expiry
match sqlx::query_as::<_, Wolves>(
"
INSERT INTO wolves (id_wolves, email)
VALUES ($1, $2)
ON CONFLICT(id_wolves) DO UPDATE SET email = $2
",
)
.bind(&user.member_id)
.bind(&user.contact_email)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
println!("Failure to insert into Wolves {user:?}");
println!("{e:?}");
}
}
}
/**
This is getting data for Clubs and Socs
*/
pub mod cns {
use crate::{
common::{
database::{get_server_config_bulk, DataBase, ServerMembers, ServerMembersWolves, Servers},
wolves::{add_users_wolves, WolvesResultUserMin},
},
Config,
};
use serenity::{client::Context, model::id::GuildId};
use sqlx::{Pool, Sqlite};
use std::collections::BTreeMap;
impl From<&wolves_oxidised::WolvesUser> for WolvesResultUserMin {
fn from(value: &wolves_oxidised::WolvesUser) -> Self {
Self {
member_id: value.member_id.to_owned(),
contact_email: value.contact_email.to_owned(),
}
}
}
pub async fn get_wolves(ctx: &Context) {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Database in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
// set up teh client
let wolves = wolves_oxidised::Client::new(&config.wolves_url, Some(&config.wolves_api));
for server_config in get_server_config_bulk(&db).await {
let Servers {
server,
// this is the unique api key for each club/soc
wolves_api,
wolves_id,
..
} = &server_config;
// dbg!(&server_config);
let existing_tmp = get_server_member(&db, server).await;
let existing = existing_tmp.iter().map(|data| (data.id_wolves, data)).collect::<BTreeMap<_, _>>();
// list of users that need to be updated for this server
let mut server_name_tmp = None;
for user in wolves.get_members(wolves_api).await {
// dbg!(&user.committee);
if server_name_tmp.is_none() {
server_name_tmp = Some(user.committee_id);
}
let id = user.member_id.parse::<u64>().unwrap_or_default();
match existing.get(&(id as i64)) {
None => {
// user does not exist already, add everything
add_users_wolves(&db, &WolvesResultUserMin::from(&user)).await;
add_users_server_members(&db, server, &user).await;
}
Some(old) => {
// always update wolves table, in case data has changed
add_users_wolves(&db, &WolvesResultUserMin::from(&user)).await;
if old.expiry != user.expiry {
add_users_server_members(&db, server, &user).await;
}
}
}
}
if let Some(cs_id) = server_name_tmp {
if &cs_id != wolves_id {
set_server_member(&db, server, cs_id).await;
}
}
}
}
async fn set_server_member(db: &Pool<Sqlite>, server: &GuildId, wolves_id: i64) {
match sqlx::query_as::<_, Servers>(
"
UPDATE servers
SET wolves_id = ?
WHERE server = ?
",
)
.bind(wolves_id)
.bind(server.get() as i64)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
println!("Failure to set server name {}", server.get());
println!("{e:?}");
}
}
}
async fn get_server_member(db: &Pool<Sqlite>, server: &GuildId) -> Vec<ServerMembersWolves> {
sqlx::query_as::<_, ServerMembersWolves>(
r#"
SELECT *
FROM server_members
JOIN wolves USING (id_wolves)
WHERE (
server = ?
AND discord IS NOT NULL
)
"#,
)
.bind(server.get() as i64)
.fetch_all(db)
.await
.unwrap_or_default()
}
async fn add_users_server_members(db: &Pool<Sqlite>, server: &GuildId, user: &wolves_oxidised::WolvesUser) {
match sqlx::query_as::<_, ServerMembers>(
"
INSERT OR REPLACE INTO server_members (server, id_wolves, expiry)
VALUES (?1, ?2, ?3)
",
)
.bind(server.get() as i64)
.bind(&user.member_id)
.bind(&user.expiry)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
println!("Failure to insert into ServerMembers {} {:?}", server.get(), user);
println!("{e:?}");
}
}
}
}
/**
Get and store the data on C&S committees
*/
pub mod committees {
use crate::{common::database::DataBase, Config};
use serenity::client::Context;
use sqlx::{Pool, Sqlite};
// Database entry for it
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct Committees {
pub id: i64,
pub name_full: String,
pub name_profile: String,
pub name_plain: String,
pub link: String,
#[sqlx(json)]
pub committee: Vec<i64>,
}
impl From<wolves_oxidised::WolvesCNS> for Committees {
fn from(value: wolves_oxidised::WolvesCNS) -> Self {
Self {
id: value.id,
name_full: value.name_full,
name_profile: value.name_profile,
name_plain: value.name_plain,
link: value.link,
committee: value.committee,
}
}
}
pub async fn get_cns(ctx: &Context) {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Database in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
let wolves = wolves_oxidised::Client::new(&config.wolves_url, Some(&config.wolves_api));
// request data from wolves
for committee_wolves in wolves.get_committees().await {
let committee = Committees::from(committee_wolves);
add_committee(&db, &committee).await;
}
}
async fn add_committee(db: &Pool<Sqlite>, committee: &Committees) {
match sqlx::query_as::<_, Committees>(
"
INSERT INTO committees (id, name_profile, name_full, name_plain, link, committee)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(id) DO UPDATE SET committee = $6
",
)
.bind(committee.id)
.bind(&committee.name_profile)
.bind(&committee.name_full)
.bind(&committee.name_plain)
.bind(&committee.link)
.bind(serde_json::to_string(&committee.committee).unwrap_or_default())
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
println!("Failure to insert into Committees {committee:?}");
println!("{e:?}");
}
}
}
}

View file

@ -1,27 +1,16 @@
pub mod common;
use chrono::{Datelike, SecondsFormat, Utc};
use dotenvy::dotenv;
use serde::{Deserialize, Serialize};
use rand::{distr::Alphanumeric, rng, Rng};
use serenity::{
model::{
guild,
id::{ChannelId, GuildId, RoleId},
},
model::id::{ChannelId, GuildId, RoleId},
prelude::TypeMapKey,
};
use crate::set_roles::get_server_member_bulk;
use chrono::{Datelike, SecondsFormat, Utc};
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use serde::de::DeserializeOwned;
use serenity::client::Context;
use serenity::model::id::UserId;
use serenity::model::prelude::application_command::ApplicationCommandInteraction;
use sqlx::{
sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteRow},
Error, FromRow, Pool, Row, Sqlite,
};
use std::{env, str::FromStr, sync::Arc};
use std::{env, sync::Arc};
use tokio::sync::RwLock;
#[derive(Debug)]
pub struct Config {
// manages where teh database is stored
pub home: String,
@ -30,6 +19,7 @@ pub struct Config {
// tokens for discord and other API's
pub discord_token: String,
pub discord_token_minecraft: String,
pub minecraft_mcprofile: String,
// email settings
pub mail_smtp: String,
@ -38,16 +28,21 @@ pub struct Config {
// wolves API base for clubs/socs
pub wolves_url: String,
// API key for accessing more general resources
pub wolves_api: String,
// discord server for committee
pub committee_server: GuildId,
pub committee_role: RoleId,
pub committee_category: Vec<ChannelId>,
// items pertaining to compsoc only
pub compsoc_server: GuildId,
}
impl TypeMapKey for Config {
type Value = Arc<RwLock<Config>>;
}
pub struct DataBase;
impl TypeMapKey for DataBase {
type Value = Arc<RwLock<Pool<Sqlite>>>;
}
pub fn get_config() -> Config {
dotenv().ok();
@ -55,6 +50,7 @@ pub fn get_config() -> Config {
let mut config = Config {
discord_token: "".to_string(),
discord_token_minecraft: "".to_string(),
minecraft_mcprofile: "".to_string(),
home: ".".to_string(),
database: "database.db".to_string(),
@ -63,6 +59,11 @@ pub fn get_config() -> Config {
mail_user: "".to_string(),
mail_pass: "".to_string(),
wolves_url: "".to_string(),
wolves_api: "".to_string(),
committee_server: GuildId::new(1),
committee_role: RoleId::new(1),
committee_category: vec![],
compsoc_server: GuildId::new(1),
};
if let Ok(x) = env::var("DATABASE_HOME") {
@ -78,6 +79,9 @@ pub fn get_config() -> Config {
if let Ok(x) = env::var("DISCORD_TOKEN_MINECRAFT") {
config.discord_token_minecraft = x.trim().to_string();
}
if let Ok(x) = env::var("MINECRAFT_MCPROFILE_KEY") {
config.minecraft_mcprofile = x.trim().to_string();
}
if let Ok(x) = env::var("EMAIL_SMTP") {
config.mail_smtp = x.trim().to_string();
@ -89,269 +93,40 @@ pub fn get_config() -> Config {
config.mail_pass = x.trim().to_string();
}
if let Ok(x) = env::var("WOLVES_URL") {
if let Ok(x) = env::var("WOLVES_URL_BASE") {
config.wolves_url = x.trim().to_string();
}
if let Ok(x) = env::var("WOLVES_API") {
config.wolves_api = x.trim().to_string();
}
if let Ok(x) = env::var("COMMITTEE_DISCORD") {
if let Ok(x) = x.trim().parse::<u64>() {
config.committee_server = GuildId::new(x);
}
}
if let Ok(x) = env::var("COMMITTEE_ROLE") {
if let Ok(x) = x.trim().parse::<u64>() {
config.committee_role = RoleId::new(x);
}
}
if let Ok(x) = env::var("COMMITTEE_CATEGORY") {
for part in x.split(',') {
if let Ok(x) = part.trim().parse::<u64>() {
config.committee_category.push(ChannelId::new(x));
}
}
}
if let Ok(x) = env::var("COMPSOC_DISCORD") {
if let Ok(x) = x.trim().parse::<u64>() {
config.compsoc_server = GuildId::new(x);
}
}
config
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerMembers {
pub server: GuildId,
pub id_wolves: i64,
pub expiry: String,
}
impl<'r> FromRow<'r, SqliteRow> for ServerMembers {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let server_tmp: i64 = row.try_get("server")?;
let server = GuildId::from(server_tmp as u64);
Ok(Self {
server,
id_wolves: row.try_get("id_wolves")?,
expiry: row.try_get("expiry")?,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerMembersWolves {
pub server: GuildId,
pub id_wolves: i64,
pub expiry: String,
pub email: String,
pub discord: Option<UserId>,
pub minecraft: Option<String>,
}
impl<'r> FromRow<'r, SqliteRow> for ServerMembersWolves {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let server_tmp: i64 = row.try_get("server")?;
let server = GuildId::from(server_tmp as u64);
let discord = match row.try_get("discord") {
Ok(x) => {
let tmp: i64 = x;
if tmp == 0 {
None
} else {
Some(UserId::from(tmp as u64))
}
}
_ => None,
};
Ok(Self {
server,
id_wolves: row.try_get("id_wolves")?,
expiry: row.try_get("expiry")?,
email: row.try_get("email")?,
discord,
minecraft: row.try_get("minecraft")?,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Wolves {
pub id_wolves: i64,
pub email: String,
pub discord: Option<UserId>,
pub minecraft: Option<String>,
}
impl<'r> FromRow<'r, SqliteRow> for Wolves {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let discord = match row.try_get("discord") {
Ok(x) => {
let tmp: i64 = x;
if tmp == 0 {
None
} else {
Some(UserId::from(tmp as u64))
}
}
_ => None,
};
Ok(Self {
id_wolves: row.try_get("id_wolves")?,
email: row.try_get("email")?,
discord,
minecraft: row.try_get("minecraft")?,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WolvesVerify {
pub email: String,
pub discord: UserId,
pub auth_code: String,
pub date_expiry: String,
}
impl<'r> FromRow<'r, SqliteRow> for WolvesVerify {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let user_tmp: i64 = row.try_get("discord")?;
let discord = UserId::from(user_tmp as u64);
Ok(Self {
email: row.try_get("email")?,
discord,
auth_code: row.try_get("auth_code")?,
date_expiry: row.try_get("date_expiry")?,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Committee {
pub email: String,
pub discord: UserId,
pub auth_code: String,
pub committee: i64,
}
impl<'r> FromRow<'r, SqliteRow> for Committee {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let user_tmp: i64 = row.try_get("discord")?;
let discord = UserId::from(user_tmp as u64);
Ok(Self {
email: row.try_get("email")?,
discord,
auth_code: row.try_get("auth_code")?,
committee: row.try_get("committee")?,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Servers {
pub server: GuildId,
pub wolves_api: String,
pub role_past: Option<RoleId>,
pub role_current: RoleId,
pub member_past: i64,
pub member_current: i64,
pub bot_channel_id: ChannelId,
// these can be removed in teh future with an API update
pub server_name: String,
pub wolves_link: String,
}
impl<'r> FromRow<'r, SqliteRow> for Servers {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let server_tmp: i64 = row.try_get("server")?;
let server = GuildId::from(server_tmp as u64);
let role_past = match row.try_get("role_past") {
Ok(x) => {
let tmp: i64 = x;
if tmp == 0 {
None
} else {
Some(RoleId::from(tmp as u64))
}
}
_ => None,
};
let role_current = match row.try_get("role_current") {
Ok(x) => {
let tmp: i64 = x;
RoleId::from(tmp as u64)
}
_ => RoleId::from(0u64),
};
let bot_channel_tmp: i64 = row.try_get("bot_channel_id")?;
let bot_channel_id = ChannelId::from(bot_channel_tmp as u64);
Ok(Self {
server,
wolves_api: row.try_get("wolves_api")?,
role_past,
role_current,
member_past: row.try_get("member_past")?,
member_current: row.try_get("member_current")?,
bot_channel_id,
server_name: row.try_get("server_name")?,
wolves_link: row.try_get("wolves_link")?,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Minecraft {
pub discord: GuildId,
pub minecraft: String,
}
impl<'r> FromRow<'r, SqliteRow> for Minecraft {
fn from_row(row: &'r SqliteRow) -> Result<Self, Error> {
let server_tmp: i64 = row.try_get("server_discord")?;
let discord = GuildId::from(server_tmp as u64);
Ok(Self {
discord,
minecraft: row.try_get("server_minecraft")?,
})
}
}
pub async fn db_init(config: &Config) -> Result<Pool<Sqlite>, Error> {
let database = format!("{}/{}", &config.home, &config.database);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect_with(
SqliteConnectOptions::from_str(&format!("sqlite://{}", database))?
.foreign_keys(true)
.create_if_missing(true),
)
.await?;
// migrations are amazing!
sqlx::migrate!("./db/migrations").run(&pool).await?;
Ok(pool)
}
pub async fn get_server_config(db: &Pool<Sqlite>, server: &GuildId) -> Option<Servers> {
sqlx::query_as::<_, Servers>(
r#"
SELECT *
FROM servers
WHERE server = ?
"#,
)
.bind(*server.as_u64() as i64)
.fetch_one(db)
.await
.ok()
}
pub async fn get_server_member(db: &Pool<Sqlite>, server: &GuildId, member: &guild::Member) -> Result<ServerMembersWolves, Error> {
sqlx::query_as::<_, ServerMembersWolves>(
r#"
SELECT *
FROM server_members
JOIN wolves USING (id_wolves)
WHERE server = ? AND discord = ?
"#,
)
.bind(*server.as_u64() as i64)
.bind(*member.user.id.as_u64() as i64)
.fetch_one(db)
.await
}
pub async fn get_server_config_bulk(db: &Pool<Sqlite>) -> Vec<Servers> {
sqlx::query_as::<_, Servers>(
r#"
SELECT *
FROM servers
"#,
)
.fetch_all(db)
.await
.unwrap_or_default()
}
pub fn get_now_iso(short: bool) -> String {
let now = Utc::now();
if short {
@ -362,478 +137,5 @@ pub fn get_now_iso(short: bool) -> String {
}
pub fn random_string(len: usize) -> String {
thread_rng().sample_iter(&Alphanumeric).take(len).map(char::from).collect()
}
pub mod set_roles {
use super::*;
pub async fn update_server(ctx: &Context, server: &Servers, remove_roles: &[Option<RoleId>], members_changed: &[UserId]) {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Database in TypeMap.").clone()
};
let db = db_lock.read().await;
let Servers {
server,
role_past,
role_current,
..
} = server;
let mut roles_set = [0, 0, 0];
let mut members = vec![];
for member in get_server_member_bulk(&db, server).await {
if let Some(x) = member.discord {
members.push(x);
}
}
let mut members_all = members.len();
if let Ok(x) = server.members(ctx, None, None).await {
for mut member in x {
// members_changed acts as an override to only deal with teh users in it
if !members_changed.is_empty() && !members_changed.contains(&member.user.id) {
continue;
}
if members.contains(&member.user.id) {
let mut roles = vec![];
if let Some(role) = &role_past {
if !member.roles.contains(role) {
roles_set[0] += 1;
roles.push(role.to_owned());
}
}
if !member.roles.contains(role_current) {
roles_set[1] += 1;
roles.push(role_current.to_owned());
}
if let Err(e) = member.add_roles(ctx, &roles).await {
println!("{:?}", e);
}
} else {
// old and never
if let Some(role) = &role_past {
if member.roles.contains(role) {
members_all += 1;
}
}
if member.roles.contains(role_current) {
roles_set[2] += 1;
// if theya re not a current member and have the role then remove it
if let Err(e) = member.remove_role(ctx, role_current).await {
println!("{:?}", e);
}
}
}
for role in remove_roles.iter().flatten() {
if let Err(e) = member.remove_role(ctx, role).await {
println!("{:?}", e);
}
}
}
}
set_server_numbers(&db, server, members_all as i64, members.len() as i64).await;
// small bit of logging to note changes over time
println!("{:?} Changes: New: +{}, Current: +{}/-{}", server.as_u64(), roles_set[0], roles_set[1], roles_set[2]);
}
pub async fn get_server_member_bulk(db: &Pool<Sqlite>, server: &GuildId) -> Vec<ServerMembersWolves> {
sqlx::query_as::<_, ServerMembersWolves>(
r#"
SELECT *
FROM server_members
JOIN wolves USING (id_wolves)
WHERE (
server = ?
AND discord IS NOT NULL
AND expiry > ?
)
"#,
)
.bind(*server.as_u64() as i64)
.bind(get_now_iso(true))
.fetch_all(db)
.await
.unwrap_or_default()
}
async fn set_server_numbers(db: &Pool<Sqlite>, server: &GuildId, past: i64, current: i64) {
match sqlx::query_as::<_, Wolves>(
"
UPDATE servers
SET member_past = ?, member_current = ?
WHERE server = ?
",
)
.bind(past)
.bind(current)
.bind(*server.as_u64() as i64)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
println!("Failure to insert into {}", server.as_u64());
println!("{:?}", e);
}
}
}
}
pub mod get_data {
use super::*;
use crate::set_roles::update_server;
use std::collections::BTreeMap;
#[derive(Deserialize, Serialize, Debug)]
struct WolvesResultUser {
committee: String,
member_id: String,
first_name: String,
last_name: String,
contact_email: String,
opt_in_email: String,
student_id: Option<String>,
note: Option<String>,
expiry: String,
requested: String,
approved: String,
sitename: String,
domain: String,
}
#[derive(Deserialize, Serialize, Debug)]
struct WolvesResult {
success: i8,
result: Vec<WolvesResultUser>,
}
#[derive(Deserialize, Serialize, Debug)]
struct WolvesResultLocal {
pub id_wolves: String,
pub email: String,
pub expiry: String,
}
pub async fn get_wolves(ctx: &Context) {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Database in TypeMap.").clone()
};
let db = db_lock.read().await;
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
for server_config in get_server_config_bulk(&db).await {
let Servers {
server,
wolves_api,
..
} = &server_config;
let existing_tmp = get_server_member(&db, server).await;
let existing = existing_tmp.iter().map(|data| (data.id_wolves, data)).collect::<BTreeMap<_, _>>();
// list of users that need to be updated for this server
let mut user_to_update = vec![];
for user in get_wolves_sub(&config, wolves_api).await {
let id = user.member_id.parse::<u64>().unwrap_or_default();
match existing.get(&(id as i64)) {
None => {
// user does not exist already, add everything
add_users_wolves(&db, &user).await;
add_users_server_members(&db, server, &user).await;
}
Some(old) => {
// always update wolves table, in case data has changed
add_users_wolves(&db, &user).await;
if old.expiry != user.expiry {
add_users_server_members(&db, server, &user).await;
if let Some(discord_id) = old.discord {
user_to_update.push(discord_id);
}
}
}
}
}
if !user_to_update.is_empty() {
update_server(ctx, &server_config, &[], &user_to_update).await;
}
}
}
pub async fn get_server_member(db: &Pool<Sqlite>, server: &GuildId) -> Vec<ServerMembersWolves> {
sqlx::query_as::<_, ServerMembersWolves>(
r#"
SELECT *
FROM server_members
JOIN wolves USING (id_wolves)
WHERE (
server = ?
AND discord IS NOT NULL
)
"#,
)
.bind(*server.as_u64() as i64)
.fetch_all(db)
.await
.unwrap_or_default()
}
async fn get_wolves_sub(config: &Config, wolves_api: &str) -> Vec<WolvesResultUser> {
if config.wolves_url.is_empty() {
return vec![];
}
// get wolves data
if let Ok(mut res) = surf::post(&config.wolves_url).header("X-AM-Identity", wolves_api).await {
if let Ok(WolvesResult {
success,
result,
}) = res.body_json().await
{
if success != 1 {
return vec![];
}
return result;
}
}
vec![]
}
async fn add_users_wolves(db: &Pool<Sqlite>, user: &WolvesResultUser) {
// expiry
match sqlx::query_as::<_, Wolves>(
"
INSERT INTO wolves (id_wolves, email)
VALUES ($1, $2)
ON CONFLICT(id_wolves) DO UPDATE SET email = $2
",
)
.bind(&user.member_id)
.bind(&user.contact_email)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
println!("Failure to insert into Wolves {:?}", user);
println!("{:?}", e);
}
}
}
async fn add_users_server_members(db: &Pool<Sqlite>, server: &GuildId, user: &WolvesResultUser) {
match sqlx::query_as::<_, ServerMembers>(
"
INSERT OR REPLACE INTO server_members (server, id_wolves, expiry)
VALUES (?1, ?2, ?3)
",
)
.bind(*server.as_u64() as i64)
.bind(&user.member_id)
.bind(&user.expiry)
.fetch_optional(db)
.await
{
Ok(_) => {}
Err(e) => {
println!("Failure to insert into ServerMembers {} {:?}", server.as_u64(), user);
println!("{:?}", e);
}
}
}
}
/**
For any time ye need to check if a user who calls a command has admin privlages
*/
pub async fn is_admin(command: &ApplicationCommandInteraction, ctx: &Context) -> Option<String> {
let mut admin = false;
let g_id = match command.guild_id {
None => return Some("Not in a server".to_string()),
Some(x) => x,
};
let roles_server = g_id.roles(&ctx.http).await.unwrap_or_default();
if let Ok(member) = g_id.member(&ctx.http, command.user.id).await {
if let Some(permissions) = member.permissions {
if permissions.administrator() {
admin = true;
}
}
for role_id in member.roles {
if admin {
break;
}
if let Some(role) = roles_server.get(&role_id) {
if role.permissions.administrator() {
admin = true;
}
}
}
}
if !admin {
Some("Administrator permission required".to_string())
} else {
None
}
}
/**
loop through all members of server
get a list of folks with mc accounts that are members
and a list that arent members
*/
pub async fn update_server(server_id: &str, db: &Pool<Sqlite>, g_id: &GuildId, config: &Config) {
let mut usernames = vec![];
for member in get_server_member_bulk(db, g_id).await {
if let Some(x) = member.minecraft {
usernames.push(x);
}
}
if !usernames.is_empty() {
whitelist_update(&usernames, server_id, &config.discord_token_minecraft).await;
}
}
async fn post<T: Serialize>(url: &str, bearer: &str, data: &T) {
match surf::post(url)
.header("Authorization", bearer)
.header("Content-Type", "application/json")
.header("Accept", "Application/vnd.pterodactyl.v1+json")
.body_json(&data)
{
Ok(req) => {
req.await.ok();
}
Err(e) => {
dbg!(e);
}
}
}
#[derive(Deserialize, Serialize, Debug)]
pub struct ServerDetailsResSub {
pub identifier: String,
pub name: String,
pub description: String,
pub is_suspended: bool,
}
#[derive(Deserialize, Serialize, Debug)]
pub struct ServerDetailsRes {
pub attributes: ServerDetailsResSub,
}
async fn get<T: Serialize + DeserializeOwned>(url: &str, bearer: &str) -> Option<T> {
match surf::get(url)
.header("Authorization", bearer)
.header("Content-Type", "application/json")
.header("Accept", "Application/vnd.pterodactyl.v1+json")
.recv_json()
.await
{
Ok(res) => Some(res),
Err(e) => {
dbg!(e);
None
}
}
}
#[derive(Deserialize, Serialize, Debug)]
struct BodyCommand {
command: String,
}
#[derive(Deserialize, Serialize, Debug)]
struct BodyDelete {
root: String,
files: Vec<String>,
}
pub async fn whitelist_update(add: &Vec<String>, server: &str, token: &str) {
let url_base = format!("http://panel.games.skynet.ie/api/client/servers/{server}");
let bearer = format!("Bearer {token}");
for name in add {
let data = BodyCommand {
command: format!("whitelist add {name}"),
};
post(&format!("{url_base}/command"), &bearer, &data).await;
}
}
pub async fn whitelist_wipe(server: &str, token: &str) {
let url_base = format!("http://panel.games.skynet.ie/api/client/servers/{server}");
let bearer = format!("Bearer {token}");
// delete whitelist
let deletion = BodyDelete {
root: "/".to_string(),
files: vec!["whitelist.json".to_string()],
};
post(&format!("{url_base}/files/delete"), &bearer, &deletion).await;
// recreate teh file, passing in the type here so the compiler knows what type of vec it is
post::<Vec<&str>>(&format!("{url_base}/files/write?file=%2Fwhitelist.json"), &bearer, &vec![]).await;
// reload the whitelist
let data = BodyCommand {
command: "whitelist reload".to_string(),
};
post(&format!("{url_base}/command"), &bearer, &data).await;
}
pub async fn server_information(server: &str, token: &str) -> Option<ServerDetailsRes> {
let url_base = format!("http://panel.games.skynet.ie/api/client/servers/{server}");
let bearer = format!("Bearer {token}");
get::<ServerDetailsRes>(&format!("{url_base}/"), &bearer).await
}
pub async fn get_minecraft_config(db: &Pool<Sqlite>) -> Vec<Minecraft> {
sqlx::query_as::<_, Minecraft>(
r#"
SELECT *
FROM minecraft
"#,
)
.fetch_all(db)
.await
.unwrap_or_default()
}
pub async fn get_minecraft_config_server(db: &Pool<Sqlite>, g_id: GuildId) -> Vec<Minecraft> {
sqlx::query_as::<_, Minecraft>(
r#"
SELECT *
FROM minecraft
WHERE server_discord = ?1
"#,
)
.bind(*g_id.as_u64() as i64)
.fetch_all(db)
.await
.unwrap_or_default()
rng().sample_iter(&Alphanumeric).take(len).map(char::from).collect()
}

View file

@ -1,34 +1,69 @@
pub mod commands;
use crate::commands::role_adder::tools::on_role_change;
use serenity::{
all::{Command, CommandDataOptionValue, CreateMessage, EditInteractionResponse, Interaction},
async_trait,
client::{Context, EventHandler},
gateway::{ActivityData, ChunkGuildFilter},
model::{
application::{command::Command, interaction::Interaction},
event::GuildMemberUpdateEvent,
gateway::{GatewayIntents, Ready},
guild,
prelude::Activity,
guild::Member,
id::GuildId,
user::OnlineStatus,
},
Client,
};
use skynet_discord_bot::{
common::{
database::{db_init, get_server_config, get_server_member, DataBase},
set_roles::committee::update_committees,
wolves::committees::Committees,
},
get_config, Config,
};
use sqlx::{Pool, Sqlite};
use std::sync::Arc;
use skynet_discord_bot::{db_init, get_config, get_server_config, get_server_member, Config, DataBase};
use tokio::sync::RwLock;
// Need To Define The Stuct (Committed To Bump The Bot)
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn guild_member_addition(&self, ctx: Context, mut new_member: guild::Member) {
// this caches members of all servers teh bot is in
async fn cache_ready(&self, ctx: Context, guilds: Vec<GuildId>) {
for guild in guilds {
ctx.shard.chunk_guild(guild, Some(2000), false, ChunkGuildFilter::None, None);
}
println!("Cache built successfully!");
}
// handles previously linked accounts joining the server
async fn guild_member_addition(&self, ctx: Context, new_member: Member) {
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Config in TypeMap.").clone()
};
let db = db_lock.read().await;
let config = match get_server_config(&db, &new_member.guild_id).await {
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config_global = config_lock.read().await;
// committee server takes priority
let committee_server = config_global.committee_server;
if new_member.guild_id.get() == committee_server.get() {
let mut member = vec![new_member.clone()];
update_committees(&db, &ctx, &config_global, &mut member).await;
return;
}
let config_server = match get_server_config(&db, &new_member.guild_id).await {
None => return,
Some(x) => x,
};
@ -36,92 +71,216 @@ impl EventHandler for Handler {
if get_server_member(&db, &new_member.guild_id, &new_member).await.is_ok() {
let mut roles = vec![];
if let Some(role) = &config.role_past {
if let Some(role) = &config_server.role_past {
if !new_member.roles.contains(role) {
roles.push(role.to_owned());
}
}
if !new_member.roles.contains(&config.role_current) {
roles.push(config.role_current.to_owned());
if !new_member.roles.contains(&config_server.role_current) {
roles.push(config_server.role_current.to_owned());
}
if let Err(e) = new_member.add_roles(&ctx, &roles).await {
println!("{:?}", e);
println!("{e:?}");
}
} else {
let msg = format!(
r#"
let tmp = get_committee(&db, config_server.wolves_id).await;
if !tmp.is_empty() {
let committee = &tmp[0];
let msg = format!(
r#"
Welcome {} to the {} server!
Sign up on [UL Wolves]({}) and go to https://discord.com/channels/{}/{} and use ``/link_wolves`` to get full access.
Sign up on [UL Wolves]({}) and go to https://discord.com/channels/{}/{} and use ``/wolves link email_here`` with the email associated with your wolves account, to get full access.
"#,
new_member.display_name(),
&config.server_name,
&config.wolves_link,
&config.server,
&config.bot_channel_id
);
new_member.display_name(),
committee.name_full,
committee.link,
&config_server.server,
&config_server.bot_channel_id
);
if let Err(err) = new_member.user.direct_message(&ctx, |m| m.content(&msg)).await {
dbg!(err);
if let Err(err) = new_member.user.direct_message(&ctx, CreateMessage::new().content(&msg)).await {
dbg!(err);
}
}
}
}
// handles role updates
async fn guild_member_update(&self, ctx: Context, _old_data: Option<Member>, new_data: Option<Member>, _: GuildMemberUpdateEvent) {
// get config/db
let db_lock = {
let data_read = ctx.data.read().await;
data_read.get::<DataBase>().expect("Expected Config in TypeMap.").clone()
};
let db = db_lock.read().await;
// check if the role changed is part of the oens for this server
if let Some(x) = new_data {
on_role_change(&db, &ctx, x).await;
}
}
async fn ready(&self, ctx: Context, ready: Ready) {
println!("[Main] {} is connected!", ready.user.name);
ctx.set_presence(Some(Activity::playing("with humanity's fate")), OnlineStatus::Online).await;
ctx.set_presence(Some(ActivityData::playing("with humanity's fate")), OnlineStatus::Online);
match Command::set_global_application_commands(&ctx.http, |commands| {
commands
.create_application_command(|command| commands::add_server::register(command))
.create_application_command(|command| commands::link_email::link::register(command))
.create_application_command(|command| commands::link_email::verify::register(command))
.create_application_command(|command| commands::minecraft::server::add::register(command))
.create_application_command(|command| commands::minecraft::server::list::register(command))
.create_application_command(|command| commands::minecraft::server::delete::register(command))
.create_application_command(|command| commands::minecraft::user::add::register(command))
// for committee server, temp
.create_application_command(|command| commands::committee::link::register(command))
.create_application_command(|command| commands::committee::verify::register(command))
})
let config_lock = {
let data_read = ctx.data.read().await;
data_read.get::<Config>().expect("Expected Config in TypeMap.").clone()
};
let config = config_lock.read().await;
match Command::set_global_commands(
&ctx.http,
vec![
commands::wolves::register(),
commands::committee::register(),
commands::minecraft::server::add::register(),
commands::minecraft::server::list::register(),
commands::minecraft::server::delete::register(),
],
)
.await
{
Ok(_) => {}
Err(e) => {
println!("{:?}", e)
println!("{e:?}")
}
}
// Inter-Committee server
match config.committee_server.set_commands(&ctx.http, vec![commands::count::committee::register()]).await {
Ok(_) => {}
Err(e) => {
println!("{e:?}")
}
}
// compsoc Server
match config
.compsoc_server
.set_commands(
&ctx.http,
vec![
// commands just for the compsoc server
commands::count::servers::register(),
commands::server_icon::user::register(),
],
)
.await
{
Ok(_) => {}
Err(e) => {
println!("{e:?}")
}
}
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::ApplicationCommand(command) = interaction {
if let Interaction::Command(command) = interaction {
let _ = command.defer_ephemeral(&ctx.http).await;
//println!("Received command interaction: {:#?}", command);
let content = match command.data.name.as_str() {
// user commands
"link_wolves" => commands::link_email::link::run(&command, &ctx).await,
"verify" => commands::link_email::verify::run(&command, &ctx).await,
"link_minecraft" => commands::minecraft::user::add::run(&command, &ctx).await,
"wolves" => match command.data.options.first() {
None => "Invalid Command".to_string(),
Some(x) => match x.name.as_str() {
"link" => commands::wolves::link::run(&command, &ctx).await,
"verify" => commands::wolves::verify::run(&command, &ctx).await,
"unlink" => commands::wolves::unlink::run(&command, &ctx).await,
"link_minecraft" => commands::minecraft::user::add::run(&command, &ctx).await,
"docs" => commands::wolves::link_docs::users::run(&command, &ctx).await,
// "link" => commands::count::servers::run(&command, &ctx).await,
&_ => format!("not implemented :( wolves {}", x.name.as_str()),
},
},
// admin commands
"add" => commands::add_server::run(&command, &ctx).await,
"committee" => match command.data.options.first() {
None => "Invalid Command".to_string(),
Some(x) => match x.name.as_str() {
"add" => commands::add_server::run(&command, &ctx).await,
"roles_adder" => commands::role_adder::edit::run(&command, &ctx).await,
"icon" => match &x.value {
CommandDataOptionValue::SubCommandGroup(y) => match y.first() {
None => "error".to_string(),
Some(z) => match z.name.as_str() {
"change" => commands::server_icon::admin::change::run(&command, &ctx).await,
&_ => format!("not implemented :( count {}", x.name.as_str()),
},
},
_ => {
format!("not implemented :( committee {}", x.name.as_str())
}
},
// TODO: move teh minecraft commands in here as a subgroup
// "link" => commands::count::servers::run(&command, &ctx).await,
&_ => format!("not implemented :( committee {}", x.name.as_str()),
},
},
"minecraft_add" => commands::minecraft::server::add::run(&command, &ctx).await,
"minecraft_list" => commands::minecraft::server::list::run(&command, &ctx).await,
"minecraft_delete" => commands::minecraft::server::delete::run(&command, &ctx).await,
// for teh committee server, temporary
"link_committee" => commands::committee::link::run(&command, &ctx).await,
"verify_committee" => commands::committee::verify::run(&command, &ctx).await,
_ => "not implemented :(".to_string(),
// sub command
"count" => match command.data.options.first() {
None => "Invalid Command".to_string(),
Some(x) => match x.name.as_str() {
"committee" => commands::count::committee::run(&command, &ctx).await,
"servers" => commands::count::servers::run(&command, &ctx).await,
&_ => format!("not implemented :( count {}", x.name.as_str()),
},
},
"icon" => match command.data.options.first() {
None => "Invalid Command".to_string(),
Some(x) => match x.name.as_str() {
"current" => {
let result = match &x.value {
CommandDataOptionValue::SubCommandGroup(y) => match y.first() {
None => "error".to_string(),
Some(z) => match z.name.as_str() {
"icon" => commands::server_icon::user::current::icon::run(&command, &ctx).await,
"festival" => commands::server_icon::user::current::festival::run(&command, &ctx).await,
&_ => format!("not implemented :( count {}", x.name.as_str()),
},
},
&_ => format!("not implemented :( {}", command.data.name.as_str()),
};
result
}
"stats" => commands::server_icon::user::stats::run(&command, &ctx).await,
&_ => format!("not implemented :( count {}", x.name.as_str()),
},
},
_ => format!("not implemented :( {}", command.data.name.as_str()),
};
if let Err(why) = command.edit_original_interaction_response(&ctx.http, |response| response.content(content)).await {
println!("Cannot respond to slash command: {}", why);
if let Err(why) = command.edit_response(&ctx.http, EditInteractionResponse::new().content(content)).await {
println!("Cannot respond to slash command: {why}");
}
}
}
}
async fn get_committee(db: &Pool<Sqlite>, wolves_id: i64) -> Vec<Committees> {
sqlx::query_as::<_, Committees>(
r#"
SELECT *
FROM committees
WHERE id = ?
"#,
)
.bind(wolves_id)
.fetch_all(db)
.await
.unwrap_or_default()
}
#[tokio::main]
async fn main() {
let config = get_config();
@ -137,7 +296,8 @@ async fn main() {
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT | GatewayIntents::GUILD_MEMBERS;
// Build our client.
let mut client = Client::builder(&config.discord_token, intents)
.event_handler(Handler {})
.event_handler(Handler)
.cache_settings(serenity::cache::Settings::default())
.await
.expect("Error creating client");
@ -153,6 +313,6 @@ async fn main() {
// Shards will automatically attempt to reconnect, and will perform
// exponential backoff until it reconnects.
if let Err(why) = client.start().await {
println!("Client error: {:?}", why);
println!("Client error: {why:?}");
}
}