Compare commits
196
Commits
rewrite-an
...
ee01d2160d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee01d2160d | ||
|
|
16cbda838e | ||
|
|
ce0cef5db7 | ||
|
|
afcfdad13d | ||
|
|
2823868460 | ||
|
|
78fcfe386d | ||
|
|
5fdea76f37 | ||
|
|
6248dc72f0 | ||
|
|
53e066f868 | ||
|
|
c9b81219e5 | ||
|
|
b0a6950005 | ||
|
|
658dbd9c8d | ||
|
|
275bfa440c | ||
|
|
943225ce1f | ||
|
|
e6b99759ce | ||
|
|
014acf55c6 | ||
|
|
769d36e9c2 | ||
|
|
12c6755897 | ||
|
|
21678239b5 | ||
|
|
d0b5f86740 | ||
|
|
70c531ddac | ||
|
|
08c4534907 | ||
|
|
6aee4f39b0 | ||
|
|
e7aa3247e5 | ||
|
|
5dd8fdcc58 | ||
|
|
a0c0c17747 | ||
|
|
2b3dc00941 | ||
|
|
1c79d4b701 | ||
|
|
384ca1f4e8 | ||
|
|
84ff48ae8c | ||
|
|
be5422345b | ||
|
|
b2297703c8 | ||
|
|
e5ecbbfa10 | ||
|
|
f9a0c8220c | ||
|
|
176280fdf4 | ||
|
|
0c3bbf9a03 | ||
|
|
08d71a5e82 | ||
|
|
86f2a6bf9c | ||
|
|
d1669003cf | ||
|
|
b442be08e2 | ||
|
|
eb057e0531 | ||
|
|
73010fec3d | ||
|
|
676fdcfd96 | ||
|
|
e5fe77c287 | ||
|
|
28d330b1b2 | ||
|
|
ec893c0cf7 | ||
|
|
16085b4bd8 | ||
|
|
ca1ce13887 | ||
|
|
e44ab51adf | ||
|
|
6d8217ed81 | ||
|
|
a75fd9f092 | ||
|
|
0f5f390d68 | ||
|
|
c1a2a6a449 | ||
|
|
82fba989a4 | ||
|
|
3f5ec4f258 | ||
|
|
39120a65fa | ||
|
|
fc9bbcc87c | ||
|
|
497d180669 | ||
|
|
bd09a55a74 | ||
|
|
14a6037101 | ||
|
|
b9dbc17b63 | ||
|
|
325b4cbc16 | ||
|
|
2a2ffd8894 | ||
|
|
134d4e8879 | ||
|
|
291c4d50b2 | ||
|
|
06faca7aa2 | ||
|
|
8e9a23b4d9 | ||
|
|
8f6d28f3b6 | ||
|
|
35f3504c00 | ||
|
|
6c72c27911 | ||
|
|
7d47a9eb35 | ||
|
|
b10484cacc | ||
|
|
7a5943a5ef | ||
|
|
8294fafeba | ||
|
|
c5d699292f | ||
|
|
679096a685 | ||
|
|
9b70714fe3 | ||
|
|
6df7fd272f | ||
|
|
d0aa3d72d5 | ||
|
|
b108d93c55 | ||
|
|
eaa38f3474 | ||
|
|
8868b18be3 | ||
|
|
55ae1c8937 | ||
|
|
5e3201e7c5 | ||
|
|
7d19e928d6 | ||
|
|
c88496a73a | ||
|
|
aa32abfa90 | ||
|
|
6c240cfece | ||
|
|
1f9f094445 | ||
|
|
e28f11d9bb | ||
|
|
408f0223a2 | ||
|
|
2b1a738379 | ||
|
|
cca77aa4a7 | ||
|
|
a92a02484c | ||
|
|
0318f002fc | ||
|
|
4563c49245 | ||
|
|
aba11cb2da | ||
|
|
ee49b0a984 | ||
|
|
3cc37cfc0c | ||
|
|
f785413696 | ||
|
|
cddda58aca | ||
|
|
0af585cf9e | ||
|
|
b6242f5a10 | ||
|
|
9799631b7c | ||
|
|
1678eedf6d | ||
|
|
a6255290b2 | ||
|
|
118dacff20 | ||
|
|
a994c86f98 | ||
|
|
23969006c2 | ||
|
|
a0f592e66f | ||
|
|
5ebbfe6458 | ||
|
|
6843865da3 | ||
|
|
f882ed7fed | ||
|
|
1149dd5bb4 | ||
|
|
54217f1d69 | ||
|
|
8ff7e3af02 | ||
|
|
040b720755 | ||
|
|
91ae88d040 | ||
|
|
31bb63a77a | ||
|
|
2f99b32aea | ||
|
|
d3e192e3c3 | ||
|
|
f98fc37191 | ||
|
|
fcb1a786be | ||
|
|
5fe3878471 | ||
|
|
d53a161716 | ||
|
|
c0b919d33c | ||
|
|
a852cd9844 | ||
|
|
36f15615b6 | ||
|
|
f909a300ad | ||
|
|
da1971060a | ||
|
|
e1c8957fc8 | ||
|
|
43ab107102 | ||
|
|
14f8df2aba | ||
|
|
a67b4f6a3d | ||
|
|
455dc27743 | ||
|
|
d39b06eb12 | ||
|
|
8eaa3f6195 | ||
|
|
f6866e1a11 | ||
|
|
700b770248 | ||
|
|
ac81e3b286 | ||
|
|
323cf7f748 | ||
|
|
281542a5c2 | ||
|
|
77182d9633 | ||
|
|
e472936c51 | ||
|
|
12948dcc15 | ||
|
|
b9410bedd7 | ||
|
|
7eb2a99b69 | ||
|
|
fb7eba11bb | ||
|
|
208e0ddbf5 | ||
|
|
deb76941ea | ||
|
|
25b96cdc2a | ||
|
|
f4f842e3c8 | ||
|
|
6dde25aa72 | ||
|
|
8e0c591b21 | ||
|
|
fe53f2e5c2 | ||
|
|
3c87fdfa69 | ||
|
|
da10b7f194 | ||
|
|
53d61cfbbb | ||
|
|
eb6dcb4604 | ||
|
|
8432cb78fa | ||
|
|
faf9d9d580 | ||
|
|
f4a282447a | ||
|
|
2b4c3d0b8b | ||
|
|
1a446ae3bf | ||
|
|
55431d580e | ||
|
|
09ed3b870e | ||
|
|
634c8eff8c | ||
|
|
8173a508ad | ||
|
|
c284ede5b4 | ||
|
|
7b3fa9905a | ||
|
|
a14ae96a47 | ||
|
|
e35b200d03 | ||
|
|
285f1d4516 | ||
|
|
2030812742 | ||
|
|
e407068a9c | ||
|
|
7270c90b62 | ||
|
|
7119730668 | ||
|
|
fb660c162b | ||
|
|
6ba65eef71 | ||
|
|
d7b8138606 | ||
|
|
66584a8239 | ||
|
|
80ed00f796 | ||
|
|
fd71345cd0 | ||
|
|
7ed2c10448 | ||
|
|
717de3eec5 | ||
|
|
09b875da97 | ||
|
|
0cdbe63c55 | ||
|
|
ed717ef94b | ||
|
|
5806493ca8 | ||
|
|
39c455f617 | ||
|
|
d04af236d2 | ||
|
|
e8a76d5e77 | ||
|
|
91d5bf73c5 | ||
|
|
458f89a3a3 | ||
|
|
4df7c8ce7d | ||
|
|
28f8c8e702 |
@@ -0,0 +1,19 @@
|
||||
/target
|
||||
/.venv
|
||||
/.ansible
|
||||
/.vscode
|
||||
/k8s
|
||||
/playbooks
|
||||
/backup
|
||||
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
.python-version
|
||||
|
||||
ansible.cfg
|
||||
compose.yml
|
||||
inventory.ini
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
|
||||
.env
|
||||
@@ -1,3 +1,10 @@
|
||||
/target
|
||||
/.venv
|
||||
/.ansible
|
||||
/backup
|
||||
|
||||
.env
|
||||
|
||||
00-vault-pwd.txt
|
||||
|
||||
reset-ssh.sh
|
||||
@@ -0,0 +1,38 @@
|
||||
stages:
|
||||
- lint
|
||||
- build
|
||||
- test
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
lint:
|
||||
stage: lint
|
||||
image: rust:latest
|
||||
script:
|
||||
- rustup component add rustfmt clippy
|
||||
- cargo fmt --check
|
||||
- cargo clippy -- -D warnings
|
||||
artifacts:
|
||||
paths:
|
||||
- "target/"
|
||||
|
||||
build:
|
||||
stage: build
|
||||
image: rust:latest
|
||||
script:
|
||||
- cargo build --release
|
||||
artifacts:
|
||||
paths:
|
||||
- "target/"
|
||||
|
||||
test:
|
||||
stage: test
|
||||
image: rust:latest
|
||||
script:
|
||||
- cargo test --release
|
||||
artifacts:
|
||||
paths:
|
||||
- "target/"
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"ansible.python.interpreterPath": "/home/nyeki/Documents/Repositories/payphone/payphone/.venv/bin/python"
|
||||
}
|
||||
Generated
+1471
-43
File diff suppressed because it is too large
Load Diff
+31
-3
@@ -1,15 +1,23 @@
|
||||
[package]
|
||||
name = "payphone"
|
||||
version = "4.0.0"
|
||||
version = "4.4.52"
|
||||
edition = "2024"
|
||||
default-run = "payphone"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.99"
|
||||
async-trait = "0.1.88"
|
||||
axum = "0.8.4"
|
||||
clap = { version = "4.5.42", features = ["derive", "env", "string"] }
|
||||
color-thief = "0.2.2"
|
||||
colored = "3.0.0"
|
||||
csv = "1.3.1"
|
||||
deadpool-redis = { version = "0.22.0", features = ["cluster", "cluster-async"] }
|
||||
dotenvy = "0.15.7"
|
||||
humantime = "2.2.0"
|
||||
image = "0.25.6"
|
||||
include_dir = "0.7.4"
|
||||
itertools = "0.14.0"
|
||||
linkify = "0.10.0"
|
||||
log = { version = "0.4.27", features = ["serde"] }
|
||||
notify = "8.2.0"
|
||||
@@ -18,16 +26,36 @@ open = "5.3.2"
|
||||
rand = "0.9.2"
|
||||
rand_chacha = { version = "0.9.0", features = ["os_rng"] }
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.23", features = [
|
||||
"gzip",
|
||||
"brotli",
|
||||
"zstd",
|
||||
"native-tls-vendored",
|
||||
] }
|
||||
rgb = "0.8.52"
|
||||
scc = "2.3.4"
|
||||
scylla = { version = "1.3.1", features = ["time-03"] }
|
||||
serde_json = "1.0.143"
|
||||
thiserror = "2.0.12"
|
||||
time = { version = "0.3.41", features = ["formatting", "local-offset", "macros"] }
|
||||
time = { version = "0.3.41", features = [
|
||||
"formatting",
|
||||
"local-offset",
|
||||
"macros",
|
||||
] }
|
||||
tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
|
||||
topgg = { version = "1.4.2", features = ["twilight"] }
|
||||
tower-http = { version = "0.6.6", features = ["fs"] }
|
||||
twilight-cache-inmemory = { version = "0.16.0", features = [
|
||||
"permission-calculator",
|
||||
] }
|
||||
twilight-gateway = "0.16.0"
|
||||
twilight-http = { version = "0.16.0", features = ["simd-json"] }
|
||||
twilight-model = "0.16.0"
|
||||
twilight-util = { version = "0.16.0", features = ["builder"] }
|
||||
twilight-util = { version = "0.16.0", features = [
|
||||
"builder",
|
||||
"permission-calculator",
|
||||
"snowflake",
|
||||
] }
|
||||
twilight-validate = "0.16.0"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unidecode = "0.3.0"
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# --- Builder stage ---
|
||||
FROM rust:1-bookworm AS builder
|
||||
|
||||
# Install musl target and musl-tools
|
||||
RUN rustup target add x86_64-unknown-linux-musl \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y musl-tools build-essential
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY . .
|
||||
|
||||
# Build fully static binary
|
||||
RUN cargo build --release --target x86_64-unknown-linux-musl
|
||||
|
||||
# --- Final stage ---
|
||||
FROM debian:bookworm
|
||||
|
||||
# Copy static binary
|
||||
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/payphone /usr/local/bin/payphone
|
||||
|
||||
RUN apt-get update && apt-get install -y ca-certificates \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/payphone"]
|
||||
CMD ["start"]
|
||||
@@ -0,0 +1,5 @@
|
||||
[ssh_connection]
|
||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
|
||||
|
||||
[defaults]
|
||||
timeout = 30
|
||||
+66
@@ -31,6 +31,66 @@ services:
|
||||
scylla-net:
|
||||
ipv4_address: 172.20.0.4
|
||||
|
||||
redis-node-1:
|
||||
image: redis:latest
|
||||
container_name: redis-node-1
|
||||
command: redis-server --port 7000 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes
|
||||
ports:
|
||||
- "7000:7000"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-p", "7000", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
redis-net:
|
||||
ipv4_address: 172.20.1.2
|
||||
|
||||
redis-node-2:
|
||||
image: redis:latest
|
||||
container_name: redis-node-2
|
||||
command: redis-server --port 7001 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes
|
||||
ports:
|
||||
- "7001:7001"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-p", "7001", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
redis-net:
|
||||
ipv4_address: 172.20.1.3
|
||||
|
||||
redis-node-3:
|
||||
image: redis:latest
|
||||
container_name: redis-node-3
|
||||
command: redis-server --port 7002 --cluster-enabled yes --cluster-config-file nodes.conf --appendonly yes
|
||||
ports:
|
||||
- "7002:7002"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-p", "7002", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
redis-net:
|
||||
ipv4_address: 172.20.1.4
|
||||
|
||||
redis-init:
|
||||
image: redis:latest
|
||||
container_name: redis-init
|
||||
command: redis-cli --cluster create 172.20.1.2:7000 172.20.1.3:7001 172.20.1.4:7002 --cluster-replicas 0 --cluster-yes
|
||||
restart: no
|
||||
depends_on:
|
||||
redis-node-1:
|
||||
condition: service_healthy
|
||||
redis-node-2:
|
||||
condition: service_healthy
|
||||
redis-node-3:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- redis-net
|
||||
|
||||
volumes:
|
||||
scylla-data-1:
|
||||
scylla-data-2:
|
||||
@@ -42,3 +102,9 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/24
|
||||
|
||||
redis-net:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.1.0/24
|
||||
@@ -0,0 +1,16 @@
|
||||
[init]
|
||||
p1 ansible_host=p1.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack1
|
||||
|
||||
[master]
|
||||
p2 ansible_host=p2.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack2
|
||||
p3 ansible_host=p3.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack3
|
||||
|
||||
[worker]
|
||||
p4 ansible_host=p4.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack1
|
||||
p5 ansible_host=p5.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack2
|
||||
p6 ansible_host=p6.vps.nyeki.dev ansible_user=root rack=ge-dc1-rack3
|
||||
|
||||
[scylla]
|
||||
p4
|
||||
p5
|
||||
p6
|
||||
@@ -0,0 +1,25 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: redis
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: redis-config
|
||||
namespace: redis
|
||||
data:
|
||||
update-node.sh: |
|
||||
#!/bin/sh
|
||||
REDIS_NODES="/data/nodes.conf"
|
||||
sed -i -e "/myself/ s/[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}/${POD_IP}/" ${REDIS_NODES}
|
||||
exec "$@"
|
||||
redis.conf: |+
|
||||
cluster-enabled yes
|
||||
cluster-require-full-coverage no
|
||||
cluster-node-timeout 15000
|
||||
cluster-config-file /data/nodes.conf
|
||||
cluster-migration-barrier 1
|
||||
save ""
|
||||
appendonly no
|
||||
protected-mode no
|
||||
@@ -0,0 +1,51 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: redis-cluster
|
||||
namespace: redis
|
||||
spec:
|
||||
serviceName: redis-cluster
|
||||
replicas: 6
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis-cluster
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis-cluster
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: client
|
||||
- containerPort: 16379
|
||||
name: gossip
|
||||
command: ["/conf/update-node.sh", "redis-server", "/conf/redis.conf"]
|
||||
env:
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
volumeMounts:
|
||||
- name: conf
|
||||
mountPath: /conf
|
||||
readOnly: false
|
||||
- name: data
|
||||
mountPath: /data
|
||||
readOnly: false
|
||||
volumes:
|
||||
- name: conf
|
||||
configMap:
|
||||
name: redis-config
|
||||
defaultMode: 0755
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
storageClassName: local-path
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis-cluster
|
||||
namespace: redis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
name: client
|
||||
- port: 16379
|
||||
targetPort: 16379
|
||||
name: gossip
|
||||
selector:
|
||||
app: redis-cluster
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
apiVersion: scylla.scylladb.com/v1alpha1
|
||||
kind: NodeConfig
|
||||
metadata:
|
||||
name: scylladb-nodepool-1
|
||||
spec:
|
||||
localDiskSetup:
|
||||
loopDevices:
|
||||
- name: persistent-volumes
|
||||
imagePath: /var/lib/persistent-volumes.img
|
||||
size: 60Gi
|
||||
filesystems:
|
||||
- device: /dev/loops/persistent-volumes
|
||||
type: xfs
|
||||
mounts:
|
||||
- device: /dev/loops/persistent-volumes
|
||||
mountPoint: /var/lib/persistent-volumes
|
||||
unsupportedOptions:
|
||||
- prjquota
|
||||
placement:
|
||||
nodeSelector:
|
||||
scylla.scylladb.com/node-type: scylla
|
||||
tolerations:
|
||||
- effect: NoSchedule
|
||||
key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: scylladb:csi-external-provisioner
|
||||
aggregationRule:
|
||||
clusterRoleSelectors:
|
||||
- matchLabels:
|
||||
rbac.operator.scylladb.com/aggregate-to-csi-external-provisioner: "true"
|
||||
@@ -0,0 +1,102 @@
|
||||
kind: ClusterRole
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: scylladb:aggregate-to-csi-external-provisioner
|
||||
labels:
|
||||
rbac.operator.scylladb.com/aggregate-to-csi-external-provisioner: "true"
|
||||
rules:
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "persistentvolumes"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- "create"
|
||||
- "delete"
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "persistentvolumeclaims"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- "update"
|
||||
- apiGroups:
|
||||
- "storage.k8s.io"
|
||||
resources:
|
||||
- "storageclasses"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "events"
|
||||
verbs:
|
||||
- "list"
|
||||
- "watch"
|
||||
- "create"
|
||||
- "update"
|
||||
- "patch"
|
||||
- apiGroups:
|
||||
- "snapshot.storage.k8s.io"
|
||||
resources:
|
||||
- "volumesnapshots"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- apiGroups:
|
||||
- "snapshot.storage.k8s.io"
|
||||
resources:
|
||||
- "volumesnapshotcontents"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- apiGroups:
|
||||
- "storage.k8s.io"
|
||||
resources:
|
||||
- "csinodes"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "nodes"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- apiGroups:
|
||||
- "storage.k8s.io"
|
||||
resources:
|
||||
- "csistoragecapacities"
|
||||
verbs:
|
||||
- "get"
|
||||
- "list"
|
||||
- "watch"
|
||||
- "create"
|
||||
- "update"
|
||||
- "patch"
|
||||
- "delete"
|
||||
# The GET permissions below are needed for walking up the ownership chain
|
||||
# for CSIStorageCapacity. They are sufficient for deployment via
|
||||
# StatefulSet (only needs to get Pod) and Deployment (needs to get
|
||||
# Pod and then ReplicaSet to find the Deployment).
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
- "pods"
|
||||
verbs:
|
||||
- "get"
|
||||
- apiGroups:
|
||||
- "apps"
|
||||
resources:
|
||||
- "replicasets"
|
||||
verbs:
|
||||
- "get"
|
||||
@@ -0,0 +1,15 @@
|
||||
kind: ClusterRole
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: scylladb:aggregate-to-csi-external-provisioner-openshift
|
||||
labels:
|
||||
rbac.operator.scylladb.com/aggregate-to-csi-external-provisioner: "true"
|
||||
rules:
|
||||
- apiGroups:
|
||||
- security.openshift.io
|
||||
resourceNames:
|
||||
- privileged
|
||||
resources:
|
||||
- securitycontextconstraints
|
||||
verbs:
|
||||
- use
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: local-csi-driver
|
||||
@@ -0,0 +1,8 @@
|
||||
kind: StorageClass
|
||||
apiVersion: storage.k8s.io/v1
|
||||
metadata:
|
||||
name: scylladb-local-xfs
|
||||
provisioner: local.csi.scylladb.com
|
||||
volumeBindingMode: WaitForFirstConsumer
|
||||
parameters:
|
||||
csi.storage.k8s.io/fstype: xfs
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: storage.k8s.io/v1
|
||||
kind: CSIDriver
|
||||
metadata:
|
||||
name: local.csi.scylladb.com
|
||||
spec:
|
||||
attachRequired: false
|
||||
storageCapacity: true
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
namespace: local-csi-driver
|
||||
name: local-csi-driver
|
||||
@@ -0,0 +1,12 @@
|
||||
kind: ClusterRoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: scylladb:csi-external-provisioner
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: local-csi-driver
|
||||
namespace: local-csi-driver
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: scylladb:csi-external-provisioner
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
@@ -0,0 +1,125 @@
|
||||
kind: DaemonSet
|
||||
apiVersion: apps/v1
|
||||
metadata:
|
||||
namespace: local-csi-driver
|
||||
name: local-csi-driver
|
||||
labels:
|
||||
app.kubernetes.io/name: local-csi-driver
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: local-csi-driver
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: local-csi-driver
|
||||
spec:
|
||||
nodeSelector:
|
||||
kubernetes.io/os: linux
|
||||
scylla.scylladb.com/node-type: scylla
|
||||
serviceAccountName: local-csi-driver
|
||||
tolerations:
|
||||
- operator: Exists
|
||||
containers:
|
||||
- name: local-csi-driver
|
||||
securityContext:
|
||||
privileged: true
|
||||
image: docker.io/scylladb/local-csi-driver:0.5.0@sha256:46c1d2e9be8edf076d8dea7e82a3bb763d4bdda5adf43c794f617e9c94251079
|
||||
imagePullPolicy: IfNotPresent
|
||||
args:
|
||||
- --listen=/csi/csi.sock
|
||||
- --node-name=$(NODE_NAME)
|
||||
- --volumes-dir=/var/lib/persistent-volumes
|
||||
- --v=2
|
||||
env:
|
||||
- name: NODE_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
volumeMounts:
|
||||
- name: kubelet-dir
|
||||
mountPath: /var/lib/kubelet
|
||||
mountPropagation: "Bidirectional"
|
||||
- name: plugin-dir
|
||||
mountPath: /csi
|
||||
- name: volumes-dir
|
||||
mountPath: /var/lib/persistent-volumes
|
||||
ports:
|
||||
- name: healthz
|
||||
containerPort: 9809
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: healthz
|
||||
initialDelaySeconds: 10
|
||||
timeoutSeconds: 3
|
||||
periodSeconds: 2
|
||||
failureThreshold: 5
|
||||
- name: csi-driver-registrar
|
||||
image: registry.k8s.io/sig-storage/csi-node-driver-registrar@sha256:fdff3ee285341bc58033b6b2458a5d45fd90ec6922a8ba6ebdd49b0c41e2cd34
|
||||
imagePullPolicy: Always
|
||||
args:
|
||||
- --csi-address=/csi/csi.sock
|
||||
- --kubelet-registration-path=/var/lib/kubelet/plugins/local.csi.scylladb.com/csi.sock
|
||||
volumeMounts:
|
||||
- name: plugin-dir
|
||||
mountPath: /csi
|
||||
- name: registration-dir
|
||||
mountPath: /registration
|
||||
- name: liveness-probe
|
||||
image: registry.k8s.io/sig-storage/livenessprobe@sha256:cacee2b5c36dd59d4c7e8469c05c9e4ef53ecb2df9025fa8c10cdaf61bce62f0
|
||||
imagePullPolicy: Always
|
||||
args:
|
||||
- --csi-address=/csi/csi.sock
|
||||
- --health-port=9809
|
||||
- --v=2
|
||||
volumeMounts:
|
||||
- name: plugin-dir
|
||||
mountPath: /csi
|
||||
- name: csi-provisioner
|
||||
image: registry.k8s.io/sig-storage/csi-provisioner@sha256:ee3b525d5b89db99da3b8eb521d9cd90cb6e9ef0fbb651e98bb37be78d36b5b8
|
||||
imagePullPolicy: Always
|
||||
args:
|
||||
- --csi-address=/csi/csi.sock
|
||||
- --v=2
|
||||
- --node-deployment
|
||||
- --feature-gates=Topology=true
|
||||
- --immediate-topology=false
|
||||
- --enable-capacity
|
||||
- --capacity-ownerref-level=0
|
||||
- --capacity-poll-interval=30s
|
||||
- --default-fstype=xfs
|
||||
env:
|
||||
- name: NAMESPACE
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.namespace
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: NODE_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
volumeMounts:
|
||||
- name: plugin-dir
|
||||
mountPath: /csi
|
||||
volumes:
|
||||
- name: kubelet-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet
|
||||
type: Directory
|
||||
- name: plugin-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet/plugins/local.csi.scylladb.com/
|
||||
type: DirectoryOrCreate
|
||||
- name: registration-dir
|
||||
hostPath:
|
||||
path: /var/lib/kubelet/plugins_registry/
|
||||
type: Directory
|
||||
- name: volumes-dir
|
||||
hostPath:
|
||||
path: /var/lib/persistent-volumes
|
||||
type: Directory
|
||||
@@ -0,0 +1,117 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: scylladb-config
|
||||
data:
|
||||
scylla.yaml: |
|
||||
authenticator: PasswordAuthenticator
|
||||
authorizer: CassandraAuthorizer
|
||||
---
|
||||
apiVersion: scylla.scylladb.com/v1
|
||||
kind: ScyllaCluster
|
||||
metadata:
|
||||
name: scylladb
|
||||
spec:
|
||||
repository: docker.io/scylladb/scylla
|
||||
version: 2025.1.5
|
||||
agentVersion: 3.5.1
|
||||
developerMode: false
|
||||
automaticOrphanedNodeCleanup: true
|
||||
sysctls:
|
||||
- fs.aio-max-nr=30000000
|
||||
datacenter:
|
||||
name: ge-dc1
|
||||
racks:
|
||||
- name: ge-dc1-rack1
|
||||
members: 1
|
||||
scyllaConfig: scylladb-config
|
||||
storage:
|
||||
capacity: 45Gi
|
||||
storageClassName: scylladb-local-xfs
|
||||
resources:
|
||||
requests:
|
||||
cpu: 2
|
||||
memory: 8Gi
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 8Gi
|
||||
placement:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: topology.kubernetes.io/zone
|
||||
operator: In
|
||||
values:
|
||||
- ge-dc1-rack1
|
||||
- key: scylla.scylladb.com/node-type
|
||||
operator: In
|
||||
values:
|
||||
- scylla
|
||||
tolerations:
|
||||
- key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
effect: NoSchedule
|
||||
- name: ge-dc1-rack2
|
||||
members: 1
|
||||
scyllaConfig: scylladb-config
|
||||
storage:
|
||||
capacity: 45Gi
|
||||
storageClassName: scylladb-local-xfs
|
||||
resources:
|
||||
requests:
|
||||
cpu: 2
|
||||
memory: 8Gi
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 8Gi
|
||||
placement:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: topology.kubernetes.io/zone
|
||||
operator: In
|
||||
values:
|
||||
- ge-dc1-rack2
|
||||
- key: scylla.scylladb.com/node-type
|
||||
operator: In
|
||||
values:
|
||||
- scylla
|
||||
tolerations:
|
||||
- key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
effect: NoSchedule
|
||||
- name: ge-dc1-rack3
|
||||
members: 1
|
||||
scyllaConfig: scylladb-config
|
||||
storage:
|
||||
capacity: 45Gi
|
||||
storageClassName: scylladb-local-xfs
|
||||
resources:
|
||||
requests:
|
||||
cpu: 2
|
||||
memory: 8Gi
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 8Gi
|
||||
placement:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: topology.kubernetes.io/zone
|
||||
operator: In
|
||||
values:
|
||||
- ge-dc1-rack3
|
||||
- key: scylla.scylladb.com/node-type
|
||||
operator: In
|
||||
values:
|
||||
- scylla
|
||||
tolerations:
|
||||
- key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
effect: NoSchedule
|
||||
@@ -0,0 +1,167 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: scylla-manager
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: scylla-manager-config
|
||||
namespace: scylla-manager
|
||||
data:
|
||||
scylla-manager.yaml: |-
|
||||
http: :5080
|
||||
logger:
|
||||
level: info
|
||||
database:
|
||||
hosts:
|
||||
- scylla-manager-cluster-manager-dc-manager-rack-0
|
||||
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
namespace: scylla-manager
|
||||
name: scylla-manager-to-scylla-pod
|
||||
spec:
|
||||
policyTypes:
|
||||
- Ingress
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/managed-by: scylla-operator
|
||||
app.kubernetes.io/name: scylla
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: scylla-manager
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
name: scylla-manager
|
||||
namespace: scylla-manager
|
||||
spec:
|
||||
ports:
|
||||
- name: api
|
||||
port: 80
|
||||
protocol: TCP
|
||||
targetPort: 5080
|
||||
- name: metrics
|
||||
port: 5090
|
||||
protocol: TCP
|
||||
targetPort: 5090
|
||||
selector:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: scylla-manager
|
||||
namespace: scylla-manager
|
||||
labels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: scylla-manager
|
||||
namespace: scylla-manager
|
||||
labels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: scylla-manager
|
||||
app.kubernetes.io/instance: scylla-manager
|
||||
spec:
|
||||
serviceAccountName: scylla-manager
|
||||
containers:
|
||||
- name: scylla-manager
|
||||
image: docker.io/scylladb/scylla-manager:3.5.1@sha256:6986ecfc8c925c3d59b65bbcb9763d62f7591a00bb30242842aada115929e816
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /usr/bin/scylla-manager
|
||||
args:
|
||||
- --config-file=/mnt/etc/scylla-manager/scylla-manager.yaml
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 20Mi
|
||||
volumeMounts:
|
||||
- mountPath: /mnt/etc/scylla-manager
|
||||
name: scylla-manager-config
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/clusters
|
||||
port: 5080
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
volumes:
|
||||
- configMap:
|
||||
name: scylla-manager-config
|
||||
name: scylla-manager-config
|
||||
|
||||
---
|
||||
apiVersion: scylla.scylladb.com/v1
|
||||
kind: ScyllaCluster
|
||||
metadata:
|
||||
name: scylla-manager-cluster
|
||||
namespace: scylla-manager
|
||||
spec:
|
||||
version: 2025.1.5
|
||||
agentVersion: 3.5.1@sha256:d1b57d08b9949c8faad2048fdf4dc7c502dae81da856c3c6b3a77dd347d5c7fc
|
||||
repository: docker.io/scylladb/scylla
|
||||
agentRepository: docker.io/scylladb/scylla-manager-agent
|
||||
developerMode: true
|
||||
cpuset: true
|
||||
sysctls:
|
||||
- fs.aio-max-nr=30000000
|
||||
datacenter:
|
||||
name: manager-dc
|
||||
racks:
|
||||
- members: 1
|
||||
name: manager-rack
|
||||
placement:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: scylla.scylladb.com/node-type
|
||||
operator: In
|
||||
values:
|
||||
- scylla
|
||||
tolerations:
|
||||
- effect: NoSchedule
|
||||
key: scylla-operator.scylladb.com/dedicated
|
||||
operator: Equal
|
||||
value: scyllaclusters
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1
|
||||
memory: 200Mi
|
||||
requests:
|
||||
cpu: 1
|
||||
memory: 200Mi
|
||||
storage:
|
||||
capacity: 5Gi
|
||||
storageClassName: scylladb-local-xfs
|
||||
---
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
set -euo pipefail
|
||||
set -x
|
||||
|
||||
uv run ansible-playbook -i inventory.ini playbooks/01-setup.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/02-copy-k8s-files.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/03-cert-manager.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/04-prometheus-operator.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/05-scylla-operator.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/06-scylla-cluster.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/07-scylla-manager.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/08-redis.yml
|
||||
uv run ansible-playbook -i inventory.ini playbooks/09-payphone.yml --vault-password-file playbooks/00-vault-pwd.txt
|
||||
@@ -0,0 +1,24 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
30363364303339383037643864396632306537333737383933363164663838333364323837633838
|
||||
3561323032663461663834613634633734636333306638380a396436646332373330393931396530
|
||||
36353439343736656432333437653764653132393364626136396230623038626330313366386263
|
||||
3630663539303665340a623961656435373337363534363863353934326336653361663538616664
|
||||
36313865646530346633626639393739653131313835383631613833393832396434633038343639
|
||||
62656236326662343236303133306137653533633064626161636165646164646165653534633036
|
||||
63623863353438616237323530383033366138656665613331666532316562336232353239323535
|
||||
33326237653463663862383266373534343434383765303237613436643439313233396338376134
|
||||
65303161313930383165636133616236386435663232313531633461383938326131353930353761
|
||||
35303238333863666137313330336366623536613230353639666133333633353566303131393666
|
||||
32313964636138343632373064366166333134343437613937303538613834623934623161626261
|
||||
63303832663438356331363534316230613561613961306561376432646132663733303366393961
|
||||
64373166633938633730643339646433623235306161386338316330336264623031363365376634
|
||||
38306639356236383464653038663732653465626265663939643234306563346330336139373235
|
||||
36616662313139356139363532633664333762623235326237636339323039373131666162613930
|
||||
63336161396635646565393565373736616431643830366163356532383034396636323163623836
|
||||
37613632343563363032303133333930653431343039633239306561303266393861366134643734
|
||||
66326534343464363634383461326330346432613266653066643139636232656638346635343130
|
||||
61383336623763353034316238313536336261616433356461666537633665636430393139663465
|
||||
61643066356466646264616562343939616336343935663939313537356237303264303761376139
|
||||
34623231623264633934363537643132643336313633663430643265633564646363623130396437
|
||||
61383763396633663439626661333630373662616633353232356165663034336131343666643633
|
||||
373138346631323465303932633962613234
|
||||
@@ -0,0 +1,186 @@
|
||||
- name: Install K3s and Apply Security Configurations on All Nodes
|
||||
hosts: all
|
||||
become: true
|
||||
tasks:
|
||||
- name: Install curl
|
||||
ansible.builtin.apt:
|
||||
name: curl
|
||||
state: present
|
||||
when: ansible_os_family == "Debian"
|
||||
|
||||
- name: Download k3s install script
|
||||
ansible.builtin.get_url:
|
||||
url: https://get.k3s.io
|
||||
dest: /tmp/k3s_install.sh
|
||||
mode: '0755'
|
||||
|
||||
- name: Install Python Kubernetes client
|
||||
ansible.builtin.apt:
|
||||
name: python3-kubernetes
|
||||
state: present
|
||||
|
||||
- name: Disable UFW
|
||||
ansible.builtin.command: ufw disable
|
||||
changed_when: false
|
||||
|
||||
- name: Disable password authentication for SSH
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: '^PasswordAuthentication'
|
||||
line: 'PasswordAuthentication no'
|
||||
state: present
|
||||
backup: true
|
||||
|
||||
- name: Disable root password login
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/ssh/sshd_config
|
||||
regexp: '^PermitRootLogin'
|
||||
line: 'PermitRootLogin prohibit-password'
|
||||
state: present
|
||||
backup: true
|
||||
|
||||
- name: Restart SSH to apply config
|
||||
ansible.builtin.service:
|
||||
name: ssh
|
||||
state: restarted
|
||||
|
||||
- name: Install fail2ban
|
||||
ansible.builtin.apt:
|
||||
name: fail2ban
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Configure fail2ban for SSH
|
||||
ansible.builtin.blockinfile:
|
||||
path: /etc/fail2ban/jail.local
|
||||
create: true
|
||||
block: |
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
mode: '0644'
|
||||
|
||||
- name: Restart fail2ban
|
||||
ansible.builtin.service:
|
||||
name: fail2ban
|
||||
state: restarted
|
||||
|
||||
- name: Set up K3s on Init Node
|
||||
hosts: init
|
||||
become: true
|
||||
tasks:
|
||||
- name: Run k3s Installer
|
||||
ansible.builtin.shell: |
|
||||
INSTALL_K3S_EXEC="server --tls-san {{ ansible_host }} --flannel-backend=wireguard-native --cluster-init" \
|
||||
sh /tmp/k3s_install.sh
|
||||
args:
|
||||
creates: /usr/local/bin/k3s
|
||||
|
||||
- name: Ensure K3s Service is Started and Enabled
|
||||
ansible.builtin.systemd:
|
||||
name: k3s
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Get Joining Token
|
||||
ansible.builtin.command: cat /var/lib/rancher/k3s/server/node-token
|
||||
register: k3s_token
|
||||
run_once: true
|
||||
tags: skip_ansible_lint
|
||||
changed_when: false
|
||||
|
||||
- name: Set up K3s on Master Nodes
|
||||
hosts: master
|
||||
become: true
|
||||
tasks:
|
||||
- name: Run k3s Installer
|
||||
ansible.builtin.shell: |
|
||||
INSTALL_K3S_EXEC="server --flannel-backend=wireguard-native --tls-san {{ ansible_host }}" \
|
||||
K3S_URL="https://{{ hostvars[groups['init'][0]].ansible_host }}:6443" \
|
||||
K3S_TOKEN="{{ hostvars[groups['init'][0]].k3s_token.stdout }}" sh /tmp/k3s_install.sh
|
||||
args:
|
||||
creates: /usr/local/bin/k3s
|
||||
|
||||
- name: Ensure k3s service is started and enabled
|
||||
ansible.builtin.systemd:
|
||||
name: k3s
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Set up K3s on Worker Nodes
|
||||
hosts: worker
|
||||
become: true
|
||||
tasks:
|
||||
- name: Run k3s Installer
|
||||
ansible.builtin.shell: |
|
||||
INSTALL_K3S_EXEC="agent" K3S_URL="https://{{ hostvars[groups['init'][0]].ansible_host }}:6443" \
|
||||
K3S_TOKEN="{{ hostvars[groups['init'][0]].k3s_token.stdout }}" sh /tmp/k3s_install.sh
|
||||
args:
|
||||
creates: /usr/local/bin/k3s
|
||||
|
||||
- name: Ensure k3s service is started and enabled
|
||||
ansible.builtin.systemd:
|
||||
name: k3s-agent
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Wait for All K3s Nodes to Become Ready
|
||||
hosts: init
|
||||
become: true
|
||||
tasks:
|
||||
- name: Wait until all nodes report Ready
|
||||
ansible.builtin.shell: |
|
||||
kubectl wait --for=condition=Ready node --all --timeout=10m
|
||||
register: wait_nodes
|
||||
retries: 5
|
||||
delay: 30
|
||||
until: wait_nodes.rc == 0
|
||||
changed_when: false
|
||||
|
||||
- name: Set Up Node Labels and Taints
|
||||
hosts: init
|
||||
become: true
|
||||
tasks:
|
||||
- name: Set Rack Tags to All Nodes
|
||||
loop: "{{ groups['all'] }}"
|
||||
kubernetes.core.k8s:
|
||||
api_version: v1
|
||||
kind: Node
|
||||
name: "{{ item }}"
|
||||
definition:
|
||||
metadata:
|
||||
labels:
|
||||
topology.kubernetes.io/zone: "{{ hostvars[item].rack }}"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
state: present
|
||||
|
||||
- name: Set Scylla Tags to Scylla Nodes
|
||||
loop: "{{ groups['scylla'] }}"
|
||||
kubernetes.core.k8s:
|
||||
api_version: v1
|
||||
kind: Node
|
||||
name: "{{ item }}"
|
||||
definition:
|
||||
metadata:
|
||||
labels:
|
||||
scylla.scylladb.com/node-type: scylla
|
||||
spec:
|
||||
taints:
|
||||
- key: scylla-operator.scylladb.com/dedicated
|
||||
value: scyllaclusters
|
||||
effect: NoSchedule
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
state: present
|
||||
|
||||
- name: Create Containerd Socket Symlink
|
||||
hosts: all
|
||||
become: true
|
||||
tasks:
|
||||
- name: Create Containerd Symlink
|
||||
ansible.builtin.command: |
|
||||
ln -s /run/k3s/containerd/containerd.sock /run/containerd/containerd.sock
|
||||
args:
|
||||
creates: /run/containerd/containerd.sock
|
||||
@@ -0,0 +1,19 @@
|
||||
- name: Copy K8s files to node
|
||||
hosts: init
|
||||
become: true
|
||||
gather_facts: false
|
||||
vars:
|
||||
k8s_files_src: "{{ playbook_dir }}/../k8s"
|
||||
k8s_files_dest: /tmp/k8s
|
||||
tasks:
|
||||
- name: Create destination directory for K8s files
|
||||
ansible.builtin.file:
|
||||
path: "{{ k8s_files_dest }}"
|
||||
state: directory
|
||||
mode: '0644'
|
||||
|
||||
- name: Copy K8s files to destination
|
||||
ansible.posix.synchronize:
|
||||
src: "{{ k8s_files_src }}/"
|
||||
dest: "{{ k8s_files_dest }}/"
|
||||
recursive: true
|
||||
@@ -0,0 +1,46 @@
|
||||
- name: Set up Cert-Manager
|
||||
hosts: init
|
||||
gather_facts: false
|
||||
become: true
|
||||
vars:
|
||||
cert_manager_ns: cert-manager
|
||||
cert_manager_crds:
|
||||
- certificates.cert-manager.io
|
||||
- issuers.cert-manager.io
|
||||
cert_manager_deployments:
|
||||
- cert-manager
|
||||
- cert-manager-cainjector
|
||||
- cert-manager-webhook
|
||||
tasks:
|
||||
- name: Apply Cert-Manager YAML
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: "/tmp/k8s/scylla/01-cert-manager.yml"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Wait for Cert-Manager CRDs
|
||||
ansible.builtin.shell: |
|
||||
kubectl wait --for condition=established --timeout=60s crd/{{ cert_manager_crds | join(' crd/') }}
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Cert-Manager deployments
|
||||
ansible.builtin.shell: |
|
||||
kubectl -n {{ cert_manager_ns }} rollout status --timeout=10m deployment.apps/{{ item }}
|
||||
loop: "{{ cert_manager_deployments }}"
|
||||
loop_control:
|
||||
loop_var: item
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Cert-Manager webhook CA secret
|
||||
ansible.builtin.shell: |
|
||||
for i in {1..30}; do
|
||||
kubectl -n {{ cert_manager_ns }} get secret/cert-manager-webhook-ca && break
|
||||
sleep 1
|
||||
done
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
@@ -0,0 +1,26 @@
|
||||
- name: Set up Prometheus operator
|
||||
hosts: init
|
||||
gather_facts: false
|
||||
become: true
|
||||
vars:
|
||||
prometheus_crds:
|
||||
- prometheuses.monitoring.coreos.com
|
||||
- prometheusrules.monitoring.coreos.com
|
||||
- servicemonitors.monitoring.coreos.com
|
||||
tasks:
|
||||
- name: Apply Prometheus operator server-side
|
||||
ansible.builtin.command: |
|
||||
kubectl apply --server-side -f /tmp/k8s/scylla/02-prometheus-operator.yml -n prometheus-operator
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Prometheus operator CRDs
|
||||
ansible.builtin.shell: |
|
||||
kubectl wait --for='condition=established' crd/{{ prometheus_crds | join(' crd/') }}
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Prometheus operator deployment
|
||||
ansible.builtin.shell: |
|
||||
kubectl -n=prometheus-operator rollout status --timeout=10m deployment.apps/prometheus-operator
|
||||
changed_when: false
|
||||
@@ -0,0 +1,67 @@
|
||||
- name: Set up Scylla Operator, NodeConfig, and the Local CSI Driver
|
||||
hosts: init
|
||||
become: true
|
||||
vars:
|
||||
scylla_crds:
|
||||
- scyllaclusters.scylla.scylladb.com
|
||||
- nodeconfigs.scylla.scylladb.com
|
||||
- scyllaoperatorconfigs.scylla.scylladb.com
|
||||
- scylladbmonitorings.scylla.scylladb.com
|
||||
scylla_deployments:
|
||||
- scylla-operator
|
||||
- webhook-server
|
||||
tasks:
|
||||
- name: Apply Scylla Operator YAML
|
||||
ansible.builtin.command: |
|
||||
kubectl apply --server-side -f /tmp/k8s/scylla/03-scylla-operator.yml -n scylla-operator
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Scylla Operator CRDs
|
||||
ansible.builtin.shell: |
|
||||
kubectl wait --for='condition=established' crd/{{ scylla_crds | join(' crd/') }}
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for Scylla Operator Deployments
|
||||
ansible.builtin.shell: |
|
||||
kubectl -n scylla-operator rollout status --timeout=10m deployment.apps/{{ item }}
|
||||
loop: "{{ scylla_deployments }}"
|
||||
loop_control:
|
||||
loop_var: item
|
||||
environment:
|
||||
KUBECONFIG: /etc/rancher/k3s/k3s.yaml
|
||||
changed_when: false
|
||||
|
||||
- name: Apply Scylla NodeConfig YAML
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: "/tmp/k8s/scylla/04-scylla-node-config.yml"
|
||||
namespace: scylla-operator
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Wait for Scylla NodeConfig to be applied across all nodes
|
||||
ansible.builtin.command: |
|
||||
kubectl wait --for='condition=Reconciled' --timeout=10m nodeconfigs.scylla.scylladb.com/scylladb-nodepool-1
|
||||
changed_when: false
|
||||
|
||||
- name: Find Scylla CSI Driver K8s YAMLs on Remote Host
|
||||
ansible.builtin.find:
|
||||
paths: /tmp/k8s/scylla
|
||||
patterns: "05-*.yml"
|
||||
file_type: file
|
||||
register: scylla_csi_driver_files
|
||||
|
||||
- name: Apply all Scylla CSI Driver K8s YAMLs
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: "{{ item.path }}"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
loop: "{{ scylla_csi_driver_files.files | sort(attribute='path') }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
|
||||
- name: Wait for Local CSI Driver DaemonSet
|
||||
ansible.builtin.command: |
|
||||
kubectl -n=local-csi-driver rollout status --timeout=10m daemonset.apps/local-csi-driver
|
||||
changed_when: false
|
||||
@@ -0,0 +1,35 @@
|
||||
- name: Set Up Scylla Cluster
|
||||
hosts: init
|
||||
become: true
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Create Scylla Namespace
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
definition:
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: scylla
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Apply Scylla Cluster Config
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: /tmp/k8s/scylla/06-scylla-cluster.yml
|
||||
namespace: scylla
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Wait for Scylla Cluster Application
|
||||
ansible.builtin.command: |
|
||||
kubectl -n scylla rollout status statefulset/{{ item }} --timeout=10m
|
||||
loop:
|
||||
- scylladb-ge-dc1-ge-dc1-rack1
|
||||
- scylladb-ge-dc1-ge-dc1-rack2
|
||||
- scylladb-ge-dc1-ge-dc1-rack3
|
||||
register: rollout
|
||||
retries: 30
|
||||
delay: 10
|
||||
until: rollout.rc == 0
|
||||
ignore_errors: true
|
||||
changed_when: false
|
||||
@@ -0,0 +1,14 @@
|
||||
- name: Set Up Scylla Manager
|
||||
hosts: init
|
||||
become: true
|
||||
tasks:
|
||||
- name: Apply Scylla Manager YAML
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: "/tmp/k8s/scylla/07-scylla-manager.yml"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Wait for Scylla Manager Deployment
|
||||
ansible.builtin.command: |
|
||||
kubectl -n=scylla-manager rollout status --timeout=10m deployment.apps/scylla-manager
|
||||
changed_when: false
|
||||
@@ -0,0 +1,56 @@
|
||||
- name: Set Up Redis Cluster
|
||||
hosts: init
|
||||
become: true
|
||||
tasks:
|
||||
- name: Create Redis Config Map
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: /tmp/k8s/redis/01-config-map.yml
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Create Redis Nodes
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: /tmp/k8s/redis/02-redis.yml
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Wait for Redis Nodes
|
||||
ansible.builtin.command:
|
||||
kubectl -n=redis rollout status --timeout=10m sts/redis-cluster
|
||||
changed_when: false
|
||||
|
||||
- name: Create ClusterIP Service
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
src: /tmp/k8s/redis/03-cluster-ip.yml
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Grab Node IPs
|
||||
ansible.builtin.shell:
|
||||
echo $(kubectl get pods -l app=redis-cluster -n redis -o json | jq -r '.items | map(.status.podIP) | join(":6379 ")'):6379
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: redis_node_ips
|
||||
changed_when: false
|
||||
tags:
|
||||
- skip_ansible_lint
|
||||
|
||||
- name: Display Node IPs
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ redis_node_ips.stdout }}"
|
||||
|
||||
- name: Check if Redis cluster is already active
|
||||
ansible.builtin.shell: >
|
||||
set -o pipefail
|
||||
kubectl exec redis-cluster-0 -n redis -- redis-cli -p 6379 cluster info | grep cluster_state
|
||||
args:
|
||||
executable: /bin/bash
|
||||
register: redis_cluster_state
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Activate Redis Cluster
|
||||
ansible.builtin.shell: >
|
||||
kubectl exec redis-cluster-0 -n redis -- redis-cli --cluster create --cluster-replicas 1 {{ redis_node_ips.stdout }} --cluster-yes
|
||||
when: "'cluster_state:ok' not in redis_cluster_state.stdout"
|
||||
changed_when: false
|
||||
@@ -0,0 +1,139 @@
|
||||
- name: Build and Push Payphone
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Get Cargo Project Metadata
|
||||
ansible.builtin.command: cargo metadata --no-deps --format-version 1
|
||||
register: cargo_metadata
|
||||
changed_when: false
|
||||
|
||||
- name: Parse version
|
||||
ansible.builtin.set_fact:
|
||||
app_version: "v{{ (cargo_metadata.stdout | from_json).packages[0].version }}"
|
||||
|
||||
- name: Build and Push the Docker image
|
||||
community.docker.docker_image:
|
||||
source: build
|
||||
build:
|
||||
path: "{{ playbook_dir }}/../"
|
||||
name: registry.nyeki.dev/payphone/payphone
|
||||
tag: "{{ app_version }}"
|
||||
push: true
|
||||
|
||||
- name: Deploy Payphone
|
||||
hosts: init
|
||||
become: true
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- 00-vault.yml
|
||||
tasks:
|
||||
- name: Create Payphone Namespace
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
definition:
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: payphone
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Create Bot Token Secret
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
namespace: payphone
|
||||
definition:
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: payphone-secrets
|
||||
type: Opaque
|
||||
stringData:
|
||||
PAYPHONE_TOKEN: "{{ bot_token }}"
|
||||
PAYPHONE_TOPGG_TOKEN: "{{ topgg_token }}"
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Set Git credentials
|
||||
ansible.builtin.shell: >
|
||||
kubectl create secret docker-registry payphone-git-credentials \
|
||||
--docker-server=registry.nyeki.dev \
|
||||
--docker-username={{ gitlab_username }} \
|
||||
--docker-password={{ gitlab_pat }} \
|
||||
--docker-email={{ gitlab_email }} \
|
||||
-n payphone \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Apply Payphone Deployment
|
||||
kubernetes.core.k8s:
|
||||
state: present
|
||||
namespace: payphone
|
||||
definition:
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: payphone
|
||||
labels:
|
||||
app: payphone
|
||||
spec:
|
||||
serviceName: payphone
|
||||
replicas: "{{ bot_shards }}"
|
||||
selector:
|
||||
matchLabels:
|
||||
app: payphone
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: payphone
|
||||
spec:
|
||||
containers:
|
||||
- name: payphone
|
||||
image: registry.nyeki.dev/payphone/payphone:{{ hostvars['localhost'].app_version }}
|
||||
imagePullPolicy: "Always"
|
||||
args: [
|
||||
"start",
|
||||
"--shard",
|
||||
"$(SHARD_ID):$(SHARD_COUNT)",
|
||||
"--database-addr",
|
||||
"scylladb-client.scylla",
|
||||
"--database-username",
|
||||
"cassandra",
|
||||
"--database-password",
|
||||
"cassandra",
|
||||
"--redis-urls",
|
||||
"redis://redis-cluster.redis",
|
||||
"--bot-owner-id",
|
||||
"1486270291410817114"
|
||||
]
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: payphone-secrets
|
||||
env:
|
||||
- name: SHARD_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.labels['apps.kubernetes.io/pod-index']
|
||||
- name: SHARD_COUNT
|
||||
value: "{{ bot_shards | string }}"
|
||||
resources:
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "1Gi"
|
||||
requests:
|
||||
cpu: "250m"
|
||||
memory: "100Mi"
|
||||
imagePullSecrets:
|
||||
- name: payphone-git-credentials
|
||||
kubeconfig: /etc/rancher/k3s/k3s.yaml
|
||||
|
||||
- name: Rollout New Version
|
||||
ansible.builtin.command:
|
||||
kubectl set image -n payphone sts/payphone payphone=registry.nyeki.dev/payphone/payphone:{{ hostvars['localhost'].app_version }}
|
||||
changed_when: false
|
||||
|
||||
- name: Bump Payphone Version
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
tasks:
|
||||
- name: Bump Version
|
||||
ansible.builtin.shell: >
|
||||
cargo set-version --bump patch
|
||||
changed_when: false
|
||||
@@ -0,0 +1,14 @@
|
||||
- name: APT Update
|
||||
hosts: all
|
||||
become: true
|
||||
tasks:
|
||||
- name: Update APT package index
|
||||
ansible.builtin.apt:
|
||||
update_cache: yes
|
||||
cache_valid_time: 3600
|
||||
|
||||
- name: Upgrade APT packages
|
||||
ansible.builtin.apt:
|
||||
upgrade: dist
|
||||
autoremove: yes
|
||||
autoclean: yes
|
||||
@@ -0,0 +1,11 @@
|
||||
[project]
|
||||
name = "payphone"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"ansible>=11.9.0",
|
||||
"ansible-lint>=25.8.1",
|
||||
"requests>=2.32.4",
|
||||
]
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
uv run ansible-playbook -i inventory.ini playbooks/09-payphone.yml --vault-password-file playbooks/00-vault-pwd.txt
|
||||
@@ -0,0 +1,2 @@
|
||||
group_imports = "StdExternalCrate"
|
||||
imports_granularity = "Module"
|
||||
+105
-4
@@ -45,21 +45,30 @@
|
||||
//! ```shell
|
||||
//! $ cargo run -- start [TOKEN]
|
||||
//! ```
|
||||
//!
|
||||
//!
|
||||
//! To run multiple shards, pass the `--shard` or `-s` argument to the `start` command with the
|
||||
//! shard ID and total amount of shards as `id:total`. For example, for a single-shard bot, you'd
|
||||
//! set it to `-s 0:1`. ID is zero-indexed (0 means first shard), total is one-indexed (0 means no
|
||||
//! shards).
|
||||
//!
|
||||
//! # Running the Documentation Server
|
||||
//!
|
||||
//!
|
||||
//! Payphone has a built-in documentation server that serves the project's documentation and
|
||||
//! refreshes it on changes. To start the documentation server, run the following command:
|
||||
//!
|
||||
//!
|
||||
//! ```shell
|
||||
//! $ cargo run -- docs --open
|
||||
//! ```
|
||||
//!
|
||||
//!
|
||||
//! The `--open` flag is optional and will open the documentation in the default browser after
|
||||
//! starting the server. You can also use the `--bind` argument to specify the address to bind the
|
||||
//! server to, which defaults to `0.0.0.0:3000`.
|
||||
|
||||
use std::fmt;
|
||||
use std::net::SocketAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use twilight_gateway::ShardId;
|
||||
|
||||
/// Payphone's argument parser.
|
||||
#[derive(clap::Parser)]
|
||||
@@ -122,6 +131,54 @@ impl Into<log::LevelFilter> for LevelFilter {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Shard {
|
||||
pub id: u32,
|
||||
pub total: u32,
|
||||
}
|
||||
|
||||
impl Shard {
|
||||
fn new(id: u32, total: u32) -> Self {
|
||||
Shard { id, total }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Shard {
|
||||
fn default() -> Self {
|
||||
Shard::new(0, 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Shard {
|
||||
type Err = &'static str;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.split_once(":") {
|
||||
Some((id, total)) => {
|
||||
let id = id.parse().map_err(|_| "Invalid shard ID")?;
|
||||
let total = total.parse().map_err(|_| "Invalid shard total")?;
|
||||
|
||||
Ok(Shard { id, total })
|
||||
}
|
||||
None => Err("Invalid shard format, expected `id:total`"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Shard {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}", self.id, self.total)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::from_over_into)]
|
||||
impl Into<ShardId> for Shard {
|
||||
fn into(self) -> ShardId {
|
||||
ShardId::new(self.id, self.total)
|
||||
}
|
||||
}
|
||||
|
||||
/// The available commands for the Payphone CLI and their available arguments.
|
||||
#[derive(clap::Subcommand, Clone)]
|
||||
pub enum Commands {
|
||||
/// Starts the bot with the provided token.
|
||||
@@ -139,6 +196,50 @@ pub enum Commands {
|
||||
default_value_t = String::from("127.0.0.1:9042"))
|
||||
]
|
||||
database_addr: String,
|
||||
|
||||
/// The username to use when connecting to the database.
|
||||
#[arg(long, env = "PAYPHONE_DATABASE_USERNAME", value_name = "USERNAME")]
|
||||
database_username: Option<String>,
|
||||
|
||||
/// The password to use when connecting to the database.
|
||||
#[arg(long, env = "PAYPHONE_DATABASE_PASSWORD", value_name = "PASSWORD")]
|
||||
database_password: Option<String>,
|
||||
|
||||
/// The current shard being run.
|
||||
#[arg(
|
||||
long,
|
||||
short,
|
||||
env = "PAYPHONE_SHARD",
|
||||
value_name = "ID:TOTAL",
|
||||
default_value_t = Shard::default())
|
||||
]
|
||||
shard: Shard,
|
||||
|
||||
/// The Redis URLs to connect to.
|
||||
#[arg(
|
||||
long,
|
||||
env = "PAYPHONE_REDIS_URLS",
|
||||
value_name = "URLS",
|
||||
default_values = &["redis://127.0.0.1:6379"],
|
||||
)]
|
||||
redis_urls: Vec<String>,
|
||||
|
||||
/// The bot owner's ID, this user will be allowed to assign moderators.
|
||||
#[arg(
|
||||
long,
|
||||
env = "PAYPHONE_OWNER_ID",
|
||||
value_name = "ID",
|
||||
default_value_t = 0
|
||||
)]
|
||||
bot_owner_id: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "PAYPHONE_TOPGG_TOKEN",
|
||||
value_name = "TOKEN",
|
||||
help = "The token to use for the Top.gg API, used for posting server counts and other stats. If not provided, Top.gg integration will be disabled."
|
||||
)]
|
||||
topgg_token: Option<String>,
|
||||
},
|
||||
|
||||
/// Starts a documentation server for the project which serves `cargo doc` and refreshes on
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_thief::ColorFormat;
|
||||
use image::{GenericImageView, ImageReader};
|
||||
use reqwest::header::USER_AGENT;
|
||||
use rgb::Rgb;
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
|
||||
use crate::constants::VERSION;
|
||||
use crate::database::models::User;
|
||||
use crate::discord::images::get_user_custom_avatar_url_or_none;
|
||||
use crate::state::State;
|
||||
|
||||
pub struct AvatarColorFetchingRequest {
|
||||
pub hash: String,
|
||||
pub user_id: u64,
|
||||
}
|
||||
|
||||
impl AvatarColorFetchingRequest {
|
||||
pub fn new(hash: impl ToString, user_id: u64) -> Self {
|
||||
Self {
|
||||
hash: hash.to_string(),
|
||||
user_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_color_fetching_queue_listener(
|
||||
mut receiver: Receiver<AvatarColorFetchingRequest>,
|
||||
state: Arc<State>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let semaphore = Arc::new(Semaphore::new(5));
|
||||
|
||||
while let Some(request) = receiver.recv().await {
|
||||
let task_semaphore = semaphore.clone();
|
||||
let task_state = state.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = task_semaphore
|
||||
.acquire()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to acquire semaphore permit: {e}"))
|
||||
.expect("Failed to acquire semaphore permit");
|
||||
|
||||
let avatar_url = get_user_custom_avatar_url_or_none(request.user_id, request.hash);
|
||||
|
||||
let image_bytes = reqwest::Client::new()
|
||||
.get(avatar_url)
|
||||
.header(USER_AGENT, format!("Payphone/v{VERSION}"))
|
||||
.send()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to fetch avatar color: {e}"))?
|
||||
.error_for_status()?
|
||||
.bytes()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to read avatar image bytes: {e}"))?;
|
||||
|
||||
let color = tokio::task::spawn_blocking(move || {
|
||||
let img = ImageReader::new(Cursor::new(image_bytes))
|
||||
.with_guessed_format()?
|
||||
.decode()?;
|
||||
|
||||
let mut pixels = Vec::new();
|
||||
|
||||
for (_x, _y, rgba) in img.pixels() {
|
||||
pixels.extend_from_slice(&rgba.0);
|
||||
}
|
||||
|
||||
drop(img);
|
||||
|
||||
let palette = color_thief::get_palette(&pixels, ColorFormat::Rgba, 1, 2)?;
|
||||
|
||||
Ok::<_, anyhow::Error>(palette.first().cloned())
|
||||
})
|
||||
.await??;
|
||||
|
||||
if let Some(color) = color {
|
||||
let Rgb { r, g, b } = color;
|
||||
|
||||
let color_int: u32 = ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
|
||||
|
||||
let mut user = User::get_by_id(request.user_id, &task_state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to fetch user to set their new avatar color: {e}")
|
||||
})?
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"User with ID {} not found when fetching their avatar URL color",
|
||||
request.user_id
|
||||
))
|
||||
.inspect_err(|e| log::error!("{e}"))?;
|
||||
|
||||
user.color = Some(color_int as i32);
|
||||
|
||||
user.insert(&task_state.db).await?;
|
||||
}
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Constants used throughout the Payphone project.
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// The default command prefix used by the bot for new servers.
|
||||
pub const DEFAULT_PREFIX: &str = "p.";
|
||||
|
||||
@@ -8,3 +10,6 @@ pub const COLOR_WHITE: u32 = 0xE2D9D0;
|
||||
|
||||
/// A red color used for error messages and moderator embeds.
|
||||
pub const COLOR_RED: u32 = 0xFF8787;
|
||||
|
||||
/// A yellow color used for mask notifications and warnings.
|
||||
pub const COLOR_YELLOW: u32 = 0xFFC868;
|
||||
|
||||
@@ -60,12 +60,11 @@ CREATE TABLE IF NOT EXISTS payphone.participants (
|
||||
PRIMARY KEY (call_id, user_id, channel_id)
|
||||
);
|
||||
|
||||
-- TTL 30 days.
|
||||
CREATE TABLE IF NOT EXISTS payphone.messages (
|
||||
CREATE TYPE IF NOT EXISTS payphone.message (
|
||||
id BIGINT,
|
||||
call_id BIGINT,
|
||||
message_id BIGINT,
|
||||
author_id BIGINT,
|
||||
|
||||
author_id BIGINT,
|
||||
author_name TEXT,
|
||||
author_avatar_url TEXT,
|
||||
author_mask ASCII,
|
||||
@@ -77,39 +76,44 @@ CREATE TABLE IF NOT EXISTS payphone.messages (
|
||||
propagation_message_id BIGINT,
|
||||
propagation_channel_id BIGINT,
|
||||
|
||||
replying_to_id BIGINT,
|
||||
replying_to_message_id BIGINT,
|
||||
|
||||
is_deleted BOOLEAN,
|
||||
is_edited BOOLEAN,
|
||||
is_censored BOOLEAN,
|
||||
is_censored BOOLEAN
|
||||
);
|
||||
|
||||
-- TTL 30 days.
|
||||
CREATE TABLE IF NOT EXISTS payphone.messages (
|
||||
call_id BIGINT,
|
||||
message_id BIGINT,
|
||||
author_id BIGINT,
|
||||
propagation_message_id BIGINT,
|
||||
|
||||
data FROZEN<payphone.message>,
|
||||
|
||||
PRIMARY KEY (call_id, message_id)
|
||||
);
|
||||
) WITH default_time_to_live = 2592000; -- 30 days
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS payphone.messages_by_propagation_id AS
|
||||
SELECT call_id, message_id, propagation_message_id FROM payphone.messages
|
||||
WHERE call_id IS NOT NULL AND message_id IS NOT NULL AND propagation_message_id IS NOT NULL
|
||||
PRIMARY KEY (call_id, propagation_message_id, message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.servers (
|
||||
server_id BIGINT PRIMARY KEY,
|
||||
|
||||
name TEXT,
|
||||
prefix TEXT,
|
||||
language TEXT,
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.reports (
|
||||
report_id BIGINT,
|
||||
user_id BIGINT,
|
||||
|
||||
message_author_id BIGINT,
|
||||
message_author_name TEXT,
|
||||
message_author_avatar_url TEXT,
|
||||
message_author_mask ASCII,
|
||||
|
||||
message_id BIGINT,
|
||||
message_content TEXT,
|
||||
message_attachments LIST<TEXT>,
|
||||
message_sticker BIGINT,
|
||||
|
||||
message_replying_to_id BIGINT,
|
||||
message_replying_to_content TEXT,
|
||||
message_replying_to_attachments LIST<TEXT>,
|
||||
message_replying_to_sticker BIGINT,
|
||||
message FROZEN<payphone.message>,
|
||||
replying_to FROZEN<payphone.message>,
|
||||
|
||||
created_at TIMESTAMP,
|
||||
reviewed_at TIMESTAMP,
|
||||
@@ -119,28 +123,18 @@ CREATE TABLE IF NOT EXISTS payphone.reports (
|
||||
reviewed_by_id BIGINT,
|
||||
resolution ASCII,
|
||||
|
||||
PRIMARY KEY (message_author_id, report_id)
|
||||
PRIMARY KEY (user_id, report_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.user_incidents (
|
||||
user_id BIGINT,
|
||||
incident_id BIGINT,
|
||||
report_id BIGINT,
|
||||
|
||||
-- ban, warning
|
||||
type ASCII,
|
||||
|
||||
call_id BIGINT,
|
||||
report_id BIGINT,
|
||||
|
||||
message_id BIGINT,
|
||||
message_content TEXT,
|
||||
message_attachments LIST<TEXT>,
|
||||
message_sticker BIGINT,
|
||||
|
||||
message_replying_to_id BIGINT,
|
||||
message_replying_to_content TEXT,
|
||||
message_replying_to_attachments LIST<TEXT>,
|
||||
message_replying_to_sticker BIGINT,
|
||||
message FROZEN<payphone.message>,
|
||||
|
||||
reason TEXT,
|
||||
|
||||
@@ -150,7 +144,7 @@ CREATE TABLE IF NOT EXISTS payphone.user_incidents (
|
||||
expires_at TIMESTAMP,
|
||||
duration ASCII,
|
||||
|
||||
PRIMARY KEY (user_id)
|
||||
PRIMARY KEY (user_id, incident_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payphone.server_incidents (
|
||||
@@ -164,4 +158,4 @@ CREATE TABLE IF NOT EXISTS payphone.server_incidents (
|
||||
created_at TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
duration ASCII
|
||||
);
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE payphone.users ADD is_banned_from_leaderboard BOOLEAN;
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS payphone.users_by_name AS
|
||||
SELECT user_id, name FROM payphone.users
|
||||
WHERE user_id IS NOT NULL AND name IS NOT NULL
|
||||
PRIMARY KEY (name, user_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE payphone.participants ADD is_masked BOOLEAN;
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS payphone.participants_by_user_id AS
|
||||
SELECT user_id, call_id, channel_id FROM payphone.participants
|
||||
WHERE user_id IS NOT NULL AND call_id IS NOT NULL AND channel_id IS NOT NULL
|
||||
PRIMARY KEY (user_id, call_id, channel_id);
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS payphone.messages_by_author_id AS
|
||||
SELECT call_id, message_id, author_id FROM payphone.messages
|
||||
WHERE call_id IS NOT NULL AND message_id IS NOT NULL AND author_id IS NOT NULL
|
||||
PRIMARY KEY (call_id, author_id, message_id);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE payphone.users ADD findings SET<TEXT>;
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE payphone.users ADD avatar_hash TEXT;
|
||||
ALTER TABLE payphone.users ADD color INT;
|
||||
ALTER TABLE payphone.users ADD is_color_set BOOLEAN;
|
||||
|
||||
CREATE MATERIALIZED VIEW payphone.users_with_no_color AS
|
||||
SELECT user_id, avatar_hash, is_color_set FROM payphone.users
|
||||
WHERE user_id IS NOT NULL AND is_color_set IS NOT NULL
|
||||
PRIMARY KEY (is_color_set, user_id);
|
||||
+24
-11
@@ -1,10 +1,9 @@
|
||||
pub mod models;
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
use scylla::{
|
||||
client::{session::Session, session_builder::SessionBuilder},
|
||||
errors::ExecutionError,
|
||||
};
|
||||
use scylla::client::session::Session;
|
||||
use scylla::client::session_builder::SessionBuilder;
|
||||
use scylla::errors::ExecutionError;
|
||||
use thiserror::Error;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
@@ -22,13 +21,27 @@ pub enum DatabaseError {
|
||||
|
||||
static MIGRATIONS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/database/migrations");
|
||||
|
||||
pub async fn init(uri: impl ToString) -> Result<Session, DatabaseError> {
|
||||
let session = SessionBuilder::new()
|
||||
.known_node(uri.to_string())
|
||||
.build()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not connect to the database! {e:?}"))
|
||||
.map_err(|_| DatabaseError::ConnectionFailed)?;
|
||||
pub async fn init(
|
||||
uri: impl ToString,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
) -> Result<Session, DatabaseError> {
|
||||
let session = if let Some((username, password)) = username.zip(password) {
|
||||
SessionBuilder::new()
|
||||
.known_node(uri.to_string())
|
||||
.user(username, password)
|
||||
.build()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not connect to the database! {e:?}"))
|
||||
.map_err(|_| DatabaseError::ConnectionFailed)?
|
||||
} else {
|
||||
SessionBuilder::new()
|
||||
.known_node(uri.to_string())
|
||||
.build()
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not connect to the database! {e:?}"))
|
||||
.map_err(|_| DatabaseError::ConnectionFailed)?
|
||||
};
|
||||
|
||||
execute_file(
|
||||
&session,
|
||||
|
||||
+1293
-135
File diff suppressed because it is too large
Load Diff
+36
-2
@@ -1,11 +1,35 @@
|
||||
//! Utilities for handling Discord's image hashes.
|
||||
|
||||
use twilight_model::util::ImageHash;
|
||||
|
||||
pub fn get_avatar_url(hash: Option<ImageHash>, user_id: u64, discriminator: u64) -> String {
|
||||
/// Returns the URL of a user's avatar.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `hash` - The user's avatar hash (if available).
|
||||
/// * `user_id` - The user's ID.
|
||||
/// * `discriminator` - The user's discriminator.
|
||||
/// * `guild_id` - The guild ID, **only set if the user has Nitro and a custom avatar for the
|
||||
/// server. Otherwise, even in a server, this is NOT SET**.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`String`] - The URL of the user's avatar.
|
||||
pub fn get_avatar_url(
|
||||
hash: Option<ImageHash>,
|
||||
user_id: u64,
|
||||
discriminator: u64,
|
||||
guild_id: Option<u64>,
|
||||
) -> String {
|
||||
if let Some(hash) = hash {
|
||||
if let Some(guild_id) = guild_id {
|
||||
return format!(
|
||||
"https://cdn.discordapp.com/guilds/{guild_id}/users/{user_id}/avatars/{hash}.webp?size=256"
|
||||
);
|
||||
}
|
||||
|
||||
format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.webp?size=256")
|
||||
} else {
|
||||
format!(
|
||||
"https://cdn.discordapp.com/embed/avatars/{}.png?size=256",
|
||||
"https://cdn.discordapp.com/embed/avatars/{}.webp?size=256",
|
||||
match discriminator.to_string().as_str() {
|
||||
"0" => (user_id >> 22) % 6,
|
||||
_ => discriminator % 6,
|
||||
@@ -13,3 +37,13 @@ pub fn get_avatar_url(hash: Option<ImageHash>, user_id: u64, discriminator: u64)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the user's avatar URL only if it's custom (i.e. not the default avatar). It doesn't
|
||||
/// take into account custom per-server avatars, i.e. only the global avatar is returned if any.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `user_id` - The user's ID.
|
||||
/// * `hash` - The hex-encoded hash string, if any.
|
||||
pub fn get_user_custom_avatar_url_or_none(user_id: u64, hash: String) -> String {
|
||||
format!("https://cdn.discordapp.com/avatars/{user_id}/{hash}.webp?size=256")
|
||||
}
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
//! A set of utilities to interact with Discord not included in the main framework.
|
||||
//!
|
||||
//! It currently includes a markdown escaper and a utility to get image URLs from Discord's image
|
||||
//! hashes.
|
||||
|
||||
pub mod images;
|
||||
pub mod sanitizing;
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
//! Utilities for sanitizing Discord messages (removing markdown from user input).
|
||||
|
||||
/// Escapes Discord markdown in a string.
|
||||
pub trait SanitizeDiscordMarkdown {
|
||||
/// Escapes Discord markdown in a string.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `hard` - If `false`, it'll only escape mentions and pings. Otherwise, it'll also escape
|
||||
/// generic markdown like headers and code blocks.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`String`] - The sanitized string.
|
||||
fn sanitize(&self, hard: bool) -> String;
|
||||
|
||||
/// Sanitizes mentions in a string.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`String`] - The sanitized string.
|
||||
fn sanitize_mentions(&self) -> String;
|
||||
}
|
||||
|
||||
impl<T> SanitizeDiscordMarkdown for T
|
||||
@@ -14,7 +31,8 @@ where
|
||||
|
||||
for ch in input.chars() {
|
||||
match ch {
|
||||
'\\' | '*' | '_' | '~' | '`' | '>' | '<' | '|' | '#' => {
|
||||
'\\' | '*' | '_' | '~' | '`' | '>' | '<' | '|' | '#' | '[' | ']' | '/'
|
||||
| '(' | ')' => {
|
||||
escaped.push('\\');
|
||||
escaped.push(ch);
|
||||
}
|
||||
@@ -28,7 +46,13 @@ where
|
||||
};
|
||||
|
||||
// Now block ping attempts — break mentions
|
||||
escaped
|
||||
escaped.sanitize_mentions()
|
||||
}
|
||||
|
||||
fn sanitize_mentions(&self) -> String {
|
||||
let input = self.to_string();
|
||||
|
||||
input
|
||||
.replace("@everyone", "@\u{200B}everyone")
|
||||
.replace("@here", "@\u{200B}here")
|
||||
.replace("<@", "<@\u{200B}") // user mentions
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
use crate::database::models::User;
|
||||
|
||||
pub struct Extras {
|
||||
pub user: User,
|
||||
}
|
||||
+344
-172
@@ -1,192 +1,251 @@
|
||||
use std::{fmt::Debug, sync::Arc};
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
use twilight_gateway::{Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt};
|
||||
use topgg::Stats;
|
||||
use twilight_cache_inmemory::{DefaultInMemoryCache, ResourceType};
|
||||
use twilight_gateway::{ConfigBuilder, Event, EventTypeFlags, Intents, Shard, ShardId, StreamExt};
|
||||
use twilight_http::Client;
|
||||
use twilight_model::gateway::payload::outgoing::update_presence::UpdatePresencePayload;
|
||||
|
||||
use crate::{
|
||||
framework::{
|
||||
commands::{Command, runner::CommandRunner},
|
||||
events::traits::{
|
||||
OnInteractionHandler, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler,
|
||||
OnReadyHandler, OnReconnectHandler, OnServerCreateHandler, OnServerDeleteHandler,
|
||||
OnServerUpdateHandler,
|
||||
},
|
||||
interactions::{components::Component, registry::ComponentRegistry},
|
||||
runtime::Runtime,
|
||||
},
|
||||
utils::Bye,
|
||||
use crate::framework::commands::Command;
|
||||
use crate::framework::commands::extras::ExtrasGetter;
|
||||
use crate::framework::commands::runner::CommandRunner;
|
||||
use crate::framework::events::traits::{
|
||||
OnInteractionHandler, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler,
|
||||
OnReadyHandler, OnReconnectHandler, OnServerCreateHandler, OnServerDeleteHandler,
|
||||
OnServerUpdateHandler,
|
||||
};
|
||||
use crate::framework::interactions::components::Component;
|
||||
use crate::framework::interactions::registry::ComponentRegistry;
|
||||
use crate::framework::interactions::runner::InteractionRunner;
|
||||
use crate::framework::runtime::Runtime;
|
||||
use crate::framework::tasks::Task;
|
||||
use crate::utils::Bye;
|
||||
|
||||
/// Errors occurred while attempting to run the bot.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RuntimeError {
|
||||
#[error("The application's details could not be fetched")]
|
||||
ApplicationFetchingFailed,
|
||||
}
|
||||
|
||||
/// Getter trait for command prefixes.
|
||||
#[async_trait]
|
||||
pub trait IntoPrefixes<T> {
|
||||
pub trait PrefixGetter<T, E> {
|
||||
/// Get the command prefixes for the given server and user IDs.
|
||||
///
|
||||
/// The getter must never fail. In case an internal process does, it must fall back to default
|
||||
/// prefixes.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `runtime` - The application [`Runtime`].
|
||||
/// * `server_id` - The server ID in which the command, interaction, or event is running.
|
||||
/// * `user_id` - The user ID who triggered the command, interaction, or event.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Vec<String>`] - A list of command prefixes. When matching for commands, all these
|
||||
/// prefixes should be checked against, as they're all valid. In case one should be picked, the
|
||||
/// first one will always be the safest choice.
|
||||
async fn get(
|
||||
&self,
|
||||
runtime: &Runtime<T>,
|
||||
runtime: &Runtime<T, E>,
|
||||
server_id: Option<u64>,
|
||||
user_id: Option<u64>,
|
||||
) -> Vec<String>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T> IntoPrefixes<T> for String {
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<T>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
impl<T, E> PrefixGetter<T, E> for String {
|
||||
async fn get(&self, _: &Runtime<T, E>, _: Option<u64>, _: Option<u64>) -> Vec<String> {
|
||||
vec![self.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T> IntoPrefixes<T> for &str {
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<T>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
impl<T, E> PrefixGetter<T, E> for &str {
|
||||
async fn get(&self, _: &Runtime<T, E>, _: Option<u64>, _: Option<u64>) -> Vec<String> {
|
||||
vec![self.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Send + Sync + 'static, const N: usize, S: Send + Sync + 'static> IntoPrefixes<S> for [T; N]
|
||||
impl<T, const N: usize, S, E> PrefixGetter<S, E> for [T; N]
|
||||
where
|
||||
T: ToString,
|
||||
T: ToString + Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<S>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
async fn get(&self, _: &Runtime<S, E>, _: Option<u64>, _: Option<u64>) -> Vec<String> {
|
||||
self.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<F, Fut, T> IntoPrefixes<T> for F
|
||||
impl<F, Fut, T, E> PrefixGetter<T, E> for F
|
||||
where
|
||||
F: Fn() -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Vec<String>> + Send,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
async fn get(
|
||||
&self,
|
||||
_: &Runtime<T>,
|
||||
_: Option<u64>,
|
||||
_: Option<u64>,
|
||||
) -> Vec<String> {
|
||||
async fn get(&self, _: &Runtime<T, E>, _: Option<u64>, _: Option<u64>) -> Vec<String> {
|
||||
(self)().await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Bot<T: Send + Sync = ()> {
|
||||
state: Arc<T>,
|
||||
prefixes: Arc<dyn IntoPrefixes<T> + Send + Sync>,
|
||||
pub struct Bot<S: Send + Sync, E: Send + Sync = ()> {
|
||||
shard: ShardId,
|
||||
state: Arc<S>,
|
||||
presence: Option<UpdatePresencePayload>,
|
||||
prefixes: Arc<dyn PrefixGetter<S, E> + Send + Sync>,
|
||||
intents: Intents,
|
||||
commands: Vec<Arc<dyn Command<T>>>,
|
||||
components: Vec<Arc<dyn Component<T>>>,
|
||||
on_ready_handlers: Vec<Arc<dyn OnReadyHandler<T>>>,
|
||||
on_reconnect_handlers: Vec<Arc<dyn OnReconnectHandler<T>>>,
|
||||
on_interaction_handlers: Vec<Arc<dyn OnInteractionHandler<T>>>,
|
||||
on_message_handlers: Vec<Arc<dyn OnMessageHandler<T>>>,
|
||||
on_message_edit_handlers: Vec<Arc<dyn OnMessageEditHandler<T>>>,
|
||||
on_message_delete_handlers: Vec<Arc<dyn OnMessageDeleteHandler<T>>>,
|
||||
on_server_create_handlers: Vec<Arc<dyn OnServerCreateHandler<T>>>,
|
||||
on_server_update_handlers: Vec<Arc<dyn OnServerUpdateHandler<T>>>,
|
||||
on_server_delete_handlers: Vec<Arc<dyn OnServerDeleteHandler<T>>>,
|
||||
extras: Arc<dyn ExtrasGetter<S, Extras = E>>,
|
||||
cached_resource_types: Vec<ResourceType>,
|
||||
commands: Vec<Arc<dyn Command<S, E>>>,
|
||||
components: Vec<Arc<dyn Component<S, E>>>,
|
||||
tasks: Vec<Arc<dyn Task<S, E>>>,
|
||||
on_ready_handlers: Vec<Arc<dyn OnReadyHandler<S, E>>>,
|
||||
on_reconnect_handlers: Vec<Arc<dyn OnReconnectHandler<S, E>>>,
|
||||
on_interaction_handlers: Vec<Arc<dyn OnInteractionHandler<S, E>>>,
|
||||
on_message_handlers: Vec<Arc<dyn OnMessageHandler<S, E>>>,
|
||||
on_message_edit_handlers: Vec<Arc<dyn OnMessageEditHandler<S, E>>>,
|
||||
on_message_delete_handlers: Vec<Arc<dyn OnMessageDeleteHandler<S, E>>>,
|
||||
on_server_create_handlers: Vec<Arc<dyn OnServerCreateHandler<S, E>>>,
|
||||
on_server_update_handlers: Vec<Arc<dyn OnServerUpdateHandler<S, E>>>,
|
||||
on_server_delete_handlers: Vec<Arc<dyn OnServerDeleteHandler<S, E>>>,
|
||||
topgg_token: Option<String>,
|
||||
}
|
||||
|
||||
impl<T: 'static + Send + Sync> Bot<T> {
|
||||
pub fn new(state: T, prefixes: impl IntoPrefixes<T> + Send + Sync + 'static) -> Self {
|
||||
impl<T, E> Bot<T, E>
|
||||
where
|
||||
T: 'static + Send + Sync,
|
||||
E: 'static + Send + Sync,
|
||||
{
|
||||
pub fn new(
|
||||
state: Arc<T>,
|
||||
prefixes: impl PrefixGetter<T, E> + Send + Sync + 'static,
|
||||
extras: impl ExtrasGetter<T, Extras = E> + 'static,
|
||||
) -> Self {
|
||||
Self {
|
||||
state: Arc::new(state),
|
||||
shard: ShardId::new(0, 1),
|
||||
state,
|
||||
presence: None,
|
||||
prefixes: Arc::new(prefixes),
|
||||
intents: Intents::empty(),
|
||||
extras: Arc::new(extras),
|
||||
cached_resource_types: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
components: Vec::new(),
|
||||
tasks: Vec::new(),
|
||||
on_ready_handlers: Vec::new(),
|
||||
on_reconnect_handlers: Vec::new(),
|
||||
on_interaction_handlers: Vec::new(),
|
||||
on_interaction_handlers: vec![Arc::new(InteractionRunner)],
|
||||
on_message_handlers: vec![Arc::new(CommandRunner)],
|
||||
on_message_edit_handlers: Vec::new(),
|
||||
on_message_delete_handlers: Vec::new(),
|
||||
on_server_create_handlers: Vec::new(),
|
||||
on_server_update_handlers: Vec::new(),
|
||||
on_server_delete_handlers: Vec::new(),
|
||||
topgg_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shard(mut self, shard: ShardId) -> Self {
|
||||
self.shard = shard;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn intent(mut self, intent: Intents) -> Self {
|
||||
self.intents.insert(intent);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn command(mut self, command: impl Command<T> + 'static) -> Self {
|
||||
pub fn presence(mut self, presence: UpdatePresencePayload) -> Self {
|
||||
self.presence = Some(presence);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn command(mut self, command: impl Command<T, E> + 'static) -> Self {
|
||||
self.commands.push(Arc::new(command));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component<T> + 'static) -> Self {
|
||||
pub fn component(mut self, component: impl Component<T, E> + 'static) -> Self {
|
||||
self.components.push(Arc::new(component));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_ready(mut self, handler: impl OnReadyHandler<T> + 'static) -> Self {
|
||||
#[allow(dead_code)]
|
||||
pub fn task(mut self, task: impl Task<T, E> + 'static) -> Self {
|
||||
self.tasks.push(Arc::new(task));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cache_resource_type(mut self, resource_type: ResourceType) -> Self {
|
||||
if !self.cached_resource_types.contains(&resource_type) {
|
||||
self.cached_resource_types.push(resource_type);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_ready(mut self, handler: impl OnReadyHandler<T, E> + 'static) -> Self {
|
||||
self.on_ready_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_reconnect(mut self, handler: impl OnReconnectHandler<T> + 'static) -> Self {
|
||||
pub fn on_reconnect(mut self, handler: impl OnReconnectHandler<T, E> + 'static) -> Self {
|
||||
self.on_reconnect_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_interaction(mut self, handler: impl OnInteractionHandler<T> + 'static) -> Self {
|
||||
#[allow(dead_code)]
|
||||
pub fn on_interaction(mut self, handler: impl OnInteractionHandler<T, E> + 'static) -> Self {
|
||||
self.on_interaction_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_message(mut self, handler: impl OnMessageHandler<T> + 'static) -> Self {
|
||||
pub fn on_message(mut self, handler: impl OnMessageHandler<T, E> + 'static) -> Self {
|
||||
self.on_message_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_message_edit(mut self, handler: impl OnMessageEditHandler<T> + 'static) -> Self {
|
||||
#[allow(dead_code)]
|
||||
pub fn on_message_edit(mut self, handler: impl OnMessageEditHandler<T, E> + 'static) -> Self {
|
||||
self.on_message_edit_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_message_delete(mut self, handler: impl OnMessageDeleteHandler<T> + 'static) -> Self {
|
||||
#[allow(dead_code)]
|
||||
pub fn on_message_delete(
|
||||
mut self,
|
||||
handler: impl OnMessageDeleteHandler<T, E> + 'static,
|
||||
) -> Self {
|
||||
self.on_message_delete_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_server_create(mut self, handler: impl OnServerCreateHandler<T> + 'static) -> Self {
|
||||
pub fn on_server_create(mut self, handler: impl OnServerCreateHandler<T, E> + 'static) -> Self {
|
||||
self.on_server_create_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_server_update(mut self, handler: impl OnServerUpdateHandler<T> + 'static) -> Self {
|
||||
pub fn on_server_update(mut self, handler: impl OnServerUpdateHandler<T, E> + 'static) -> Self {
|
||||
self.on_server_update_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_server_delete(mut self, handler: impl OnServerDeleteHandler<T> + 'static) -> Self {
|
||||
pub fn on_server_delete(mut self, handler: impl OnServerDeleteHandler<T, E> + 'static) -> Self {
|
||||
self.on_server_delete_handlers.push(Arc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn topgg_token(mut self, token: impl ToString) -> Self {
|
||||
self.topgg_token = Some(token.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn run(self, token: &str) -> Result<(), RuntimeError> {
|
||||
let http = Client::new(token.to_string());
|
||||
let app = http
|
||||
@@ -198,6 +257,60 @@ impl<T: 'static + Send + Sync> Bot<T> {
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Could not parse application data! {e:?}"))
|
||||
.map_err(|_| RuntimeError::ApplicationFetchingFailed)?;
|
||||
|
||||
if let Some(topgg_token) = &self.topgg_token
|
||||
&& self.shard.number() == 0
|
||||
{
|
||||
let topgg_token = topgg_token.clone();
|
||||
let http = Client::new(token.to_string());
|
||||
tokio::spawn(async move {
|
||||
let topgg_client = topgg::Client::new(topgg_token);
|
||||
|
||||
loop {
|
||||
let app = match http
|
||||
.current_user_application()
|
||||
.await
|
||||
.map_err(|_| RuntimeError::ApplicationFetchingFailed)
|
||||
{
|
||||
Ok(v) => {
|
||||
match v
|
||||
.model()
|
||||
.await
|
||||
.map_err(|_| RuntimeError::ApplicationFetchingFailed)
|
||||
{
|
||||
Ok(app) => app,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Could not parse application data, skipping stats post to Top.gg! {err:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Could not fetch application data, skipping stats post to Top.gg! {err:?}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
topgg_client
|
||||
.post_stats(Stats::from_count(
|
||||
app.approximate_guild_count.unwrap() as usize,
|
||||
Some(self.shard.total() as usize),
|
||||
))
|
||||
.await
|
||||
.inspect_err(|err| {
|
||||
log::warn!("Could not report bot stats to Top.gg: {err}")
|
||||
})
|
||||
.ok();
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(120)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let registry = ComponentRegistry::new();
|
||||
|
||||
for component in &self.components {
|
||||
@@ -211,6 +324,18 @@ impl<T: 'static + Send + Sync> Bot<T> {
|
||||
|
||||
let bot = Arc::new(self);
|
||||
|
||||
let mut resource_types = ResourceType::empty();
|
||||
|
||||
for resource_type in &bot.cached_resource_types {
|
||||
resource_types.insert(*resource_type);
|
||||
}
|
||||
|
||||
let cache = DefaultInMemoryCache::builder()
|
||||
.resource_types(resource_types)
|
||||
.build();
|
||||
|
||||
let (gateway_tx, mut gateway_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
let runtime = Arc::new(Runtime {
|
||||
http,
|
||||
app,
|
||||
@@ -218,112 +343,159 @@ impl<T: 'static + Send + Sync> Bot<T> {
|
||||
state: bot.state.clone(),
|
||||
prefixes: bot.prefixes.clone(),
|
||||
commands: bot.commands.clone(),
|
||||
cache,
|
||||
gateway_tx,
|
||||
shard_number: bot.shard.number(),
|
||||
shard_total: bot.shard.total(),
|
||||
});
|
||||
|
||||
let mut gateway = Shard::new(ShardId::new(0, 1), token.to_string(), bot.intents);
|
||||
for task in bot.tasks.clone() {
|
||||
let task_rt = runtime.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
task.run(&task_rt)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("An error occurred while running task {task:?}! {e:?}")
|
||||
})
|
||||
.bye();
|
||||
|
||||
tokio::time::sleep(task.delay()).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut config = ConfigBuilder::new(token.to_string(), bot.intents);
|
||||
|
||||
if let Some(presence) = &bot.presence {
|
||||
config = config.presence(presence.clone());
|
||||
}
|
||||
|
||||
let config = config.build();
|
||||
|
||||
let mut gateway = Shard::with_config(bot.shard, config);
|
||||
|
||||
loop {
|
||||
if let Some(Ok(event)) = gateway.next_event(EventTypeFlags::all()).await {
|
||||
let task_rt = runtime.clone();
|
||||
let task_bot = bot.clone();
|
||||
tokio::select! {
|
||||
Some(Ok(event)) = gateway.next_event(EventTypeFlags::all()) => {
|
||||
let task_rt = runtime.clone();
|
||||
let task_bot = bot.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
match event {
|
||||
Event::Ready(event) => {
|
||||
for handler in &task_bot.on_ready_handlers {
|
||||
handler
|
||||
.on_ready(&event, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on ready for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GatewayReconnect => {
|
||||
for handler in &task_bot.on_reconnect_handlers {
|
||||
handler
|
||||
.on_reconnect(task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on reconnect for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::InteractionCreate(interaction) => {
|
||||
for handler in &task_bot.on_interaction_handlers {
|
||||
handler
|
||||
.on_interaction(&interaction, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on interaction for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageCreate(message) => {
|
||||
for handler in &task_bot.on_message_handlers {
|
||||
log::trace!("Running {handler:?}");
|
||||
tokio::spawn(async move {
|
||||
task_rt.cache.update(&event);
|
||||
|
||||
handler
|
||||
.on_message(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
match event {
|
||||
Event::Ready(event) => {
|
||||
for handler in &task_bot.on_ready_handlers {
|
||||
handler
|
||||
.on_ready(&event, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on ready for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GatewayReconnect => {
|
||||
for handler in &task_bot.on_reconnect_handlers {
|
||||
handler
|
||||
.on_reconnect(task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on reconnect for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::InteractionCreate(interaction) => {
|
||||
for handler in &task_bot.on_interaction_handlers {
|
||||
handler
|
||||
.on_interaction(&interaction, task_rt.clone(), task_bot.extras.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on interaction for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageCreate(message) => {
|
||||
for handler in &task_bot.on_message_handlers {
|
||||
log::trace!("Running {handler:?}");
|
||||
|
||||
handler
|
||||
.on_message(&message, task_rt.clone(), task_bot.extras.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageUpdate(message) => {
|
||||
for handler in &task_bot.on_message_edit_handlers {
|
||||
handler
|
||||
.on_message_edit(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message edit for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageDelete(message) => {
|
||||
for handler in &task_bot.on_message_delete_handlers {
|
||||
handler
|
||||
.on_message_delete(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message delete for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GuildCreate(guild) => {
|
||||
for handler in &task_bot.on_server_create_handlers {
|
||||
handler
|
||||
.on_server_create(&guild, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on server create for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GuildUpdate(guild) => {
|
||||
for handler in &task_bot.on_server_update_handlers {
|
||||
handler
|
||||
.on_server_update(&guild, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on server update for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GuildDelete(guild) => {
|
||||
for handler in &task_bot.on_server_delete_handlers {
|
||||
handler
|
||||
.on_server_delete(&guild, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on server delete for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::trace!("Unhandled event {:?} received", event.kind());
|
||||
}
|
||||
}
|
||||
Event::MessageUpdate(message) => {
|
||||
for handler in &task_bot.on_message_edit_handlers {
|
||||
handler
|
||||
.on_message_edit(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message edit for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::MessageDelete(message) => {
|
||||
for handler in &task_bot.on_message_delete_handlers {
|
||||
handler
|
||||
.on_message_delete(&message, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on message delete for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GuildCreate(guild) => {
|
||||
for handler in &task_bot.on_server_create_handlers {
|
||||
handler
|
||||
.on_server_create(&guild, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on server create for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
Event::GuildDelete(guild) => {
|
||||
for handler in &task_bot.on_server_delete_handlers {
|
||||
handler
|
||||
.on_server_delete(&guild, task_rt.clone())
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| log::error!("An error occurred while handling on server delete for {handler:?}! {e:?}")
|
||||
)
|
||||
.bye();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::trace!("Unhandled event {:?} received", event.kind());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Some(json) = gateway_rx.recv() => {
|
||||
gateway.command(&json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use std::{
|
||||
fmt,
|
||||
ops::{Deref, DerefMut},
|
||||
};
|
||||
use std::fmt;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::database::models::DatabaseError;
|
||||
use crate::framework::commands::context::CommandContext;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ArgumentParsingError {
|
||||
#[error("This argument was missing in the arguments array")]
|
||||
@@ -12,14 +13,23 @@ pub enum ArgumentParsingError {
|
||||
|
||||
#[error("An argument was there but it could not be parsed into the specified value")]
|
||||
InvalidArgument,
|
||||
|
||||
#[error("A database operation failed")]
|
||||
DatabaseFailed(#[from] DatabaseError),
|
||||
}
|
||||
|
||||
pub trait FromArguments: Sized {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError>;
|
||||
pub trait FromArguments<S, E>: Sized {
|
||||
async fn from_args<'a>(
|
||||
args: &'a str,
|
||||
ctx: &'a CommandContext<'_, S, E>,
|
||||
) -> Result<(Self, &'a str), ArgumentParsingError>;
|
||||
}
|
||||
|
||||
impl FromArguments for String {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
impl<S, E> FromArguments<S, E> for String {
|
||||
async fn from_args<'a>(
|
||||
args: &'a str,
|
||||
_ctx: &'a CommandContext<'_, S, E>,
|
||||
) -> Result<(Self, &'a str), ArgumentParsingError> {
|
||||
if !args.is_empty() {
|
||||
match args.split_once(" ") {
|
||||
Some((arg, rest)) => Ok((arg.to_string(), rest)),
|
||||
@@ -31,10 +41,17 @@ impl FromArguments for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes the entire remaining string as a single argument. If no string is remaining, it
|
||||
/// returns an empty string.
|
||||
///
|
||||
/// Parsing this argument type never fails.
|
||||
pub struct GreedyString(pub String);
|
||||
|
||||
impl FromArguments for GreedyString {
|
||||
fn from_args<'a>(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
impl<S, E> FromArguments<S, E> for GreedyString {
|
||||
async fn from_args<'a>(
|
||||
args: &'a str,
|
||||
_ctx: &'a CommandContext<'_, S, E>,
|
||||
) -> Result<(Self, &'a str), ArgumentParsingError> {
|
||||
let value = GreedyString(args.to_string());
|
||||
|
||||
Ok((value, ""))
|
||||
@@ -83,9 +100,12 @@ impl fmt::Display for NeedyString {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromArguments for NeedyString {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
if !args.is_empty() {
|
||||
impl<S, E> FromArguments<S, E> for NeedyString {
|
||||
async fn from_args<'a>(
|
||||
args: &'a str,
|
||||
_ctx: &'a CommandContext<'_, S, E>,
|
||||
) -> Result<(Self, &'a str), ArgumentParsingError> {
|
||||
if !args.trim().is_empty() {
|
||||
match args.split_once(" ") {
|
||||
Some((arg, rest)) => Ok((Self(arg.to_string()), rest)),
|
||||
None => Ok((Self(args.to_string()), "")),
|
||||
@@ -118,18 +138,24 @@ impl fmt::Display for NeedyGreedyString {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromArguments for NeedyGreedyString {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
if !args.is_empty() {
|
||||
Ok((Self(args.to_string()), ""))
|
||||
impl<S, E> FromArguments<S, E> for NeedyGreedyString {
|
||||
async fn from_args<'a>(
|
||||
args: &'a str,
|
||||
_ctx: &'a CommandContext<'_, S, E>,
|
||||
) -> Result<(Self, &'a str), ArgumentParsingError> {
|
||||
if !args.trim().is_empty() {
|
||||
Ok((Self(args.trim().to_string()), ""))
|
||||
} else {
|
||||
Err(ArgumentParsingError::MissingArgument)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromArguments for bool {
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
impl<S, E> FromArguments<S, E> for bool {
|
||||
async fn from_args<'a>(
|
||||
args: &'a str,
|
||||
_ctx: &'a CommandContext<'_, S, E>,
|
||||
) -> Result<(Self, &'a str), ArgumentParsingError> {
|
||||
let arg: &str;
|
||||
let rest: &str;
|
||||
|
||||
@@ -144,7 +170,7 @@ impl FromArguments for bool {
|
||||
}
|
||||
}
|
||||
|
||||
match arg {
|
||||
match arg.to_lowercase().as_str() {
|
||||
"y" | "yes" | "on" | "true" | "t" | "1" => Ok((true, rest)),
|
||||
"n" | "no" | "off" | "false" | "f" | "0" => Ok((false, rest)),
|
||||
_ => Err(ArgumentParsingError::InvalidArgument),
|
||||
@@ -152,73 +178,98 @@ impl FromArguments for bool {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> FromArguments for Option<T>
|
||||
impl<T, S, E> FromArguments<S, E> for Option<T>
|
||||
where
|
||||
T: FromArguments,
|
||||
T: FromArguments<S, E>,
|
||||
{
|
||||
fn from_args(args: &str) -> Result<(Self, &str), ArgumentParsingError> {
|
||||
match T::from_args(args) {
|
||||
async fn from_args<'a>(
|
||||
args: &'a str,
|
||||
_ctx: &'a CommandContext<'_, S, E>,
|
||||
) -> Result<(Self, &'a str), ArgumentParsingError> {
|
||||
match T::from_args(args, _ctx).await {
|
||||
Ok((result, rest)) => Ok((Some(result), rest)),
|
||||
Err(_) => Ok((None, args)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ParseArguments: Sized {
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError>;
|
||||
}
|
||||
pub struct Arg<T>(pub T);
|
||||
|
||||
impl ParseArguments for () {
|
||||
fn parse(_: &str) -> Result<Self, ArgumentParsingError> {
|
||||
Ok(())
|
||||
impl<T> Deref for Arg<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ParseArguments for A
|
||||
where
|
||||
A: FromArguments,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, _) = A::from_args(args)?;
|
||||
|
||||
Ok(a)
|
||||
impl<T> DerefMut for Arg<T> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> ParseArguments for (A,)
|
||||
pub trait ParseArguments<S, E>: Sized {
|
||||
async fn parse(args: &str, ctx: &CommandContext<S, E>) -> Result<Self, ArgumentParsingError>;
|
||||
}
|
||||
|
||||
impl<T, S, E> ParseArguments<S, E> for Arg<T>
|
||||
where
|
||||
A: FromArguments,
|
||||
T: FromArguments<S, E>,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, _) = A::from_args(args)?;
|
||||
async fn parse(
|
||||
args: &str,
|
||||
ctx: &CommandContext<'_, S, E>,
|
||||
) -> Result<Self, ArgumentParsingError> {
|
||||
let (value, _) = T::from_args(args, ctx).await?;
|
||||
|
||||
Ok(Arg(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, S, E> ParseArguments<S, E> for (A,)
|
||||
where
|
||||
A: FromArguments<S, E>,
|
||||
{
|
||||
async fn parse(
|
||||
args: &str,
|
||||
ctx: &CommandContext<'_, S, E>,
|
||||
) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, _) = A::from_args(args, ctx).await?;
|
||||
|
||||
Ok((a,))
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, B> ParseArguments for (A, B)
|
||||
impl<A, B, S, E> ParseArguments<S, E> for (A, B)
|
||||
where
|
||||
A: FromArguments,
|
||||
B: FromArguments,
|
||||
A: FromArguments<S, E>,
|
||||
B: FromArguments<S, E>,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, args) = A::from_args(args)?;
|
||||
let (b, _) = B::from_args(args)?;
|
||||
async fn parse(
|
||||
args: &str,
|
||||
ctx: &CommandContext<'_, S, E>,
|
||||
) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, args) = A::from_args(args, ctx).await?;
|
||||
let (b, _) = B::from_args(args, ctx).await?;
|
||||
|
||||
Ok((a, b))
|
||||
}
|
||||
}
|
||||
|
||||
impl<A, B, C> ParseArguments for (A, B, C)
|
||||
impl<A, B, C, S, E> ParseArguments<S, E> for (A, B, C)
|
||||
where
|
||||
A: FromArguments,
|
||||
B: FromArguments,
|
||||
C: FromArguments,
|
||||
A: FromArguments<S, E>,
|
||||
B: FromArguments<S, E>,
|
||||
C: FromArguments<S, E>,
|
||||
{
|
||||
fn parse(args: &str) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, args) = A::from_args(args)?;
|
||||
let (b, args) = B::from_args(args)?;
|
||||
let (c, _) = C::from_args(args)?;
|
||||
async fn parse(
|
||||
args: &str,
|
||||
ctx: &CommandContext<'_, S, E>,
|
||||
) -> Result<Self, ArgumentParsingError> {
|
||||
let (a, args) = A::from_args(args, ctx).await?;
|
||||
let (b, args) = B::from_args(args, ctx).await?;
|
||||
let (c, _) = C::from_args(args, ctx).await?;
|
||||
|
||||
Ok((a, b, c))
|
||||
}
|
||||
|
||||
@@ -1,24 +1,97 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_http::{Error, Response};
|
||||
use twilight_model::channel::Message;
|
||||
use twilight_model::channel::message::Component;
|
||||
use twilight_model::gateway::payload::incoming::MessageCreate;
|
||||
|
||||
use crate::framework::{commands::messages::MessageSender, runtime::Runtime};
|
||||
use crate::framework::commands::messages::MessageSender;
|
||||
use crate::framework::interactions::components::ActionRow;
|
||||
use crate::framework::messages::MessageBuilder;
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
pub struct CommandContext<'a, T> {
|
||||
pub struct CommandContext<'a, T, E> {
|
||||
pub event: &'a MessageCreate,
|
||||
pub rt: Arc<Runtime<T>>,
|
||||
pub rt: Arc<Runtime<T, E>>,
|
||||
pub prefix: &'a str,
|
||||
pub extras: E,
|
||||
}
|
||||
|
||||
impl<'a, T> CommandContext<'a, T>
|
||||
impl<'a, T, E> CommandContext<'a, T, E>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(event: &'a MessageCreate, rt: Arc<Runtime<T>>, prefix: &'a str) -> Self {
|
||||
Self { event, rt, prefix }
|
||||
pub fn new(
|
||||
event: &'a MessageCreate,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
prefix: &'a str,
|
||||
extras: E,
|
||||
) -> Self {
|
||||
Self {
|
||||
event,
|
||||
rt,
|
||||
prefix,
|
||||
extras,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self) -> MessageSender<T> {
|
||||
pub fn send(&self) -> MessageSender<T, E> {
|
||||
MessageSender::new(self.event.channel_id, self.rt.clone())
|
||||
}
|
||||
|
||||
pub async fn send_from_builder(
|
||||
&self,
|
||||
builder: MessageBuilder<T, E>,
|
||||
) -> Result<Response<Message>, Error> {
|
||||
let mut sender = self.rt.http.create_message(self.event.channel_id);
|
||||
|
||||
if let Some(content) = &builder.content {
|
||||
sender = sender.content(content);
|
||||
}
|
||||
|
||||
let mut current_row = ActionRow::new(&[]);
|
||||
let mut current_index = 0;
|
||||
|
||||
let mut rows: Vec<Component> = vec![];
|
||||
let mut custom_ids: Vec<String> = vec![];
|
||||
|
||||
for (component, index) in builder.components {
|
||||
if index != current_index {
|
||||
if !current_row.is_empty() {
|
||||
rows.push(current_row.build());
|
||||
current_row = ActionRow::new(&[]);
|
||||
}
|
||||
|
||||
current_index += 1;
|
||||
}
|
||||
|
||||
let custom_id = component.custom_id();
|
||||
|
||||
if let Some(custom_id) = &custom_id {
|
||||
custom_ids.push(custom_id.clone());
|
||||
|
||||
self.rt.registry.register(custom_id, component.clone());
|
||||
}
|
||||
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
}
|
||||
|
||||
if !current_row.is_empty() {
|
||||
rows.push(current_row.build());
|
||||
}
|
||||
|
||||
sender = sender.components(&rows);
|
||||
sender = sender.embeds(&builder.embeds);
|
||||
|
||||
let result = sender.await;
|
||||
|
||||
if result.is_err() {
|
||||
for custom_id in custom_ids {
|
||||
self.rt.registry.unregister(custom_id);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::error::Error;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::user::User;
|
||||
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ExtrasGetter<S>: Send + Sync {
|
||||
type Extras;
|
||||
|
||||
async fn get_extra(
|
||||
&self,
|
||||
user: &User,
|
||||
rt: &Runtime<S, Self::Extras>,
|
||||
) -> Result<Self::Extras, Box<dyn Error>>;
|
||||
}
|
||||
|
||||
pub struct DefaultExtraGetter;
|
||||
|
||||
#[async_trait]
|
||||
impl<S> ExtrasGetter<S> for DefaultExtraGetter {
|
||||
type Extras = ();
|
||||
|
||||
async fn get_extra(
|
||||
&self,
|
||||
_user: &User,
|
||||
_rt: &Runtime<S, Self::Extras>,
|
||||
) -> Result<Self::Extras, Box<dyn Error>> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,44 @@
|
||||
use std::{
|
||||
future::{Future, IntoFuture},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::future::{Future, IntoFuture};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_http::{Error, Response, response::marker::EmptyBody};
|
||||
use twilight_model::{
|
||||
channel::{
|
||||
Message,
|
||||
message::{Component as TwilightComponent, Embed},
|
||||
},
|
||||
http::interaction::{InteractionResponse, InteractionResponseType},
|
||||
id::{
|
||||
Id,
|
||||
marker::{ChannelMarker, InteractionMarker},
|
||||
},
|
||||
};
|
||||
use twilight_http::response::marker::EmptyBody;
|
||||
use twilight_http::{Error, Response};
|
||||
use twilight_model::channel::Message;
|
||||
use twilight_model::channel::message::{Component as TwilightComponent, Embed};
|
||||
use twilight_model::http::interaction::{InteractionResponse, InteractionResponseType};
|
||||
use twilight_model::id::Id;
|
||||
use twilight_model::id::marker::{ChannelMarker, InteractionMarker, MessageMarker};
|
||||
use twilight_util::builder::InteractionResponseDataBuilder;
|
||||
|
||||
use crate::framework::{
|
||||
interactions::components::{ActionRow, Component},
|
||||
runtime::Runtime,
|
||||
};
|
||||
use crate::framework::interactions::components::{ActionRow, Component};
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
pub struct MessageSender<T> {
|
||||
pub struct MessageSender<T, E> {
|
||||
channel_id: Id<ChannelMarker>,
|
||||
|
||||
rt: Arc<Runtime<T>>,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
|
||||
content: Option<String>,
|
||||
embeds: Vec<Embed>,
|
||||
reply_to: Option<Id<MessageMarker>>,
|
||||
|
||||
// Row index, component
|
||||
components: Vec<(u8, Arc<dyn Component<T>>)>,
|
||||
components: Vec<(u8, Arc<dyn Component<T, E>>)>,
|
||||
}
|
||||
|
||||
impl<T> MessageSender<T>
|
||||
impl<T, E> MessageSender<T, E>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(channel_id: Id<ChannelMarker>, rt: Arc<Runtime<T>>) -> Self {
|
||||
pub fn new(channel_id: Id<ChannelMarker>, rt: Arc<Runtime<T, E>>) -> Self {
|
||||
Self {
|
||||
channel_id,
|
||||
rt,
|
||||
content: None,
|
||||
components: vec![],
|
||||
reply_to: None,
|
||||
embeds: vec![],
|
||||
}
|
||||
}
|
||||
@@ -61,12 +55,18 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component<T> + 'static, row: u8) -> Self {
|
||||
pub fn component(mut self, component: impl Component<T, E> + 'static, row: u8) -> Self {
|
||||
self.components.push((row, Arc::new(component)));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reply_to(mut self, message_id: Id<MessageMarker>) -> Self {
|
||||
self.reply_to = Some(message_id);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn send(self) -> Result<Response<Message>, Error> {
|
||||
let mut builder = self.rt.http.create_message(self.channel_id);
|
||||
|
||||
@@ -82,7 +82,7 @@ where
|
||||
|
||||
for (index, component) in self.components {
|
||||
if index != current_index {
|
||||
if current_row.len() > 0 {
|
||||
if !current_row.is_empty() {
|
||||
rows.push(current_row.build());
|
||||
current_row = ActionRow::new(&[]);
|
||||
}
|
||||
@@ -101,13 +101,17 @@ where
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
}
|
||||
|
||||
if current_row.len() > 0 {
|
||||
if !current_row.is_empty() {
|
||||
rows.push(current_row.build());
|
||||
}
|
||||
|
||||
builder = builder.components(&rows);
|
||||
builder = builder.embeds(&self.embeds);
|
||||
|
||||
if let Some(reply_to) = self.reply_to {
|
||||
builder = builder.reply(reply_to);
|
||||
}
|
||||
|
||||
let result = builder.await;
|
||||
|
||||
if result.is_err() {
|
||||
@@ -120,7 +124,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Send + Sync + 'static> IntoFuture for MessageSender<T> {
|
||||
impl<T: Send + Sync + 'static, E: Send + Sync + 'static> IntoFuture for MessageSender<T, E> {
|
||||
type Output = Result<Response<Message>, Error>;
|
||||
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
|
||||
|
||||
@@ -129,27 +133,28 @@ impl<T: Send + Sync + 'static> IntoFuture for MessageSender<T> {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OriginalMessageUpdater<T> {
|
||||
pub struct OriginalMessageUpdater<T, E> {
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
|
||||
rt: Arc<Runtime<T>>,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
|
||||
content: Option<String>,
|
||||
embeds: Vec<Embed>,
|
||||
|
||||
// Row index, component
|
||||
components: Vec<(u8, Arc<dyn Component<T>>)>,
|
||||
components: Vec<(u8, Arc<dyn Component<T, E>>)>,
|
||||
}
|
||||
|
||||
impl<T> OriginalMessageUpdater<T>
|
||||
impl<T, E> OriginalMessageUpdater<T, E>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
rt: Arc<Runtime<T>>,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
interaction_id,
|
||||
@@ -161,6 +166,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn content(mut self, content: impl ToString) -> Self {
|
||||
self.content = Some(content.to_string());
|
||||
|
||||
@@ -173,7 +179,7 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component<T> + 'static, row: u8) -> Self {
|
||||
pub fn component(mut self, component: impl Component<T, E> + 'static, row: u8) -> Self {
|
||||
self.components.push((row, Arc::new(component)));
|
||||
|
||||
self
|
||||
@@ -194,7 +200,7 @@ where
|
||||
|
||||
for (index, component) in self.components {
|
||||
if index != current_index {
|
||||
if current_row.len() > 0 {
|
||||
if !current_row.is_empty() {
|
||||
rows.push(current_row.build());
|
||||
current_row = ActionRow::new(&[]);
|
||||
}
|
||||
@@ -213,7 +219,7 @@ where
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
}
|
||||
|
||||
if current_row.len() > 0 {
|
||||
if !current_row.is_empty() {
|
||||
rows.push(current_row.build());
|
||||
}
|
||||
|
||||
@@ -244,7 +250,9 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Send + Sync + 'static> IntoFuture for OriginalMessageUpdater<T> {
|
||||
impl<T: Send + Sync + 'static, E: Send + Sync + 'static> IntoFuture
|
||||
for OriginalMessageUpdater<T, E>
|
||||
{
|
||||
type Output = Result<Response<EmptyBody>, Error>;
|
||||
type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
|
||||
|
||||
|
||||
+161
-17
@@ -1,40 +1,130 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use deadpool_redis::cluster::PoolError;
|
||||
use deadpool_redis::redis::RedisError;
|
||||
use thiserror::Error;
|
||||
use twilight_http::response::DeserializeBodyError;
|
||||
|
||||
use crate::framework::commands::{arguments::Argument, context::CommandContext};
|
||||
use crate::database::models::{DatabaseError, DatabaseOrDiscordError};
|
||||
use crate::framework::commands::arguments::Argument;
|
||||
use crate::framework::commands::context::CommandContext;
|
||||
use crate::framework::commands::permissions::Permission;
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
pub mod arguments;
|
||||
pub mod context;
|
||||
pub mod extras;
|
||||
pub mod messages;
|
||||
pub mod permissions;
|
||||
pub mod runner;
|
||||
|
||||
/// An error occurred during command execution.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CommandError {
|
||||
#[error("A message was attempted to be sent but it failed")]
|
||||
MessageSendingFailed,
|
||||
|
||||
#[error("A database query failed")]
|
||||
DatabaseQueryFailed,
|
||||
DatabaseFailed(#[from] DatabaseError),
|
||||
|
||||
#[error("A database operation failed due to an inconsistent state")]
|
||||
DatabaseInconsistentState,
|
||||
|
||||
#[error("The command was used in DMs, but it can only be used in servers")]
|
||||
ServerCommandRunInDMs,
|
||||
|
||||
#[error(transparent)]
|
||||
DiscordAPIFailed(#[from] twilight_http::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
DiscordDeserializationFailed(#[from] DeserializeBodyError),
|
||||
|
||||
#[error(transparent)]
|
||||
RedisPoolFailed(#[from] PoolError),
|
||||
|
||||
#[error(transparent)]
|
||||
RedisFailed(#[from] RedisError),
|
||||
|
||||
#[error(transparent)]
|
||||
DatabaseOrDiscordError(#[from] DatabaseOrDiscordError),
|
||||
}
|
||||
|
||||
/// Represents a bot command.
|
||||
///
|
||||
/// Only the [`Command::name()`] and the [`Command::execute()`] methods must be implemented. The
|
||||
/// rest have default implementations. They can all be overriden to tweak the command's behavior
|
||||
/// and display in the help command.
|
||||
#[async_trait]
|
||||
pub trait Command<T>: Send + Sync + Debug {
|
||||
pub trait Command<T, E>: Send + Sync + Debug {
|
||||
/// The command's name.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`&'static str`] - The command's name.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// The command's aliases.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`&'static [&'static str]`] - The command's aliases.
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// The command's description, shown in the help command.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`&'static str`] - The command's description.
|
||||
fn description(&self) -> &'static str {
|
||||
"A Payphone command."
|
||||
}
|
||||
|
||||
/// The command's arguments, shown in the help command.
|
||||
///
|
||||
/// This does not affect parsing in any way, as that's done inside [`Command::execute()`]. This
|
||||
/// is for display in the help command only.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`&'static [Argument]`] - The command's arguments.
|
||||
fn arguments(&self) -> &'static [Argument] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// Permissions that will be checked for before running the command.
|
||||
///
|
||||
/// If any of the permissions fail, the command will not be run and the permission's
|
||||
/// [`Permission::on_handle_denied()`] will be called.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Vec<Box<dyn Permission<T, E> + Send + Sync>>`] - A vector of the command's permissions.
|
||||
fn permissions(&self) -> Vec<Box<dyn Permission<T, E> + Send + Sync + 'static>> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// Whether the command should be shown in the help command.
|
||||
///
|
||||
/// This is useful for commands that are not meant to be used by users directly, like internal
|
||||
/// commands or easter egg commands.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`bool`] - Whether the command should be shown in the help command.
|
||||
fn show_in_help(&self, _rt: &Runtime<T, E>, _extras: &E) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the command only runs when the message starts with the command prefix.
|
||||
///
|
||||
/// When this is false, the command will be run when the command is found on any part of the
|
||||
/// message. This is useful for about-group commands, like:
|
||||
///
|
||||
/// ```text
|
||||
/// User: You can donate $6 at p.d to get unbanned.
|
||||
/// Bot: https://ko-fi.com/payphonedev
|
||||
/// ```
|
||||
///
|
||||
/// Returns:
|
||||
/// [`bool`] - Whether the command only runs when the message starts with the command prefix.
|
||||
fn only_runs_if_startswith(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns the command name + all its aliases.
|
||||
///
|
||||
/// Returns:
|
||||
@@ -45,32 +135,86 @@ pub trait Command<T>: Send + Sync + Debug {
|
||||
all
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: CommandContext<'_, T>, args: &str) -> Result<(), CommandError>;
|
||||
/// Executes the command.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `ctx` - The command context, containing information about the command invocation and
|
||||
/// helpers for responding.
|
||||
/// * `args` - The raw arguments passed to the command, without any parsing. This is the part
|
||||
/// of the message after the command name.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Result<(), CommandError>`] - Ok if the command executed successfully, or an error if
|
||||
/// something went wrong during execution.
|
||||
async fn execute(&self, ctx: CommandContext<'_, T, E>, args: &str) -> Result<(), CommandError>;
|
||||
}
|
||||
|
||||
/// The parts of a command found in a message, without processing.
|
||||
///
|
||||
/// This is not guaranteed to point to a valid command, as it only finds a prefix in a message and
|
||||
/// parses it without checking for command existance nor argument validity.
|
||||
pub struct CommandParts<'a> {
|
||||
/// The name of the command.
|
||||
pub name: &'a str,
|
||||
|
||||
/// The arguments passed to the command, raw as a string.
|
||||
pub args: &'a str,
|
||||
|
||||
/// The index in the start-trimmed message where the command was found.
|
||||
///
|
||||
/// For example, given the prefix is `p.`:
|
||||
/// ```text
|
||||
/// Donate $6 at p.d to get unbanned.
|
||||
/// ^
|
||||
/// index = 13
|
||||
/// ```
|
||||
pub start_index: usize,
|
||||
}
|
||||
|
||||
impl<'a> CommandParts<'a> {
|
||||
fn new(name: &'a str, args: &'a str) -> Self {
|
||||
Self { name, args }
|
||||
/// Creates a new [`CommandParts`] struct.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `name` - The name of the command.
|
||||
/// * `args` - The arguments passed to the command, raw as a string.
|
||||
/// * `start_index` - The index in the start-trimmed message where the command was found.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`CommandParts`] - The created struct.
|
||||
fn new(name: &'a str, args: &'a str, start_index: usize) -> Self {
|
||||
Self {
|
||||
name,
|
||||
args,
|
||||
start_index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a message content to get the command name and arguments.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `prefix` - The prefix to look for in the message.
|
||||
/// * `message` - The message content to parse.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Option<CommandParts>`] - The parsed command parts, or None if the prefix was not found in the
|
||||
/// message.
|
||||
pub fn get_command_parts<'a>(prefix: &str, message: &'a str) -> Option<CommandParts<'a>> {
|
||||
let message_trimmed_start = message.trim_ascii_start();
|
||||
let message_trimmed_start_lowercase = message_trimmed_start.to_ascii_lowercase();
|
||||
|
||||
if message_trimmed_start
|
||||
.to_ascii_lowercase()
|
||||
.starts_with(&prefix.to_ascii_lowercase())
|
||||
{
|
||||
let message = message_trimmed_start.split_at(prefix.len()).1;
|
||||
let prefix_lowercase = prefix.to_ascii_lowercase();
|
||||
|
||||
match message.split_once(" ") {
|
||||
Some((name, args)) => Some(CommandParts::new(name, args)),
|
||||
None => Some(CommandParts::new(message, "")),
|
||||
if let Some(index) = message_trimmed_start_lowercase.find(&prefix_lowercase) {
|
||||
let command_text = &message_trimmed_start[index + prefix.len()..];
|
||||
|
||||
match command_text.trim_ascii_start().split_once(" ") {
|
||||
Some((name, args)) => Some(CommandParts::new(name, args, index)),
|
||||
None => Some(CommandParts::new(
|
||||
command_text.trim_ascii_start(),
|
||||
"",
|
||||
index,
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::framework::commands::CommandError;
|
||||
use crate::framework::commands::context::CommandContext;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Permission<S: Send + Sync + 'static, E>: Debug {
|
||||
async fn check(&self, ctx: &CommandContext<S, E>) -> Result<bool, CommandError>;
|
||||
|
||||
async fn on_handle_denied(&self, _ctx: &CommandContext<S, E>) -> Result<(), CommandError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,27 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::MessageCreate;
|
||||
|
||||
use crate::framework::{
|
||||
commands::{context::CommandContext, get_command_parts},
|
||||
events::traits::{EventResult, OnMessageHandler},
|
||||
runtime::Runtime,
|
||||
};
|
||||
use crate::framework::commands::context::CommandContext;
|
||||
use crate::framework::commands::extras::ExtrasGetter;
|
||||
use crate::framework::commands::get_command_parts;
|
||||
use crate::framework::events::traits::{EventResult, OnMessageHandler};
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CommandRunner;
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Send + Sync + 'static> OnMessageHandler<T> for CommandRunner {
|
||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<T>>) -> EventResult {
|
||||
impl<T, E> OnMessageHandler<T, E> for CommandRunner
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
async fn on_message(
|
||||
&self,
|
||||
event: &MessageCreate,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
extras: Arc<dyn ExtrasGetter<T, Extras = E>>,
|
||||
) -> EventResult {
|
||||
if event.author.bot || event.author.system.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -23,17 +32,37 @@ impl<T: Send + Sync + 'static> OnMessageHandler<T> for CommandRunner {
|
||||
.get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get()))
|
||||
.await;
|
||||
|
||||
for prefix in prefixes {
|
||||
if let Some(parts) = get_command_parts(&prefix, &event.content) {
|
||||
for prefix in &prefixes {
|
||||
if let Some(parts) = get_command_parts(prefix, &event.content) {
|
||||
let command = rt.commands.iter().find(|cmd| {
|
||||
cmd.identifiers()
|
||||
.contains(&parts.name.to_ascii_lowercase().as_str())
|
||||
});
|
||||
|
||||
if let Some(command) = command {
|
||||
command
|
||||
.execute(CommandContext::new(event, rt.clone(), &prefix), parts.args)
|
||||
.await?;
|
||||
if command.only_runs_if_startswith() && parts.start_index != 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let extras = extras
|
||||
.get_extra(&event.author, &rt)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get extras: {e}"))?;
|
||||
|
||||
let ctx = CommandContext::new(event, rt.clone(), &prefixes[0], extras);
|
||||
|
||||
for permission in command.permissions() {
|
||||
if !permission
|
||||
.check(&ctx)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Permission check failed: {e}"))?
|
||||
{
|
||||
permission.on_handle_denied(&ctx).await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
command.execute(ctx, parts.args).await?;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::{error::Error, fmt::Debug, sync::Arc};
|
||||
use std::error::Error;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::{
|
||||
@@ -6,78 +8,99 @@ use twilight_model::gateway::payload::incoming::{
|
||||
MessageUpdate, Ready,
|
||||
};
|
||||
|
||||
use crate::framework::commands::extras::ExtrasGetter;
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
pub type EventResult = Result<(), Box<dyn Error>>;
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnReadyHandler<T>: Send + Sync + Debug
|
||||
pub trait OnReadyHandler<T, E>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
E: Send + Sync,
|
||||
{
|
||||
async fn on_ready(&self, event: &Ready, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
async fn on_ready(&self, event: &Ready, rt: Arc<Runtime<T, E>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnReconnectHandler<T>: Send + Sync + Debug
|
||||
pub trait OnReconnectHandler<T, E>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
E: Send + Sync,
|
||||
{
|
||||
async fn on_reconnect(&self, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
async fn on_reconnect(&self, rt: Arc<Runtime<T, E>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnInteractionHandler<T>: Send + Sync + Debug
|
||||
pub trait OnInteractionHandler<T, E>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
E: Send + Sync,
|
||||
{
|
||||
async fn on_interaction(&self, event: &InteractionCreate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
async fn on_interaction(
|
||||
&self,
|
||||
event: &InteractionCreate,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
extras: Arc<dyn ExtrasGetter<T, Extras = E>>,
|
||||
) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMessageHandler<T>: Send + Sync + Debug
|
||||
pub trait OnMessageHandler<T, E>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
E: Send + Sync,
|
||||
{
|
||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
async fn on_message(
|
||||
&self,
|
||||
event: &MessageCreate,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
extras: Arc<dyn ExtrasGetter<T, Extras = E>>,
|
||||
) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMessageEditHandler<T>: Send + Sync + Debug
|
||||
pub trait OnMessageEditHandler<T, E>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
E: Send + Sync,
|
||||
{
|
||||
async fn on_message_edit(&self, event: &MessageUpdate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
async fn on_message_edit(&self, event: &MessageUpdate, rt: Arc<Runtime<T, E>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnMessageDeleteHandler<T>: Send + Sync + Debug
|
||||
pub trait OnMessageDeleteHandler<T, E>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
E: Send + Sync,
|
||||
{
|
||||
async fn on_message_delete(&self, event: &MessageDelete, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
async fn on_message_delete(&self, event: &MessageDelete, rt: Arc<Runtime<T, E>>)
|
||||
-> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnServerCreateHandler<T>: Send + Sync + Debug
|
||||
pub trait OnServerCreateHandler<T, E>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
E: Send + Sync,
|
||||
{
|
||||
async fn on_server_create(&self, event: &GuildCreate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
async fn on_server_create(&self, event: &GuildCreate, rt: Arc<Runtime<T, E>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnServerUpdateHandler<T>: Send + Sync + Debug
|
||||
pub trait OnServerUpdateHandler<T, E>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
E: Send + Sync,
|
||||
{
|
||||
async fn on_server_update(&self, event: &GuildUpdate, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
async fn on_server_update(&self, event: &GuildUpdate, rt: Arc<Runtime<T, E>>) -> EventResult;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OnServerDeleteHandler<T>: Send + Sync + Debug
|
||||
pub trait OnServerDeleteHandler<T, E>: Send + Sync + Debug
|
||||
where
|
||||
T: Send + Sync,
|
||||
E: Send + Sync,
|
||||
{
|
||||
async fn on_server_delete(&self, event: &GuildDelete, rt: Arc<Runtime<T>>) -> EventResult;
|
||||
async fn on_server_delete(&self, event: &GuildDelete, rt: Arc<Runtime<T, E>>) -> EventResult;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,32 @@
|
||||
use std::{fmt::Debug, sync::Arc};
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::channel::message::{
|
||||
Component as TwilightComponent,
|
||||
component::{ActionRow as TwilightActionRow, Button as TwilightButton, ButtonStyle},
|
||||
use twilight_model::channel::message::Component as TwilightComponent;
|
||||
use twilight_model::channel::message::component::{
|
||||
ActionRow as TwilightActionRow, Button as TwilightButton, ButtonStyle,
|
||||
};
|
||||
|
||||
use crate::framework::interactions::{InteractionError, context::InteractionContext};
|
||||
use crate::framework::interactions::InteractionError;
|
||||
use crate::framework::interactions::context::InteractionContext;
|
||||
|
||||
type ComponentWithID<T> = (Option<String>, Arc<dyn Component<T>>);
|
||||
type ComponentWithID<T, E> = (Option<String>, Arc<dyn Component<T, E>>);
|
||||
|
||||
pub struct ActionRow<T> {
|
||||
components: Vec<ComponentWithID<T>>,
|
||||
pub struct ActionRow<T, E> {
|
||||
components: Vec<ComponentWithID<T, E>>,
|
||||
}
|
||||
|
||||
impl<T> ActionRow<T> {
|
||||
pub fn new(components: &[ComponentWithID<T>]) -> Self {
|
||||
impl<T, E> ActionRow<T, E> {
|
||||
pub fn new(components: &[ComponentWithID<T, E>]) -> Self {
|
||||
Self {
|
||||
components: components.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, id: Option<String>, component: Arc<dyn Component<T>>) {
|
||||
pub fn add(&mut self, id: Option<String>, component: Arc<dyn Component<T, E>>) {
|
||||
self.components.push((id, component));
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.components.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.components.is_empty()
|
||||
}
|
||||
@@ -45,19 +43,21 @@ impl<T> ActionRow<T> {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Component<T>: Send + Sync + Debug {
|
||||
pub trait Component<T, E>: Send + Sync + Debug {
|
||||
fn custom_id(&self) -> Option<String> {
|
||||
Some(String::from("component"))
|
||||
}
|
||||
fn build(&self, id: Option<String>) -> TwilightComponent;
|
||||
|
||||
async fn execute(&self, ctx: InteractionContext<T>) -> Result<(), InteractionError>;
|
||||
async fn execute(&self, ctx: InteractionContext<T, E>) -> Result<(), InteractionError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T, S: Send + Sync + 'static> Component<S> for T
|
||||
impl<T, S, E> Component<S, E> for T
|
||||
where
|
||||
T: Button<S>,
|
||||
T: Button<S, E>,
|
||||
S: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
fn custom_id(&self) -> Option<String> {
|
||||
Button::id(self)
|
||||
@@ -66,7 +66,7 @@ where
|
||||
fn build(&self, id: Option<String>) -> TwilightComponent {
|
||||
TwilightComponent::Button(TwilightButton {
|
||||
custom_id: id,
|
||||
disabled: false,
|
||||
disabled: self.disabled(),
|
||||
emoji: None,
|
||||
label: self.label().map(|i| i.to_string()),
|
||||
style: if self.url().is_none() {
|
||||
@@ -79,13 +79,17 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, ctx: InteractionContext<S>) -> Result<(), InteractionError> {
|
||||
async fn execute(&self, ctx: InteractionContext<S, E>) -> Result<(), InteractionError> {
|
||||
self.on_press(ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Button<S: Send + Sync + 'static>: Send + Sync + Debug {
|
||||
pub trait Button<S, E>: Send + Sync + Debug
|
||||
where
|
||||
S: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
fn id(&self) -> Option<String> {
|
||||
if self.url().is_none() {
|
||||
Some(String::from("Button"))
|
||||
@@ -93,17 +97,24 @@ pub trait Button<S: Send + Sync + 'static>: Send + Sync + Debug {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn style(&self) -> ButtonStyle {
|
||||
ButtonStyle::Secondary
|
||||
}
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
|
||||
fn label(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn on_press(&self, ctx: InteractionContext<S>) -> Result<(), InteractionError> {
|
||||
fn url(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn disabled(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn on_press(&self, ctx: InteractionContext<S, E>) -> Result<(), InteractionError> {
|
||||
ctx.defer().await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_model::{
|
||||
gateway::payload::incoming::InteractionCreate,
|
||||
http::interaction::{InteractionResponse, InteractionResponseType},
|
||||
id::{Id, marker::InteractionMarker},
|
||||
};
|
||||
use twilight_http::response::marker::EmptyBody;
|
||||
use twilight_http::{Error, Response};
|
||||
use twilight_model::gateway::payload::incoming::InteractionCreate;
|
||||
use twilight_model::http::interaction::{InteractionResponse, InteractionResponseType};
|
||||
use twilight_util::builder::InteractionResponseDataBuilder;
|
||||
|
||||
use crate::framework::{
|
||||
commands::messages::OriginalMessageUpdater, interactions::InteractionError, runtime::Runtime,
|
||||
};
|
||||
use crate::framework::interactions::InteractionError;
|
||||
use crate::framework::interactions::components::ActionRow;
|
||||
use crate::framework::messages::MessageEditBuilder;
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
pub struct InteractionContext<T> {
|
||||
pub struct InteractionContext<T, E> {
|
||||
pub event: InteractionCreate,
|
||||
pub rt: Arc<Runtime<T>>,
|
||||
pub rt: Arc<Runtime<T, E>>,
|
||||
pub prefix: String,
|
||||
pub extras: E,
|
||||
}
|
||||
|
||||
impl<T> InteractionContext<T> {
|
||||
pub fn new(event: InteractionCreate, rt: Arc<Runtime<T>>, prefix: String) -> Self {
|
||||
Self { event, rt, prefix }
|
||||
impl<T, E> InteractionContext<T, E>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new(
|
||||
event: InteractionCreate,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
prefix: String,
|
||||
extras: E,
|
||||
) -> Self {
|
||||
Self {
|
||||
event,
|
||||
rt,
|
||||
prefix,
|
||||
extras,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn defer(&self) -> Result<(), InteractionError> {
|
||||
@@ -33,54 +49,85 @@ impl<T> InteractionContext<T> {
|
||||
kind: InteractionResponseType::DeferredUpdateMessage,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| InteractionError::DeferringFailed)?;
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn think(&self) -> Result<(), InteractionError> {
|
||||
self.rt
|
||||
pub async fn edit_original_message(
|
||||
&self,
|
||||
builder: MessageEditBuilder<T, E>,
|
||||
) -> Result<Response<EmptyBody>, Error> {
|
||||
let mut sender = InteractionResponseDataBuilder::new();
|
||||
|
||||
if let Some(content) = &builder.content {
|
||||
if let Some(content) = content {
|
||||
sender = sender.content(content);
|
||||
} else {
|
||||
sender = sender.content("");
|
||||
}
|
||||
}
|
||||
|
||||
let mut current_row = ActionRow::new(&[]);
|
||||
let mut current_index = 0;
|
||||
|
||||
let mut custom_ids: Vec<String> = vec![];
|
||||
|
||||
if let Some(components) = builder.components {
|
||||
let mut rows = vec![];
|
||||
|
||||
for (component, index) in components {
|
||||
if index != current_index {
|
||||
if !current_row.is_empty() {
|
||||
rows.push(current_row.build());
|
||||
current_row = ActionRow::new(&[]);
|
||||
}
|
||||
|
||||
current_index += 1;
|
||||
}
|
||||
|
||||
let custom_id = component.custom_id();
|
||||
|
||||
if let Some(custom_id) = &custom_id {
|
||||
custom_ids.push(custom_id.clone());
|
||||
|
||||
self.rt.registry.register(custom_id, component.clone());
|
||||
}
|
||||
|
||||
current_row.add(custom_id.clone(), component.clone());
|
||||
}
|
||||
|
||||
if !current_row.is_empty() {
|
||||
rows.push(current_row.build());
|
||||
}
|
||||
|
||||
sender = sender.components(rows);
|
||||
}
|
||||
|
||||
if let Some(embeds) = builder.embeds {
|
||||
sender = sender.embeds(embeds);
|
||||
}
|
||||
|
||||
let result = self
|
||||
.rt
|
||||
.http
|
||||
.interaction(self.rt.app.id)
|
||||
.create_response(
|
||||
self.event.id,
|
||||
&self.event.token,
|
||||
&InteractionResponse {
|
||||
data: None,
|
||||
kind: InteractionResponseType::DeferredChannelMessageWithSource,
|
||||
data: Some(sender.build()),
|
||||
kind: InteractionResponseType::UpdateMessage,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| InteractionError::DeferringFailed)?;
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
if result.is_err() {
|
||||
for custom_id in custom_ids {
|
||||
self.rt.registry.unregister(custom_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn original_message(&self) -> Option<OriginalMessage<T>> {
|
||||
self.event.message.as_ref().map(|_| OriginalMessage {
|
||||
interaction_id: self.event.id,
|
||||
interaction_token: self.event.token.clone(),
|
||||
rt: self.rt.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OriginalMessage<T> {
|
||||
interaction_id: Id<InteractionMarker>,
|
||||
interaction_token: String,
|
||||
rt: Arc<Runtime<T>>,
|
||||
}
|
||||
|
||||
impl<T> OriginalMessage<T>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
{
|
||||
pub fn edit(&self) -> OriginalMessageUpdater<T> {
|
||||
OriginalMessageUpdater::new(
|
||||
self.interaction_id,
|
||||
self.interaction_token.clone(),
|
||||
self.rt.clone(),
|
||||
)
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::database::models::DatabaseError;
|
||||
|
||||
pub mod components;
|
||||
pub mod context;
|
||||
pub mod registry;
|
||||
@@ -12,9 +14,6 @@ pub enum InteractionError {
|
||||
)]
|
||||
InteractionNotFound,
|
||||
|
||||
#[error("The interaction was attempted to be deferred, but it failed to be done")]
|
||||
DeferringFailed,
|
||||
|
||||
#[error("Discord responded with an interaction type Payphone does not use")]
|
||||
InteractionTypeUnsent,
|
||||
|
||||
@@ -23,4 +22,13 @@ pub enum InteractionError {
|
||||
|
||||
#[error("No prefixes were returned for the event")]
|
||||
PrefixesVectorEmpty,
|
||||
|
||||
#[error("The extras getter failed to retrieve extras")]
|
||||
ExtrasGetterFailed,
|
||||
|
||||
#[error(transparent)]
|
||||
DatabaseError(#[from] DatabaseError),
|
||||
|
||||
#[error(transparent)]
|
||||
DiscordAPIError(#[from] twilight_http::Error),
|
||||
}
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use scc::HashMap;
|
||||
|
||||
use twilight_model::{
|
||||
application::interaction::InteractionData, gateway::payload::incoming::InteractionCreate,
|
||||
};
|
||||
|
||||
use crate::framework::{
|
||||
interactions::{InteractionError, context::InteractionContext},
|
||||
runtime::Runtime,
|
||||
};
|
||||
use twilight_model::application::interaction::InteractionData;
|
||||
use twilight_model::gateway::payload::incoming::InteractionCreate;
|
||||
|
||||
use super::components::Component;
|
||||
use crate::framework::commands::extras::ExtrasGetter;
|
||||
use crate::framework::interactions::InteractionError;
|
||||
use crate::framework::interactions::context::InteractionContext;
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ComponentRegistry<T> {
|
||||
inner: HashMap<String, Arc<dyn Component<T>>>,
|
||||
pub struct ComponentRegistry<T, E> {
|
||||
inner: HashMap<String, Arc<dyn Component<T, E>>>,
|
||||
}
|
||||
|
||||
impl<T> ComponentRegistry<T>
|
||||
impl<T, E> ComponentRegistry<T, E>
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -28,7 +26,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&self, id: impl ToString, component: Arc<dyn Component<T>>) {
|
||||
pub fn register(&self, id: impl ToString, component: Arc<dyn Component<T, E>>) {
|
||||
self.inner.upsert(id.to_string(), component);
|
||||
}
|
||||
|
||||
@@ -39,12 +37,16 @@ where
|
||||
pub async fn execute(
|
||||
&self,
|
||||
event: &InteractionCreate,
|
||||
rt: Arc<Runtime<T>>,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
extras: Arc<dyn ExtrasGetter<T, Extras = E>>,
|
||||
) -> Result<(), InteractionError> {
|
||||
if let Some(data) = &event.data {
|
||||
match data {
|
||||
InteractionData::MessageComponent(data) => {
|
||||
if let Some(component) = self.inner.get_async(&data.custom_id).await {
|
||||
if let Some(guarded_component) = self.inner.get(&data.custom_id) {
|
||||
let component = guarded_component.clone();
|
||||
drop(guarded_component);
|
||||
|
||||
let prefixes = rt
|
||||
.get_prefixes(
|
||||
event.guild_id.map(|id| id.get()),
|
||||
@@ -55,13 +57,27 @@ where
|
||||
.first()
|
||||
.ok_or(InteractionError::PrefixesVectorEmpty)?;
|
||||
|
||||
let extras = extras
|
||||
.get_extra(
|
||||
event.author().expect("Component interaction had no author"),
|
||||
&rt,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to get extras for component interaction: {e:?}")
|
||||
})
|
||||
.map_err(|_| InteractionError::ExtrasGetterFailed)?;
|
||||
|
||||
component
|
||||
.execute(InteractionContext::new(
|
||||
event.clone(),
|
||||
rt.clone(),
|
||||
prefix.to_string(),
|
||||
extras,
|
||||
))
|
||||
.await
|
||||
.inspect(|_| log::debug!("Executed component interaction"))
|
||||
.inspect_err(|e| log::error!("Interaction execution failed: {e:?}"))
|
||||
} else {
|
||||
Err(InteractionError::InteractionNotFound)
|
||||
}
|
||||
|
||||
@@ -3,24 +3,25 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use twilight_model::gateway::payload::incoming::InteractionCreate;
|
||||
|
||||
use crate::{
|
||||
framework::{
|
||||
events::traits::{EventResult, OnInteractionHandler},
|
||||
runtime::Runtime,
|
||||
},
|
||||
state::State,
|
||||
};
|
||||
use crate::framework::commands::extras::ExtrasGetter;
|
||||
use crate::framework::events::traits::{EventResult, OnInteractionHandler};
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InteractionRunner;
|
||||
|
||||
#[async_trait]
|
||||
impl OnInteractionHandler<State> for InteractionRunner {
|
||||
impl<T, E> OnInteractionHandler<T, E> for InteractionRunner
|
||||
where
|
||||
T: Send + Sync + 'static,
|
||||
E: Send + Sync + 'static,
|
||||
{
|
||||
async fn on_interaction(
|
||||
&self,
|
||||
event: &InteractionCreate,
|
||||
rt: Arc<Runtime<State>>,
|
||||
rt: Arc<Runtime<T, E>>,
|
||||
extras: Arc<dyn ExtrasGetter<T, Extras = E>>,
|
||||
) -> EventResult {
|
||||
Ok(rt.registry.execute(event, rt.clone()).await?)
|
||||
Ok(rt.registry.execute(event, rt.clone(), extras).await?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_model::channel::message::Embed;
|
||||
|
||||
use crate::framework::interactions::components::Component;
|
||||
|
||||
type ComponentWithRowIndex<S, E> = (Arc<dyn Component<S, E>>, usize);
|
||||
|
||||
pub struct MessageBuilder<S, E> {
|
||||
pub content: Option<String>,
|
||||
pub embeds: Vec<Embed>,
|
||||
pub components: Vec<ComponentWithRowIndex<S, E>>,
|
||||
}
|
||||
|
||||
impl<S, E> MessageBuilder<S, E> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
content: None,
|
||||
embeds: Vec::new(),
|
||||
components: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: impl Into<String>) -> Self {
|
||||
self.content = Some(content.into());
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn embed(mut self, embed: Embed) -> Self {
|
||||
self.embeds.push(embed);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component<S, E> + 'static, row: usize) -> Self {
|
||||
self.components.push((Arc::new(component), row));
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MessageEditBuilder<S, E> {
|
||||
pub content: Option<Option<String>>,
|
||||
pub embeds: Option<Vec<Embed>>,
|
||||
pub components: Option<Vec<ComponentWithRowIndex<S, E>>>,
|
||||
}
|
||||
|
||||
impl<S, E> MessageEditBuilder<S, E> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
content: None,
|
||||
embeds: None,
|
||||
components: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn content(mut self, content: impl Into<String>) -> Self {
|
||||
self.content = Some(Some(content.into()));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn embed(mut self, embed: Embed) -> Self {
|
||||
if self.embeds.is_none() {
|
||||
self.embeds = Some(Vec::new());
|
||||
}
|
||||
|
||||
self.embeds.as_mut().unwrap().push(embed);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn component(mut self, component: impl Component<S, E> + 'static, row: usize) -> Self {
|
||||
if self.components.is_none() {
|
||||
self.components = Some(Vec::new());
|
||||
}
|
||||
|
||||
self.components
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.push((Arc::new(component), row));
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, E> From<MessageBuilder<S, E>> for MessageEditBuilder<S, E> {
|
||||
fn from(builder: MessageBuilder<S, E>) -> Self {
|
||||
Self {
|
||||
content: builder.content.map(Some),
|
||||
embeds: Some(builder.embeds),
|
||||
components: Some(builder.components),
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-41
@@ -1,35 +1,35 @@
|
||||
//! A framework for building Discord bots, built with Twilight.
|
||||
//!
|
||||
//!
|
||||
//! Since Twilight is a low-level library, it can be hard to build a bot with it. Payphone's
|
||||
//! framework provides a high-level interface for building Discord bots, with a focus on modularity
|
||||
//! and ease of use. The framework is designed to be extensible, allowing you to add new features
|
||||
//! and commands without modifying the core codebase. It also provides a set of utilities for
|
||||
//! common tasks, such as sending messages, handling events, and managing state.
|
||||
//!
|
||||
//!
|
||||
//! # Creating a Bot
|
||||
//!
|
||||
//!
|
||||
//! Creating a bot with Payphone is as simple as creating a new instance of the `Bot` struct and
|
||||
//! running it. The `Bot` struct is the main entry point for the framework and provides methods for
|
||||
//! running the bot, handling events, and managing state.
|
||||
//!
|
||||
//!
|
||||
//! ```
|
||||
//! use crate::framework::bot::Bot;
|
||||
//! use crate::state::State;
|
||||
//!
|
||||
//!
|
||||
//! // Your app's state.
|
||||
//! let state = State::new();
|
||||
//!
|
||||
//!
|
||||
//! // Create a new bot instance with the state and a list of prefixes.
|
||||
//! let bot: Bot<State> = Bot::new(state, ["p."]);
|
||||
//!
|
||||
//!
|
||||
//! bot.run(&token).await.unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//!
|
||||
//! ### Prefixes
|
||||
//!
|
||||
//!
|
||||
//! The framework supports multiple prefixes per command and allows you to set them dynamically
|
||||
//! based on server and user IDs.
|
||||
//!
|
||||
//!
|
||||
//! ```
|
||||
//! async fn get_prefixes(
|
||||
//! rt: &Runtime<State>,
|
||||
@@ -37,15 +37,15 @@
|
||||
//! user_id: Option<u64>
|
||||
//! ) -> Vec<String> {
|
||||
//! // The runtime contains your state at [`Runtime::state`].
|
||||
//!
|
||||
//!
|
||||
//! vec!["p.".to_string()]
|
||||
//! }
|
||||
//!
|
||||
//!
|
||||
//! let bot: Bot<State> = Bot::new(state, get_prefixes);
|
||||
//! ```
|
||||
//!
|
||||
//!
|
||||
//! Otherwise, you can just pass a static list of prefixes as shown in the previous example.
|
||||
//!
|
||||
//!
|
||||
//! ```
|
||||
//! // All of these are valid ways to create a bot with a prefix.
|
||||
//! let bot: Bot<State> = Bot::new(state, ["p."]);
|
||||
@@ -53,77 +53,76 @@
|
||||
//! let bot: Bot<State> = Bot::new(state, "p.".to_string());
|
||||
//! let bot: Bot<State> = Bot::new(state, ["p.".to_string()]);
|
||||
//! ```
|
||||
//!
|
||||
//!
|
||||
//! Setting no prefix will make the bot raise errors when receiving messages.
|
||||
//!
|
||||
//!
|
||||
//! ## Adding Commands
|
||||
//!
|
||||
//!
|
||||
//! Creating a command is quite simple. You just need to implement the `Command` trait and register
|
||||
//! it with the bot.
|
||||
//!
|
||||
//!
|
||||
//! ```
|
||||
//! use crate::framework::commands::{Command, CommandError, context::CommandContext};
|
||||
//!
|
||||
//!
|
||||
//! pub struct MyCommand;
|
||||
//!
|
||||
//!
|
||||
//! #[async_trait::async_trait]
|
||||
//! impl Command<State> for MyCommand {
|
||||
//! fn name(&self) -> &'static str {
|
||||
//! "my_command"
|
||||
//! }
|
||||
//!
|
||||
//!
|
||||
//! async fn execute(&self, ctx: CommandContext<'_, T>, _args: &str) -> Result<(), CommandError> {
|
||||
//! // Your command logic here.
|
||||
//! ctx.send()
|
||||
//! .content("Hello, world!")
|
||||
//! .await
|
||||
//! .map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
//!
|
||||
//! .await?;
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//!
|
||||
//! Then you just need to register the command with the bot:
|
||||
//!
|
||||
//!
|
||||
//! ```
|
||||
//! let bot = Bot::new(state, ["p."]).command(MyCommand);
|
||||
//!
|
||||
//!
|
||||
//! bot.run(&token).await.unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//!
|
||||
//! Log into your Discord account and invite the bot to your server. Then you can test the command
|
||||
//! by sending `p.my_command` in a channel the bot has access to.
|
||||
//!
|
||||
//!
|
||||
//! ### Configuring Commands Further
|
||||
//!
|
||||
//!
|
||||
//! Commands can be configured with additional metadata, such as aliases, descriptions, and
|
||||
//! arguments. The following is a full example of a command with all these features:
|
||||
//!
|
||||
//!
|
||||
//! ```
|
||||
//! use crate::framework::commands::{
|
||||
//! Command, CommandError, context::CommandContext, arguments::NeedyGreedyString
|
||||
//! };
|
||||
//! use crate::state::State;
|
||||
//!
|
||||
//!
|
||||
//! pub struct HelloCommand;
|
||||
//!
|
||||
//!
|
||||
//! #[async_trait::async_trait]
|
||||
//! impl Command<State> for HelloCommand {
|
||||
//! fn name(&self) -> &'static str {
|
||||
//! "hello"
|
||||
//! }
|
||||
//!
|
||||
//!
|
||||
//! /// A list of alternative names for the command.
|
||||
//! fn aliases(&self) -> &'static [&'static str] {
|
||||
//! &["hola", "hey", "hi", "konnichiwa"]
|
||||
//! }
|
||||
//!
|
||||
//!
|
||||
//! /// A description of the command and its functionality.
|
||||
//! fn description(&self) -> &'static str {
|
||||
//! "This command responds with a greeting."
|
||||
//! }
|
||||
//!
|
||||
//!
|
||||
//! /// A list of arguments the command accepts. These are solely for documentation purposes,
|
||||
//! /// they won't affect the parsing you do in the command's `execute` method.
|
||||
//! fn arguments(&self) -> &'static [Argument] {
|
||||
@@ -135,7 +134,7 @@
|
||||
//! },
|
||||
//! ]
|
||||
//! }
|
||||
//!
|
||||
//!
|
||||
//! async fn execute(
|
||||
//! &self,
|
||||
//! ctx: CommandContext<'_, T>,
|
||||
@@ -146,12 +145,11 @@
|
||||
//! args
|
||||
//! )
|
||||
//! .unwrap_or(NeedyGreedyString("???".to_string()));
|
||||
//!
|
||||
//!
|
||||
//! ctx.send()
|
||||
//! .content(format!("Hello, {}!", name.0))
|
||||
//! .await
|
||||
//! .map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
//!
|
||||
//! .await?;
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! }
|
||||
@@ -161,4 +159,6 @@ pub mod bot;
|
||||
pub mod commands;
|
||||
pub mod events;
|
||||
pub mod interactions;
|
||||
pub mod messages;
|
||||
pub mod runtime;
|
||||
pub mod tasks;
|
||||
|
||||
@@ -1,27 +1,88 @@
|
||||
//! Runtime app state of the shard.
|
||||
//!
|
||||
//! The [`Runtime`] is an abstraction over the application's state and provides access to various
|
||||
//! components, such as the HTTP client, application information, and command registry. This was
|
||||
//! done because there's a lot of data the application depends on to execute, and passing all those
|
||||
//! values around individually was cumbersome.
|
||||
//!
|
||||
//! The [`Runtime`] includes the following data:
|
||||
//!
|
||||
//! - The HTTP API client.
|
||||
//! - The application's details, including the user profile.
|
||||
//! - The component registry.
|
||||
//! - The prefix getter.
|
||||
//! - The list of available commands.
|
||||
//! - The app state.
|
||||
//!
|
||||
//! The [`Runtime`] is always passed around as [`Arc<Runtime>`], so it can be easily shared between
|
||||
//! different parts of the application.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use twilight_cache_inmemory::InMemoryCache;
|
||||
use twilight_http::Client;
|
||||
use twilight_model::gateway::payload::outgoing::UpdatePresence;
|
||||
use twilight_model::oauth::Application;
|
||||
|
||||
use crate::framework::{
|
||||
bot::IntoPrefixes, commands::Command, interactions::registry::ComponentRegistry,
|
||||
};
|
||||
use crate::framework::bot::PrefixGetter;
|
||||
use crate::framework::commands::Command;
|
||||
use crate::framework::interactions::registry::ComponentRegistry;
|
||||
|
||||
pub struct Runtime<T> {
|
||||
/// The application's runtime tools.
|
||||
pub struct Runtime<T, E> {
|
||||
/// The HTTP client used for making requests to the HTTP API.
|
||||
pub http: Client,
|
||||
|
||||
/// Details about the application.
|
||||
pub app: Application,
|
||||
pub registry: ComponentRegistry<T>,
|
||||
pub prefixes: Arc<dyn IntoPrefixes<T> + Send + Sync + 'static>,
|
||||
pub commands: Vec<Arc<dyn Command<T>>>,
|
||||
|
||||
/// The component registry.
|
||||
pub registry: ComponentRegistry<T, E>,
|
||||
|
||||
/// The command prefixes getter.
|
||||
pub prefixes: Arc<dyn PrefixGetter<T, E> + Send + Sync + 'static>,
|
||||
|
||||
/// The commands available in the application.
|
||||
pub commands: Vec<Arc<dyn Command<T, E>>>,
|
||||
|
||||
/// The application state.
|
||||
pub state: Arc<T>,
|
||||
|
||||
pub cache: InMemoryCache,
|
||||
|
||||
/// Used to send messages through the gateway.
|
||||
pub gateway_tx: UnboundedSender<UpdatePresence>,
|
||||
|
||||
pub shard_number: u32,
|
||||
pub shard_total: u32,
|
||||
}
|
||||
|
||||
impl<T> Runtime<T> {
|
||||
impl<T, E> Runtime<T, E> {
|
||||
/// Get the command prefixes set for the server and user ID.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `server_id` - The ID of the server in which the command, interaction, or event is
|
||||
/// running. When this is [`None`] it means the prefix should be returned for the user's DM
|
||||
/// channel.
|
||||
/// * `user_id` - The user who triggered the event.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Vec<String>`] - A list of prefixes, all which should be taken into account. It's safe to
|
||||
/// default to the first item of the list when a single prefix should be used (e.g. to display
|
||||
/// it to the user).
|
||||
pub async fn get_prefixes(&self, server_id: Option<u64>, user_id: Option<u64>) -> Vec<String> {
|
||||
self.prefixes.clone().get(self, server_id, user_id).await
|
||||
}
|
||||
|
||||
pub fn get_command(&self, name: &str) -> Option<Arc<dyn Command<T>>> {
|
||||
/// Get a command by its name or alias.
|
||||
///
|
||||
/// Arguments:
|
||||
/// * `name` - The command's name or alias, without prefix.
|
||||
///
|
||||
/// Returns:
|
||||
/// [`Option<Arc<dyn Command<T>>>`] - The command if found, otherwise `None`.
|
||||
pub fn get_command(&self, name: &str) -> Option<Arc<dyn Command<T, E>>> {
|
||||
self.commands
|
||||
.iter()
|
||||
.find(|cmd| {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
use std::fmt::Debug;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::framework::runtime::Runtime;
|
||||
|
||||
#[async_trait]
|
||||
pub trait Task<S, E>: Send + Sync + Debug {
|
||||
fn delay(&self) -> Duration;
|
||||
|
||||
async fn run(&self, rt: &Runtime<S, E>) -> anyhow::Result<()>;
|
||||
}
|
||||
+100
-25
@@ -1,25 +1,24 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
framework::commands::{
|
||||
Command, CommandError,
|
||||
arguments::{Argument, NeedyString, ParseArguments},
|
||||
context::CommandContext,
|
||||
},
|
||||
groups::about::{
|
||||
components::{
|
||||
HelpDonateLinkButton, HelpGoBackButton, HelpSupportLinkButton, HelpWebsiteLinkButton,
|
||||
},
|
||||
embeds::{get_all_command_help_embed, get_command_help_embed, get_command_not_found_embed},
|
||||
},
|
||||
state::State,
|
||||
use crate::extras::Extras;
|
||||
use crate::framework::commands::arguments::{Arg, Argument, NeedyString, ParseArguments};
|
||||
use crate::framework::commands::context::CommandContext;
|
||||
use crate::framework::commands::{Command, CommandError};
|
||||
use crate::framework::runtime::Runtime;
|
||||
use crate::groups::about::components::{
|
||||
HelpDonateLinkButton, HelpGoBackButton, HelpSupportLinkButton, HelpWebsiteLinkButton,
|
||||
};
|
||||
use crate::groups::about::embeds::{
|
||||
get_all_command_help_embed, get_command_help_embed, get_command_not_found_embed,
|
||||
};
|
||||
use crate::groups::calls::components::CodeOfConductButton;
|
||||
use crate::state::State;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State> for HelpCommand {
|
||||
impl Command<State, Extras> for HelpCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"help"
|
||||
}
|
||||
@@ -38,38 +37,114 @@ impl Command<State> for HelpCommand {
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State>,
|
||||
ctx: CommandContext<'_, State, Extras>,
|
||||
args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let command_name: Option<NeedyString> = ParseArguments::parse(args).unwrap();
|
||||
let command_name = Arg::<Option<NeedyString>>::parse(args, &ctx).await.unwrap();
|
||||
|
||||
if let Some(command_name) = command_name {
|
||||
if let Some(command_name) = command_name.0 {
|
||||
if let Some(command) = ctx.rt.get_command(&command_name) {
|
||||
ctx.send()
|
||||
.embed(get_command_help_embed(&command, &ctx))
|
||||
.component(HelpGoBackButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to send message! {e}"))
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.inspect_err(|e| log::error!("Failed to send message! {e}"))?;
|
||||
} else {
|
||||
ctx.send()
|
||||
.embed(get_command_not_found_embed(ctx.rt, command_name.0))
|
||||
.embed(get_command_not_found_embed(&ctx.rt, command_name.0))
|
||||
.component(HelpGoBackButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
ctx.send()
|
||||
.embed(get_all_command_help_embed(ctx.prefix, ctx.rt))
|
||||
.embed(get_all_command_help_embed(ctx.prefix, &ctx.rt, &ctx.extras))
|
||||
.component(HelpWebsiteLinkButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.component(HelpDonateLinkButton, 1)
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.component(CodeOfConductButton, 1)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CodeOfConductCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State, Extras> for CodeOfConductCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"codeofconduct"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["co"]
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Shows a link to Payphone's Code of Conduct."
|
||||
}
|
||||
|
||||
fn show_in_help(&self, _rt: &Runtime<State, Extras>, _extras: &Extras) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn only_runs_if_startswith(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State, Extras>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
ctx.send()
|
||||
.content("<https://payphone.lat/code-of-conduct>")
|
||||
.reply_to(ctx.event.id)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DonateCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State, Extras> for DonateCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"donate"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["d"]
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Shows a link to Payphone's Ko-Fi page."
|
||||
}
|
||||
|
||||
fn show_in_help(&self, _rt: &Runtime<State, Extras>, _extras: &Extras) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn only_runs_if_startswith(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State, Extras>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
ctx.send()
|
||||
.reply_to(ctx.event.id)
|
||||
.content("<https://ko-fi.com/payphonedev>")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
framework::interactions::{InteractionError, components::Button, context::InteractionContext},
|
||||
groups::about::embeds::get_all_command_help_embed,
|
||||
state::State,
|
||||
};
|
||||
use crate::extras::Extras;
|
||||
use crate::framework::interactions::InteractionError;
|
||||
use crate::framework::interactions::components::Button;
|
||||
use crate::framework::interactions::context::InteractionContext;
|
||||
use crate::framework::messages::MessageEditBuilder;
|
||||
use crate::groups::about::embeds::get_all_command_help_embed;
|
||||
use crate::state::State;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HelpGoBackButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpGoBackButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("← Go back to commands list")
|
||||
impl Button<State, Extras> for HelpGoBackButton {
|
||||
fn id(&self) -> Option<String> {
|
||||
Some("help:go-back".into())
|
||||
}
|
||||
|
||||
async fn on_press(&self, ctx: InteractionContext<State>) -> Result<(), InteractionError> {
|
||||
fn label(&self) -> Option<String> {
|
||||
Some("← Go back to commands list".into())
|
||||
}
|
||||
|
||||
async fn on_press(
|
||||
&self,
|
||||
ctx: InteractionContext<State, Extras>,
|
||||
) -> Result<(), InteractionError> {
|
||||
ctx
|
||||
.original_message()
|
||||
.expect(
|
||||
"Buttons always have the original message in the interaction context, this one didn't (got None)"
|
||||
.edit_original_message(
|
||||
MessageEditBuilder::new().embed(
|
||||
get_all_command_help_embed(&ctx.prefix, &ctx.rt, &ctx.extras)
|
||||
)
|
||||
.component(HelpWebsiteLinkButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.component(HelpDonateLinkButton, 1)
|
||||
.component(CodeOfConductLinkButton, 1)
|
||||
)
|
||||
.edit()
|
||||
.embed(get_all_command_help_embed(&ctx.prefix, ctx.rt))
|
||||
.component(HelpWebsiteLinkButton, 1)
|
||||
.component(HelpSupportLinkButton, 1)
|
||||
.component(HelpDonateLinkButton, 1)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("An error occurred while attempting to edit the original message of an interaction. {e:?}"))
|
||||
.map_err(
|
||||
@@ -40,13 +49,13 @@ impl Button<State> for HelpGoBackButton {
|
||||
pub struct HelpSupportLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpSupportLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Get help")
|
||||
impl Button<State, Extras> for HelpSupportLinkButton {
|
||||
fn label(&self) -> Option<String> {
|
||||
Some("Get help".into())
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://discord.gg/9daSsrfTmE")
|
||||
fn url(&self) -> Option<String> {
|
||||
Some("https://discord.gg/9daSsrfTmE".into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,13 +63,13 @@ impl Button<State> for HelpSupportLinkButton {
|
||||
pub struct HelpWebsiteLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpWebsiteLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Website")
|
||||
impl Button<State, Extras> for HelpWebsiteLinkButton {
|
||||
fn label(&self) -> Option<String> {
|
||||
Some("Website".into())
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://payphone.lat/")
|
||||
fn url(&self) -> Option<String> {
|
||||
Some("https://payphone.lat/".into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +77,26 @@ impl Button<State> for HelpWebsiteLinkButton {
|
||||
pub struct HelpDonateLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State> for HelpDonateLinkButton {
|
||||
fn label(&self) -> Option<&'static str> {
|
||||
Some("Donate")
|
||||
impl Button<State, Extras> for HelpDonateLinkButton {
|
||||
fn label(&self) -> Option<String> {
|
||||
Some("Donate".into())
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<&'static str> {
|
||||
Some("https://ko-fi.com/payphonedev")
|
||||
fn url(&self) -> Option<String> {
|
||||
Some("https://ko-fi.com/payphonedev".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CodeOfConductLinkButton;
|
||||
|
||||
#[async_trait]
|
||||
impl Button<State, Extras> for CodeOfConductLinkButton {
|
||||
fn label(&self) -> Option<String> {
|
||||
Some("Code of Conduct".into())
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<String> {
|
||||
Some("https://payphone.lat/code-of-conduct".into())
|
||||
}
|
||||
}
|
||||
|
||||
+29
-18
@@ -5,20 +5,19 @@ use twilight_util::builder::embed::{
|
||||
EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder, ImageSource,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
constants::{COLOR_RED, COLOR_WHITE},
|
||||
discord::{images, sanitizing::SanitizeDiscordMarkdown},
|
||||
framework::{
|
||||
commands::{Command, context::CommandContext},
|
||||
runtime::Runtime,
|
||||
},
|
||||
state::State,
|
||||
utils::ToTitleCase,
|
||||
};
|
||||
use crate::constants::{COLOR_RED, COLOR_WHITE, VERSION};
|
||||
use crate::discord::images;
|
||||
use crate::discord::sanitizing::SanitizeDiscordMarkdown;
|
||||
use crate::extras::Extras;
|
||||
use crate::framework::commands::Command;
|
||||
use crate::framework::commands::context::CommandContext;
|
||||
use crate::framework::runtime::Runtime;
|
||||
use crate::state::State;
|
||||
use crate::utils::ToTitleCase;
|
||||
|
||||
pub(super) fn get_command_help_embed(
|
||||
command: &Arc<dyn Command<State>>,
|
||||
ctx: &CommandContext<'_, State>,
|
||||
command: &Arc<dyn Command<State, Extras>>,
|
||||
ctx: &CommandContext<'_, State, Extras>,
|
||||
) -> Embed {
|
||||
let name = format!("{}{}", ctx.prefix, command.name());
|
||||
let arguments = command
|
||||
@@ -42,6 +41,7 @@ pub(super) fn get_command_help_embed(
|
||||
ctx.rt.app.bot.as_ref().unwrap().avatar,
|
||||
ctx.rt.app.bot.as_ref().unwrap().id.get(),
|
||||
ctx.rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -52,6 +52,7 @@ pub(super) fn get_command_help_embed(
|
||||
ctx.rt.app.bot.as_ref().unwrap().avatar,
|
||||
ctx.rt.app.bot.as_ref().unwrap().id.get(),
|
||||
ctx.rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
@@ -79,12 +80,17 @@ pub(super) fn get_command_help_embed(
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(super) fn get_all_command_help_embed<T>(prefix: &str, rt: Arc<Runtime<T>>) -> Embed {
|
||||
pub(super) fn get_all_command_help_embed(
|
||||
prefix: &str,
|
||||
rt: &Runtime<State, Extras>,
|
||||
extras: &Extras,
|
||||
) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.author(EmbedAuthorBuilder::new(rt.app.name.clone()).icon_url(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
))
|
||||
.unwrap()))
|
||||
.title("Pick up the phone and start making friends!")
|
||||
@@ -93,31 +99,35 @@ pub(super) fn get_all_command_help_embed<T>(prefix: &str, rt: Arc<Runtime<T>>) -
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
))
|
||||
.unwrap())
|
||||
.color(COLOR_WHITE)
|
||||
.field(EmbedFieldBuilder::new("Commands", get_commands_table(rt)).build())
|
||||
.footer(EmbedFooterBuilder::new(format!("v{} - Use {}help <command> to get more information about a specific command.", env!("CARGO_PKG_VERSION"), prefix)))
|
||||
.field(EmbedFieldBuilder::new("Commands", get_commands_table(rt, extras)).build())
|
||||
.footer(EmbedFooterBuilder::new(format!("v{VERSION}-{} - Use {}help <command> to get more information about a specific command.", rt.shard_number + 1, prefix)))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub(super) fn get_commands_table<T>(rt: Arc<Runtime<T>>) -> String {
|
||||
pub(super) fn get_commands_table<T>(rt: &Runtime<T, Extras>, extras: &Extras) -> String {
|
||||
let mut table = String::new();
|
||||
|
||||
for command in rt.commands.iter() {
|
||||
table.push_str(&format!("{:<12}", command.name()));
|
||||
if command.show_in_help(rt, extras) {
|
||||
table.push_str(&format!("{:<13}", command.name()));
|
||||
}
|
||||
}
|
||||
|
||||
format!("```\n{table}\n```")
|
||||
}
|
||||
|
||||
pub(super) fn get_command_not_found_embed<T>(rt: Arc<Runtime<T>>, name: String) -> Embed {
|
||||
pub(super) fn get_command_not_found_embed<T>(rt: &Runtime<T, Extras>, name: String) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.color(COLOR_RED)
|
||||
.author(EmbedAuthorBuilder::new(rt.app.name.clone()).icon_url(ImageSource::url(images::get_avatar_url(
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
))
|
||||
.unwrap()))
|
||||
.title("That's not a command")
|
||||
@@ -126,6 +136,7 @@ pub(super) fn get_command_not_found_embed<T>(rt: Arc<Runtime<T>>, name: String)
|
||||
rt.app.bot.as_ref().unwrap().avatar,
|
||||
rt.app.bot.as_ref().unwrap().id.get(),
|
||||
rt.app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
))
|
||||
.unwrap())
|
||||
.build()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod commands;
|
||||
pub mod components;
|
||||
pub mod embeds;
|
||||
pub mod tasks;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
use async_trait::async_trait;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use twilight_model::gateway::payload::outgoing::UpdatePresence;
|
||||
use twilight_model::gateway::presence::{Activity, ActivityType, Status};
|
||||
|
||||
use crate::extras::Extras;
|
||||
use crate::framework::runtime::Runtime;
|
||||
use crate::framework::tasks::Task;
|
||||
use crate::state::State;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdatePresenceTask;
|
||||
|
||||
#[async_trait]
|
||||
impl Task<State, Extras> for UpdatePresenceTask {
|
||||
fn delay(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(120)
|
||||
}
|
||||
|
||||
async fn run(&self, rt: &Runtime<State, Extras>) -> anyhow::Result<()> {
|
||||
let presences = vec![
|
||||
"Billie Jean is not my lover",
|
||||
"Baby vamo' a buscar una excusa para vernos",
|
||||
"Vi que estaba sola y que queria bellaquear",
|
||||
"Esto es real gangsta love",
|
||||
"Dice que está acompañada, pero sé que en el fondo ella se encuentra sola",
|
||||
"Conmigo todo eso ignora, bailando se van las horas",
|
||||
"Me dice 'I wanna know you more', pero no logro entenderte",
|
||||
"Tiene una amiga que le traduce, ella sola se lo introduce",
|
||||
"Y aunque intentara olvidarte, mi boca no lo haría",
|
||||
"Qué más da si besarte es tan fácil",
|
||||
"Quería olvidarte, no lo logré, pero casi",
|
||||
"Tú no me dejas en paz, de mi mente no te vas",
|
||||
"No me busque' en Instagram, mami, búscame en casa",
|
||||
"Dime dónde tú está', que yo por ti cojo un vuelo, y a Yonaguni le llego",
|
||||
"Masivo, we run the world (da-ddy)",
|
||||
"Vamos cógele el ritmo, cintura, rodilla al piso",
|
||||
"Oh-eh-oh, mano arriba, no la dejes caer",
|
||||
"Y esto está como, como pa, como pa, como pa rumbiar, pa pasarla caliente, con toda mi gente",
|
||||
"Nos favorece el sol, tu bronceado y el calor",
|
||||
"Porque hoy hace caloooor, hace un re caloooor",
|
||||
"Tan bonita como tu, tan bonita y sola",
|
||||
"Te he visto antes, te pareces al amor de mi vida",
|
||||
"Tú está' como pa' mete-, pa' meterte el cora'",
|
||||
"Whine up, whine up, whine up, mami baila, baila-a",
|
||||
"Esto es pa que baile'-e'-e', mueve la cintura y dale pa'l suelo",
|
||||
"Para todo' eso' amore que, que fueron obligados a ser separados",
|
||||
"Dime ¿Cómo le explico a mi destino que ya no estás ahí?",
|
||||
"Eres la niña de mis ojos, tú, eres todo lo que quiero yo",
|
||||
"Las tentacione' así como tú merecen pecado' como yo",
|
||||
"Ay, tienes la magia, tú sí tienes una vainita que a mí me encanta, me enloquece",
|
||||
"Ay tú no me dices que sí, que sí que sí que sí",
|
||||
"Si tú me dices ahorita que me quieres a tu la'o que lindo sería",
|
||||
"La gente me dice que ya es muy obvio que ya se me nota",
|
||||
"Si tú supieras que por ti me muero, que yo te quiero",
|
||||
"Si tú supieras lo que te esperado, que yo te amo",
|
||||
"Y me dijeron que te vieron sola ¿Por qué estás sola?",
|
||||
"Ella está recién soltera, de hace poquito",
|
||||
"El pelo se lo suelta, el vestido cortito",
|
||||
"Si yo no te escribo, tú no me escribe'",
|
||||
"Negra, rara vez te vi bien tando con aquel",
|
||||
"Te estoy viendo de hace un tiempo, 'toy esperando el momento, ma', no paro de pensarte, ¿qué le voy a hacer?",
|
||||
"Antes que te vayas dame un beso",
|
||||
"C'mon (Oh oh oh oh oh oh), Daddy (Oh oh oh oh oh oh)",
|
||||
"Y el día está soleao, te quiero de mi lao",
|
||||
"Baby, te vo'a dar una vuelta por la playita",
|
||||
"Aquella noche que volviste, llorando me convenciste",
|
||||
"Malo, malo, pero malo",
|
||||
"Goin' deep in with me for no reason",
|
||||
"It ain't no problem, it's cool to do it in the night",
|
||||
"You give me your love, but it's borrowed",
|
||||
"Y no colme la paciencia si no quieres tu demencia",
|
||||
"Tengo su criptonita, y para sus mujeres les tengo una vitamina",
|
||||
"Sólo somos tú y yo",
|
||||
"Llegó el tiempo de los dos",
|
||||
"De verdad tienes lo que me gusta a mí",
|
||||
"Al ritmo y al movimiento, le ponemos sentimiento",
|
||||
"Tú eres hielo, yo soy fuego, vamo' a ver quién se va a quemar",
|
||||
"Bailando así pidiendo más y más calor",
|
||||
"Rumba pa' que tú abuses, BUM!",
|
||||
"Nueva temporada ya empezó, dale play",
|
||||
"Sonido del bajo, Bizarrap, suena el bass",
|
||||
"Si me preguntan, a nadie yo le debo",
|
||||
"Ahora estoy buscando algo más, una razón para volverme a enamorar (puede que seas tú)",
|
||||
"Quiero una chica, quiero una gyal",
|
||||
"Que-que-que no diga que no, que no, que no, que no, que no, que",
|
||||
"Me monté en un barco, he cruzado el mar, he subido el río",
|
||||
"Le digo hola, ella me dice goodbye",
|
||||
"Si yo le salgo por la izquierda, se va pa' la derecha",
|
||||
"No se lo que le pasa, conmigo ella no quiere bailar",
|
||||
"Yo quería decirle que me encanta",
|
||||
"Una varita diseño su cuerpo",
|
||||
"Y la verdad, si está mas buena, explota",
|
||||
"Baby yo llevo tiempo deseandote aquí",
|
||||
"Tiraré la casa por la ventana",
|
||||
"Y no te importa cuantos caigan por error",
|
||||
"Presiento que tu siempre vas y vienes",
|
||||
"Presiento que serás de esos errores que estoy dispuesto a cometer",
|
||||
"Podré vivir sin escuchar tu voz",
|
||||
"Bebé, me enteré que con ella peleaste",
|
||||
"Y a estas alturas no importa si estás con él",
|
||||
"La luna llena alumbrandote",
|
||||
"Cuando te vi, supe que iba a pasar",
|
||||
"Que me gustas mucho con bastante",
|
||||
"En la radio, tu song favorito",
|
||||
"Como mango y limón, saborearte",
|
||||
"Tú eres la dulce fruta que mi paladar añora",
|
||||
"Esa cara de ternura sé que esconde una atrevida",
|
||||
"Teresa es una asesina, que cuando camina la ven y dicen oooh",
|
||||
"Y no le importa si el novio le corta, se corta una botella y se va para el club",
|
||||
"Essa novinha é terrorista, é especialista",
|
||||
"Olha o que ela faz num baile funk com as amiga",
|
||||
"Cara de doncella, pero mas bestia que bella",
|
||||
"Si me avisa en cinco estoy, porque sabe como soy",
|
||||
"Su gato no le da, entonces me llama y voy",
|
||||
"Se te nota que quieres de mi boca",
|
||||
"Si hay rumba no quiero mas na'",
|
||||
"Yo lo que vine es a pasarla bien",
|
||||
"Ella no e' tuya, te vendió sueño, ey, dice que no tiene dueño",
|
||||
"Te está haciendo lucir mal, manito; yo que tú, ruedo",
|
||||
"Los besos no se dan, se roban",
|
||||
"Yo tengo tu amor, I got your love",
|
||||
"Como ríe la vida si tus ojos negros me quieren mirar",
|
||||
"El día que me quieras la rosa que engalana se vestirá de fiesta con su mejor color",
|
||||
"Luciérnagas curiosas que verán que eres mi consuelo",
|
||||
"DANZA KUDUROOOOOO",
|
||||
"La mano arriba, cintura sola, da media vuelta, danza kuduro",
|
||||
"¿Quién puede domar la fuerza del mal que se mete por tus vena'?",
|
||||
"¿Quién puede parar eso que al bailar descontrola tu cadera?",
|
||||
"Hey DJ, póngale la música que le gusta",
|
||||
"Y cómo hago con mi mente pa' decirle que los momentos que tuvimos se quedan en la nada",
|
||||
"No necesito quejas, sino contención",
|
||||
"Cómo hago pa' mentirme diciéndome que no quiero verte aunque extrañe tu cara",
|
||||
"Y no puedo sacarte de mi mente, y no quiero hablar con otra gente",
|
||||
"Y si tengo que escoger, me quedo, me quedo contigo",
|
||||
"Y si yo vuelvo a San Juan, yo bailo, yo bailo contigo",
|
||||
"Y si te tengo que olvidar, tú sabe' que no va a pasar",
|
||||
"Contigo desde Alaska hasta Buenos Aires",
|
||||
"There on the subway, I nearly had a breakdown",
|
||||
"Tú no fuiste la primera, tampoco serás la última",
|
||||
"He giving me that good shit, that make me not quit",
|
||||
"Oh, he give it to me everyday, everyday, everyday",
|
||||
"You just want attention, you don't want my heart",
|
||||
"Maybe you just hate the thought of me with someone new",
|
||||
"Y la vi, que va a ser, una hermosa lucifer",
|
||||
"Y es que este amor es azul como el mar azul",
|
||||
"Como de tu mirada nació mi ilusión",
|
||||
"Fue una mañana que yo te encontré, cuando la brisa besaba tu dulce piel",
|
||||
"Tus ojos tristes que al ver adoré, la noche que yo te amé, azul",
|
||||
"Hoy miro al cielo y en ti puedo ver, la estrella que siempre soñé (azul)",
|
||||
"Eres la niña que siempre busqué, azul",
|
||||
"Es tu inocencia que quiero entender",
|
||||
"Y con la lluvia pintada de azul, por siempre serás solo tú",
|
||||
"Azul como el lucero de nuestra pasión",
|
||||
"I heard the music through the walls",
|
||||
"I felt you here before I even saw your face",
|
||||
"Though we can't get higher",
|
||||
"But we keep going up, going up, going up, going up, going up",
|
||||
"They could describe everything with one single word",
|
||||
"Eres lo primero que yo pienso cuando alguien me dice 'pide un deseo'",
|
||||
"Tengamos algo sin mentiras",
|
||||
"Y ya sé que estás aburrida de promesas incumplidas",
|
||||
"Quiero que tu seas mi princesa",
|
||||
"I wanna let my hair down while I'm staring down your face",
|
||||
"Que lindo que te queda ese look bronceado",
|
||||
"Si tú me bailas así yo me muero de amor",
|
||||
"Boy, don't make me choose",
|
||||
"We can share one seat",
|
||||
"I think you wanna uh",
|
||||
"I think you know what this is",
|
||||
"Pretty blue streetlights",
|
||||
"In a room full of people I look at you",
|
||||
"Somos dos desconocidos que se quieren conocer muy bien",
|
||||
"Frequentemos nuestros cuerpos para ver que tal nos va",
|
||||
"Nena, yo quiero con usted ser caballero",
|
||||
"Deja que pase, pues que pase, lo que tenga que pasar",
|
||||
"Todo casual, nada ideal, medio eventual, casi ilegal",
|
||||
"Mami, long time sin saber de ti",
|
||||
"Un brindis, vasitos arriba, y tú te pones loquita, mamita",
|
||||
"Estoy ansioso por estar solos tú y yo",
|
||||
"Good girl, I know you want it",
|
||||
"I know you want it, you're a good girl (oh yeah)",
|
||||
"Baby, it's in your nature (meow)",
|
||||
"The way you grab me, must wanna get nasty",
|
||||
"What do they make dreams for?",
|
||||
"Ella sale por deporte, por la' noches",
|
||||
"Sé muy dentro que yo sin ti estoy mejor",
|
||||
"Oye, escucha lo que tengo que decir",
|
||||
"Que sea así nomá y en no má' de seis tomas",
|
||||
"Ya no me seduce la que se produce toda",
|
||||
"Creo que me aburre lo que hoy produce la moda",
|
||||
"Tu sonrisa rota me trastorna todavía",
|
||||
"Vuelvo por necesidad de algo real y recíproco",
|
||||
"Yo lo sentí en un segundo, me enamoré de primero",
|
||||
"Tú te quedaste callada, y yo te dije te quiero",
|
||||
"Imaginando de esos besos tuyos, porque no tengo de esos besos tuyos",
|
||||
"Y mientras me enamoraba dijiste que había un tercero",
|
||||
"Y aunque sé bien que si no hay otra voy a buscarte en otra boca",
|
||||
"Como te extraño nena, no sabés",
|
||||
"Me siento fuerte y débil a la vez",
|
||||
"Quizá eso sea el amor",
|
||||
"Dímelo si el cabrón no vale la pena y lo borramos de la escena",
|
||||
"Confiesalo",
|
||||
"Seeing you tonight, it's a bad idea, right?",
|
||||
"You know what would be really fucking cool on this? Sabrina",
|
||||
"She was covered in leather and gold",
|
||||
"Little Miss Perfect sitting at the train stop",
|
||||
"She's, somewhere, somewhere, somewhere in Brooklyn",
|
||||
"Before I got her name, along came a train",
|
||||
"Next stop, Brooklyn",
|
||||
"Y si te invito a una copa, y me acerco a tu boca",
|
||||
"Y si te robo un besito, a ver, ¿te enojas conmigo?",
|
||||
"Una aventura es más divertida si huele a peligro",
|
||||
"That's when the taxi man turned on the radio and a Jay-Z song was on",
|
||||
"I'm nodding my head like, yeah, moving my hips like, yeah",
|
||||
"Yeah, it's a party in the U.S.A.",
|
||||
"Just shoot for the stars if it feels right",
|
||||
"And aim for my heart if you feel like it",
|
||||
"You say I'm a kid, my ego is big, I don't give a shit",
|
||||
"Cuando se apaga la luz",
|
||||
"Annie, are you ok?",
|
||||
"Will you tell us that you're ok?",
|
||||
"Life's like a road that you travel on",
|
||||
"Life is a highway, I wanna ride it all night long",
|
||||
"I wanna drive it all night long (all night long)",
|
||||
"Tu cuerpo y el mío, llenando el vacío, subiendo y bajando",
|
||||
"Yo quiero estar contigo, vivir contigo, bailar contigo, tener contigo una noche loca",
|
||||
"But let me stop you there, oh, before you speak",
|
||||
"Nah to the ah to the, no, no, no",
|
||||
"My name is no, my sign is no, my number is no",
|
||||
"If that boy ain't giving up, lick your lips and swing your hips",
|
||||
];
|
||||
|
||||
let mut rng = ChaCha20Rng::from_os_rng();
|
||||
let index = rng.random_range(0..presences.len());
|
||||
|
||||
rt.gateway_tx.send(UpdatePresence::new(
|
||||
vec![Activity {
|
||||
application_id: None,
|
||||
assets: None,
|
||||
buttons: vec![],
|
||||
created_at: None,
|
||||
details: None,
|
||||
emoji: None,
|
||||
flags: None,
|
||||
id: None,
|
||||
instance: None,
|
||||
kind: ActivityType::Listening,
|
||||
name: format!(
|
||||
"p.help - {} - {}/{}",
|
||||
presences[index], rt.shard_number, rt.shard_total
|
||||
),
|
||||
party: None,
|
||||
secrets: None,
|
||||
state: None,
|
||||
timestamps: None,
|
||||
url: None,
|
||||
}],
|
||||
false,
|
||||
None,
|
||||
Status::Online,
|
||||
)?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+474
-50
@@ -1,13 +1,27 @@
|
||||
use async_trait::async_trait;
|
||||
use deadpool_redis::redis::AsyncTypedCommands;
|
||||
use scc::HashSet;
|
||||
use time::UtcDateTime;
|
||||
use twilight_model::id::Id;
|
||||
|
||||
use crate::{
|
||||
database::models::{Call, Channel},
|
||||
framework::commands::{Command, CommandError, context::CommandContext},
|
||||
state::State,
|
||||
utils::Bye,
|
||||
use crate::database::models::{Call, Channel, Participant};
|
||||
use crate::discord::sanitizing::SanitizeDiscordMarkdown;
|
||||
use crate::extras::Extras;
|
||||
use crate::framework::commands::arguments::{Arg, Argument, ParseArguments};
|
||||
use crate::framework::commands::context::CommandContext;
|
||||
use crate::framework::commands::permissions::Permission;
|
||||
use crate::framework::commands::{Command, CommandError};
|
||||
use crate::groups::calls::masks::{get_mask_display_name, get_mask_emoji};
|
||||
use crate::groups::calls::permissions::{
|
||||
IsCallParticipantPermission, NonNewAccountPermission, NotBannedPermission,
|
||||
};
|
||||
use crate::groups::calls::strings::{
|
||||
get_string_call_answered, get_string_call_hangup_other_party,
|
||||
get_string_call_hangup_this_party, get_string_call_no_answer, get_string_call_searching,
|
||||
get_string_tips,
|
||||
};
|
||||
use crate::state::State;
|
||||
use crate::utils::Bye;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct CallCommand {
|
||||
@@ -27,7 +41,7 @@ impl CallCommand {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State> for CallCommand {
|
||||
impl Command<State, Extras> for CallCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"call"
|
||||
}
|
||||
@@ -36,9 +50,16 @@ impl Command<State> for CallCommand {
|
||||
&["c"]
|
||||
}
|
||||
|
||||
fn permissions(&self) -> Vec<Box<dyn Permission<State, Extras> + Send + Sync + 'static>> {
|
||||
vec![
|
||||
Box::new(NotBannedPermission),
|
||||
Box::new(NonNewAccountPermission),
|
||||
]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State>,
|
||||
ctx: CommandContext<'_, State, Extras>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let channel = Channel::get_or_create_by_id(
|
||||
@@ -49,8 +70,7 @@ impl Command<State> for CallCommand {
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to get or create channel: {e:?}");
|
||||
})
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
})?;
|
||||
|
||||
if channel.is_searching || self.channel_lock.contains(&ctx.event.channel_id.get()) {
|
||||
ctx.send()
|
||||
@@ -58,8 +78,7 @@ impl Command<State> for CallCommand {
|
||||
"☎️ You are already searching for a call! Use `{}hangup` to hang up the phone.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
@@ -68,41 +87,213 @@ impl Command<State> for CallCommand {
|
||||
ctx.send()
|
||||
.content(format!("☎️ You are already on a call! Use `{}hangup` to hang up the phone, or `{}skip` to skip to the next call.", ctx.prefix, ctx.prefix))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.channel_lock
|
||||
.insert_async(ctx.event.channel_id.get())
|
||||
.await
|
||||
.bye();
|
||||
self.channel_lock.insert(ctx.event.channel_id.get()).bye();
|
||||
|
||||
let call = Call::start(&channel, &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to start call: {e:?}");
|
||||
self.channel_lock.remove(&ctx.event.channel_id.get());
|
||||
})
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
})?;
|
||||
|
||||
self.channel_lock
|
||||
.remove_async(&ctx.event.channel_id.get())
|
||||
.await;
|
||||
self.channel_lock.remove(&ctx.event.channel_id.get());
|
||||
|
||||
if let Some(call) = call {
|
||||
ctx.send().content("☎️ Call connected. Say hi!").await.bye();
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"**TIP:** {}\n☎️ {}\n☎️ {}",
|
||||
get_string_tips(ctx.prefix),
|
||||
get_string_call_searching(ctx.prefix),
|
||||
get_string_call_answered(ctx.prefix)
|
||||
))
|
||||
.await
|
||||
.bye();
|
||||
|
||||
ctx.rt
|
||||
.http
|
||||
.create_message(Id::new(call.other_party_channel_id(channel.id)))
|
||||
.content("☎️ Call connected. Say hi!")
|
||||
.content(&format!("☎️ {}", get_string_call_answered(ctx.prefix)))
|
||||
.await
|
||||
.bye();
|
||||
|
||||
let mut redis = ctx.rt.state.redis.get().await?;
|
||||
|
||||
let timestamp = UtcDateTime::now().unix_timestamp();
|
||||
|
||||
redis
|
||||
.zadd(
|
||||
"active-calls",
|
||||
format!("{}:{}", call.id, channel.id),
|
||||
timestamp,
|
||||
)
|
||||
.await?;
|
||||
redis
|
||||
.zadd(
|
||||
"active-calls",
|
||||
format!("{}:{}", call.id, call.other_party_channel_id(channel.id)),
|
||||
timestamp,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
ctx.send()
|
||||
.content("☎️ Searching for a call...")
|
||||
.content(format!(
|
||||
"**TIP:** {}\n☎️ {}",
|
||||
get_string_tips(ctx.prefix),
|
||||
get_string_call_searching(ctx.prefix)
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SkipCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State, Extras> for SkipCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"skip"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["s", "next"]
|
||||
}
|
||||
|
||||
fn permissions(&self) -> Vec<Box<dyn Permission<State, Extras> + Send + Sync + 'static>> {
|
||||
vec![
|
||||
Box::new(NonNewAccountPermission),
|
||||
Box::new(NotBannedPermission),
|
||||
Box::new(IsCallParticipantPermission),
|
||||
]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State, Extras>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let channel = Channel::get_or_create_by_id(
|
||||
ctx.event.channel_id.get(),
|
||||
&ctx.rt.state.db,
|
||||
&ctx.rt.http,
|
||||
)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to get or create channel: {e:?}");
|
||||
})?;
|
||||
|
||||
if channel.call_id.is_none() {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❌ You're not on a call! Use `{}call` to start one.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if channel.is_searching {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"☎️ You're already searching for a call! Use `{}hangup` to cancel the search.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let call = Call::get_by_id(channel.call_id.unwrap(), &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get call: {e:?}"))?
|
||||
.ok_or(CommandError::DatabaseInconsistentState)?;
|
||||
|
||||
call.hangup(&ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to hang up call: {e:?}"))?;
|
||||
|
||||
let other_channel_id = call.other_party_channel_id(ctx.event.channel_id.get());
|
||||
|
||||
ctx.rt
|
||||
.http
|
||||
.create_message(Id::new(other_channel_id))
|
||||
.content(&format!(
|
||||
"☎️ {}",
|
||||
get_string_call_hangup_other_party(ctx.prefix)
|
||||
))
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to send message to other party: {e:?}");
|
||||
})?;
|
||||
|
||||
let mut redis = ctx.rt.state.redis.get().await?;
|
||||
|
||||
let channel_keys = [
|
||||
format!("{}:{}", call.id, call.channel_ids[0]),
|
||||
format!("{}:{}", call.id, call.channel_ids[1]),
|
||||
];
|
||||
|
||||
redis
|
||||
.zrem("active-calls", &channel_keys)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to remove inactive call from redis: {e}"))
|
||||
.ok();
|
||||
|
||||
let call = Call::start(&channel, &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to start (skipped) call: {e:?}");
|
||||
})?;
|
||||
|
||||
if let Some(call) = call {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"**TIP:** {}\n☎️ Skipped! Searching for a call...\n☎️ {}",
|
||||
get_string_tips(ctx.prefix),
|
||||
get_string_call_answered(ctx.prefix)
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.bye();
|
||||
ctx.rt
|
||||
.http
|
||||
.create_message(Id::new(call.other_party_channel_id(channel.id)))
|
||||
.content(&format!("☎️ {}", get_string_call_answered(ctx.prefix)))
|
||||
.await
|
||||
.bye();
|
||||
|
||||
let mut redis = ctx.rt.state.redis.get().await?;
|
||||
|
||||
let timestamp = UtcDateTime::now().unix_timestamp();
|
||||
|
||||
redis
|
||||
.zadd(
|
||||
"active-calls",
|
||||
format!("{}:{}", call.id, channel.id),
|
||||
timestamp,
|
||||
)
|
||||
.await?;
|
||||
redis
|
||||
.zadd(
|
||||
"active-calls",
|
||||
format!("{}:{}", call.id, call.other_party_channel_id(channel.id)),
|
||||
timestamp,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"**TIP:** {}\n☎️ Skipped! Searching for a call...",
|
||||
get_string_tips(ctx.prefix)
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -113,7 +304,7 @@ impl Command<State> for CallCommand {
|
||||
pub struct HangupCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State> for HangupCommand {
|
||||
impl Command<State, Extras> for HangupCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"hangup"
|
||||
}
|
||||
@@ -122,23 +313,29 @@ impl Command<State> for HangupCommand {
|
||||
&["h", "hang"]
|
||||
}
|
||||
|
||||
fn permissions(&self) -> Vec<Box<dyn Permission<State, Extras> + Send + Sync + 'static>> {
|
||||
vec![
|
||||
Box::new(NonNewAccountPermission),
|
||||
Box::new(NotBannedPermission),
|
||||
Box::new(IsCallParticipantPermission),
|
||||
]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State>,
|
||||
ctx: CommandContext<'_, State, Extras>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let Some(channel) = Channel::get_by_id(ctx.event.channel_id.get(), &ctx.rt.state.db)
|
||||
let Some(mut channel) = Channel::get_by_id(ctx.event.channel_id.get(), &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get channel: {e:?}"))
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?
|
||||
.inspect_err(|e| log::error!("Failed to get channel: {e:?}"))?
|
||||
else {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❌ You're not on a call! Use `{}call` to start one.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
@@ -149,52 +346,73 @@ impl Command<State> for HangupCommand {
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!("Failed to cancel search: {e:?}");
|
||||
})
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
})?;
|
||||
|
||||
ctx.send()
|
||||
.content("☎️ The phone was hung up.")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.content(format!("☎️ {}", get_string_call_no_answer(ctx.prefix)))
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(call_id) = channel.call_id {
|
||||
let mut redis = ctx.rt.state.redis.get().await?;
|
||||
|
||||
let Some(call) = Call::get_by_id(call_id, &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get call: {e:?}"))
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?
|
||||
.inspect_err(|e| log::error!("Failed to get call: {e:?}"))?
|
||||
else {
|
||||
ctx.send()
|
||||
.content("❌ Call not found.")
|
||||
ctx.send().content("❌ The call could not be found.\n-# This is not supposed to happen. If the issue persists, contact a moderator in the official server.").await?;
|
||||
|
||||
channel.call_id = None;
|
||||
channel.upsert(&ctx.rt.state.db).await.bye();
|
||||
|
||||
redis
|
||||
.zrem("active-calls", format!("{}:{}", call_id, channel.id))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.bye();
|
||||
|
||||
return Err(CommandError::DatabaseInconsistentState);
|
||||
};
|
||||
|
||||
let channel_keys = [
|
||||
format!("{call_id}:{}", call.channel_ids[0]),
|
||||
format!("{call_id}:{}", call.channel_ids[1]),
|
||||
];
|
||||
|
||||
redis
|
||||
.zrem("active-calls", &channel_keys)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to remove inactive call from redis: {e}"))
|
||||
.ok();
|
||||
|
||||
call.hangup(&ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to hang up call: {e:?}"))
|
||||
.map_err(|_| CommandError::DatabaseQueryFailed)?;
|
||||
.inspect_err(|e| log::error!("Failed to hang up call: {e:?}"))?;
|
||||
|
||||
ctx.send().content("☎️ You hung up the phone.").await.bye();
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"☎️ {}",
|
||||
get_string_call_hangup_this_party(ctx.prefix)
|
||||
))
|
||||
.await
|
||||
.bye();
|
||||
|
||||
ctx.rt
|
||||
.http
|
||||
.create_message(Id::new(call.other_party_channel_id(channel.id)))
|
||||
.content("☎️ The other party hung up the phone.")
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.content(&format!(
|
||||
"☎️ {}",
|
||||
get_string_call_hangup_other_party(ctx.prefix)
|
||||
))
|
||||
.await?;
|
||||
} else {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❌ You're not on a call! Use `{}call` to start one.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await
|
||||
.map_err(|_| CommandError::MessageSendingFailed)?;
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
@@ -202,3 +420,209 @@ impl Command<State> for HangupCommand {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FriendRequestCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State, Extras> for FriendRequestCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"friendreq"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["f", "fr", "friendrequest"]
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Shows your username to the other party and asks them to send you a friend request on Discord."
|
||||
}
|
||||
|
||||
fn permissions(&self) -> Vec<Box<dyn Permission<State, Extras> + Send + Sync + 'static>> {
|
||||
vec![
|
||||
Box::new(NonNewAccountPermission),
|
||||
Box::new(NotBannedPermission),
|
||||
Box::new(IsCallParticipantPermission),
|
||||
]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
ctx: CommandContext<'_, State, Extras>,
|
||||
_args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let channel = Channel::get_by_id(ctx.event.channel_id.get(), &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get channel: {e:?}"))?;
|
||||
|
||||
let Some(channel) = channel else {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❌ You're not on a call! Use `{}call` to start one.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let call = if let Some(call_id) = channel.call_id {
|
||||
Call::get_by_id(call_id, &ctx.rt.state.db)
|
||||
.await
|
||||
.inspect_err(|e| log::error!("Failed to get call: {e:?}"))?
|
||||
} else {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❌ You're not on a call! Use `{}call` to start one.",
|
||||
ctx.prefix
|
||||
))
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(call) = call else {
|
||||
ctx.send()
|
||||
.content("❌ Oops! We could not find that call.")
|
||||
.await?;
|
||||
|
||||
return Err(CommandError::DatabaseInconsistentState);
|
||||
};
|
||||
|
||||
let other_channel_id = call.other_party_channel_id(channel.id);
|
||||
|
||||
let Some(participant) = Participant::get_for_user_and_channel_in_call(
|
||||
ctx.event.author.id.get(),
|
||||
ctx.event.channel_id.get(),
|
||||
call.id,
|
||||
&ctx.rt.state.db,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
ctx.send()
|
||||
.content("❌ You haven't even said hi! Who are you trying to add?")
|
||||
.await?;
|
||||
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
ctx
|
||||
.rt
|
||||
.http
|
||||
.create_message(
|
||||
Id::new(other_channel_id)
|
||||
)
|
||||
.content(
|
||||
&format!(
|
||||
"📬 **{}** wants you to send them a friend request on Discord! Their username is `{}`.",
|
||||
participant.display_name().sanitize(false),
|
||||
ctx.event.author.name.sanitize(false)
|
||||
)
|
||||
)
|
||||
.await
|
||||
?;
|
||||
|
||||
ctx.send()
|
||||
.content("📬 Asked the other party to send you a friend request!")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MaskCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl Command<State, Extras> for MaskCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"mask"
|
||||
}
|
||||
|
||||
fn aliases(&self) -> &'static [&'static str] {
|
||||
&["m"]
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Hides your profile from the other party by replacing your profile with a random animal. This command's preferences persist across calls.\n\nNote: Using this command without `on/off` will display your current mask status, without making any changes to it."
|
||||
}
|
||||
|
||||
fn arguments(&self) -> &'static [Argument] {
|
||||
&[Argument {
|
||||
name: "on/off",
|
||||
is_optional: true,
|
||||
is_greedy: false,
|
||||
}]
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
mut ctx: CommandContext<'_, State, Extras>,
|
||||
args: &str,
|
||||
) -> Result<(), CommandError> {
|
||||
let Arg(toggle) = match Arg::<Option<bool>>::parse(args, &ctx).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
ctx.send()
|
||||
.content(format!(
|
||||
"❓ Are you sure that's correct? Use `{}mask on` and `{}mask off`, or just `{}mask` to toggle it.",
|
||||
ctx.prefix, ctx.prefix, ctx.prefix
|
||||
))
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let mask_verb: &'static str;
|
||||
|
||||
// If toggle is Some, update user mask preference
|
||||
if let Some(toggle) = toggle {
|
||||
ctx.extras.user.is_masked = toggle;
|
||||
ctx.extras.user.insert(&ctx.rt.state.db).await?;
|
||||
|
||||
mask_verb = "was";
|
||||
} else {
|
||||
mask_verb = "is";
|
||||
}
|
||||
|
||||
let mask_status = if ctx.extras.user.is_masked {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
};
|
||||
|
||||
let channel = Channel::get_by_id(ctx.event.channel_id.get(), &ctx.rt.state.db).await?;
|
||||
let mut mask_message = format!(
|
||||
"🎭 Your mask {mask_verb} set to `{}`. You can toggle your mask on or off using `{}mask on` and `{}mask off`.",
|
||||
mask_status, ctx.prefix, ctx.prefix
|
||||
);
|
||||
|
||||
if let Some(channel) = channel
|
||||
&& let Some(call_id) = channel.call_id
|
||||
&& let Some(mut participant) = Participant::get_for_user_and_channel_in_call(
|
||||
ctx.extras.user.id,
|
||||
ctx.event.channel_id.get(),
|
||||
call_id,
|
||||
&ctx.rt.state.db,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
participant.is_masked = ctx.extras.user.is_masked;
|
||||
participant.upsert(&ctx.rt.state.db).await?;
|
||||
|
||||
if ctx.extras.user.is_masked {
|
||||
mask_message = format!(
|
||||
"🎭 Your mask {mask_verb} set to `on`. You're currently being displayed as **{} {}**. You can toggle your mask on or off using `{}mask on` and `{}mask off`.",
|
||||
get_mask_emoji(&participant.user_mask).expect("User's mask did not exist"),
|
||||
get_mask_display_name(&participant.user_mask),
|
||||
ctx.prefix,
|
||||
ctx.prefix
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.send().content(mask_message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::extras::Extras;
|
||||
use crate::framework::interactions::components::Button;
|
||||
use crate::state::State;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CodeOfConductButton;
|
||||
|
||||
impl Button<State, Extras> for CodeOfConductButton {
|
||||
fn label(&self) -> Option<String> {
|
||||
Some("Code of Conduct".into())
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<String> {
|
||||
Some("https://payphone.lat/code-of-conduct".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NeedHelpButton;
|
||||
|
||||
impl Button<State, Extras> for NeedHelpButton {
|
||||
fn label(&self) -> Option<String> {
|
||||
Some("Need Help?".into())
|
||||
}
|
||||
|
||||
fn url(&self) -> Option<String> {
|
||||
Some("https://discord.gg/9daSsrfTmE".into())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use twilight_model::channel::message::Embed;
|
||||
use twilight_model::oauth::Application;
|
||||
use twilight_util::builder::embed::{
|
||||
EmbedAuthorBuilder, EmbedBuilder, EmbedFieldBuilder, EmbedFooterBuilder, ImageSource,
|
||||
};
|
||||
|
||||
use crate::constants::{COLOR_RED, COLOR_YELLOW};
|
||||
use crate::database::models::Message;
|
||||
use crate::discord::images;
|
||||
use crate::groups::calls::moderation::Rule;
|
||||
use crate::utils::ellipse;
|
||||
|
||||
pub fn first_warning_embed(
|
||||
app: &Application,
|
||||
message: &Message,
|
||||
rules_broken: Vec<Arc<dyn Rule>>,
|
||||
) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.author(
|
||||
EmbedAuthorBuilder::new(app.name.clone())
|
||||
.icon_url(
|
||||
ImageSource::url(
|
||||
images::get_avatar_url(
|
||||
app.icon,
|
||||
app.bot.as_ref().unwrap().id.get(),
|
||||
app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
)
|
||||
).unwrap()
|
||||
)
|
||||
)
|
||||
.title("Warning")
|
||||
.description("You broke [Payphone's code of conduct](https://payphone.lat/code-of-conduct) during a call. Read the code of conduct to prevent getting banned.")
|
||||
.field(EmbedFieldBuilder::new("What message broke the rules?", ellipse(message.data.content.clone(), 500)))
|
||||
.field(EmbedFieldBuilder::new(
|
||||
"What rules did you break?",
|
||||
rules_broken
|
||||
.iter()
|
||||
.map(|rule| format!("- {}", rule.name()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
))
|
||||
.color(COLOR_YELLOW)
|
||||
.thumbnail(
|
||||
ImageSource::url(
|
||||
images::get_avatar_url(
|
||||
app.icon,
|
||||
app.bot.as_ref().unwrap().id.get(),
|
||||
app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
))
|
||||
.unwrap()
|
||||
)
|
||||
.footer(EmbedFooterBuilder::new("This is a warning because it's your first offense. Breaking the rules again will result in a ban."))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn repeat_warning_embed(
|
||||
app: &Application,
|
||||
message: &Message,
|
||||
rules_broken: Vec<Arc<dyn Rule>>,
|
||||
) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.author(
|
||||
EmbedAuthorBuilder::new(app.name.clone())
|
||||
.icon_url(
|
||||
ImageSource::url(
|
||||
images::get_avatar_url(
|
||||
app.icon,
|
||||
app.bot.as_ref().unwrap().id.get(),
|
||||
app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
)
|
||||
).unwrap()
|
||||
)
|
||||
)
|
||||
.title("Long Time no See!")
|
||||
.description("You broke [Payphone's code of conduct](https://payphone.lat/code-of-conduct) during a call. It's not your first offense, yet you've gone for a long time with a good record so we're making this a warning instead. In case you've forgotten the rules, consider this a reminder to re-read them.")
|
||||
.field(EmbedFieldBuilder::new("What message broke the rules?", ellipse(message.data.content.clone(), 500)))
|
||||
.field(EmbedFieldBuilder::new(
|
||||
"What rules did you break?",
|
||||
rules_broken
|
||||
.iter()
|
||||
.map(|rule| format!("- {}", rule.name()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
))
|
||||
.color(COLOR_YELLOW)
|
||||
.thumbnail(
|
||||
ImageSource::url(
|
||||
images::get_avatar_url(
|
||||
app.icon,
|
||||
app.bot.as_ref().unwrap().id.get(),
|
||||
app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
))
|
||||
.unwrap()
|
||||
)
|
||||
.footer(EmbedFooterBuilder::new("Breaking the rules again will result in a ban."))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn first_ban_embed(
|
||||
app: &Application,
|
||||
message: &Message,
|
||||
rules_broken: Vec<Arc<dyn Rule>>,
|
||||
) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.author(
|
||||
EmbedAuthorBuilder::new(app.name.clone())
|
||||
.icon_url(
|
||||
ImageSource::url(
|
||||
images::get_avatar_url(
|
||||
app.icon,
|
||||
app.bot.as_ref().unwrap().id.get(),
|
||||
app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
)
|
||||
).unwrap()
|
||||
)
|
||||
)
|
||||
.title("Banned")
|
||||
.description("You broke [Payphone's code of conduct](https://payphone.lat/code-of-conduct) during a call. This is your first offense, and as such, you are being issued a ban. This ban will last for 24 hours, next one will be permanent.")
|
||||
.field(EmbedFieldBuilder::new("What message broke the rules?", ellipse(message.data.content.clone(), 500)))
|
||||
.field(EmbedFieldBuilder::new(
|
||||
"What rules did you break?",
|
||||
rules_broken
|
||||
.iter()
|
||||
.map(|rule| format!("- {}", rule.name()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
))
|
||||
.color(COLOR_RED)
|
||||
.thumbnail(
|
||||
ImageSource::url(
|
||||
images::get_avatar_url(
|
||||
app.icon,
|
||||
app.bot.as_ref().unwrap().id.get(),
|
||||
app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
))
|
||||
.unwrap()
|
||||
)
|
||||
.footer(EmbedFooterBuilder::new("Breaking the rules again will result in being permanently banned."))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn final_ban_embed(
|
||||
app: &Application,
|
||||
message: &Message,
|
||||
rules_broken: Vec<Arc<dyn Rule>>,
|
||||
) -> Embed {
|
||||
EmbedBuilder::new()
|
||||
.author(
|
||||
EmbedAuthorBuilder::new(app.name.clone())
|
||||
.icon_url(
|
||||
ImageSource::url(
|
||||
images::get_avatar_url(
|
||||
app.icon,
|
||||
app.bot.as_ref().unwrap().id.get(),
|
||||
app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
)
|
||||
).unwrap()
|
||||
)
|
||||
)
|
||||
.title("Permanently Banned")
|
||||
.description("You broke [Payphone's code of conduct](https://payphone.lat/code-of-conduct) during a call. This is your final offense, and as such, you are being issued a permanent ban.")
|
||||
.field(EmbedFieldBuilder::new("What message broke the rules?", ellipse(message.data.content.clone(), 500)))
|
||||
.field(EmbedFieldBuilder::new(
|
||||
"What rules did you break?",
|
||||
rules_broken
|
||||
.iter()
|
||||
.map(|rule| format!("- {}", rule.name()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
))
|
||||
.color(COLOR_RED)
|
||||
.thumbnail(
|
||||
ImageSource::url(
|
||||
images::get_avatar_url(
|
||||
app.icon,
|
||||
app.bot.as_ref().unwrap().id.get(),
|
||||
app.bot.as_ref().unwrap().discriminator as u64,
|
||||
None
|
||||
)
|
||||
).unwrap()
|
||||
)
|
||||
.footer(EmbedFooterBuilder::new("You broke the rules 3 times."))
|
||||
.build()
|
||||
}
|
||||
+570
-58
@@ -1,27 +1,120 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::error::Error;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use linkify::LinkFinder;
|
||||
use twilight_model::{gateway::payload::incoming::MessageCreate, id::Id};
|
||||
use twilight_validate::request::webhook_username;
|
||||
|
||||
use crate::{
|
||||
database::models::{Call, Channel}, discord::{images, sanitizing::SanitizeDiscordMarkdown}, framework::{
|
||||
events::traits::{EventResult, OnMessageHandler},
|
||||
runtime::Runtime,
|
||||
}, groups::calls::moderation::escape_message, state::State
|
||||
use deadpool_redis::redis::AsyncTypedCommands;
|
||||
use itertools::Itertools;
|
||||
use time::{Duration, UtcDateTime};
|
||||
use twilight_http::api_error::ApiError;
|
||||
use twilight_http::error::ErrorType;
|
||||
use twilight_model::gateway::payload::incoming::{MessageCreate, MessageDelete, MessageUpdate};
|
||||
use twilight_model::id::Id;
|
||||
use twilight_util::builder::embed::{
|
||||
EmbedAuthorBuilder, EmbedBuilder, EmbedFooterBuilder, ImageSource,
|
||||
};
|
||||
use twilight_util::snowflake::Snowflake;
|
||||
|
||||
use crate::constants::{COLOR_WHITE, COLOR_YELLOW};
|
||||
use crate::database::models::{Call, Channel, Message, Participant, User, UserIncident};
|
||||
use crate::discord::images;
|
||||
use crate::discord::sanitizing::SanitizeDiscordMarkdown;
|
||||
use crate::extras::Extras;
|
||||
use crate::framework::commands::extras::ExtrasGetter;
|
||||
use crate::framework::events::traits::{
|
||||
EventResult, OnMessageDeleteHandler, OnMessageEditHandler, OnMessageHandler,
|
||||
};
|
||||
use crate::framework::interactions::components::ActionRow;
|
||||
use crate::framework::runtime::Runtime;
|
||||
use crate::groups::calls::components::{CodeOfConductButton, NeedHelpButton};
|
||||
use crate::groups::calls::masks::{get_mask_avatar_url, get_mask_display_name};
|
||||
use crate::groups::calls::moderation::{Action, Rule, escape_message, moderate_message};
|
||||
use crate::groups::calls::{embeds, moderation};
|
||||
use crate::state::State;
|
||||
use crate::utils::{ellipse, i64_to_u64};
|
||||
|
||||
async fn penalize_user(
|
||||
user: User,
|
||||
message: Message,
|
||||
broken_rules: Vec<Arc<dyn Rule>>,
|
||||
rt: &Runtime<State, Extras>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let reason = format!(
|
||||
"You broke the following rules during a call:\n{}",
|
||||
broken_rules
|
||||
.iter()
|
||||
.map(|rule| format!("- {}", rule.name()))
|
||||
.join("\n")
|
||||
);
|
||||
|
||||
let new_incident = user
|
||||
.incident(
|
||||
reason.clone(),
|
||||
Some(message.data.clone()),
|
||||
None,
|
||||
&rt.state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let dm_channel = rt
|
||||
.http
|
||||
.create_private_channel(Id::new(user.id))
|
||||
.await?
|
||||
.model()
|
||||
.await?;
|
||||
|
||||
let embed = if new_incident.is_first_incident {
|
||||
embeds::first_warning_embed(&rt.app, &message, broken_rules)
|
||||
} else if new_incident.counting_incidents == 0 {
|
||||
embeds::repeat_warning_embed(&rt.app, &message, broken_rules)
|
||||
} else if new_incident.counting_incidents == 1 {
|
||||
embeds::first_ban_embed(&rt.app, &message, broken_rules)
|
||||
} else {
|
||||
embeds::final_ban_embed(&rt.app, &message, broken_rules)
|
||||
};
|
||||
|
||||
rt.http
|
||||
.create_message(dm_channel.id)
|
||||
.embeds(&[embed])
|
||||
.components(&[ActionRow::new(&[
|
||||
(None, Arc::new(CodeOfConductButton)),
|
||||
(None, Arc::new(NeedHelpButton)),
|
||||
])
|
||||
.build()])
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MessageRedirector;
|
||||
pub struct CallMessageHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl OnMessageHandler<State> for MessageRedirector {
|
||||
async fn on_message(&self, event: &MessageCreate, rt: Arc<Runtime<State>>) -> EventResult {
|
||||
impl OnMessageHandler<State, Extras> for CallMessageHandler {
|
||||
async fn on_message(
|
||||
&self,
|
||||
event: &MessageCreate,
|
||||
rt: Arc<Runtime<State, Extras>>,
|
||||
_extras: Arc<dyn ExtrasGetter<State, Extras = Extras>>,
|
||||
) -> EventResult {
|
||||
if event.author.bot || event.author.system.unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if UtcDateTime::from_unix_timestamp(event.author.id.timestamp() / 1000).unwrap()
|
||||
> UtcDateTime::now() - Duration::days(30)
|
||||
&& event.author.id.get() != rt.state.bot_owner_id
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if event.content.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(guild_id) = event.guild_id else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let prefixes = rt
|
||||
.get_prefixes(event.guild_id.map(|i| i.get()), Some(event.author.id.get()))
|
||||
.await;
|
||||
@@ -72,7 +165,44 @@ impl OnMessageHandler<State> for MessageRedirector {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(opposite_channel) = Channel::get_by_id(
|
||||
let mut user = User::create_or_update(
|
||||
event.author.clone(),
|
||||
&rt.state.db,
|
||||
&rt.state.color_queue_sender,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_incidents = UserIncident::get_for_user(event.author.id.get(), &rt.state.db)
|
||||
.await?
|
||||
.iter()
|
||||
.filter(|incident| incident.is_active())
|
||||
.count();
|
||||
|
||||
if user_incidents > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if event.content.to_ascii_lowercase().starts_with("x ") {
|
||||
if !user.findings.contains("feature:hide-message") {
|
||||
user.findings.insert("feature:hide-message".into());
|
||||
user.insert(&rt.state.db).await?;
|
||||
|
||||
rt
|
||||
.http
|
||||
.create_message(
|
||||
event.channel_id
|
||||
)
|
||||
.content(
|
||||
"✨ You found a hidden feature! Messages starting with `x` followed by a space will not be sent to the other party (nor moderated, i.e. you won't get banned for what you say in them)."
|
||||
)
|
||||
.reply(event.id)
|
||||
.await?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(mut opposite_channel) = Channel::get_by_id(
|
||||
call.other_party_channel_id(event.channel_id.get()),
|
||||
&rt.state.db,
|
||||
)
|
||||
@@ -86,69 +216,451 @@ impl OnMessageHandler<State> for MessageRedirector {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if opposite_channel.call_id != Some(call.id) {
|
||||
log::error!(
|
||||
"Channel {} is listed as the opposite channel of channel {} in call {}, but its call ID is {}.",
|
||||
opposite_channel.id,
|
||||
channel.id,
|
||||
call.id,
|
||||
opposite_channel
|
||||
.call_id
|
||||
.map_or("None".into(), |i| i.to_string())
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some((webhook_id, webhook_token)) = opposite_channel
|
||||
.webhook_id
|
||||
.zip(opposite_channel.webhook_token)
|
||||
.zip(opposite_channel.webhook_token.clone())
|
||||
else {
|
||||
// Handle DM calls in the future.
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let profile = get_display_profile(event);
|
||||
let (content, rules) = escape_message(event, user.is_moderator);
|
||||
|
||||
rt.http
|
||||
.execute_webhook(Id::new(webhook_id), &webhook_token)
|
||||
.allowed_mentions(None)
|
||||
.username(&profile.name)
|
||||
.avatar_url(&profile.avatar_url)
|
||||
.content(&escape_message(event))
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to send message to webhook for call {} in channel {}: {e:?}",
|
||||
let message: Message;
|
||||
|
||||
let replying_to = if let Some(reference) = &event.reference {
|
||||
if let Some(replying_to_message_id) = reference.message_id {
|
||||
Message::get_by_call_and_message_id(
|
||||
call.id,
|
||||
opposite_channel.id
|
||||
replying_to_message_id.get(),
|
||||
&rt.state.db,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let replying_to_id = replying_to.as_ref().map(|i| i.message_id);
|
||||
|
||||
let participant = Participant::get_or_create(
|
||||
user.id,
|
||||
event.channel_id.get(),
|
||||
call.id,
|
||||
event
|
||||
.member
|
||||
.clone()
|
||||
.and_then(|member| member.nick)
|
||||
.or(event.author.global_name.clone())
|
||||
.unwrap_or(event.author.name.clone()),
|
||||
images::get_avatar_url(
|
||||
event
|
||||
.member
|
||||
.clone()
|
||||
.and_then(|inner| inner.avatar)
|
||||
.or(event.author.avatar),
|
||||
event.author.id.get(),
|
||||
event.author.discriminator as u64,
|
||||
event
|
||||
.member
|
||||
.as_ref()
|
||||
.and_then(|member| member.avatar)
|
||||
.map(|_| guild_id.get()),
|
||||
),
|
||||
user.is_masked,
|
||||
&rt.state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if rules
|
||||
.iter()
|
||||
.filter(|rule| !rule.ignore_for_moderators() || !user.is_moderator)
|
||||
.any(|rule| rule.action() == &Action::BlockAndPenalty)
|
||||
{
|
||||
rt.http
|
||||
.create_message(event.channel_id)
|
||||
.content(
|
||||
"🚩 Your message has been blocked because it broke Payphone's code of conduct. Check your DMs for more information.",
|
||||
)
|
||||
.await?;
|
||||
|
||||
rt.http
|
||||
.create_message(Id::new(opposite_channel.id))
|
||||
.content(&format!(
|
||||
"🚩 **{}** Sent a message that was blocked.\n-# If you see an inappropiate message, please report it using `Message > Apps > Report Message`.",
|
||||
participant.display_name().sanitize(false)
|
||||
))
|
||||
.await?;
|
||||
|
||||
message = Message::build()
|
||||
.call_id(call.id)
|
||||
.message(event.0.clone())
|
||||
.participant(participant)
|
||||
.is_censored(!rules.is_empty())
|
||||
.replying_to_message_id(replying_to_id)
|
||||
.build()
|
||||
.expect(
|
||||
"The message builder was missing either the call ID, the message, or the profile",
|
||||
);
|
||||
})?;
|
||||
} else {
|
||||
let mut embeds = vec![];
|
||||
|
||||
if let Some(replying_to) = replying_to
|
||||
&& let Some((propagation_channel_id, propagation_message_id)) = replying_to
|
||||
.data
|
||||
.propagation_channel_id
|
||||
.zip(replying_to.data.propagation_message_id)
|
||||
{
|
||||
let (display_name, avatar_url) = if let Some(mask) = &replying_to.data.author_mask {
|
||||
(
|
||||
if i64_to_u64(propagation_channel_id) != event.channel_id.get() {
|
||||
get_mask_display_name(mask)
|
||||
} else {
|
||||
format!(
|
||||
"{} ({})",
|
||||
get_mask_display_name(mask),
|
||||
moderation::moderate_message(&replying_to.data.author_name, false)
|
||||
.0
|
||||
)
|
||||
},
|
||||
get_mask_avatar_url(mask).expect("Failed to get mask avatar URL"),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
moderation::moderate_message(&replying_to.data.author_name, false).0,
|
||||
replying_to.data.author_avatar_url.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
// Replied-to links are always
|
||||
// .../{other_party_server_id}/{other_party_channel_id}/{other_party_message_id}
|
||||
//
|
||||
// When the message being replied to was sent by the other party, the
|
||||
// `other_party_message_id` is the original message ID, given it was sent from
|
||||
// their channel so the original is in their channel.
|
||||
//
|
||||
// When the message being replied to was sent by this party, the
|
||||
// `other_party_message_id` is the propagation message ID given the other party
|
||||
// will have seen the propagated message instead.
|
||||
let message_link = if i64_to_u64(propagation_channel_id) == channel.id {
|
||||
// if the message replied-to was sent by the other party
|
||||
//
|
||||
// replied_to.message_id -> other party's channel id
|
||||
// replied_to.propagation_channel_id -> this channel's ID
|
||||
// replied_to.propagation_message_id -> this channel's message ID
|
||||
format!(
|
||||
"https://discord.com/channels/{}/{}/{}",
|
||||
opposite_channel.server_id, opposite_channel.id, replying_to.message_id
|
||||
)
|
||||
} else {
|
||||
// if the message replied-to was sent by this party
|
||||
//
|
||||
// replied_to.message_id -> this channel's message ID
|
||||
// replied_to.propagation_channel_id -> other party's channel id
|
||||
// replied_to.propagation_message_id -> other party's message ID
|
||||
format!(
|
||||
"https://discord.com/channels/{}/{}/{}",
|
||||
opposite_channel.server_id, propagation_channel_id, propagation_message_id
|
||||
)
|
||||
};
|
||||
|
||||
let replying_to_user = User::get_by_id(replying_to.author_id, &rt.state.db).await?;
|
||||
|
||||
embeds.push(
|
||||
EmbedBuilder::new()
|
||||
.author(
|
||||
EmbedAuthorBuilder::new(format!("Replying to {display_name}",))
|
||||
.icon_url(ImageSource::url(avatar_url).unwrap())
|
||||
.url(message_link),
|
||||
)
|
||||
.color(if replying_to.data.author_mask.is_some() {
|
||||
COLOR_WHITE
|
||||
} else {
|
||||
replying_to_user
|
||||
.and_then(|i| i.color.map(|c| c as u32))
|
||||
.unwrap_or(COLOR_WHITE)
|
||||
})
|
||||
.description(ellipse(
|
||||
moderate_message(&replying_to.data.content, false).0,
|
||||
100,
|
||||
))
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
if participant.is_masked {
|
||||
let last_message =
|
||||
Message::get_last_by_author_id(call.id, event.author.id.get(), &rt.state.db)
|
||||
.await?;
|
||||
|
||||
if last_message
|
||||
.map(|lm| lm.data.author_mask.is_none())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
embeds.push(
|
||||
EmbedBuilder::new()
|
||||
.color(COLOR_YELLOW)
|
||||
.description(format!(
|
||||
"🎭 **{}** is wearing a mask! Their real identity is hidden.",
|
||||
participant.display_name()
|
||||
))
|
||||
.footer(EmbedFooterBuilder::new(format!(
|
||||
"You can set your own mask using {}mask on.",
|
||||
prefixes[0]
|
||||
)))
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut redis = rt.state.redis.get().await?;
|
||||
|
||||
redis
|
||||
.zadd(
|
||||
"active-calls",
|
||||
format!("{}:{}", call.id, channel.id),
|
||||
UtcDateTime::now().unix_timestamp(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let message_sent = match rt
|
||||
.http
|
||||
.execute_webhook(Id::new(webhook_id), &webhook_token)
|
||||
.allowed_mentions(None)
|
||||
.username(&moderation::moderate_message(&participant.display_name(), false).0)
|
||||
.avatar_url(&participant.avatar_url())
|
||||
.content(&content.sanitize_mentions())
|
||||
.embeds(&embeds)
|
||||
.wait()
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
log::error!(
|
||||
"Failed to send message to webhook for call {} in channel {}: {e:?}",
|
||||
call.id,
|
||||
opposite_channel.id
|
||||
);
|
||||
}) {
|
||||
Ok(message) => message,
|
||||
Err(e) => {
|
||||
if let ErrorType::Response {
|
||||
error: ApiError::General(error),
|
||||
..
|
||||
} = e.kind()
|
||||
&& error.code == 10015
|
||||
{
|
||||
log::debug!("Webhook not found, updating...");
|
||||
opposite_channel
|
||||
.update_webhook(&rt.state.db, &rt.http)
|
||||
.await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
.model()
|
||||
.await?;
|
||||
|
||||
message = Message::build()
|
||||
.call_id(call.id)
|
||||
.message(event.0.clone())
|
||||
.participant(participant)
|
||||
.is_censored(!rules.is_empty())
|
||||
.replying_to_message_id(replying_to_id)
|
||||
.propagation_message_id(message_sent.id.get())
|
||||
.propagation_channel_id(opposite_channel.id)
|
||||
.build()
|
||||
.expect(
|
||||
"The message builder was missing either the call ID, the message, or the profile",
|
||||
);
|
||||
}
|
||||
|
||||
message.insert(&rt.state.db).await?;
|
||||
|
||||
if rules
|
||||
.iter()
|
||||
.any(|rule| rule.action() == &Action::BlockAndPenalty)
|
||||
{
|
||||
penalize_user(user, message, rules, &rt).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct DisplayProfile {
|
||||
pub name: String,
|
||||
pub avatar_url: String,
|
||||
#[derive(Debug)]
|
||||
pub struct CallMessageEditHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl OnMessageEditHandler<State, Extras> for CallMessageEditHandler {
|
||||
async fn on_message_edit(
|
||||
&self,
|
||||
event: &MessageUpdate,
|
||||
rt: Arc<Runtime<State, Extras>>,
|
||||
) -> EventResult {
|
||||
if event.edited_timestamp.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::trace!(
|
||||
"Edited message was '{}' sent by {} (bot? {:?})",
|
||||
event.content,
|
||||
event.author.id.get(),
|
||||
event.author.bot
|
||||
);
|
||||
|
||||
let Some(channel) = Channel::get_by_id(event.channel_id.get(), &rt.state.db).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let call = if let Some(call_id) = channel.call_id {
|
||||
if let Some(call) = Call::get_by_id(call_id, &rt.state.db).await? {
|
||||
if !call.is_active() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
call
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let Some(mut message) =
|
||||
Message::get_by_call_and_message_id(call.id, event.id.get(), &rt.state.db).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let opposite_channel_id = call.other_party_channel_id(event.channel_id.get());
|
||||
let Some(opposite_channel) = Channel::get_by_id(opposite_channel_id, &rt.state.db).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(propagation_message_id) = message.data.propagation_message_id {
|
||||
let (content, rules) = escape_message(event, false);
|
||||
|
||||
if rules
|
||||
.iter()
|
||||
.any(|rule| rule.action() == &Action::BlockAndPenalty)
|
||||
{
|
||||
rt.http
|
||||
.create_message(event.channel_id)
|
||||
.content(
|
||||
"🚩 Your message's edit has been blocked because it broke Payphone's code of conduct. Check your DMs for more information.",
|
||||
)
|
||||
.reply(event.id)
|
||||
.await?;
|
||||
|
||||
let user = User::create_or_update(
|
||||
event.author.clone(),
|
||||
&rt.state.db,
|
||||
&rt.state.color_queue_sender,
|
||||
)
|
||||
.await?;
|
||||
|
||||
penalize_user(user, message, rules, &rt).await?;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some((webhook_id, webhook_token)) = opposite_channel
|
||||
.webhook_id
|
||||
.zip(opposite_channel.webhook_token)
|
||||
{
|
||||
rt.http
|
||||
.update_webhook_message(
|
||||
Id::new(webhook_id),
|
||||
&webhook_token,
|
||||
Id::new(i64_to_u64(propagation_message_id)),
|
||||
)
|
||||
.content(Some(&content))
|
||||
.await?;
|
||||
|
||||
message.data.content = content;
|
||||
message.data.is_edited = true;
|
||||
message.data.is_censored = !rules.is_empty();
|
||||
|
||||
message.insert(&rt.state.db).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_display_profile(event: &MessageCreate) -> DisplayProfile {
|
||||
let member = event.member.clone();
|
||||
#[derive(Debug)]
|
||||
pub struct CallMessageDeleteHandler;
|
||||
|
||||
let name = if let Some(nick) = &member.clone().and_then(|member| member.nick) {
|
||||
if webhook_username(nick).is_err() {
|
||||
"Invalid Nickname".to_string()
|
||||
#[async_trait]
|
||||
impl OnMessageDeleteHandler<State, Extras> for CallMessageDeleteHandler {
|
||||
async fn on_message_delete(
|
||||
&self,
|
||||
event: &MessageDelete,
|
||||
rt: Arc<Runtime<State, Extras>>,
|
||||
) -> EventResult {
|
||||
let Some(channel) = Channel::get_by_id(event.channel_id.get(), &rt.state.db).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let call = if let Some(call_id) = channel.call_id {
|
||||
if let Some(call) = Call::get_by_id(call_id, &rt.state.db).await? {
|
||||
if !call.is_active() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
call
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
nick.clone()
|
||||
}
|
||||
} else if let Some(name) = &event.author.global_name {
|
||||
if webhook_username(name).is_err() {
|
||||
"Invalid Global Name".to_string()
|
||||
} else {
|
||||
name.clone()
|
||||
}
|
||||
} else if webhook_username(&event.author.name).is_err() {
|
||||
"Invalid Username".to_string()
|
||||
} else {
|
||||
event.author.name.clone()
|
||||
};
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let avatar_hash = member
|
||||
.and_then(|inner| inner.avatar)
|
||||
.or(event.author.avatar);
|
||||
let avatar_url = images::get_avatar_url(
|
||||
avatar_hash,
|
||||
event.author.id.get(),
|
||||
event.author.discriminator as u64,
|
||||
);
|
||||
let Some(mut message) =
|
||||
Message::get_by_call_and_message_id(call.id, event.id.get(), &rt.state.db).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
DisplayProfile { name, avatar_url }
|
||||
let opposite_channel_id = call.other_party_channel_id(event.channel_id.get());
|
||||
let Some(opposite_channel) = Channel::get_by_id(opposite_channel_id, &rt.state.db).await?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(propagation_message_id) = message.data.propagation_message_id
|
||||
&& let Some((webhook_id, webhook_token)) = opposite_channel
|
||||
.webhook_id
|
||||
.zip(opposite_channel.webhook_token)
|
||||
{
|
||||
rt.http
|
||||
.delete_webhook_message(
|
||||
Id::new(webhook_id),
|
||||
&webhook_token,
|
||||
Id::new(i64_to_u64(propagation_message_id)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
message.data.is_deleted = true;
|
||||
|
||||
message.insert(&rt.state.db).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
scum
|
||||
idiot
|
||||
stupid
|
||||
dumb
|
||||
moron
|
||||
fool
|
||||
fool
|
||||
stfu
|
||||
syfm
|
||||
sybau
|
||||
gtfo
|
||||
// Keys is only censored because there have been many genuine conversations about piano keys, keys,
|
||||
// and other stuff.
|
||||
keys
|
||||
// Many people not knowing paki is a slur, it's better to keep it here.
|
||||
paki
|
||||
hoe
|
||||
ho
|
||||
@@ -1,5 +1,7 @@
|
||||
sex
|
||||
segs
|
||||
esex
|
||||
esegs
|
||||
give head
|
||||
gave head
|
||||
give me head
|
||||
@@ -35,7 +37,11 @@ stick it in yr pussy
|
||||
cock
|
||||
dick
|
||||
dih
|
||||
pussy
|
||||
pusy
|
||||
posay
|
||||
vagina
|
||||
vgina
|
||||
vgna
|
||||
dck
|
||||
psy
|
||||
pu-c
|
||||
@@ -50,27 +56,32 @@ gang bang
|
||||
squirt
|
||||
squirting
|
||||
squirts
|
||||
bitch
|
||||
bih
|
||||
bich
|
||||
ho
|
||||
hoe
|
||||
huz
|
||||
slut
|
||||
dldo
|
||||
dildo
|
||||
goon
|
||||
goon / go on
|
||||
gooning
|
||||
i will fuh you
|
||||
i will fuck you
|
||||
i will fuh u
|
||||
i will fuh you
|
||||
i will fuck u
|
||||
i will fuck you
|
||||
i will fvck u
|
||||
i will fvck you
|
||||
i will fck u
|
||||
i will fck you
|
||||
nut
|
||||
nuting
|
||||
goonbeam
|
||||
goonbeaming
|
||||
gooncream
|
||||
gooncreaming
|
||||
goonbud
|
||||
goonbuds
|
||||
goonete
|
||||
goonetes
|
||||
gooner
|
||||
goonr
|
||||
goner
|
||||
boty hole
|
||||
suck my pp
|
||||
ass cracker
|
||||
@@ -85,4 +96,44 @@ faping
|
||||
masturbate
|
||||
masturbating
|
||||
masterbating
|
||||
masterbaiting
|
||||
masterbaiting
|
||||
masturbatigng
|
||||
masterbaiter
|
||||
mastrbaiter
|
||||
mastrbait
|
||||
mastrbate
|
||||
horny
|
||||
hrny
|
||||
whore / who re
|
||||
whre
|
||||
tickle your pickle
|
||||
tickle yr pickle
|
||||
tickle his pickle
|
||||
tickle my pickle
|
||||
peg
|
||||
peged
|
||||
peging
|
||||
butbang
|
||||
butbanged
|
||||
eat my ass
|
||||
eat my ahh
|
||||
eat mi ass
|
||||
eat my ahh
|
||||
eat my bum
|
||||
eat mi bum
|
||||
ima touch you
|
||||
ima touch u
|
||||
i ma touch you
|
||||
i ma touch u
|
||||
il touch you
|
||||
il touch u
|
||||
im going to touch you
|
||||
im going to touch u
|
||||
im gonna touch you
|
||||
im gonna touch u
|
||||
nude
|
||||
nudes
|
||||
nde
|
||||
ndes
|
||||
nood
|
||||
noods
|
||||
@@ -1,47 +1,53 @@
|
||||
niga
|
||||
nigas
|
||||
niger
|
||||
nigers
|
||||
nig
|
||||
nigs
|
||||
nga
|
||||
ngas
|
||||
ngr
|
||||
ngrs
|
||||
nger
|
||||
ngers
|
||||
qiga
|
||||
qigas
|
||||
niqa
|
||||
niqas
|
||||
niqer
|
||||
niqers
|
||||
nqa
|
||||
nqas
|
||||
nqer
|
||||
nlga
|
||||
nlger
|
||||
digga
|
||||
nlgers
|
||||
ngiga
|
||||
ngigas
|
||||
negaras
|
||||
nigy
|
||||
nyga
|
||||
diga
|
||||
digas
|
||||
fag
|
||||
fags
|
||||
fagot
|
||||
fagots
|
||||
fgot
|
||||
fgots
|
||||
fgt
|
||||
paki
|
||||
fgts
|
||||
phagot
|
||||
phagots
|
||||
retard
|
||||
retards
|
||||
rtrd
|
||||
rtrds
|
||||
tard
|
||||
dumbfuck
|
||||
dumbfck
|
||||
dumbfuk
|
||||
dumbfuh
|
||||
dumbfuhk
|
||||
dmbfuck
|
||||
dmbfck
|
||||
dmbfuk
|
||||
dmbfuh
|
||||
dmbfuhk
|
||||
dumb fuck
|
||||
dumb fck
|
||||
dumb fuk
|
||||
dumb fuh
|
||||
dumb fuhk
|
||||
stfu
|
||||
shut the fuck up
|
||||
syfm
|
||||
shut your fucking mouth
|
||||
sybau
|
||||
shut your bitch as up
|
||||
shut your bih as up
|
||||
gtfo
|
||||
get the fuck out
|
||||
pedo
|
||||
pdf
|
||||
pdfile
|
||||
blackie
|
||||
tards
|
||||
blackie
|
||||
blackies
|
||||
motherfucker
|
||||
motherfuckers
|
||||
chinese ape
|
||||
chinese apes
|
||||
@@ -8,4 +8,11 @@ fuh
|
||||
fuck you
|
||||
f u
|
||||
f you
|
||||
fuck u
|
||||
fuck u
|
||||
shit
|
||||
sht
|
||||
ass
|
||||
huz
|
||||
bitch
|
||||
bih
|
||||
bich
|
||||
@@ -0,0 +1,3 @@
|
||||
pedo
|
||||
pdf
|
||||
pdfile
|
||||
@@ -0,0 +1 @@
|
||||
slap that ass
|
||||
@@ -5,13 +5,9 @@ hang urself
|
||||
hang yourself
|
||||
gkys
|
||||
gokys
|
||||
keys
|
||||
get raped
|
||||
rape you
|
||||
rape u
|
||||
slit
|
||||
grape
|
||||
graped
|
||||
throat slit
|
||||
slit your throat
|
||||
slit ur throat
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dinosaur": 1278255435652010014,
|
||||
"shleepy cat": 1278255475066146816,
|
||||
"cozy cat": 1278255420124696663,
|
||||
"coquette cat": 1278255408254816300,
|
||||
"panic cat": 1278255451993145345
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"ferret": 1278176917123760139,
|
||||
"octopus": 1278176904410697880,
|
||||
"cat": 1278176891290783824,
|
||||
"monkey": 1278176881195094049,
|
||||
"panda": 1278176868767629455,
|
||||
"sheep": 1278176856910073946,
|
||||
"horse": 1278176842116894760,
|
||||
"bee": 1278176823846506577,
|
||||
"wild boar": 1278176806498996235,
|
||||
"duck": 1278176794591232102,
|
||||
"chicken": 1278176781790216233,
|
||||
"elephant": 1278176771413512213,
|
||||
"cow": 1278176761565413483,
|
||||
"deer": 1278176744691597312,
|
||||
"turkey": 1278176730619576321,
|
||||
"dog": 1278176719932751903,
|
||||
"beaver": 1278176709530882091,
|
||||
"camel": 1278176699921465394,
|
||||
"koala": 1278176687493746709,
|
||||
"hippo": 1278176675846295603,
|
||||
"ostrich": 1278176661317095494,
|
||||
"rhino": 1278176650340864000,
|
||||
"squirrel": 1278176637627924570,
|
||||
"chamaleon": 1278176625392877648,
|
||||
"frog": 1278176614512857088,
|
||||
"skunk": 1278176604509442079,
|
||||
"walrus": 1278176554723311678,
|
||||
"hedgehog": 1278176541750067252,
|
||||
"pig": 1278176531415564332,
|
||||
"hen": 1278176518643646485,
|
||||
"turtle": 1278176507428343891,
|
||||
"mouse": 1278175957047578696,
|
||||
"rabbit": 1278175944007487489,
|
||||
"goat": 1278175933915856938,
|
||||
"lion": 1278175922964664382,
|
||||
"wolf": 1278175902047535165,
|
||||
"snake": 1278175869331968061,
|
||||
"llama": 1278175850008674325,
|
||||
"crab": 1278175835634925620,
|
||||
"owl": 1278175822947029152,
|
||||
"bat": 1278175813572886550,
|
||||
"zebra": 1278175799962505216,
|
||||
"fox": 1278175788818104474,
|
||||
"gorilla": 1278175775891259456,
|
||||
"sloth": 1278175765661356175,
|
||||
"puffer fish": 1278175753497743360,
|
||||
"bear": 1278175741569142867,
|
||||
"giraffe": 1278175732601983006,
|
||||
"penguin": 1278175721956573236,
|
||||
"parrot": 1278175706798489642
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use rand::{SeedableRng, seq::IndexedRandom};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
use crate::utils::ToTitleCase;
|
||||
|
||||
static MASKS_COMMON: Lazy<HashMap<String, u64>> = Lazy::new(|| {
|
||||
let (file_name, json) = if cfg!(debug_assertions) {
|
||||
("dev/common.json", include_str!("dev/common.json"))
|
||||
} else {
|
||||
("prod/common.json", include_str!("prod/common.json"))
|
||||
};
|
||||
|
||||
let masks: HashMap<String, u64> = serde_json::from_str(json)
|
||||
.unwrap_or_else(|_| panic!("Failed to parse masks/{file_name} masks"));
|
||||
|
||||
assert_ne!(masks.len(), 0, "No masks found in masks/{file_name}");
|
||||
|
||||
masks
|
||||
});
|
||||
|
||||
pub fn get_random_mask(not_in: &[String]) -> Option<(String, u64)> {
|
||||
let mask_names = MASKS_COMMON
|
||||
.iter()
|
||||
.filter(|(name, _)| !not_in.contains(name))
|
||||
.map(|(name, value)| (name.clone(), *value))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut rng = ChaCha20Rng::from_os_rng();
|
||||
|
||||
mask_names.choose(&mut rng).cloned()
|
||||
}
|
||||
|
||||
pub fn get_mask_display_name(mask: impl ToString) -> String {
|
||||
mask.to_title_case()
|
||||
}
|
||||
|
||||
pub fn get_mask_emoji(mask: impl ToString) -> Option<String> {
|
||||
let emoji_name = mask.to_string().replace(" ", "");
|
||||
let emoji_id = MASKS_COMMON.get(&mask.to_string())?;
|
||||
|
||||
Some(format!("<:{emoji_name}:{emoji_id}>"))
|
||||
}
|
||||
|
||||
pub fn get_mask_avatar_url(mask: impl ToString) -> Option<String> {
|
||||
let mask_emoji_id = MASKS_COMMON.get(&mask.to_string())?;
|
||||
|
||||
Some(format!(
|
||||
"https://cdn.discordapp.com/emojis/{mask_emoji_id}.png?size=128&quality=lossless"
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dinosaur": 1278254725833297965,
|
||||
"shleepy cat": 1278254779767984181,
|
||||
"cozy cat": 1278254704324771893,
|
||||
"coquette cat": 1278254684200636428,
|
||||
"panic cat": 1278254760700674098
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user