Feat: add web, database and discord micro-services (#1)
Rust / Run tests (push) Failing after 58s
Details
Rust / Run tests (push) Failing after 58s
Details
Reviewed-on: #1 Co-authored-by: DataHearth <antoine.l@antoine-langlois.net> Co-committed-by: DataHearth <antoine.l@antoine-langlois.net>
This commit is contained in:
parent
e0b19f8eb4
commit
5ba1965127
|
@ -1,9 +1,17 @@
|
|||
**/.git*
|
||||
|
||||
target
|
||||
.env
|
||||
**/*.db*
|
||||
|
||||
**/.env
|
||||
|
||||
Dockerfile
|
||||
.gitignore
|
||||
*.db
|
||||
*.db.wal
|
||||
.git*
|
||||
.dockerignore
|
||||
LICENSE
|
||||
|
||||
LICENSE
|
||||
README.md
|
||||
|
||||
frontend/node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
frontend/pnpm-lock.yaml
|
|
@ -0,0 +1,2 @@
|
|||
*.bmp filter=lfs diff=lfs merge=lfs -text
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
|
@ -0,0 +1,76 @@
|
|||
name: Docker bot
|
||||
|
||||
run-name: Build and push Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*-bot"
|
||||
|
||||
env:
|
||||
GITEA_REGISTRY: gitea.antoine-langlois.net
|
||||
GH_REGISTRY: ghcr.io
|
||||
GH_REPOSITORY: wyll-io/tech-bot
|
||||
GITEA_REPOSITORY: datahearth/tech-bot
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
name: Build and push Docker image
|
||||
|
||||
runs-on: debian-docker
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log into registry Docker
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Log into registry Gitea
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Log into registry GitHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.GH_REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
password: ${{ secrets.GH_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.GITEA_REPOSITORY }}
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}
|
||||
${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}
|
||||
tags: |
|
||||
type=semver,pattern=bot-latest
|
||||
type=semver,pattern=bot-{{version}}
|
||||
type=semver,pattern=bot-{{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: bot
|
||||
cache-to: type=inline
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.GITEA_REPOSITORY }}:bot-latest
|
||||
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:bot-latest
|
||||
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:bot-latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
|
@ -0,0 +1,76 @@
|
|||
name: Docker database
|
||||
|
||||
run-name: Build and push Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*-database"
|
||||
|
||||
env:
|
||||
GITEA_REGISTRY: gitea.antoine-langlois.net
|
||||
GH_REGISTRY: ghcr.io
|
||||
GH_REPOSITORY: wyll-io/tech-bot
|
||||
GITEA_REPOSITORY: datahearth/tech-bot
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
name: Build and push Docker image
|
||||
|
||||
runs-on: debian-docker
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log into registry Docker
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Log into registry Gitea
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Log into registry GitHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.GH_REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
password: ${{ secrets.GH_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.GITEA_REPOSITORY }}
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}
|
||||
${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}
|
||||
tags: |
|
||||
type=semver,pattern=database-latest
|
||||
type=semver,pattern=database-{{version}}
|
||||
type=semver,pattern=database-{{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: database
|
||||
cache-to: type=inline
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.GITEA_REPOSITORY }}:database-latest
|
||||
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:database-latest
|
||||
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:database-latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
|
@ -0,0 +1,76 @@
|
|||
name: Docker web
|
||||
|
||||
run-name: Build and push Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*-web"
|
||||
|
||||
env:
|
||||
GITEA_REGISTRY: gitea.antoine-langlois.net
|
||||
GH_REGISTRY: ghcr.io
|
||||
GH_REPOSITORY: wyll-io/tech-bot
|
||||
GITEA_REPOSITORY: datahearth/tech-bot
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
name: Build and push Docker image
|
||||
|
||||
runs-on: debian-docker
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Log into registry Docker
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Log into registry Gitea
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.GITEA_REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Log into registry GitHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.GH_REGISTRY }}
|
||||
username: ${{ gitea.repository_owner }}
|
||||
password: ${{ secrets.GH_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.GITEA_REPOSITORY }}
|
||||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}
|
||||
${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}
|
||||
tags: |
|
||||
type=semver,pattern=web-latest
|
||||
type=semver,pattern=web-{{version}}
|
||||
type=semver,pattern=web-{{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: web
|
||||
cache-to: type=inline
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.GITEA_REPOSITORY }}:web-latest
|
||||
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:web-latest
|
||||
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:web-latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
|
@ -16,8 +16,13 @@ env:
|
|||
jobs:
|
||||
build-push:
|
||||
name: Build and push Docker image
|
||||
|
||||
runs-on: debian-docker
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
target: [database, bot, web]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
@ -54,8 +59,10 @@ jobs:
|
|||
${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}
|
||||
${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern=${{ matrix.target }}-latest
|
||||
type=semver,pattern=${{ matrix.target }}-{{version}}
|
||||
type=semver,pattern=${{ matrix.target }}-{{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
|
@ -63,10 +70,11 @@ jobs:
|
|||
with:
|
||||
context: .
|
||||
push: true
|
||||
target: ${{ matrix.target }}
|
||||
cache-to: type=inline
|
||||
cache-from: |
|
||||
type=registry,ref=${{ env.GITEA_REPOSITORY }}:latest
|
||||
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:latest
|
||||
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:latest
|
||||
type=registry,ref=${{ env.GITEA_REPOSITORY }}:${{ matrix.target }}-latest
|
||||
type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ env.GITEA_REPOSITORY }}:${{ matrix.target }}-latest
|
||||
type=registry,ref=${{ env.GH_REGISTRY }}/${{ env.GH_REPOSITORY }}:${{ matrix.target }}-latest
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
|
|
@ -23,4 +23,4 @@ jobs:
|
|||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
run: cargo build --verbose
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
/target
|
||||
.env
|
||||
*.db
|
||||
*.wal
|
||||
*.wal
|
||||
node_modules
|
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
|
@ -1,15 +1,2 @@
|
|||
[package]
|
||||
name = "tech-bot"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Antoine Langlois <antoine.l@antoine-langlois.net>"]
|
||||
description = "A Discord bot for the Tech channel"
|
||||
|
||||
[dependencies]
|
||||
once_cell = "1.17"
|
||||
poise = "0.5"
|
||||
polodb_core = "4.4"
|
||||
regex = "1.8"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tokio = { version = "1.28", features = ["macros", "rt-multi-thread"] }
|
||||
url = "2.3"
|
||||
[workspace]
|
||||
members = ["database", "bot"]
|
||||
|
|
59
Dockerfile
59
Dockerfile
|
@ -1,14 +1,34 @@
|
|||
FROM rust:1 as builder
|
||||
# ********************************
|
||||
# * Build the bot and database *
|
||||
# ********************************
|
||||
|
||||
FROM rust:1 as rust-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY bot /app/bot
|
||||
COPY database /app/database
|
||||
|
||||
RUN cargo build --release
|
||||
RUN cargo build -r --workspace
|
||||
|
||||
FROM gcr.io/distroless/cc
|
||||
# ********************************
|
||||
# * Deploy the bot *
|
||||
# ********************************
|
||||
|
||||
COPY --from=builder /app/target/release/tech-bot /tech-bot
|
||||
FROM gcr.io/distroless/cc as bot
|
||||
|
||||
COPY --from=rust-builder /app/target/release/bot /bot
|
||||
|
||||
CMD [ "/bot" ]
|
||||
|
||||
# ********************************
|
||||
# * Deploy the database *
|
||||
# ********************************
|
||||
|
||||
FROM gcr.io/distroless/cc as database
|
||||
|
||||
COPY --from=rust-builder /app/target/release/database /database
|
||||
|
||||
ENV DB_PATH=/app/tech-bot.db
|
||||
|
||||
|
@ -16,4 +36,31 @@ WORKDIR /app
|
|||
|
||||
VOLUME [ "/app" ]
|
||||
|
||||
CMD [ "/tech-bot" ]
|
||||
CMD [ "/database" ]
|
||||
|
||||
# ********************************
|
||||
# * Build the web *
|
||||
# ********************************
|
||||
|
||||
FROM node:18-slim as web-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/ ./
|
||||
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
FROM node:18-alpine3.18 as web
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=web-builder /app/build ./build
|
||||
COPY --from=web-builder /app/node_modules ./node_modules
|
||||
COPY --from=web-builder /app/package.json /app/package-lock.json ./
|
||||
|
||||
ENV DOTENV_CONFIG_PATH=/app/config/.env
|
||||
|
||||
VOLUME [ "/app/config" ]
|
||||
|
||||
CMD [ "node", "-r", "dotenv/config", "build" ]
|
108
README.md
108
README.md
|
@ -11,50 +11,110 @@ Save technologies you want to share/remember in a simple way inside Discord.
|
|||
#### Docker
|
||||
|
||||
```bash
|
||||
# BOT
|
||||
export DISCORD_TOKEN=TOKEN
|
||||
export ADMIN_USERS=123456789,987654321
|
||||
export GRAPHQL_ENDPOINT="http://host/graphql"
|
||||
|
||||
# DATABASE
|
||||
export DB_PATH="/path/to/db/database.db"
|
||||
|
||||
# FRONTEND
|
||||
export FRONT_VOLUME
|
||||
cat << EOF > $FRONT_VOLUME/.env
|
||||
PORT=3000
|
||||
HOST=127.0.0.1
|
||||
PUBLIC_GRAPHQL_ENDPOINT="$GRAPHQL_ENDPOINT"
|
||||
CLIENT_ID="DISCORD_APP_ID"
|
||||
CLIENT_SECRET="DISCORD_APP_SECRET"
|
||||
ORIGIN="http://host"
|
||||
EOF
|
||||
|
||||
docker run -d \
|
||||
--name tech-bot \
|
||||
--name tech-bot-bot \
|
||||
-e DISCORD_TOKEN=$DISCORD_TOKEN \
|
||||
-e ADMIN_USERS=$ADMIN_USERS \
|
||||
-v /path/to/tech-bot/data:/data \
|
||||
-e GRAPHQL_ENDPOINT=$GRAPHQL_ENDPOINT \
|
||||
--restart unless-stopped \
|
||||
gitea.antoine-langlois.net/datahearth/tech-bot
|
||||
gitea.antoine-langlois.net/datahearth/tech-bot:bot-latest
|
||||
|
||||
docker run -d \
|
||||
--name tech-bot-database \
|
||||
-v $DB_PATH:/app/database.db \
|
||||
--restart unless-stopped \
|
||||
gitea.antoine-langlois.net/datahearth/tech-bot:database-latest
|
||||
|
||||
docker run -d \
|
||||
--name tech-bot-front \
|
||||
-v $FRONT_VOLUME:/app/config \
|
||||
-p ${EXTERNAL_PORT}:${INTERNAL_PORT} \
|
||||
--restart unless-stopped \
|
||||
gitea.antoine-langlois.net/datahearth/tech-bot:front-latest
|
||||
```
|
||||
|
||||
#### Manually
|
||||
|
||||
```bash
|
||||
export DISCORD_TOKEN=TOKEN
|
||||
export ADMIN_USERS=123456789,987654321
|
||||
export DB_PATH=database.db
|
||||
|
||||
# Setup
|
||||
git clone https://gitea.antoine-langlois.net/datahearth/tech-bot.git
|
||||
cd tech-bot
|
||||
|
||||
# Database & Bot
|
||||
## Build
|
||||
cargo build --release
|
||||
./target/release/tech-bot
|
||||
|
||||
# Run
|
||||
. ./target/release/tech-bot/database
|
||||
. ./target/release/tech-bot/bot
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
|
||||
## Build
|
||||
pnpm install --prod --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
## Run
|
||||
export PORT=3000
|
||||
export HOST=127.0.0.1
|
||||
export PUBLIC_GRAPHQL_ENDPOINT="$GRAPHQL_ENDPOINT"
|
||||
export CLIENT_ID="DISCORD_APP_ID"
|
||||
export CLIENT_SECRET="DISCORD_APP_SECRET"
|
||||
export ORIGIN="http://host"
|
||||
node -r dotenv/config build
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
/tech help
|
||||
```
|
||||
```text
|
||||
Hello fellow human! I am a bot that can help you adding new technologies to a git repository.
|
||||
|
||||
```bash
|
||||
/tech add <Technology name> <Technology link> <OPTIONAL: Technology tags (comma separated)>
|
||||
```
|
||||
To add a new technology, just type:
|
||||
|
||||
```bash
|
||||
/tech list
|
||||
```
|
||||
/add <technology> <link>
|
||||
|
||||
```bash
|
||||
/tech search <Technology name> <OPTIONAL: Regex> <OPTIONAL: Technology tags (comma separated)>
|
||||
```
|
||||
To list all technologies, just type:
|
||||
|
||||
/list
|
||||
|
||||
|
||||
To search for a technology, just type:
|
||||
|
||||
/search <technology>
|
||||
|
||||
|
||||
To remove a technology, you need to have the permission to remote a tech from the list.
|
||||
If so, just type:
|
||||
|
||||
/remove <technology>
|
||||
|
||||
|
||||
To update a technology, you need to have the permission to update a tech from the list.
|
||||
If so, just type:
|
||||
|
||||
/update <UUID> <technology> <link> <tags>
|
||||
|
||||
|
||||
To get help, just type:
|
||||
|
||||
/help
|
||||
|
||||
```bash
|
||||
/tech remove <tech-name>
|
||||
```
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
[package]
|
||||
name = "bot"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Antoine Langlois <antoine.l@antoine-langlois.net>"]
|
||||
description = "Discord bot"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
graphql_client = { version = "0.13", features = ["reqwest"] }
|
||||
poise = "0.5"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
serde = { version = "1.0.179", features = ["derive"] }
|
||||
tokio = { version = "1.28", features = ["macros", "rt-multi-thread"] }
|
||||
url = "2.3"
|
||||
uuid = { version = "1.4", features = ["v4", "serde"] }
|
|
@ -0,0 +1,53 @@
|
|||
query GetTechnology($name: String!, $options: String!, $tags: [String!]!) {
|
||||
technology(name: $name, options: $options, tags: $tags) {
|
||||
id
|
||||
link
|
||||
name
|
||||
tags
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
query GetTechnologies {
|
||||
technologies {
|
||||
id
|
||||
link
|
||||
name
|
||||
tags
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateTechnology(
|
||||
$name: String!
|
||||
$link: String!
|
||||
$tags: [String!]!
|
||||
$userId: String!
|
||||
) {
|
||||
createTechnology(name: $name, link: $link, tags: $tags, userId: $userId) {
|
||||
id
|
||||
link
|
||||
name
|
||||
tags
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
mutation DeleteTechnology($id: Uuid!) {
|
||||
deleteTechnology(id: $id)
|
||||
}
|
||||
mutation DeleteTechnologies($ids: [Uuid!]!) {
|
||||
deleteTechnologies(ids: $ids)
|
||||
}
|
||||
mutation UpdateTechnology(
|
||||
$id: Uuid!
|
||||
$name: String
|
||||
$link: String
|
||||
$tags: [String!]
|
||||
) {
|
||||
updateTechnology(id: $id, name: $name, link: $link, tags: $tags)
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,289 @@
|
|||
use std::env;
|
||||
|
||||
use graphql_client::{GraphQLQuery, Response};
|
||||
use poise::command;
|
||||
use reqwest::Client;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::graphql::{
|
||||
create_technology, delete_technology, get_technologies, get_technology, update_technology,
|
||||
CreateTechnology, DeleteTechnology, GetTechnologies, GetTechnology, UpdateTechnology,
|
||||
};
|
||||
|
||||
const HELP_MESSAGE: &str = "
|
||||
Hello fellow human! I am a bot that can help you adding new technologies to a git repository.
|
||||
|
||||
To add a new technology, just type:
|
||||
```
|
||||
/add <technology> <link>
|
||||
```
|
||||
|
||||
To list all technologies, just type:
|
||||
```
|
||||
/list
|
||||
```
|
||||
|
||||
To search for a technology, just type:
|
||||
```
|
||||
/search <technology>
|
||||
```
|
||||
|
||||
To remove a technology, you need to have the permission to remote a tech from the list.
|
||||
If so, just type:
|
||||
```
|
||||
/remove <technology>
|
||||
```
|
||||
|
||||
To update a technology, you need to have the permission to update a tech from the list.
|
||||
If so, just type:
|
||||
```
|
||||
/update <UUID> <technology> <link> <tags>
|
||||
```
|
||||
|
||||
To get help, just type:
|
||||
```
|
||||
/help
|
||||
```
|
||||
";
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
type Context<'a> = poise::Context<'a, MsgData, Error>;
|
||||
|
||||
pub struct MsgData {
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
/// Show help for all commands
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
|
||||
ctx.say(HELP_MESSAGE).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a new technology to the technologies list
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn add(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Technology name"] technology: String,
|
||||
#[description = "Git repository link"] link: String,
|
||||
#[description = "Technology tags (comma separated)"] tags: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
if !Url::parse(&link).is_ok() {
|
||||
ctx.say(format!("Link {link} is not a valid URL")).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tags = tags.unwrap_or(String::new());
|
||||
let final_tags = tags.split(',').map(|s| s.to_string());
|
||||
|
||||
let rsp: Response<create_technology::ResponseData> = ctx
|
||||
.data()
|
||||
.client
|
||||
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
|
||||
.json(&CreateTechnology::build_query(
|
||||
create_technology::Variables {
|
||||
name: technology,
|
||||
link,
|
||||
tags: final_tags.collect::<Vec<String>>(),
|
||||
user_id: ctx.author().id.to_string(),
|
||||
},
|
||||
))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if let Some(errors) = rsp.errors {
|
||||
ctx.say(format!("command failed: {:?}", errors)).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let data = rsp.data.unwrap().create_technology;
|
||||
|
||||
ctx.say(format!(
|
||||
"Added {} with link {} and tags [{}] (id: {})",
|
||||
data.name,
|
||||
data.link,
|
||||
data.tags.join(","),
|
||||
data.id
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all available technologies
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn list(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let rsp: Response<get_technologies::ResponseData> = ctx
|
||||
.data()
|
||||
.client
|
||||
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
|
||||
.json(&GetTechnologies::build_query(
|
||||
get_technologies::Variables {},
|
||||
))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if let Some(errors) = rsp.errors {
|
||||
ctx.say(format!("command failed: {:?}", errors)).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let techs = rsp.data.unwrap().technologies;
|
||||
|
||||
if techs.len() == 0 {
|
||||
ctx.say("No technologies saved").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.say(format!(
|
||||
"Saved technologies: \n{}",
|
||||
techs
|
||||
.iter()
|
||||
.map(|tech| format!(
|
||||
"[{}]({}) **{}** (id: {})",
|
||||
tech.name,
|
||||
tech.link,
|
||||
tech.tags.join(","),
|
||||
tech.id
|
||||
))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search for a technology
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn search(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Technology name (can be a regex string)"] technology: String,
|
||||
#[description = "Regex options"] options: Option<String>,
|
||||
#[description = "Technology tags (comma separated)"] tags: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let tags = tags.unwrap_or(String::new());
|
||||
let final_tags = tags.split(',').map(|s| s.to_string());
|
||||
|
||||
let rsp: Response<get_technology::ResponseData> = ctx
|
||||
.data()
|
||||
.client
|
||||
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
|
||||
.json(&GetTechnology::build_query(get_technology::Variables {
|
||||
name: technology,
|
||||
options: options.unwrap_or(String::new()),
|
||||
tags: final_tags.collect::<Vec<String>>(),
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if let Some(errors) = rsp.errors {
|
||||
ctx.say(format!("command failed: {:?}", errors)).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let found_techs = rsp.data.unwrap().technology;
|
||||
|
||||
if found_techs.len() == 0 {
|
||||
ctx.say("No technologies found").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.say(format!(
|
||||
"Found technologies: {}",
|
||||
found_techs
|
||||
.iter()
|
||||
.map(|tech| format!("\nName: {} => Link: {}", tech.name, tech.link))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a technology from the technologies list
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn remove(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Technology name"] id: Uuid,
|
||||
) -> Result<(), Error> {
|
||||
let rsp: Response<delete_technology::ResponseData> = ctx
|
||||
.data()
|
||||
.client
|
||||
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
|
||||
.json(&DeleteTechnology::build_query(
|
||||
delete_technology::Variables { id },
|
||||
))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if let Some(errors) = rsp.errors {
|
||||
ctx.say(format!("command failed: {:?}", errors)).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.say("Technology removed").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Update a technology
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn update(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Technology UUID"] id: Uuid,
|
||||
#[description = "New name"] name: Option<String>,
|
||||
#[description = "New link"] link: Option<String>,
|
||||
#[description = "New tags"] tags: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
if name.is_none() && link.is_none() && tags.is_none() {
|
||||
ctx.say("Nothing to update").await?;
|
||||
return Ok(());
|
||||
}
|
||||
let mut final_tags = None;
|
||||
|
||||
if let Some(tags) = tags {
|
||||
let res = tags
|
||||
.split(",")
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<String>>();
|
||||
final_tags = Some(res);
|
||||
}
|
||||
|
||||
let rsp: Response<update_technology::ResponseData> = ctx
|
||||
.data()
|
||||
.client
|
||||
.post(env::var("GRAPHQL_ENDPOINT").expect("GRAPHQL_ENDPOINT not set"))
|
||||
.json(&UpdateTechnology::build_query(
|
||||
update_technology::Variables {
|
||||
id,
|
||||
name,
|
||||
link,
|
||||
tags: final_tags,
|
||||
},
|
||||
))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
if let Some(errors) = rsp.errors {
|
||||
ctx.say(format!("command failed: {:?}", errors)).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.say("Technology updated").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
use graphql_client::GraphQLQuery;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "graphql/schema.json",
|
||||
query_path = "graphql/query.graphql",
|
||||
response_derives = "Debug"
|
||||
)]
|
||||
pub struct CreateTechnology;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "graphql/schema.json",
|
||||
query_path = "graphql/query.graphql",
|
||||
response_derives = "Debug"
|
||||
)]
|
||||
pub struct GetTechnologies;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "graphql/schema.json",
|
||||
query_path = "graphql/query.graphql",
|
||||
response_derives = "Debug"
|
||||
)]
|
||||
pub struct GetTechnology;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "graphql/schema.json",
|
||||
query_path = "graphql/query.graphql",
|
||||
response_derives = "Debug"
|
||||
)]
|
||||
pub struct DeleteTechnology;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "graphql/schema.json",
|
||||
query_path = "graphql/query.graphql",
|
||||
response_derives = "Debug"
|
||||
)]
|
||||
pub struct DeleteTechnologies;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "graphql/schema.json",
|
||||
query_path = "graphql/query.graphql",
|
||||
response_derives = "Debug"
|
||||
)]
|
||||
pub struct UpdateTechnology;
|
|
@ -1,17 +1,23 @@
|
|||
mod commands;
|
||||
mod database;
|
||||
mod graphql;
|
||||
|
||||
use poise::serenity_prelude as serenity;
|
||||
|
||||
use commands::{add, add_auth_user, help, list, remove, search, MsgData};
|
||||
use database::DB;
|
||||
use polodb_core::Database;
|
||||
use commands::{add, help, list, remove, search, update, MsgData};
|
||||
use reqwest::Client;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
match dotenvy::from_path(std::env::var("DOTENV_PATH").unwrap_or_else(|_| "/.env".to_string())) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
println!("Failed to load .env file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let framework = poise::Framework::builder()
|
||||
.options(poise::FrameworkOptions {
|
||||
commands: vec![help(), add(), list(), search(), remove(), add_auth_user()],
|
||||
commands: vec![help(), add(), list(), search(), remove(), update()],
|
||||
..Default::default()
|
||||
})
|
||||
.token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"))
|
||||
|
@ -21,13 +27,11 @@ async fn main() {
|
|||
.setup(|ctx, _ready, framework| {
|
||||
Box::pin(async move {
|
||||
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||
Ok(MsgData {})
|
||||
Ok(MsgData {
|
||||
client: Client::new(),
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
DB.get_or_init(|| {
|
||||
Database::open_file(std::env::var("DB_PATH").expect("missing DB_PATH"))
|
||||
.expect("failed to initialize database")
|
||||
});
|
||||
framework.run().await.unwrap();
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "database"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Antoine Langlois <antoine.l@antoine-langlois.net>"]
|
||||
description = "Database endpoint with GraphQL for tech-bot"
|
||||
|
||||
[dependencies]
|
||||
actix-cors = "0.6"
|
||||
actix-web = "4.3"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dotenvy = "0.15"
|
||||
env_logger = "0.10"
|
||||
# until v0.16 is released, use master for uuid support
|
||||
juniper = { git = "https://github.com/graphql-rust/juniper.git", features = [
|
||||
"uuid",
|
||||
"chrono",
|
||||
] }
|
||||
juniper_actix = "0.4.0"
|
||||
log = "0.4"
|
||||
polodb_core = { git = "https://github.com/DataHearth/PoloDB.git", branch = "fix/in-operator" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
uuid = { version = "1.4", features = ["serde", "v4"] }
|
|
@ -0,0 +1,290 @@
|
|||
use std::io::{Error, ErrorKind};
|
||||
|
||||
use juniper::{
|
||||
graphql_object, graphql_value, Context, EmptySubscription, FieldError, FieldResult, RootNode,
|
||||
};
|
||||
use polodb_core::{
|
||||
bson::{doc, Regex},
|
||||
Database,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::schema::{database, query};
|
||||
|
||||
pub struct DB {
|
||||
db: Database,
|
||||
}
|
||||
|
||||
impl Context for DB {}
|
||||
|
||||
pub struct QueryRoot;
|
||||
pub struct MutationRoot;
|
||||
|
||||
pub type Schema = RootNode<'static, QueryRoot, MutationRoot, EmptySubscription<DB>>;
|
||||
|
||||
#[graphql_object(Context = DB)]
|
||||
impl QueryRoot {
|
||||
fn technology(
|
||||
context: &DB,
|
||||
name: String,
|
||||
options: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> FieldResult<Vec<query::Technology>> {
|
||||
context
|
||||
.search_tech(
|
||||
name,
|
||||
options.unwrap_or(String::new()),
|
||||
&tags
|
||||
.unwrap_or(Vec::new())
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<&str>>(),
|
||||
)
|
||||
.map_err(map_fielderror)
|
||||
.map(|v| {
|
||||
v.iter()
|
||||
.map(|v1| query::Technology {
|
||||
_id: v1._id,
|
||||
link: v1.link.clone().into(),
|
||||
name: v1.name.clone().into(),
|
||||
tags: v1.tags.clone().into(),
|
||||
user_id: v1.user_id.to_string(),
|
||||
created_at: v1.created_at.clone(),
|
||||
updated_at: v1.updated_at.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn technologies(context: &DB) -> FieldResult<Vec<query::Technology>> {
|
||||
context.list_tech().map_err(map_fielderror).map(|v| {
|
||||
v.iter()
|
||||
.map(|v1| query::Technology {
|
||||
_id: v1._id,
|
||||
link: v1.link.clone().into(),
|
||||
name: v1.name.clone().into(),
|
||||
tags: v1.tags.clone().into(),
|
||||
user_id: v1.user_id.to_string(),
|
||||
created_at: v1.created_at.clone(),
|
||||
updated_at: v1.updated_at.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[graphql_object(Context = DB)]
|
||||
impl MutationRoot {
|
||||
fn create_technology(
|
||||
context: &DB,
|
||||
name: String,
|
||||
link: String,
|
||||
tags: Vec<String>,
|
||||
user_id: String,
|
||||
) -> FieldResult<query::Technology> {
|
||||
Ok(context
|
||||
.add_tech(
|
||||
&name,
|
||||
&link,
|
||||
&tags.iter().map(|s| s.as_str()).collect::<Vec<&str>>(),
|
||||
user_id.parse::<i64>().map_err(|e| {
|
||||
let err_str = e.to_string();
|
||||
FieldError::new(
|
||||
"failed to convert string user_id to i64",
|
||||
graphql_value!({ "error": err_str }),
|
||||
)
|
||||
})?,
|
||||
)
|
||||
.map_err(map_fielderror)?)
|
||||
.map(|v| v.into())
|
||||
}
|
||||
|
||||
fn delete_technology(context: &DB, id: Uuid) -> FieldResult<Uuid> {
|
||||
context.remove_tech(id).map_err(map_fielderror)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
fn delete_technologies(context: &DB, ids: Vec<Uuid>) -> FieldResult<Vec<Uuid>> {
|
||||
context
|
||||
.bulk_remove_technologies(ids.clone())
|
||||
.map_err(map_fielderror)?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn update_technology(
|
||||
context: &DB,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
link: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> FieldResult<bool> {
|
||||
context
|
||||
.edit_tech(id, name, link, tags)
|
||||
.map_err(map_fielderror)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl DB {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
db: Database::open_file(std::env::var("DB_PATH").expect("missing DB_PATH"))
|
||||
.expect("failed to initialize database"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new technology to the database.
|
||||
pub fn add_tech(
|
||||
&self,
|
||||
name: &str,
|
||||
link: &str,
|
||||
tags: &[&str],
|
||||
user_id: i64,
|
||||
) -> Result<database::Technology, Error> {
|
||||
let tech = database::Technology {
|
||||
_id: Uuid::new_v4(),
|
||||
link: link.into(),
|
||||
name: name.into(),
|
||||
tags: tags.iter().map(|s| s.to_string()).collect(),
|
||||
user_id,
|
||||
created_at: chrono::Utc::now().timestamp().to_string(),
|
||||
updated_at: None,
|
||||
};
|
||||
|
||||
self.db
|
||||
.collection::<database::Technology>("technologies")
|
||||
.insert_one(&tech)
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
|
||||
|
||||
Ok(tech)
|
||||
}
|
||||
|
||||
pub fn remove_tech(&self, id: Uuid) -> Result<(), Error> {
|
||||
self.db
|
||||
.collection::<database::Technology>("technologies")
|
||||
.delete_one(doc! { "_id": id.to_string() })
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn bulk_remove_technologies(&self, ids: Vec<Uuid>) -> Result<(), Error> {
|
||||
let ids = ids.iter().map(|id| id.to_string()).collect::<Vec<String>>();
|
||||
self.db
|
||||
.collection::<database::Technology>("technologies")
|
||||
.delete_many(doc! {
|
||||
"_id": {
|
||||
"$in": ids
|
||||
}
|
||||
})
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_tech(&self) -> Result<Vec<database::Technology>, Error> {
|
||||
Ok(self
|
||||
.db
|
||||
.collection("technologies")
|
||||
.find(None)
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
|
||||
.filter(|doc| doc.is_ok())
|
||||
.map(|doc| doc.unwrap())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn search_tech(
|
||||
&self,
|
||||
name: String,
|
||||
options: String,
|
||||
tags: &[&str],
|
||||
) -> Result<Vec<database::Technology>, Error> {
|
||||
let mut query = doc! {};
|
||||
if !name.is_empty() {
|
||||
query.insert(
|
||||
"name",
|
||||
doc! {
|
||||
"$regex": Regex {
|
||||
pattern: name,
|
||||
options: options,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if !tags.is_empty() {
|
||||
query.insert(
|
||||
"tags",
|
||||
doc! {
|
||||
"$in": tags
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(self
|
||||
.db
|
||||
.collection::<database::Technology>("technologies")
|
||||
.find(query)
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
|
||||
.map(
|
||||
|doc| doc.unwrap(), // todo: find a way to handle error
|
||||
)
|
||||
.collect::<Vec<database::Technology>>())
|
||||
}
|
||||
|
||||
pub fn edit_tech(
|
||||
&self,
|
||||
id: Uuid,
|
||||
name: Option<String>,
|
||||
link: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<(), Error> {
|
||||
let mut update_doc = doc! {};
|
||||
update_doc.insert("updated_at", chrono::Utc::now().timestamp().to_string());
|
||||
|
||||
if let Some(name) = name {
|
||||
update_doc.insert("name", name);
|
||||
}
|
||||
if let Some(link) = link {
|
||||
update_doc.insert("link", link);
|
||||
}
|
||||
if let Some(tags) = tags {
|
||||
update_doc.insert("tags", tags);
|
||||
}
|
||||
|
||||
self.db
|
||||
.collection::<database::Technology>("technologies")
|
||||
.update_one(
|
||||
doc! {
|
||||
"_id": id.to_string()
|
||||
},
|
||||
doc! {
|
||||
"$set": update_doc
|
||||
},
|
||||
)
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_fielderror(err: Error) -> FieldError {
|
||||
let err_str = err.to_string();
|
||||
match err.kind() {
|
||||
ErrorKind::NotFound => FieldError::new("not found", graphql_value!({ "error": err_str })),
|
||||
ErrorKind::PermissionDenied => {
|
||||
FieldError::new("permission denied", graphql_value!({ "error": err_str }))
|
||||
}
|
||||
ErrorKind::InvalidData => {
|
||||
FieldError::new("invalid data", graphql_value!({ "error": err_str }))
|
||||
}
|
||||
ErrorKind::Unsupported => FieldError::new(
|
||||
"unsupported operation",
|
||||
graphql_value!({ "error": err_str }),
|
||||
),
|
||||
ErrorKind::Other => FieldError::new(
|
||||
"failed to search technology",
|
||||
graphql_value!({ "error": err_str }),
|
||||
),
|
||||
_ => FieldError::new("internal error", graphql_value!({ "error": err_str })),
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
use std::{
|
||||
io,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{
|
||||
get,
|
||||
middleware::{self, Logger},
|
||||
route,
|
||||
web::{Data, Json},
|
||||
App, HttpResponse, HttpServer, Responder,
|
||||
};
|
||||
use juniper::{
|
||||
http::{graphiql::graphiql_source, GraphQLRequest},
|
||||
EmptySubscription,
|
||||
};
|
||||
|
||||
use crate::graphql::{MutationRoot, QueryRoot, Schema, DB};
|
||||
|
||||
mod graphql;
|
||||
mod schema;
|
||||
|
||||
/// GraphiQL playground UI
|
||||
#[get("/graphiql")]
|
||||
async fn graphql_playground() -> impl Responder {
|
||||
HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(graphiql_source("/graphql", None))
|
||||
}
|
||||
|
||||
// GraphQL endpoint
|
||||
#[route("/graphql", method = "GET", method = "POST")]
|
||||
async fn graphql_handler_route(st: Data<Schema>, data: Json<GraphQLRequest>) -> impl Responder {
|
||||
let user = data.execute(&st, CONTEXT.get().unwrap()).await;
|
||||
HttpResponse::Ok().json(user)
|
||||
}
|
||||
|
||||
static CONTEXT: OnceLock<DB> = OnceLock::new();
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
match dotenvy::from_path(std::env::var("DOTENV_PATH").unwrap_or_else(|_| "/.env".to_string())) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
println!("Failed to load .env file: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
||||
|
||||
// Create Juniper schema
|
||||
let schema = Arc::new(Schema::new(
|
||||
QueryRoot,
|
||||
MutationRoot,
|
||||
EmptySubscription::new(),
|
||||
));
|
||||
CONTEXT.get_or_init(|| DB::new());
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.wrap(Logger::default())
|
||||
.app_data(Data::from(schema.clone()))
|
||||
.service(graphql_handler_route)
|
||||
.service(graphql_playground)
|
||||
.wrap(Cors::permissive())
|
||||
.wrap(middleware::Logger::default())
|
||||
})
|
||||
.bind((
|
||||
std::env::var("HOST").unwrap_or(String::from("127.0.0.1")),
|
||||
std::env::var("PORT")
|
||||
.unwrap_or(String::from("8080"))
|
||||
.parse::<u16>()
|
||||
.expect("failed to convert port to u16"),
|
||||
))?
|
||||
.run()
|
||||
.await
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::query;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Technology {
|
||||
pub _id: Uuid,
|
||||
pub link: String,
|
||||
pub name: String,
|
||||
pub tags: Vec<String>,
|
||||
pub user_id: i64,
|
||||
pub created_at: String, // can't use chrono::NaiveDateTime bc it fails to deserialize
|
||||
pub updated_at: Option<String>, // can't use chrono::NaiveDateTime bc it fails to deserialize
|
||||
}
|
||||
|
||||
impl Into<query::Technology> for Technology {
|
||||
fn into(self) -> query::Technology {
|
||||
query::Technology {
|
||||
_id: self._id,
|
||||
link: self.link,
|
||||
name: self.name,
|
||||
tags: self.tags,
|
||||
user_id: self.user_id.to_string(),
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
pub mod database;
|
||||
pub mod mutation;
|
||||
pub mod query;
|
|
@ -0,0 +1,14 @@
|
|||
use juniper::GraphQLInputObject;
|
||||
|
||||
#[derive(GraphQLInputObject)]
|
||||
#[graphql(description = "New technology to add to database")]
|
||||
pub struct Technology {
|
||||
#[graphql(description = "Name of technology")]
|
||||
pub name: String,
|
||||
#[graphql(description = "Link to technology")]
|
||||
pub link: String,
|
||||
#[graphql(description = "Technology tags (e.g. 'javascript', 'react')")]
|
||||
pub tags: Vec<String>,
|
||||
#[graphql(description = "user's Discord ID")]
|
||||
pub user_id: f64,
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
use juniper::GraphQLObject;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Serialize, Deserialize, GraphQLObject)]
|
||||
#[graphql(description = "Technology found in database")]
|
||||
pub struct Technology {
|
||||
#[graphql(description = "Technology's unique ID")]
|
||||
pub _id: Uuid,
|
||||
#[graphql(description = "Link to technology")]
|
||||
pub link: String,
|
||||
#[graphql(description = "Name of technology")]
|
||||
pub name: String,
|
||||
#[graphql(description = "Technology tags (e.g. 'javascript', 'react')")]
|
||||
pub tags: Vec<String>,
|
||||
#[graphql(description = "user's Discord ID")]
|
||||
pub user_id: String,
|
||||
#[graphql(description = "Technology creation timestamp")]
|
||||
pub created_at: String,
|
||||
#[graphql(description = "Technology update timestamp")]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
|
@ -0,0 +1,30 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:svelte/recommended',
|
||||
'prettier'
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: ['.svelte']
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.svelte'],
|
||||
parser: 'svelte-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
|
@ -0,0 +1,2 @@
|
|||
engine-strict=true
|
||||
resolution-mode=highest
|
|
@ -0,0 +1,13 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"pluginSearchDirs": ["."],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/svelte": "^3.1.4",
|
||||
"@sveltejs/adapter-auto": "^2.0.0",
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.20.4",
|
||||
"@tailwindcss/forms": "^0.5.4",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/chart.js": "^2.9.37",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.45.0",
|
||||
"@typescript-eslint/parser": "^5.45.0",
|
||||
"@urql/svelte": "^4.0.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"chart.js": "^4.3.0",
|
||||
"daisyui": "^3.2.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-svelte": "^2.30.0",
|
||||
"oauth4webapi": "^2.3.0",
|
||||
"postcss": "^8.4.26",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.10.1",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-check": "^3.4.3",
|
||||
"sveltekit-superforms": "^1.5.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"uuid": "^9.0.0",
|
||||
"vite": "^4.4.2",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
|
@ -0,0 +1,18 @@
|
|||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {
|
||||
// status: number;
|
||||
// message: string;
|
||||
// }
|
||||
|
||||
interface Locals {
|
||||
session: boolean;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" data-theme="light">
|
||||
%sveltekit.body%
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,125 @@
|
|||
import { env } from '$env/dynamic/private';
|
||||
import type { OAuth2Response } from '$lib/types';
|
||||
import { error, redirect, type Handle } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
|
||||
const protectedRoutes = ['/'];
|
||||
|
||||
const auth: Handle = async ({ resolve, event }) => {
|
||||
const refreshToken = event.cookies.get('refresh-token');
|
||||
const accessToken = event.cookies.get('access-token');
|
||||
|
||||
if (!accessToken && refreshToken) {
|
||||
const rsp = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: env.CLIENT_ID,
|
||||
client_secret: env.CLIENT_SECRET,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
if (!rsp.ok) {
|
||||
console.error(`failed to refresh token: ${rsp.status}`);
|
||||
event.cookies.delete('refresh-token');
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
const { access_token, expires_in }: OAuth2Response = await rsp.json();
|
||||
event.cookies.set('access-token', access_token, {
|
||||
maxAge: expires_in,
|
||||
expires: new Date(Date.now() + expires_in),
|
||||
httpOnly: true,
|
||||
sameSite: true,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
// * grab the access token again, in case it was just refreshed
|
||||
event.locals.session = !!(event.cookies.get('access-token') && refreshToken);
|
||||
|
||||
return await resolve(event);
|
||||
};
|
||||
|
||||
const handleAuth: Handle = async ({ resolve, event }) => {
|
||||
if (event.locals.session && event.url.pathname.includes('/auth')) throw redirect(303, '/');
|
||||
else if (event.locals.session && event.url.pathname === '/logout') {
|
||||
event.cookies.delete('access-token');
|
||||
event.cookies.delete('refresh-token');
|
||||
|
||||
throw redirect(303, '/login');
|
||||
} else if (event.locals.session) return await resolve(event);
|
||||
|
||||
if (event.url.origin !== env.ORIGIN) {
|
||||
console.error(`invalid origin. ${event.url.origin}`);
|
||||
throw error(403, 'invalid origin');
|
||||
}
|
||||
|
||||
if (event.url.pathname === '/auth/discord') {
|
||||
const params = new URLSearchParams({
|
||||
client_id: env.CLIENT_ID,
|
||||
redirect_uri: `${env.ORIGIN}/auth/callback/discord`,
|
||||
response_type: 'code',
|
||||
scope: 'identify'
|
||||
});
|
||||
throw redirect(302, `https://discord.com/api/oauth2/authorize?${params.toString()}`);
|
||||
} else if (event.url.pathname === '/auth/callback/discord') {
|
||||
const code = event.url.searchParams.get('code');
|
||||
if (!code) {
|
||||
console.error(`failed to get code in callback url: ${event.url}`);
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
const rsp = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: env.CLIENT_ID,
|
||||
client_secret: env.CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: `${env.ORIGIN}/auth/callback/discord`,
|
||||
code
|
||||
})
|
||||
});
|
||||
|
||||
if (!rsp.ok) throw redirect(303, '/login');
|
||||
|
||||
const { access_token, refresh_token, expires_in }: OAuth2Response = await rsp.json();
|
||||
event.cookies.set('access-token', access_token, {
|
||||
maxAge: expires_in,
|
||||
expires: new Date(Date.now() + expires_in),
|
||||
httpOnly: true,
|
||||
sameSite: true,
|
||||
path: '/'
|
||||
});
|
||||
event.cookies.set('refresh-token', refresh_token, {
|
||||
maxAge: Date.now() + 60 * 60 * 24 * 30,
|
||||
expires: new Date(Date.now() + 60 * 60 * 24 * 30),
|
||||
httpOnly: true,
|
||||
sameSite: true,
|
||||
path: '/'
|
||||
});
|
||||
|
||||
console.info('successfully authenticated user');
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
|
||||
return await resolve(event);
|
||||
};
|
||||
|
||||
const guard: Handle = async ({ resolve, event }) => {
|
||||
if (protectedRoutes.includes(event.url.pathname) && !event.locals.session) {
|
||||
console.warn(`authentication failed for: ${event.url.pathname}`);
|
||||
throw redirect(303, '/login');
|
||||
} else if (event.url.pathname === '/login' && event.locals.session) throw redirect(303, '/');
|
||||
|
||||
return await resolve(event);
|
||||
};
|
||||
|
||||
export const handle: Handle = sequence(auth, guard, handleAuth);
|
|
@ -0,0 +1,6 @@
|
|||
<div class="dropdown dropdown-end">
|
||||
<slot name="heading" />
|
||||
<ul class="menu dropdown-content z-[1] p-2 shadow bg-base-100 rounded-box w-auto mt-4 text-black">
|
||||
<slot name="content" />
|
||||
</ul>
|
||||
</div>
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import type { Writable } from 'svelte/store';
|
||||
import type { ValidationErrors } from 'sveltekit-superforms';
|
||||
import type { ZodObject } from 'zod';
|
||||
|
||||
export let value: string;
|
||||
export let errors: Writable<ValidationErrors<ZodObject<Record<string, any>>>>;
|
||||
export let name: string;
|
||||
export let label: string;
|
||||
export let options: {
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
value: string;
|
||||
text: string;
|
||||
}[];
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="form-control w-full max-w-xs {$errors[name] ? 'input-error' : ''}"
|
||||
aria-invalid={$errors[name] ? 'true' : undefined}
|
||||
>
|
||||
<label for={name} class="label">
|
||||
<span class="label-text">{label}</span>
|
||||
</label>
|
||||
<select class="select select-bordered" bind:value {name}>
|
||||
{#each options as opt}
|
||||
<option value={opt.value} selected={opt.selected} disabled={opt.disabled}>{opt.text}</option>
|
||||
{/each}
|
||||
<option disabled selected>Pick one</option>
|
||||
<option>Star Wars</option>
|
||||
<option>Harry Potter</option>
|
||||
<option>Lord of the Rings</option>
|
||||
<option>Planet of the Apes</option>
|
||||
<option>Star Trek</option>
|
||||
</select>
|
||||
{#if $errors[name]}
|
||||
<label for={name} class="label">
|
||||
<span class="label-text-alt text-red-600">{$errors[name]}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import type { Writable } from 'svelte/store';
|
||||
import type { ValidationErrors } from 'sveltekit-superforms';
|
||||
import type { ZodObject } from 'zod';
|
||||
|
||||
export let value: string;
|
||||
export let errors: Writable<ValidationErrors<ZodObject<Record<string, any>>>>;
|
||||
export let name: string;
|
||||
export let label: string;
|
||||
export let placeholder: string;
|
||||
export let inputClasses: string = '';
|
||||
export let showtopLabel = true;
|
||||
export let disabled = false;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-start">
|
||||
{#if showtopLabel}
|
||||
<label for={name} class="label">
|
||||
<span class="label-text">{label}</span>
|
||||
</label>
|
||||
{/if}
|
||||
<input
|
||||
type="text"
|
||||
{name}
|
||||
class="input input-bordered max-w-xs w-full {inputClasses} {$errors[name] ? 'input-error' : ''}"
|
||||
{placeholder}
|
||||
aria-invalid={$errors[name] ? 'true' : undefined}
|
||||
bind:value
|
||||
{disabled}
|
||||
/>
|
||||
{#if $errors[name]}
|
||||
<label for={name} class="label">
|
||||
<span class="label-text-alt text-red-600">{$errors[name]}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { deleteToast, toasts } from '$lib/toast';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
</script>
|
||||
|
||||
{#if $toasts}
|
||||
<div class="toast toast-top toast-end z-50">
|
||||
{#each Object.entries($toasts) as [id, toast]}
|
||||
<div class="alert alert-{toast.type}" transition:fade>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
class="btn btn-ghost w-fit"
|
||||
on:click={() => {
|
||||
deleteToast(id);
|
||||
}}
|
||||
>
|
||||
<Icon icon="iwwa:delete" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
|
@ -0,0 +1,28 @@
|
|||
import { writable } from 'svelte/store';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
type Toast = {
|
||||
type?: 'success' | 'error' | 'info';
|
||||
message: string;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const toasts = writable<{ [K: string]: Toast }>({});
|
||||
|
||||
export const toast = (toast: Toast) => {
|
||||
if (typeof toast.type === 'undefined') toast.type = 'info';
|
||||
if (typeof toast.timeout === 'undefined') toast.timeout = 5000;
|
||||
|
||||
const id = uuid();
|
||||
toasts.update((v) => ({ ...v, [id]: toast }));
|
||||
setTimeout(() => {
|
||||
deleteToast(id);
|
||||
}, toast.timeout);
|
||||
};
|
||||
|
||||
export const deleteToast = (id: string) => {
|
||||
toasts.update((v) => {
|
||||
delete v[id];
|
||||
return v;
|
||||
});
|
||||
};
|
|
@ -0,0 +1,62 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const form = {
|
||||
addTechnology: z.object({
|
||||
name: z.string().min(2),
|
||||
link: z.string().url('link must be an URL'),
|
||||
tags: z
|
||||
.string()
|
||||
.regex(
|
||||
/^((\w|\/|-|_)+|((\w|\/|-|_)+,)+\w+)$/gm,
|
||||
'tags must be a comma separated list of words'
|
||||
)
|
||||
}),
|
||||
searchTechnology: z.object({
|
||||
regex: z.string(),
|
||||
options: z.string().max(7),
|
||||
tags: z
|
||||
.optional(
|
||||
z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?=\s*$)|^((\w|\/|-|_)+|((\w|\/|-|_)+,)+\w+)$/gm,
|
||||
'tags must be a comma separated list of words'
|
||||
)
|
||||
)
|
||||
.default('')
|
||||
})
|
||||
};
|
||||
|
||||
export type Technology = {
|
||||
id: string;
|
||||
name: string;
|
||||
link: string;
|
||||
tags: string[];
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type OAuth2Response = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
type clientData = {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
export type RequestToken = {
|
||||
grantType: 'authorization_code';
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
} & clientData;
|
||||
|
||||
export type RefreshToken = {
|
||||
grantType: 'refresh_token';
|
||||
refresh_token: string;
|
||||
} & clientData;
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Icon from '@iconify/svelte';
|
||||
</script>
|
||||
|
||||
<section class="bg-white dark:bg-gray-900">
|
||||
<div class="container flex items-center h-full px-6 py-12 mx-auto">
|
||||
<div class="flex flex-col items-center max-w-sm mx-auto text-center">
|
||||
<p class="p-3 text-sm font-medium text-blue-500 rounded-full bg-blue-50 dark:bg-gray-800">
|
||||
<Icon icon="material-symbols:error-outline" width="32" />
|
||||
</p>
|
||||
|
||||
<h1 class="mt-3 text-2xl font-semibold text-gray-800 dark:text-white md:text-3xl">
|
||||
{$page.status}
|
||||
</h1>
|
||||
<h1 class="mt-3 text-2xl font-semibold text-gray-800 dark:text-white md:text-3xl">
|
||||
{$page.error?.message}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center w-full mt-6 gap-x-3 shrink-0 sm:w-auto">
|
||||
<button
|
||||
class="flex items-center justify-center w-1/2 px-5 py-2 text-sm text-gray-700 transition-colors duration-200 bg-white border rounded-lg gap-x-2 sm:w-auto dark:hover:bg-gray-800 dark:bg-gray-900 hover:bg-gray-100 dark:text-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<Icon icon="radix-icons:pin-left" />
|
||||
Go back
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex items-center justify-center w-1/2 px-5 py-2 text-sm text-white transition-colors duration-200 bg-blue-500 rounded-lg sm:w-auto hover:bg-blue-600 dark:hover:bg-blue-500 dark:bg-blue-600"
|
||||
>
|
||||
<Icon icon="clarity:home-line" class="mr-2" />
|
||||
Take me home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
|
@ -0,0 +1,28 @@
|
|||
import { toast } from '$lib/toast';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals: { session }, cookies }) => {
|
||||
if (session) {
|
||||
const rsp = await fetch('https://discord.com/api/v10/users/@me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookies.get('access-token')}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!rsp.ok) {
|
||||
console.log("failed to fetch user's information");
|
||||
toast({ type: 'error', message: "failed to fetch user's information" });
|
||||
return {};
|
||||
}
|
||||
|
||||
const { id, username, avatar } = await rsp.json();
|
||||
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
avatar: `https://cdn.discordapp.com/avatars/${id}/${avatar}.png`
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
|
@ -0,0 +1,59 @@
|
|||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import Toast from '$lib/components/toast.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { Client, cacheExchange, fetchExchange, setContextClient } from '@urql/svelte';
|
||||
import '../app.css';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
setContextClient(
|
||||
new Client({
|
||||
url: env.PUBLIC_GRAPHQL_ENDPOINT,
|
||||
exchanges: [cacheExchange, fetchExchange]
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<Toast />
|
||||
|
||||
<div class="flex flex-col min-h-screen justify-center items-center">
|
||||
<div class="navbar bg-base-content text-white">
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost normal-case text-xl" href="/">tech-bot</a>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 justify-end space-x-5 mr-5">
|
||||
{#if data.id && data.avatar && data.username}
|
||||
<div class="tooltip tooltip-bottom tooltip-info" data-tip="Copy ID">
|
||||
<button
|
||||
class="btn btn-ghost p-0 normal-case"
|
||||
on:click={() => navigator.clipboard.writeText(data.id)}
|
||||
>
|
||||
{data.username}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-bottom tooltip-info" data-tip="Logout">
|
||||
<a href="/logout">
|
||||
<Icon icon="material-symbols:logout" width="28" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="avatar">
|
||||
<div class="w-8 rounded-full">
|
||||
<img src={data.avatar} alt="user's avatar" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow flex items-center">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
|
||||
<div>
|
||||
<p>Copyright © 2023 - Made by Antoine Langlois</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
|
@ -0,0 +1,28 @@
|
|||
import { form as formType } from '$lib/types';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
add: await superValidate(formType.addTechnology),
|
||||
search: await superValidate(formType.searchTechnology)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
add: async ({ request }) => {
|
||||
const form = await superValidate(request, formType.addTechnology);
|
||||
|
||||
if (!form.valid) return fail(400, { form });
|
||||
|
||||
return { form };
|
||||
},
|
||||
search: async ({ request }) => {
|
||||
const form = await superValidate(request, formType.searchTechnology);
|
||||
|
||||
if (!form.valid) return fail(400, { form });
|
||||
|
||||
return { form };
|
||||
}
|
||||
};
|
|
@ -0,0 +1,353 @@
|
|||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import Text from '$lib/components/inputs/text.svelte';
|
||||
import { toast } from '$lib/toast';
|
||||
import type { Technology } from '$lib/types';
|
||||
import { getContextClient, gql, mutationStore, queryStore } from '@urql/svelte';
|
||||
import type { ChangeEventHandler } from 'svelte/elements';
|
||||
import { writable } from 'svelte/store';
|
||||
import { superForm } from 'sveltekit-superforms/client';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const client = getContextClient();
|
||||
const ltsTechnologies = writable<Technology[]>([]);
|
||||
const searchedTechnologies = writable<Technology[]>([]);
|
||||
const tbd = writable<string[]>([]);
|
||||
|
||||
queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query {
|
||||
technologies {
|
||||
id
|
||||
name
|
||||
link
|
||||
tags
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`
|
||||
}).subscribe(({ error, data }) => {
|
||||
if (error) {
|
||||
toast({ message: error.message, type: 'error' });
|
||||
console.log(error.message);
|
||||
} else if (data) {
|
||||
ltsTechnologies.set(data.technologies);
|
||||
}
|
||||
});
|
||||
|
||||
const clearAddForm = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
$addForm.name = '';
|
||||
$addForm.link = '';
|
||||
$addForm.tags = '';
|
||||
};
|
||||
|
||||
const {
|
||||
enhance: addEnhance,
|
||||
form: addForm,
|
||||
errors: addErrors
|
||||
} = superForm(data.add, {
|
||||
onError({ result }) {
|
||||
toast({
|
||||
message: result.error.message,
|
||||
type: 'error'
|
||||
});
|
||||
},
|
||||
onUpdated({ form }) {
|
||||
if (form.valid) {
|
||||
mutationStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
mutation ($name: String!, $link: String!, $tags: [String!]!, $userId: String!) {
|
||||
createTechnology(name: $name, link: $link, tags: $tags, userId: $userId) {
|
||||
id
|
||||
name
|
||||
link
|
||||
tags
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
name: form.data.name,
|
||||
link: form.data.link,
|
||||
tags: form.data.tags.split(','),
|
||||
userId: data.id
|
||||
}
|
||||
}).subscribe(({ error, data }) => {
|
||||
if (error) {
|
||||
toast({ message: error.message, type: 'error' });
|
||||
console.log(error.message);
|
||||
} else if (data) {
|
||||
ltsTechnologies.update((v) => [data.createTechnology, ...v]);
|
||||
$addForm.name = '';
|
||||
$addForm.link = '';
|
||||
$addForm.tags = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
resetForm: true
|
||||
});
|
||||
|
||||
const {
|
||||
enhance: searchEnhance,
|
||||
form: searchForm,
|
||||
errors: searchErrors
|
||||
} = superForm(data.search, {
|
||||
onError({ result }) {
|
||||
toast({
|
||||
message: result.error.message,
|
||||
type: 'error'
|
||||
});
|
||||
},
|
||||
onUpdated({ form }) {
|
||||
if (form.valid) {
|
||||
queryStore({
|
||||
client: client,
|
||||
query: gql`
|
||||
query ($name: String!, $options: String!, $tags: [String!]) {
|
||||
technology(name: $name, options: $options, tags: $tags) {
|
||||
id
|
||||
name
|
||||
link
|
||||
tags
|
||||
userId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
name: form.data.regex,
|
||||
options: form.data.options,
|
||||
tags:
|
||||
form.data.tags.includes(',') || form.data.tags.length > 0
|
||||
? form.data.tags.split(',')
|
||||
: null
|
||||
}
|
||||
}).subscribe(({ error, data }) => {
|
||||
if (error) {
|
||||
toast({ message: error.message, type: 'error' });
|
||||
console.log(error.message);
|
||||
} else if (data) {
|
||||
searchedTechnologies.set(data.technology);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updateTBD = (id: string | 'all'): ChangeEventHandler<HTMLInputElement> => {
|
||||
return (el) => {
|
||||
if (el.target && (el.target as Record<string, any>).checked) {
|
||||
if (id === 'all') tbd.set($searchedTechnologies.map((tech) => tech.id));
|
||||
else tbd.update((v) => [...v, id]);
|
||||
return;
|
||||
}
|
||||
|
||||
tbd.update((v) => {
|
||||
const i = v.indexOf(id);
|
||||
if (i > -1) v.splice(i, 1);
|
||||
return v;
|
||||
});
|
||||
};
|
||||
};
|
||||
const deleteTBD = () => {
|
||||
mutationStore({
|
||||
client,
|
||||
query: gql`
|
||||
mutation ($ids: [Uuid!]!) {
|
||||
deleteTechnologies(ids: $ids)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
ids: $tbd
|
||||
}
|
||||
}).subscribe(({ error }) => {
|
||||
if (error) {
|
||||
toast({ message: error.message, type: 'error' });
|
||||
} else {
|
||||
toast({ message: 'technologies deleted!', type: 'success' });
|
||||
invalidateAll().catch((error) => toast({ message: error.message, type: 'error' }));
|
||||
}
|
||||
});
|
||||
};
|
||||
const formatDate = (date: Date) => {
|
||||
return `${date.getDate()}/${date.getMonth()}/${date.getFullYear()} ${date.getHours()}:${date.getMinutes()}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<h1 class="text-xl font-bold text-center">Latest added technologies</h1>
|
||||
<div class="card w-11/12 bg-base-200 shadow-inner h-auto my-4">
|
||||
<div class="card-body items-center text-center">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Link</th>
|
||||
<th>Tags</th>
|
||||
<th>Created at</th>
|
||||
<th>Updated at</th>
|
||||
<th>User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $ltsTechnologies as tech, index}
|
||||
<tr>
|
||||
<th>{index + 1}</th>
|
||||
<td>{tech.id}</td>
|
||||
<td>{tech.name}</td>
|
||||
<td>{tech.link}</td>
|
||||
<td>{tech.tags}</td>
|
||||
<td>{formatDate(new Date(Number(tech.createdAt) * 1000))}</td>
|
||||
<td
|
||||
>{tech.updatedAt
|
||||
? formatDate(new Date(Number(tech.updatedAt) * 1000))
|
||||
: tech.updatedAt}</td
|
||||
>
|
||||
<td>{tech.userId}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-bold text-center">Add a technology</h1>
|
||||
<div class="card w-11/12 bg-base-200 shadow-inner h-auto my-4">
|
||||
<div class="card-body items-center text-center">
|
||||
<form method="POST" action="?/add" use:addEnhance class="flex flex-col space-y-6">
|
||||
<div class="flex flex-row space-x-6">
|
||||
<Text
|
||||
bind:value={$addForm.name}
|
||||
errors={addErrors}
|
||||
label="Name"
|
||||
name="name"
|
||||
placeholder="GitHub"
|
||||
/>
|
||||
|
||||
<Text
|
||||
bind:value={$addForm.link}
|
||||
errors={addErrors}
|
||||
label="Link"
|
||||
name="link"
|
||||
placeholder="https://github.com"
|
||||
/>
|
||||
|
||||
<Text
|
||||
bind:value={$addForm.tags}
|
||||
errors={addErrors}
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="CI/CD,git"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row space-x-4 self-center">
|
||||
<button class="btn btn-success w-fit">Add</button>
|
||||
<button class="btn btn-error w-fit" on:click={clearAddForm}>Clear form</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-bold text-center">Delete a technology</h1>
|
||||
<div class="card w-11/12 bg-base-200 shadow-inner h-auto my-4">
|
||||
<div class="card-body items-center text-center">
|
||||
<form method="POST" action="?/search" use:searchEnhance class="flex flex-col space-y-6">
|
||||
<div class="join self-center">
|
||||
<Text
|
||||
bind:value={$searchForm.regex}
|
||||
errors={searchErrors}
|
||||
label="Regular expression"
|
||||
name="regex"
|
||||
placeholder="github|etree"
|
||||
inputClasses="join-item"
|
||||
showtopLabel={false}
|
||||
/>
|
||||
|
||||
<Text
|
||||
bind:value={$searchForm.options}
|
||||
errors={searchErrors}
|
||||
label="Options"
|
||||
name="options"
|
||||
placeholder="valid: imsRUux"
|
||||
inputClasses="join-item"
|
||||
showtopLabel={false}
|
||||
/>
|
||||
<Text
|
||||
bind:value={$searchForm.tags}
|
||||
errors={searchErrors}
|
||||
label="Tags"
|
||||
name="tags"
|
||||
placeholder="git,rust,library"
|
||||
inputClasses="join-item"
|
||||
showtopLabel={false}
|
||||
/>
|
||||
|
||||
<button class="btn btn-success join-item">Search</button>
|
||||
</div>
|
||||
|
||||
{#if $searchedTechnologies.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" on:change={updateTBD('all')} />
|
||||
</label>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Link</th>
|
||||
<th>Tags</th>
|
||||
<th>Created at</th>
|
||||
<th>Updated at</th>
|
||||
<th>User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $searchedTechnologies as tech}
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" on:change={updateTBD(tech.id)} />
|
||||
</label>
|
||||
</th>
|
||||
<td>{tech.name}</td>
|
||||
<td>{tech.link}</td>
|
||||
<td>{tech.tags}</td>
|
||||
<td>{formatDate(new Date(Number(tech.createdAt) * 1000))}</td>
|
||||
<td
|
||||
>{tech.updatedAt
|
||||
? formatDate(new Date(Number(tech.updatedAt) * 1000))
|
||||
: tech.updatedAt}</td
|
||||
>
|
||||
<td>{tech.userId}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex flex-row self-center">
|
||||
<button class="btn btn-error w-fit" on:click={deleteTBD}>Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,13 @@
|
|||
<div class="flex flex-col items-center">
|
||||
<div class="card w-96 bg-base-100 shadow-xl">
|
||||
<figure class="px-10 pt-10">
|
||||
<img src="/discord.bmp" alt="Discord" class="rounded-xl w-28" />
|
||||
</figure>
|
||||
<div class="card-body items-center text-center">
|
||||
<h2 class="card-title">Sign in to access the dashboard</h2>
|
||||
<div class="card-actions mt-4">
|
||||
<a class="btn btn-primary" href="/auth/discord">Login with discord</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,12 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/kit/vite';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
|
@ -0,0 +1,16 @@
|
|||
import forms from '@tailwindcss/forms';
|
||||
import typography from '@tailwindcss/typography';
|
||||
import daisyui from 'daisyui';
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{ts,svelte,html}'],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
daisyui: {
|
||||
themes: ['dark', 'light', 'cupcake']
|
||||
},
|
||||
plugins: [daisyui, forms, typography],
|
||||
safelist: ['alert-success', 'alert-error', 'alert-info', 'input-error']
|
||||
} satisfies Config;
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
optimizeDeps: {
|
||||
exclude: ['@urql/svelte']
|
||||
}
|
||||
});
|
173
src/commands.rs
173
src/commands.rs
|
@ -1,173 +0,0 @@
|
|||
use poise::command;
|
||||
use url::Url;
|
||||
|
||||
use crate::database::*;
|
||||
|
||||
const HELP_MESSAGE: &str = "
|
||||
Hello fellow human! I am a bot that can help you adding new technologies to a git repository.
|
||||
|
||||
To add a new technology, just type:
|
||||
```
|
||||
/add <technology> <link>
|
||||
```
|
||||
|
||||
To list all technologies, just type:
|
||||
```
|
||||
/list
|
||||
```
|
||||
|
||||
To search for a technology, just type:
|
||||
```
|
||||
/search <technology>
|
||||
```
|
||||
|
||||
To remove a technology, you need to have the permission to remote a tech from the list.
|
||||
If so, just type:
|
||||
```
|
||||
/remove <technology>
|
||||
```
|
||||
|
||||
To get help, just type:
|
||||
```
|
||||
/help
|
||||
```
|
||||
";
|
||||
|
||||
type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
type Context<'a> = poise::Context<'a, MsgData, Error>;
|
||||
|
||||
pub struct MsgData {}
|
||||
|
||||
/// Show help for all commands
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn help(ctx: Context<'_>) -> Result<(), Error> {
|
||||
ctx.say(HELP_MESSAGE).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a new technology to the technologies list
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn add(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Technology name"] technology: String,
|
||||
#[description = "Git repository link"] link: String,
|
||||
#[description = "Technology tags (comma separated)"] tags: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
if !Url::parse(&link).is_ok() {
|
||||
ctx.say(format!("Link {link} is not a valid URL")).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let tags = tags.unwrap_or(String::new());
|
||||
|
||||
add_tech(
|
||||
&link,
|
||||
&technology,
|
||||
&(&tags).split(",").collect::<Vec<&str>>(),
|
||||
)?;
|
||||
|
||||
ctx.say(format!(
|
||||
"Added {technology} with link {link} and tags [{tags}]"
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all available technologies
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn list(ctx: Context<'_>) -> Result<(), Error> {
|
||||
let techs = list_tech()?;
|
||||
if techs.len() == 0 {
|
||||
ctx.say("No technologies saved").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.say(format!(
|
||||
"Saved technologies: {}",
|
||||
techs
|
||||
.iter()
|
||||
.map(|tech| format!("[{}]({}) **{}**", tech.name, tech.link, tech.tags.join(",")))
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search for a technology
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn search(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Technology name (can be a regex string)"] technology: String,
|
||||
#[description = "Regex options"] options: Option<String>,
|
||||
#[description = "Technology tags (comma separated)"] tags: Option<String>,
|
||||
) -> Result<(), Error> {
|
||||
let found_techs = search_tech(
|
||||
technology,
|
||||
options.map_or(String::new(), |opts| opts),
|
||||
&tags
|
||||
.unwrap_or(String::new())
|
||||
.split(",")
|
||||
.collect::<Vec<&str>>(),
|
||||
)?;
|
||||
if found_techs.len() == 0 {
|
||||
ctx.say("No technologies found").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
ctx.say(format!(
|
||||
"Found technologies: {}",
|
||||
found_techs
|
||||
.iter()
|
||||
.map(|tech| format!("Name: {} => Link: {}", tech.name, tech.link))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n")
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a technology from the technologies list
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn remove(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Technology name"] technology: String,
|
||||
) -> Result<(), Error> {
|
||||
if is_auth_user(ctx.author().id.to_string())? {
|
||||
ctx.say("You don't have permission to remove a technology")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
remove_tech(technology)?;
|
||||
|
||||
ctx.say("Technology removed").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a technology from the technologies list
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn add_auth_user(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Discord user ID"] id: String,
|
||||
) -> Result<(), Error> {
|
||||
if !std::env::var("ADMIN_USERS")
|
||||
.unwrap_or(String::new())
|
||||
.contains(&ctx.author().id.to_string())
|
||||
{
|
||||
ctx.say("You don't have permission to add a new user")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
set_auth_user(id)?;
|
||||
|
||||
ctx.say("User added!").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
use once_cell::sync::OnceCell;
|
||||
use polodb_core::{
|
||||
bson::{doc, Regex},
|
||||
Database,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{Error, ErrorKind};
|
||||
|
||||
pub static DB: OnceCell<Database> = OnceCell::new();
|
||||
|
||||
#[derive(Default, Debug, Serialize, Deserialize)]
|
||||
pub struct Technology {
|
||||
pub link: String,
|
||||
pub name: String,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AuthorizedUser {
|
||||
discord_id: String,
|
||||
}
|
||||
|
||||
/// Add a new technology to the database.
|
||||
pub fn add_tech(name: &str, link: &str, tags: &[&str]) -> Result<(), Error> {
|
||||
DB.get()
|
||||
.unwrap()
|
||||
.collection::<Technology>("technologies")
|
||||
.insert_one(Technology {
|
||||
link: link.into(),
|
||||
name: name.into(),
|
||||
tags: tags.iter().map(|s| s.to_string()).collect(),
|
||||
})
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_tech(name: String) -> Result<(), Error> {
|
||||
DB.get()
|
||||
.unwrap()
|
||||
.collection::<Technology>("technologies")
|
||||
.delete_one(doc! { "name": name })
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_tech() -> Result<Vec<Technology>, Error> {
|
||||
Ok(DB
|
||||
.get()
|
||||
.unwrap()
|
||||
.collection("technologies")
|
||||
.find(None)
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
|
||||
.filter(|doc| doc.is_ok())
|
||||
.map(|doc| doc.unwrap())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn search_tech(name: String, options: String, tags: &[&str]) -> Result<Vec<Technology>, Error> {
|
||||
Ok(DB
|
||||
.get()
|
||||
.unwrap()
|
||||
.collection::<Technology>("technologies")
|
||||
.find(doc! { "name": {"$regex": Regex {
|
||||
pattern: name,
|
||||
options: options,
|
||||
}}, "tags": {
|
||||
"$in": tags
|
||||
} })
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
|
||||
.map(
|
||||
|doc| doc.unwrap(), // todo: find a way to handle error
|
||||
)
|
||||
.collect::<Vec<Technology>>())
|
||||
}
|
||||
|
||||
pub fn set_auth_user(discord_id: String) -> Result<(), Error> {
|
||||
DB.get()
|
||||
.unwrap()
|
||||
.collection("authorized_users")
|
||||
.insert_one(AuthorizedUser { discord_id })
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_auth_user(discord_id: String) -> Result<bool, Error> {
|
||||
Ok(DB
|
||||
.get()
|
||||
.unwrap()
|
||||
.collection::<AuthorizedUser>("authorized_users")
|
||||
.find_one(doc! { "discord_id": discord_id })
|
||||
.map_err(|err| Error::new(ErrorKind::InvalidInput, err))?
|
||||
.is_some())
|
||||
}
|
Loading…
Reference in New Issue