Compare commits

..
77 Commits
Author SHA1 Message Date
Hermes Agent 7cde9361bb feat(hermes): surface de test locale du pont (POST /hermes/ask)
Docker / Deploy (push) Canceled after 0s
Docker / build (push) Canceled after 0s
Docker / build (push) Canceled after 0s
Docker / test-python-versions (macos-latest, 3.10) (push) Canceled after 0s
Docker / test-python-versions (macos-latest, 3.8) (push) Canceled after 0s
Docker / test-python-versions (macos-latest, 3.9) (push) Canceled after 0s
Docker / test-python-versions (ubuntu-latest, 3.10) (push) Canceled after 0s
Docker / test-python-versions (ubuntu-latest, 3.8) (push) Canceled after 0s
Docker / test-python-versions (ubuntu-latest, 3.9) (push) Canceled after 0s
Docker / test-python-versions (windows-latest, 3.10) (push) Canceled after 0s
Docker / test-python-versions (windows-latest, 3.8) (push) Canceled after 0s
Docker / test-python-versions (windows-latest, 3.9) (push) Canceled after 0s
Docker / test-python-versions (macos-latest, 3.10) (push) Canceled after 0s
Docker / test-python-versions (macos-latest, 3.8) (push) Canceled after 0s
Docker / test-python-versions (macos-latest, 3.9) (push) Canceled after 0s
Docker / test-python-versions (ubuntu-latest, 3.10) (push) Canceled after 0s
Docker / test-python-versions (ubuntu-latest, 3.8) (push) Canceled after 0s
Docker / test-python-versions (ubuntu-latest, 3.9) (push) Canceled after 0s
Docker / test-python-versions (windows-latest, 3.10) (push) Canceled after 0s
Docker / test-python-versions (windows-latest, 3.8) (push) Canceled after 0s
Docker / test-python-versions (windows-latest, 3.9) (push) Canceled after 0s
Le pont n'etait verifiable qu'en parlant au bot depuis Discord, et l'API du bot
(/command/channel) ne peut pas renvoyer la reponse : py-cord n'expose pas la
valeur de retour d'une commande. Le cog ouvre donc son propre listener loopback
(127.0.0.1, defaut 8052, HERMES_BRIDGE_ASK_PORT) :

    GET  /live        healthcheck
    POST /hermes/ask  {"message": "...", "user_id": optionnel} -> {"answer": "..."}

Meme convention d'auth que cogs/api.py (Bearer DISCORD_BOT_API_TOKEN seulement
si la variable est posee). Historique par user_id comme en DM.
2026-09-27 16:58:46 -04:00
Hermes Agent 641bf79967 fix(hermes): pointer le pont sur le listener API server unique
L'API server Hermes est un listener unique par hote (profil default) : un profil
secondaire est expose sous /p/<profil>/. L'URL par defaut devient
http://127.0.0.1:8642/p/discord-bridge/v1/chat/completions (l'env systemd
HERMES_BRIDGE_URL garde la priorite).
2026-09-27 16:41:29 -04:00
Hermes Agent b793be5aa1 feat(hermes): bridge to a dedicated Hermes Agent gateway
Cog /hermes qui relaie les messages vers l'API server OpenAI-compatible d'un
gateway Hermes dedie (profil discord-bridge, loopback 127.0.0.1:8643 cote NixOS).
Reserve aux masters du bot et aux porteurs du role Discord hermes
(HERMES_BRIDGE_ROLE_NAME), en DM comme via !hermes <message>.
Historique par utilisateur (!hermes-reset), decoupage a 2000 caracteres,
!hermes-setup cree le role et l'attribue aux masters.

URL et cle viennent de l'environnement (HERMES_BRIDGE_URL, HERMES_BRIDGE_API_KEY),
injectees par systemd.
2026-09-27 16:24:27 -04:00
mazex f399cc3fed refactor(nixos): configure bot service with dedicated user
Replaces DynamicUser with a specific system user and group for the
discord bot service, enhancing security and predictability.

Updates the virtual environment path to use a runtime directory
and sets an explicit RuntimeDirectory with a size limit for the
systemd service.
2026-09-13 12:04:48 -04:00
mazex 5770b64a6b chore(flake): refine uv cache paths and add TERM env var
Update `UV_PROJECT_ENVIRONMENT` to use `CACHE_DIRECTORY`.
Adjust `UV_CACHE_DIR` to a more specific path.
Add `TERM=dumb` to the service environment for non-interactive
compatibility.
2026-09-13 11:57:07 -04:00
mazex d6087a2624 build(nix): update flake for bot runtime and service
add `ncurses` to runtime dependencies.
set working directory to bot's source directory for proper execution.
refactor systemd service configuration for environment variables and cache.
2026-09-13 11:52:08 -04:00
mazex 0bcc4ce56b fix(flake): set pythonpath for discord bot module
Ensure the discord_bot module can resolve internal imports by
dynamically setting PYTHONPATH to its parent directory.
2026-09-13 11:49:31 -04:00
mazex 03986dc63e chore(systemd): set cache and home for bot service
Configure systemd service to use a dedicated cache directory and
set `UV_CACHE_DIR` and `HOME` environment variables for the bot.
2026-09-13 11:41:17 -04:00
mazex 127670486f feat(nix): add NixOS module and upgrade docker python
Introduces a NixOS module for declarative deployment.
Upgrades Docker Python from 3.8 to 3.13, regenerating dependencies.
The Nix development environment remains on Python 3.12.

BREAKING CHANGE: Docker Python version upgraded from 3.8 to 3.13.
2026-09-13 11:35:58 -04:00
mazex e6eec52220 refactor: standardize cog setup and enhance api
Changed all cog `setup` functions to be synchronous, removing `await bot.add_cog()`.
Refactored the API cog for improved robustness, error handling, and security.
Added API token authentication for all endpoints except `/live`.
Implemented centralized JSON error handling for API responses.
Ensured idempotent server startup on bot `on_ready` events.
2026-09-13 01:53:02 -04:00
mazexandClaude Opus 4.5 0697fe1a29 Fix API endpoints error handling
- Initialize _server_started flag in API.__init__
- Fix /debug/bot to return proper JSON (not raw bot object)
- Add comprehensive error handling to /send/channel and /send/user
- Properly handle missing fields, permissions, and not found errors

All API endpoints now work correctly with proper HTTP status codes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-09-13 01:28:29 -04:00
mazexandClaude Opus 4.5 9a60167b4b Fix HTTP API server startup and add tests
- Convert API cog to use aiohttp directly (removed dpy-http-server dependency)
- Fix cog loading: setup() must be non-async in py-cord 2.5.0
- Start HTTP server in Main.on_ready() after all cogs are loaded
- Remove server import from storage cog
- Add comprehensive API test suite (test_api.py)
- Add Justfile for convenient bot management

HTTP server now starts correctly on port 8050 and responds to requests.
Some endpoints return 500 errors which will be debugged separately.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-09-13 01:25:00 -04:00
mazexandClaude Opus 4.5 6438014bfa Add Nix flake for development environment
- Add flake.nix with Python 3.12, uv, just, ffmpeg, selenium
- Remove dpy-http-server dependency (conflicts with py-cord)
- Configure LD_LIBRARY_PATH for binary wheels
- Auto-setup venv on nix develop

This provides a reproducible development environment with all dependencies.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-09-13 00:49:58 -04:00
mazexandClaude Opus 4.5 99dc8759af Fix startup issues and make optional environment variables
- Add async-timeout dependency for music cog
- Fix randint TypeError in quiz model (floats to ints)
- Make WOLFRAM_ALPHA_ID optional in credentials
- Make CHANNEL_ID_NOTIF optional in api and storage cogs
- Make DOCKER_API_URL optional in docker cog

This allows the bot to start without requiring all optional service credentials.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-09-13 00:38:20 -04:00
marc 9f8030c94d library cog to store yml conf (#28) 2024-12-28 01:15:32 +01:00
marc 971c87b3b6 Make different functions to divide http requests from actual search within discord attachments then generate HTTP GET url to retrieve file (#27)
* show channel id

* storage urls that work
2024-12-28 01:13:20 +01:00
marc 28dfb29bf2 update discord_bot/cogs/backup.py trigger str meanwhile 2024-09-16 19:56:15 +02:00
marc 6a40baf5c2 jobs management (#26)
* jobs management

* clean

* cron expression pls

* get cron expression
2024-09-16 19:33:42 +02:00
marc 479cd1074e role management (#25) 2024-09-16 18:57:31 +02:00
marc 30d5432d17 make it work im done (#24)
* build

* build.sh

* it works but i dont know why

* dont understand but done trying to

* rebase

* readme is necessary so it appears

* just lock pls

* no no dev

* uv pip compile

* uv script

* try uv script

* build

* fg

* uv lock and pyproject are back

* wait again

* path

* test with just pip

* im done i just want it to work
2024-09-16 16:47:56 +02:00
marc ce04c9d91c no entrypoint.sh 2024-09-15 22:52:33 +02:00
marc 1713fd8e06 no entrypoint juste uv prune (#23)
* entrypoint

* fix chmod

* better build with prune

* no entrypoint
2024-09-15 22:51:36 +02:00
marc 5ac63dc7cc fix docker-compse 2024-09-15 22:25:52 +02:00
marc 4755fd2824 Dockerfiler with build.sh that works for dockerfile in prod (#22)
* build

* build.sh

* it works but i dont know why

* dont understand but done trying to
2024-09-15 22:24:19 +02:00
marc 76ce4a5ade Compact and fast docker image (#21)
* change order

* no version

* docker
2024-09-15 21:01:33 +02:00
marc 2a1decce4e pull all 2024-09-15 19:59:49 +02:00
marc ab7f3e4a00 change workdir 2024-09-15 19:55:56 +02:00
marc 04b7e7a397 pull and up force recreate 2024-09-15 19:44:32 +02:00
marc f5dd566ec6 storage cog that works (#20) 2024-09-15 11:46:06 +02:00
marc ab69a66c2b it works 2024-09-14 19:45:47 +02:00
marc e60c65ae0f backup cog that works so not everything is in api cog (#19)
* backup cog that works

* backupcog that works

* try to run http server

* deploy

* discord bot
2024-09-14 19:25:15 +02:00
marc 1fb0015df1 migrating to 2.4 needs async setup await add_cog (#18)
* migrating to 2.4 needs async setup await add_cog

* fix async

* no jurigged

* uv directory
2024-09-14 18:58:07 +02:00
marc f6fba10fdb Backup files using Discord Storages and Automatically do PostgreSQL Backups every day (#17)
* send file

* send_file_in_chunks backup backup_all retrieve_file send_file

* automatic postgres backup every hour job
2024-09-09 22:33:25 +02:00
marc 3c1312d35f update 2024-09-09 02:13:32 +02:00
marc 138ee20e8c workdir 2024-09-09 01:56:31 +02:00
marc 53756553e6 = 2024-09-09 01:45:13 +02:00
marc 09a6220292 copy only one folder 2024-09-09 01:43:33 +02:00
marc d034e5c952 mv for uv 2024-09-09 01:41:14 +02:00
marc 13973248fe ffmpeg build-base libffi-dev 2024-09-09 01:30:26 +02:00
marc 18f21171a8 no apk package 2024-09-09 01:19:27 +02:00
marc 08ab986840 discord_bot 2024-09-09 01:02:16 +02:00
marc f3731be8af Merge pull request #16 from MarcPartensky/fix-uv
Fix uv lock shit
2024-09-09 00:50:29 +02:00
marc 004e68bfaa match all 2024-09-09 00:49:09 +02:00
marc 4691eb1bed uv sync 2024-09-09 00:46:58 +02:00
marc f725e7528a no profanity check 2024-09-09 00:44:30 +02:00
marc 4abf25f06e uv lock 2024-09-09 00:39:29 +02:00
marc 38b48e95ab clean uv 2024-09-08 19:02:05 +02:00
marc 105fc9bccd Merge pull request #15 from MarcPartensky/uv
uv transition that works
2024-09-08 19:00:58 +02:00
marc 03b6b0cedd uv transition that works 2024-09-08 19:00:12 +02:00
marc 9106c08c95 update 2024-06-18 00:56:04 +02:00
marc 3b4bdac5df groq clean 2024-06-18 00:53:04 +02:00
marc 70a11d7216 groq that works 2024-06-18 00:50:49 +02:00
marc b44e832146 audio recording 2024-06-17 15:17:58 +02:00
marc efea057cf2 Merge branch 'master' into alpine 2023-08-16 19:46:39 +02:00
marc 5612ff02b2 docker test pipeline to test docker builds 2023-08-16 19:46:03 +02:00
marc 8e851d890e build 2023-08-16 16:49:19 +02:00
marc c6532aa640 tag and latest ids 2023-08-16 16:48:22 +02:00
marc bc63ee1a04 no need for pipfiles anymore since poetry is used 2023-08-16 16:41:41 +02:00
marc d3be8ea543 update 2023-08-16 16:40:45 +02:00
marc d0724764dd ignore poetry stuff 2023-08-16 16:37:25 +02:00
marc 528651681b alpine 2023-08-16 16:29:38 +02:00
marc 71a696f21d good requirements.txt 2023-08-14 00:45:34 +02:00
marc 0205ce03d8 fix makefile 2023-08-14 00:42:36 +02:00
marc 2898bc2afd fix discord bot with poetry 2023-08-14 00:37:12 +02:00
marc 946c7382e5 Merge branch 'master' into poetry 2023-08-09 08:19:00 +02:00
marc e9e26cbe89 no .python-version pls 2023-08-09 08:18:41 +02:00
marc 5e098345d3 poetry lock changed 2023-08-08 18:49:22 +02:00
marc 57184f0b21 poetry first lock file 2023-08-06 16:04:33 +02:00
marc b8cbe8c80c poetry 2023-08-06 15:43:59 +02:00
marc 9e6a89fe78 update 2023-01-15 18:27:37 +01:00
marc 78d3c42040 update 2023-01-15 18:26:27 +01:00
marc c354ca6362 env var 2023-01-15 18:08:25 +01:00
marc 0306f11305 funding 2022-12-20 17:02:27 +01:00
marc 3668ea7d98 fix 2022-06-01 21:10:06 +02:00
marc 534050a991 update 2022-05-23 01:25:32 +02:00
marc 2893f516dc latest 2022-05-22 00:04:41 +02:00
marc be20e90f08 colorama 2022-05-22 00:03:50 +02:00
149 changed files with 3846 additions and 1937 deletions
-3
View File
@@ -4,9 +4,6 @@
Makefile
Dockerfile
docker-compose.yml
README.md
Procfile
run.sh
runtime.txt
Pipfile
Pipfile.lock
+6
View File
@@ -1 +1,7 @@
liberapay: marcpartensky
patreon: marcpartensky
github: marcpartensky
custom:
# - monero:43J2XmRtjDPRLD35UsYGH2AZamFBQz7dt4VdvZXVm3SuadTrvcoXUXBRUNHqukFte23Q5TWXqhbwaLJJgoVQzzbe2AJDANQ
- https://paypal.me/marcpartensky
- https://buymeacoffee.com/marcpartensky
+26 -3
View File
@@ -13,7 +13,7 @@ on:
- master
env:
IMAGE_NAME: website
IMAGE_NAME: discord-bot
jobs:
build:
@@ -32,12 +32,18 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: docker_build
- name: Build and push with tag
id: tag-build
uses: docker/build-push-action@v2
with:
push: true
tags: marcpartensky/discord-bot:v${{ github.run_number }}.${{ steps.tag.outputs.tag }}
- name: Build and push latest
id: latest-build
uses: docker/build-push-action@v2
with:
push: true
tags: marcpartensky/discord-bot:latest
test-python-versions:
runs-on: ${{ matrix.os }}
@@ -61,3 +67,20 @@ jobs:
- name: Display Python version
run: python -c "import sys; print(sys.version)"
deploy:
name: Deploy
runs-on: ubuntu-latest
needs:
- build
steps:
- name: executing remote ssh commands using password
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST }}
username: ${{ secrets.USERNAME }}
key: ${{ secrets.KEY}}
port: ${{ secrets.PORT }}
script: |
cd ~/git/docker/services
podman-compose -f discord.yml pull
podman-compose -f discord.yml up -d --force-recreate bot
+53
View File
@@ -0,0 +1,53 @@
name: Docker
on:
push:
branches:
- '*'
env:
IMAGE_NAME: discord-bot
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push latest
id: latest-build
uses: docker/build-push-action@v2
with:
push: false
tags: marcpartensky/discord-bot:latest
test-python-versions:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ['3.8', '3.9', '3.10']
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
cache: pip
- uses: actions/cache@v2
with:
path: ~/.cache/pip
key: ${{ hashFiles('setup.py') }}-${{ hashFiles('requirements.txt') }}
- name: Install requirements
run: pip install -r requirements.txt
- name: Display Python version
run: python -c "import sys; print(sys.version)"
+3
View File
@@ -8,3 +8,6 @@ venv/
.env
.env.dev
./config/marc_store.yml
.python-version
.ash_history
.cache
+10 -20
View File
@@ -1,26 +1,16 @@
FROM python:3.8.12-slim
LABEL maintainer="marc.partensky@gmail.com"
FROM python:3.13-alpine AS alpine
LABEL maintainer="marc.partensky@proton.me"
LABEL image="https://hub.docker.com/r/marcpartensky/discord-bot"
LABEL source="https://github.com/marcpartensky/discord-bot"
RUN apt-get update && apt-get upgrade -y && apt-get autoremove -y
RUN apt-get install -y ffmpeg git curl
COPY requirements.txt ./
RUN pip install -U pip
RUN pip install -r requirements.txt
WORKDIR /app
COPY ./tts tts
COPY ./libs libs
COPY ./assets assets
COPY ./utils utils
COPY ./config config
COPY ./models models
COPY ./cogs cogs
COPY __main__.py LICENSE ./
COPY README.md LICENSE pyproject.toml uv.lock build.sh requirements.txt ./
COPY ./discord_bot discord_bot
RUN ./build.sh
WORKDIR /app/discord_bot
ENV DISCORD_BOT_HOST 0.0.0.0
ENV DISCORD_BOT_PORT 8000
ENV DISCORD_BOT_HOST=0.0.0.0
ENV DISCORD_BOT_PORT=8000
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD curl -f http://localhost:8000/live || exit 1
ENTRYPOINT ["python", "__main__.py"]
# ENTRYPOINT ["uv", "run", "--directory", "discord_bot", "python", "."]
ENTRYPOINT ["python", "."]
+28
View File
@@ -0,0 +1,28 @@
set dotenv-load := true
image := "marcpartensky/discord-bot"
# lance le bot en local
run:
uv run --directory discord_bot python .
# installe les deps
sync:
uv sync
# build l'image
build:
podman build -t {{image}}:local .
# lance l'image
docker-run:
podman run --rm -it --env-file .env {{image}}:local
# logs du service
logs:
journalctl -fu podman-discord-bot
# relance le service
restart:
sudo systemctl reset-failed podman-discord-bot
sudo systemctl restart podman-discord-bot
+9 -7
View File
@@ -1,8 +1,10 @@
start:
pipenv run python .
run:
uv --directory discord_bot run python .
update:
pipenv update
pipenv lock -r > requirements.txt
uv lock
uv pip compile requirements.txt \
--universal \
--output-file requirements.txt
dev:
docker-compose -f docker-compose.yml up -d --build --force-recreate --remove-orphans dev
prod:
@@ -14,8 +16,8 @@ push: build
deploy: push
DOCKER_HOST=ssh://vps docker service update vps_esclave
setup:
pip install pipenv
pipenv shell
pipenv install
uv pip install
uv shell
recreate:
docker-compose -f docker-compose.yml up --force-recreate dev
-46
View File
@@ -1,46 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
beautifulsoup4 = "*"
"discord.py[voice]" = "*"
fbchat = "*"
# fbchat = {git = "git://github.com/carpedm20/fbchat.git"}
nightcore = "*"
profanity-check = "*"
progressbar2 = "*"
# pyicloud = "*"
pymongo = "*"
requests = "*"
rich = "*"
translate = "*"
wikipedia = "*"
wolframalpha = "*"
html2text = "*"
gTTS = "2.2.1"
Pillow = "*"
PyGithub = "*"
PyYAML = "*"
youtube_dl = "*"
python-dotenv = "*"
dpy-http-server = "*"
pexpect = "*"
# libs/sherlock
dnspython = "*"
requests-futures = "*"
torrequest = "*"
# libs/google
unidecode = '*'
future = '*'
selenium = "*"
fake-useragent = "*"
[dev-packages]
black = "*"
pylint = "*"
jedi = "*"
[requires]
python_version = "3.8.13"
Generated
-1443
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
import discord
import os
import dotenv
from discord.ext import commands
dotenv.load_dotenv()
intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix=">", intents=intents)
# bot = discord.Bot()
@bot.command()
async def hello(ctx, name: str = None):
name = name or ctx.author.name
await ctx.respond(f"Hello {name}!")
@bot.command(name="Say Hello")
async def hi(ctx, user):
await ctx.respond(f"{ctx.author.mention} says hello to {user.name}!")
@bot.command()
async def ping(ctx):
await ctx.send("pong")
bot.run(os.environ["DISCORD_TOKEN"])
Executable
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
apk update
apk add curl
curl -LsSf https://astral.sh/uv/install.sh | sh &
apk add --no-cache --virtual .build build-base libffi-dev &
wait
export PATH="$PATH:$HOME/.cargo/bin"
# uv sync -v &
# uv pip compile pyproject.toml -o requirements.txt
# uv export --format requirements.txt > requirements.txt
# sed -i '1,2d' requirements.txt
pip install -r requirements.txt &
apk add ffmpeg postgresql p7zip &
wait
# apk del .build &
# rm -rf /var/cache/apk/* /tmp/* &
# wait
-158
View File
@@ -1,158 +0,0 @@
#!/usr/bin/env python
"""HTTP web service to interact with the bot."""
__author__ = "Marc Partensky"
# from config.config import cluster, access, check
# from utils.check import check
# from utils import tools
import os
import typing
import uuid
import traceback
import discord
from discord.ext import commands
import server
from aiohttp import web
class API(commands.Cog):
"""HTTP web service to interact with the bot."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.server = server.HTTPServer(
bot=self.bot,
host=os.environ.get("DISCORD_BOT_HOST") or "localhost",
port=int(os.environ.get("DISCORD_BOT_PORT") or "8000"),
)
self.guild_id = int(
os.environ.get("DISCORD_BOT_GUILD_ID") or "550332212340326428"
)
self.bot.loop.create_task(self._start_server())
self.contexts: typing.Dict[str, commands.Context] = {}
async def _start_server(self):
"""Task to run the HTTP server."""
await self.bot.wait_until_ready()
await self.server.start()
# async def checker(self, a, request):
# print(a)
# print(request)
# # return request.headers.get("authorization") == "password"
# return True
async def fail_handler(self, request):
return web.json_response(data={"message": "you are not authorized"}, status=401)
@server.add_route(path="/send/user", method="POST", cog="API")
async def send_user(self, request: web.Request):
"""API home path."""
body = await request.json()
user: discord.User = await self.bot.fetch_user(body["id"])
await user.send(body["message"])
return web.json_response(
data={"text": "Successfully sent message."}, status=200
)
@server.add_route(path="/send/channel", method="POST", cog="API")
async def send_channel(self, request: web.Request):
"""API home path."""
body = await request.json()
channel: discord.TextChannel = await self.bot.fetch_channel(body["id"])
await channel.send(body["message"])
self.bot.commands
return web.json_response(
data={"text": "Successfully sent message."}, status=200
)
# def build_context(self, channel: discord.TextChannel, args: list) -> commands.Context:
# """Build a fake context to run bot commands."""
# # state = discord
# data = {}
# state = discord.message.ConnectionState({}, {}, {}, http, loop)
# message = discord.Message(state, channel, data)
# view = discord.channel.TextChannel
# return commands.Context(message, self.bot, view, args=args))
@server.add_route(path="/command/channel", method="POST", cog="API")
async def command_channel(self, request: web.Request):
"""API home path."""
body = await request.json()
ctx_id = body["ctx"]
cmd = body["cmd"]
if "args" in body:
args = body["args"]
else:
args = []
if "kwargs" in body:
kwargs = body["kwargs"]
else:
kwargs = {}
channel: discord.TextChannel = await self.bot.fetch_channel(body["id"])
command_list = []
for command in self.bot.commands:
command_list.append(dict(name=command.name, doc=command.short_doc))
print(command.name)
if command.name == cmd:
# context = self.build_context(channel, args)
ctx = self.contexts[ctx_id]
try:
print(ctx.args)
print(ctx.kwargs)
ctx.args = args
ctx.kwargs = kwargs
# command.clean_params
# for param in command.params:
# print(command._parse_arguments(ctx))
# await command.prepare(ctx)
# await converter._construct_default(ctx)
# await command.callback(context, __p1=command.clean_params)
# print(command.clean_params)
# print(command.params)
# print(command.invoke())
# ctx.args = args
result = await command.invoke(ctx)
return web.json_response(data={"text": str(result)}, status=200)
except Exception as exception:
traceback.print_exc()
return web.json_response(
data={"error": str(exception), "doc": command.short_doc},
status=500,
)
return web.json_response(
data={"error": "No match found.", "commands": command_list}, status=404
)
@server.add_route(path="/debug/bot", method="GET", cog="API")
async def debug_bot(self, request: web.Request):
"""API home path."""
body = await request.json()
return web.json_response(data=self.bot, status=200)
@server.add_route(path="/live", method="GET", cog="API")
# @server.check(predicate=checker, fail_handler="fail_handler")
async def live(self, request: web.Request):
"""API home path."""
return web.json_response(text="OK", status=200)
@commands.command(name="save-context")
async def save_context(self, ctx: commands.Context, context_id: str or None):
"""Save a context since it is really hard to build one from scratch."""
context_id = context_id or str(uuid.uuid1())
self.contexts[context_id] = ctx
await ctx.send(f"> Saved context as **{context_id}**")
@commands.Cog.listener()
async def on_message(self, msg: discord.Message):
"""Nettoie les messages des salons innapropriés, dès que
ceux-ci sont postés."""
if msg.author.bot:
return
def setup(bot: commands.Bot):
bot.add_cog(API(bot))
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env python
"""Control ssh sessions."""
import typing
import discord
from pexpect import pxssh
from discord.ext import commands
from discord.ext.commands.core import has_role
import pexpect
SSH_ROLE = "Ssh"
class Ssh(commands.Cog):
"""Control ssh sessions."""
def __init__(self, bot: commands.Bot):
"""Initialize the cog with the bot."""
self.bot = bot
self.color: discord.Color = discord.Color.purple()
self.thumbnail: str = "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/GNOME_Terminal_icon_2019.svg/1200px-GNOME_Terminal_icon_2019.svg.png"
self.timeout = 3600
self.shells: typing.Dict[int, pexpect.pty_spawn.spawn] = {}
@has_role(SSH_ROLE)
@commands.command()
async def ssh(self, ctx: commands.Context, hostname: str, port: int=0):
"""Run a command in a ssh session"""
username=""
await create_role_if_missing(ctx, SSH_ROLE, self.color)
embed = discord.Embed(color=self.color)
embed.set_thumbnail(url=self.thumbnail)
embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar)
embed.description = self.shell_path
shell = pxssh.pxssh()
shell.login(hostname, username, password)
self.shells[ctx.author.id] = shell
shell.sendline("echo $HOSTNAME")
hostname = shell.before.decode("utf-8")
embed.title = f"Connected to {hostname}"
await ctx.send(embed=embed)
@commands.command(name="shells")
async def list_shells(self, ctx: commands.Context):
"""List ."""
if len(self.shells) == 0:
return await ctx.send("> No active ssh connexion.")
embed = discord.Embed(title="Active ssh connexions:", color=self.color)
embed.set_thumbnail(url=self.thumbnail)
shell_users = []
for shell_user in map(self.bot.fetch_user, self.shells.keys()):
shell_users.append(f"- {await shell_user}")
embed.description = "\n".join(shell_users)
await ctx.send(embed=embed)
@commands.Cog.listener(name="on_message")
async def on_message(self, msg: discord.Message):
"""Read all messages"""
if msg.author == self.bot.user:
return
if msg.content.startswith(self.bot.command_prefix):
return
if msg.author.id in self.shells:
shell = self.shells[msg.author.id]
shell.sendline(msg.content)
shell.prompt()
output = shell.before.decode("utf-8")
print(output)
await msg.channel.send(output)
# async def cog_common_error(self, ctx, error):
# """Close all shell sessions."""
# for shell in self.shells.values():
# shell.close()
def setup(bot):
"""Setup the Shell cog."""
bot.add_cog(Ssh(bot))
+97 -64
View File
@@ -4,22 +4,22 @@
import os
import random
import itertools
import asyncio
import warnings
from rich import print
warnings.filterwarnings("ignore")
import progressbar
import discord
import html
import progressbar
import discord
from discord.ext import commands, tasks
from dotenv import load_dotenv
load_dotenv()
from config.config import prefix, masters, delete_after_time, status, access
from config.config import prefix, masters, access
from config.credentials import token, client_id
from discord.ext import commands, tasks
# TODO
@@ -39,67 +39,69 @@ from discord.ext import commands, tasks
# # If we are in a guild, we allow for the user to mention us or use any of the prefixes in our list.
# return commands.when_mentioned_or(*prefixes)(bot, message)
client = commands.Bot(command_prefix=prefix, case_insensitive=True)
client.id = client_id
class Main(commands.Cog):
class Main(commands.Bot):
"""Main cog for commands."""
def __init__(self, bot: commands.Bot):
def __init__(self, *args, client_id, **kwargs):
"""Create the main cog using the bot as argument."""
self.bot = bot
super().__init__(*args, **kwargs)
self.id = client_id
self.invited = "invité"
self.good_invited = "bon invité"
self.bad_invited = "mauvais invité"
self.help_every = 5
self.load_cogs()
self.load_status()
self.load_icloud()
self.api: PycloudService = None
self._status = itertools.cycle([]) # Will be populated in load_status()
def load_icloud(self):
"""Charge l'api d'icloud."""
pass
async def setup_hook(self) -> None:
"""Automatically run by discord.py."""
print("DEBUG: setup_hook called")
await self.load_cogs()
print("DEBUG: load_cogs finished")
self.load_status()
print("DEBUG: load_status finished")
def load_status(self):
"""Charge les statuts."""
self.status = []
for command in self.bot.commands:
self._status = []
for command in self.commands:
msg = prefix + command.name + " " + command.short_doc
self.status.append(msg)
random.shuffle(self.status)
self._status.append(msg)
random.shuffle(self._status)
i = 0
while i < len(self.status):
while i < len(self._status):
msg = prefix + "help Voir les commandes."
self.status.insert(i, msg)
self._status.insert(i, msg)
i += self.help_every
self.status = itertools.cycle(self.status)
self._status = itertools.cycle(self._status)
@commands.command()
async def environment(self, ctx: commands.Context):
"""Affiche l'environnement dans lequel le bot tourne."""
host = os.environ.get("HOST")
await ctx.send(f"> Environment: **{host}**")
@commands.command(name="charge-tous")
@access.admin
async def load_all(self, ctx: commands.Context):
"""Charge tous les cogs."""
self.load_cogs()
await ctx.send("Tous les cogs sont chargés.")
def load_cogs(self):
async def load_cogs(self):
"""Charge tous les cogs."""
cogs = os.listdir("./cogs")
with progressbar.ProgressBar(max_value=len(cogs)) as bar:
for i, filename in enumerate(cogs):
bar.update(i)
if filename.endswith(".py"):
self.bot.load_extension(f"cogs.{filename[:-3]}")
print(filename)
self.load_extension(f"cogs.{filename[:-3]}")
@commands.command()
async def cogs(self, ctx: commands.Context):
async def environment(self, ctx: commands.Context):
"""Affiche l'environnement dans lequel le bot tourne."""
host = os.environ.get("HOST")
print(f"environment: {host}")
await ctx.send(f"> Environment: **{host}**")
@commands.command(name="charge-tous")
@access.admin
async def load_all(self, ctx: commands.Context):
"""Charge tous les cogs."""
await self.load_cogs()
await ctx.send("Tous les cogs sont chargés.")
@commands.command()
async def list_cogs(self, ctx: commands.Context):
"""Liste tous les cogs."""
cog_list = []
for file in os.listdir("./cogs"):
@@ -114,36 +116,40 @@ class Main(commands.Cog):
@commands.command()
async def loaded_cogs(self, ctx: commands.Context):
"""Liste tous les cogs."""
loaded_cog_list = [f"- {s[5:]}" for s in self.bot.extensions.keys()]
list_text = "\n".join(loaded_cog_list)
text = "```md\n" + list_text + "\n```"
await ctx.send(text)
loaded_cog_list = [f"- {s[5:]}" for s in self.extensions.keys()]
if len(loaded_cog_list) == 0:
await ctx.send("> Aucun cog chargé")
else:
list_text = "\n".join(loaded_cog_list)
text = "```md\n" + list_text + "\n```"
print(text)
await ctx.send(text)
@commands.command(name="décharge-tous")
@access.admin
async def unload_all(self, ctx: commands.Context):
"""Décharge tous les cogs."""
self.unload_cogs()
await self.unload_cogs()
await ctx.send("Tous les cogs sont déchargés.")
def unload_cogs(self):
async def unload_cogs(self):
"""Décharge tous les cogs."""
for filename in os.listdir("./cogs"):
if filename.endswith(".py"):
self.bot.unload_extension(f"cogs.{filename[:-3]}")
await self.unload_extension(f"cogs.{filename[:-3]}")
@commands.command(name="charge", aliases=["load"])
@access.admin
async def load(self, ctx, extension):
"""Charge les extensions."""
self.bot.load_extension(f"cogs.{extension}")
await self.load_extension(f"cogs.{extension}")
await ctx.send(f"Le cog {extension} a été chargée.")
@commands.command(name="décharge", aliases=["unload"])
@access.admin
async def unload(self, ctx, extension):
"""Décharge les extensions."""
self.bot.unload_extension(f"cogs.{extension}")
await self.unload_extension(f"cogs.{extension}")
await ctx.send(f"Le cog {extension} a été déchargée.")
@tasks.loop(seconds=10)
@@ -157,17 +163,34 @@ class Main(commands.Cog):
- discord.ActivityType.watching,
- discord.ActivityType.competing
"""
await self.bot.change_presence(
activity=discord.Activity(
type=discord.ActivityType.watching, name=next(self.status)
try:
status_name = next(self._status)
await self.change_presence(
activity=discord.Activity(
type=discord.ActivityType.watching, name=status_name
)
)
)
except StopIteration:
# Pas de statuts disponibles, on ne fait rien
pass
@commands.Cog.listener()
async def on_ready(self):
"""Déclare être prêt."""
self.change_status.start()
print(f"{self.bot.user} is connected to Discord!")
print(f"{self.user} is connected to Discord!")
# Démarrer le serveur HTTP API
print(f"DEBUG: on_ready - Looking for API cog...")
api_cog = self.get_cog("API")
print(f"DEBUG: API cog = {api_cog}")
if api_cog and hasattr(api_cog, '_start_server'):
print(f"DEBUG: Starting HTTP server from on_ready...")
if not hasattr(api_cog, '_server_started') or not api_cog._server_started:
await api_cog._start_server()
api_cog._server_started = True
else:
print("DEBUG: API cog not found or no _start_server method")
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member):
@@ -203,7 +226,7 @@ class Main(commands.Cog):
return False
try:
msg = await self.bot.wait_for(
msg = await self.wait_for(
"message", check=check, timeout=timeout
)
await channel.send("T'es un bon toi. T'iras loin!")
@@ -231,7 +254,7 @@ class Main(commands.Cog):
def check(ctx):
return not ctx.author.bot
msg = await self.bot.wait_for(
msg = await self.wait_for(
"message", check=check, timeout=timeout
)
await channel.send(f"On a dit une minute de silence!")
@@ -273,12 +296,22 @@ class Main(commands.Cog):
# break
def setup(bot: commands.Bot):
async def main():
"""Setup the bot for the main cog."""
bot.add_cog(Main(bot))
os.system("clear")
print(f"prefix: {prefix}")
# warnings.filterwarnings("ignore")
intents = discord.Intents.default()
intents.message_content = True
# client = commands.Bot(command_prefix=prefix, case_insensitive=True)
bot = Main(intents=intents, command_prefix=prefix, case_insensitive=False, client_id=client_id)
async with bot:
await bot.setup_hook()
await bot.start(token)
if __name__ == "__main__":
os.system("clear")
setup(client)
client.run(token)
asyncio.run(main())

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python
"""HTTP web service to interact with the bot."""
__author__ = "Marc Partensky"
import asyncio
import functools
import os
import traceback
import typing
import uuid
import discord
from aiohttp import web
from discord.ext import commands
def json_errors(handler):
"""Mappe les exceptions Discord vers des réponses JSON."""
@functools.wraps(handler)
async def wrapper(self, request: web.Request):
try:
return await handler(self, request)
except KeyError as e:
return web.json_response({"error": f"Missing required field: {e}"}, status=400)
except discord.NotFound:
return web.json_response({"error": "Not found"}, status=404)
except discord.Forbidden:
return web.json_response({"error": "Forbidden"}, status=403)
except Exception as e:
traceback.print_exc()
return web.json_response({"error": str(e)}, status=500)
return wrapper
class API(commands.Cog):
"""HTTP web service to interact with the bot."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.host = os.environ.get("DISCORD_BOT_HOST", "0.0.0.0")
self.port = int(os.environ.get("DISCORD_BOT_PORT", "8050"))
self.token = os.environ.get("DISCORD_BOT_API_TOKEN")
self.contexts: typing.Dict[str, commands.Context] = {}
self.runner: typing.Optional[web.AppRunner] = None
self.site: typing.Optional[web.TCPSite] = None
self._lock = asyncio.Lock()
self.app = web.Application(middlewares=[self._auth])
self.app.router.add_post("/send/user", self.send_user)
self.app.router.add_post("/send/channel", self.send_channel)
self.app.router.add_post("/command/channel", self.command_channel)
self.app.router.add_get("/debug/bot", self.debug_bot)
self.app.router.add_get("/live", self.live)
@web.middleware
async def _auth(self, request: web.Request, handler):
if self.token and request.path != "/live":
if request.headers.get("Authorization") != f"Bearer {self.token}":
return web.json_response({"error": "Unauthorized"}, status=401)
return await handler(request)
@commands.Cog.listener()
async def on_ready(self):
"""on_ready peut se redéclencher à chaque reconnexion: idempotent."""
async with self._lock:
if self.site is not None:
return
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, self.host, self.port)
try:
await self.site.start()
except OSError:
self.site = None
await self.runner.cleanup()
self.runner = None
raise
print(f"HTTP server listening on {self.host}:{self.port}")
def cog_unload(self):
if self.runner:
asyncio.create_task(self.runner.cleanup())
async def _send(self, request: web.Request, fetch):
body = await request.json()
target = await fetch(int(body["id"]))
await target.send(body["message"])
return web.json_response({"text": "Successfully sent message."})
@json_errors
async def send_user(self, request: web.Request):
"""Send a DM to a Discord user."""
return await self._send(request, self.bot.fetch_user)
@json_errors
async def send_channel(self, request: web.Request):
"""Send a message to a Discord channel."""
return await self._send(request, self.bot.fetch_channel)
@json_errors
async def command_channel(self, request: web.Request):
"""Invoke a bot command on a saved context."""
body = await request.json()
command = self.bot.get_command(body["cmd"])
if command is None:
return web.json_response({
"error": "No match found.",
"commands": [
{"name": c.name, "doc": c.short_doc} for c in self.bot.commands
],
}, status=404)
ctx = self.contexts.get(body["ctx"])
if ctx is None:
return web.json_response(
{"error": f"Unknown context: {body['ctx']}"}, status=404
)
ctx.args = body.get("args", [])
ctx.kwargs = body.get("kwargs", {})
result = await command.invoke(ctx)
return web.json_response({"text": str(result)})
async def debug_bot(self, request: web.Request):
"""Return bot debug information."""
return web.json_response({
"id": str(self.bot.user.id) if self.bot.user else None,
"name": str(self.bot.user) if self.bot.user else None,
"guilds": len(self.bot.guilds),
"cogs": list(self.bot.cogs.keys()),
})
async def live(self, request: web.Request):
"""Healthcheck."""
return web.Response(text="OK")
@commands.command(name="save-context")
async def save_context(self, ctx: commands.Context, context_id: typing.Optional[str] = None):
"""Save a context since it is really hard to build one from scratch."""
context_id = context_id or str(uuid.uuid1())
self.contexts[context_id] = ctx
await ctx.send(f"> Saved context as **{context_id}**")
def setup(bot: commands.Bot):
bot.add_cog(API(bot))
+355
View File
@@ -0,0 +1,355 @@
#!/bin/env python
# from config.config import masters, access, check
# from config import shops
# from utils import tools
from config.config import access, cluster
from discord.ext import commands, tasks
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
import discord
import os
import logging
import psycopg2, subprocess
import aiofiles
SECONDS = 3600
ROLE = "Backup"
class Backup(commands.Cog):
def __init__(self, bot):
"""Setup postgres logins"""
self.bot = bot
self.host = os.environ.get("POSTGRES_HOST")
self.port = os.environ.get("POSTGRES_PORT")
self.database = os.environ.get("POSTGRES_DATABASE")
self.user = os.environ.get("POSTGRES_USER")
self.password = os.environ.get("POSTGRES_PASSWORD")
self.backup_filename = os.environ.get("POSTGRES_DUMP_FILENAME") or "backup.sql"
self.channel_id = int(os.environ.get("BACKUP_CHANNEL_ID") or 0)
self.default_cron_expression = os.environ.get("BACKUP_DEFAULT_CRON_EXPRESSION") or "0 5 * * *"
self.default_jobname = os.environ.get("BACKUP_DEFAULT_JOBNAME") or "daily"
self.jobs = {}
self.tmp_directory = "/tmp"
self.color = 0x0064a5
self.scheduler = AsyncIOScheduler() # Le scheduler APScheduler
self.scheduler.start() # Démarrer le scheduler
self.setup_default_backup()
# self.connection: psycopg2.connection
# self.cursor: psycopg2.cursor
# self.backup_job.start()
def setup_default_backup(self):
"""Ajoute un job par défaut tous les jours à 5h du matin."""
job_name = self.default_jobname
cron_expression = self.default_cron_expression
trigger = CronTrigger.from_crontab(cron_expression)
job = self.scheduler.add_job(self._backup_postgres, trigger, name=job_name)
self.jobs[job_name] = job
logging.info("Job de backup quotidien à 5h du matin ajouté.")
@property
def storage(self):
return self.bot.get_cog("Storage")
# def setup_jobs(self):
# """Configurer les jobs récurrents avec APScheduler."""
# # Planifier le job quotidien à 5h du matin
# self.scheduler.add_job(
# self._backup_postgres, # La fonction à exécuter
# CronTrigger(hour=5, minute=0), # CronTrigger à 5h00 chaque jour
# name="backup", # Nom du job
# replace_existing=True # Remplacer si le job existe déjà
# )
# logging.info("Job de backup quotidien à 5h du matin configuré.")
async def _execute_pg_command(self, command: list, dump_file_path: str, env: dict):
"""
Execute a PostgreSQL dump command.
"""
try:
_ = subprocess.run(command + ["--file", dump_file_path], env=env, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Backup failed: {e.stderr.decode()}")
async def _backup_postgres(self, *db_names: str) -> str:
"""
Backup specified PostgreSQL databases or all databases if no arguments are provided.
Concatenate the backups into a single file if multiple databases are specified.
"""
if not self.user or not self.password:
raise RuntimeError("PostgreSQL credentials are missing")
env = os.environ.copy()
env["PGPASSWORD"] = self.password
filename = self.backup_filename
if db_names:
# Création d'un fichier de dump unique pour plusieurs bases
dump_file_path = f"{self.tmp_directory}/{filename}"
# Ouvre le fichier pour concaténer les résultats des dumps
async with aiofiles.open(dump_file_path, 'w') as dump_file:
for db_name in db_names:
pg_dump_command = [
"pg_dump",
"--username", self.user,
"--host", self.host,
"--port", self.port,
"--dbname", f"postgresql://{self.user}@{self.host}:{self.port}/{self.database}"
]
try:
# Exécute pg_dump et capture la sortie
result = subprocess.run(pg_dump_command, env=env, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
await dump_file.write(result.stdout.decode()) # Ajoute le dump au fichier
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Backup failed for {db_name}: {e.stderr.decode()}")
else:
# Si aucune base de données spécifiée, on sauvegarde toutes les bases
dump_file_path = f"{self.tmp_directory}/{filename}"
pg_dumpall_command = [
"pg_dumpall",
"--username", self.user,
"--host", self.host,
"--port", self.port
]
try:
await self._execute_pg_command(pg_dumpall_command, dump_file_path, env)
except Exception as e:
raise RuntimeError(f"Backup all databases failed: {str(e)}")
chunks = await self.storage.send_file_in_chunks(dump_file_path, filename, channel_id=self.channel_id)
await self.storage.clean_files(chunks)
os.remove(dump_file_path)
return dump_file_path
# @tasks.loop(seconds=SECONDS)
# async def backup_job(self):
# """La tâche qui sera exécutée tous les jours à 5h."""
# try:
# # Effectuer un backup de toutes les bases
# dump_file_paths = await self._backup_postgres()
# logging.info(f"Backup quotidien réussi : {dump_file_paths}")
# except Exception as e:
# logging.error(f"Erreur lors du backup quotidien : {e}")
def build_embed(self):
"""Return the embed that contains all the infos of the backup cog."""
embed = discord.Embed(
title="PostgreSQL Logins", color=discord.Color(self.color)
)
embed.add_field(name="user", value=str(self.user))
embed.add_field(name="password", value="*"*len(str(self.password)))
embed.add_field(name="database", value=str(self.database))
embed.add_field(name="host", value=str(self.host))
embed.add_field(name="port", value=str(self.port))
embed.add_field(name="channel", value=str(self.channel_id))
return embed
@commands.group(aliases=["bk"])
async def backup(self, ctx: commands.Context):
"""Groupe de commandes du cog 'Backup'."""
if not ctx.invoked_subcommand:
await self.backup_dump(ctx)
# await ctx.send("> No command invoked")
@backup.group(name="role")
@commands.is_owner()
async def role(self, ctx: commands.Context):
"""Groupe de commandes pour controller le role 'Backup'."""
if not ctx.invoked_subcommand:
await ctx.send("> Aucune sous-commande invoquée.")
@role.command(name="create")
async def create_backup_role(self, ctx: commands.Context):
"""Crée un rôle nommé 'Backup' si l'utilisateur est le propriétaire du bot."""
guild = ctx.guild # Obtenir le serveur actuel
role_name = ROLE
# Vérifier si le rôle existe déjà
existing_role = discord.utils.get(guild.roles, name=role_name)
if existing_role:
await ctx.send(f"> Le rôle `{role_name}` existe déjà.")
return
try:
# Créer le nouveau rôle
existing_role = await guild.create_role(name=role_name, color=self.color)
await ctx.author.add_roles(existing_role)
await ctx.send(f"> Le rôle `{role_name}` a été créé et ajouté à **{ctx.author}** avec succès.")
except discord.Forbidden:
await ctx.send("> Je n'ai pas la permission de créer des rôles.")
except discord.HTTPException as e:
await ctx.send(f"> Une erreur est survenue : {e}")
@role.command(name="delete")
async def delete_backup_role(self, ctx: commands.Context):
"""Supprime le rôle nommé 'Backup'."""
role_name = ROLE
# Vérifier si le rôle existe
existing_role = discord.utils.get(ctx.guild.roles, name=role_name)
if not existing_role:
await ctx.send(f"> Le rôle `{role_name}` n'existe pas.")
return
try:
# Supprimer le rôle
await existing_role.delete()
await ctx.send(f"> Le rôle `{role_name}` a été supprimé avec succès.")
except discord.Forbidden:
await ctx.send("> Je n'ai pas la permission de supprimer des rôles.")
except discord.HTTPException as e:
await ctx.send(f"> Une erreur est survenue : {e}")
@backup.group(name="job")
@commands.has_role(ROLE)
async def job(self, ctx: commands.Context):
"""Groupe de commandes pour gérer les jobs de backup."""
if not ctx.invoked_subcommand:
await ctx.send("> Aucune sous-commande invoquée.")
@job.command(name="add")
async def add_job(self, ctx: commands.Context, job_name: str, cron_expression: str):
"""Ajoute un job de backup avec un nom et une expression cron."""
try:
trigger = CronTrigger.from_crontab(cron_expression)
job = self.scheduler.add_job(self._backup_postgres, trigger, name=job_name)
self.jobs[job_name] = job
await ctx.send(f"> Job `{job_name}` ajouté avec succès.")
await ctx.send(embed=await self.build_job_embed())
except Exception as e:
await ctx.send(f"> Erreur lors de l'ajout du job : {e}")
@job.command(name="list")
async def list_jobs(self, ctx: commands.Context):
"""Liste tous les jobs de backup configurés."""
if not self.jobs:
await ctx.send("> Aucun job de backup configuré.")
return
# await ctx.send(f"> Jobs configurés :\n{job_list}")
await ctx.send(embed=await self.build_job_embed())
@job.command(name="delete")
async def delete_job(self, ctx: commands.Context, job_name: str):
"""Supprime un job de backup par son nom."""
job = self.jobs.pop(job_name, None)
if job:
job.remove()
await ctx.send(f"> Job `{job_name}` supprimé avec succès.")
await ctx.send(embed=await self.build_job_embed())
else:
await ctx.send("> Job non trouvé.")
def get_cron_expression(self, trigger: CronTrigger) -> str:
"""Retourne une représentation de l'expression Cron sous forme de chaîne de caractères."""
return str(trigger)
async def build_job_embed(self) -> discord.Embed:
"""Affiche les jobs configurés."""
embed = discord.Embed(
title="Jobs de Backup Configurés",
description="Liste de tous les jobs de sauvegarde actuellement configurés.",
color=self.color
)
for job_name, job in self.jobs.items():
embed.add_field(
name=job_name,
value=self.get_cron_expression(job.trigger),
inline=True
)
return embed
@backup.command(name="connect")
@commands.has_role(ROLE)
async def connect_postgres(self, ctx: commands.Context):
"""Change les identifiants postgres avec un string user,password,database,host,port."""
self.connection = psycopg2.connect(
user=self.user,
password=self.password,
host=self.host,
port=self.port,
database=self.database
)
self.cursor = self.connection.cursor()
await ctx.send("Connected to the database")
@backup.command(name="set")
@commands.has_role(ROLE)
async def set_backup(
self,
ctx: commands.Context,
*,
args: str = ""
):
"""
Change les identifiants postgres avec une chaîne de format key=value.
Exemple: ?backup set host=localhost user=postgres
"""
# Analyse des arguments
params = {}
for pair in args.split():
if '=' in pair:
key, value = pair.split('=', 1)
params[key.strip()] = value.strip()
# Mise à jour des attributs
if 'user' in params:
self.user = params['user']
if 'password' in params:
self.password = params['password']
if 'host' in params:
self.host = params['host']
if 'port' in params:
self.port = params['port']
if 'database' in params:
self.database = params['database']
if 'channel_id' in params:
self.channel_id = int(params['channel_id'])
embed = self.build_embed()
await ctx.send(embed=embed)
@backup.command(name="get")
@commands.has_role(ROLE)
async def get_postgres_logins(self, ctx: commands.Context):
"""Affiche les identifiants postgres uniquement si master"""
embed = self.build_embed()
await ctx.send(embed=embed)
@backup.command(name="claim")
@commands.has_role(ROLE)
async def claim_channel(self, ctx: commands.Context):
"""Déclare le salon textuel qui fait office de stockage de backup."""
self.channel_id = ctx.channel.id
embed = self.build_embed()
await ctx.send(embed=embed)
@backup.command(name="dump")
@commands.has_role(ROLE)
async def backup_dump(self, ctx: commands.Context, *db_names: str):
"""
Commande Discord pour créer une sauvegarde (dump) de bases de données PostgreSQL spécifiques
ou de toutes les bases de données si aucun nom n'est fourni.
"""
try:
await self._backup_postgres(*db_names)
await ctx.send("> Backup done")
except Exception as e:
await ctx.send(f"Erreur: {str(e)}")
def setup(bot):
bot.add_cog(Backup(bot))
+8 -6
View File
@@ -1,14 +1,11 @@
from config.config import access, status, delete_after_time, masters, link
from config import emoji
from config.config import access, delete_after_time, link
from utils.date import days, months
from utils import tools
from discord.ext import commands, tasks
from discord.ext import commands
from translate import Translator
from discord.utils import get
from discord import Message, PartialEmoji, Emoji
from bs4 import BeautifulSoup
from discord import Message, PartialEmoji
import requests
import datetime
import discord
@@ -374,5 +371,10 @@ class Basic(commands.Cog):
file = requests.get(url)
await self.bot.user.edit(avatar=file.content)
@commands.command(name="channel")
async def channel(self, ctx: commands.Context):
"""Affiche l'id du channel."""
await ctx.send("> "+str(ctx.channel.id))
def setup(bot):
bot.add_cog(Basic(bot))
@@ -88,7 +88,7 @@ class BlackJack(commands.Cog):
room.embed_message = await ctx.send(embed=embed)
else:
if room.message:
room.message.edit(content=content)
await room.message.edit(content=content)
else:
room.message = await ctx.send(content)
@@ -103,7 +103,7 @@ class BlackJack(commands.Cog):
@commands.Cog.listener()
async def on_command_error(self, ctx: commands.Context, error):
"""Envoie l'erreur aux utilisateurs."""
self.send(ctx, error)
await self.send(ctx, error)
print("erreur capturée")
@blackjack.command(name="créer", aliases=["create", "c"])
@@ -235,7 +235,7 @@ class BlackJack(commands.Cog):
room = self.getRoom(ctx)
player = room.find(member)
player.drawing = False
self.send(ctx, f"> {member.name} se couche.")
await self.send(ctx, f"> {member.name} se couche.")
@blackjack.command(name="retirer", aliases=["draw", "r", "d"])
async def draw(self, ctx: commands.Context):
@@ -23,8 +23,8 @@ class Docker(commands.Cog):
"""Initialize the ialab cog."""
super().__init__(**kwargs)
self.bot: commands.Bot = bot
self.url: str = os.environ["DOCKER_API_URL"]
self.color = discord.Colour(0x4EB3F2)
self.url: str = os.environ.get("DOCKER_API_URL", "")
self.color = discord.Color(0x4EB3F2)
self.states = dict(
exited=discord.Color.red(),
running=discord.Color.green(),
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python
"""Use groq"""
import typing
import discord
from discord.ext import commands
from discord.ext.commands.core import has_role
from utils.tools import create_role_if_missing, Capturing
import sys
import asyncio, os
from websockets.sync.client import connect
GROQ_ROLE = "Groq"
class Groq(commands.Cog):
"""Control Groq shells"""
def __init__(self, bot: commands.Bot):
"""Initialize the cog with the bot."""
self.host = "localhost"
self.port = 8765
self.bot = bot
self.color: discord.Color = discord.Color.darker_grey()
self.thumbnail: str = "https://wow.groq.com/wp-content/uploads/2021/04/groq-logo.svg"
self.shells: typing.Dict[int, dict] = {}
# self.timeout = 3600
# @commands.command(name="groq-shells")
# async def groq_shells(self, ctx: commands.Context):
# """List opened shells."""
# if len(self.shells) == 0:
# return await ctx.send("> No shell opened.")
# embed = discord.Embed(title="Shells :", color=self.color)
# embed.set_thumbnail(url=self.thumbnail)
# shell_users = []
# for shell_user in map(self.bot.fetch_user, self.shells.keys()):
# shell_users.append(f"- {await shell_user}")
# embed.description = "\n".join(shell_users)
# await ctx.send(embed=embed)
@commands.Cog.listener(name="on_message")
async def on_message(self, msg: discord.Message):
"""Read all messages"""
if msg.author == self.bot.user:
return
if msg.content.startswith(self.bot.command_prefix):
return
if self.hash(msg) in self.shells:
answer = await self.ask(msg, msg.content)
await msg.channel.send(answer)
# async def cog_common_error(self, ctx, error):
# """Close all shell sessions."""
# for shell in self.shells.values():
# shell.close()
@commands.command()
async def groq(self, ctx: commands.Context):
"""Go in groq mode."""
key = self.hash(ctx)
# Create a role if in guild
if ctx.guild:
await create_role_if_missing(ctx, GROQ_ROLE, self.color)
embed = discord.Embed(color=self.color)
embed.set_thumbnail(url=self.thumbnail)
embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar)
# embed.description = self.shell_path
if ctx.author.id in self.shells:
shell = self.shells[key]
del self.shells[key]
embed.title = "Closed shell"
await ctx.send(embed=embed)
else:
embed.title = "Opened shell"
await ctx.send(embed=embed)
self.shells[key] = dict(messages=[])
def hash(self, ctx):
"""Make a unique hash with channel id and author."""
print(ctx.author.id, ctx.channel.id)
print(self.shells)
return hash((ctx.author.id, ctx.channel.id))
def build_prompt(self, messages: list) -> str:
"""Build prompt."""
prompt = ""
for (me, bot) in messages:
prompt += f"me: {me}\n"
prompt += f"you: {bot}\n"
return prompt
async def ask(self, ctx, message: str) -> str:
"""Return groq answer"""
key = self.hash(ctx)
prompt = self.build_prompt(self.shells[key]["messages"])
prompt += f"me: {message}\n"
prompt += "answer my last message"
with connect(f"ws://{self.host}:{self.port}") as websocket:
websocket.send(prompt)
answer = str(websocket.recv())
print(f"{prompt} => {answer}")
self.shells[key]["messages"].append((message, answer))
return answer
def setup(bot):
"""Setup the Shell cog."""
bot.add_cog(Groq(bot))
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python
"""Pont vers un gateway Hermes Agent dedie (profil `discord-bridge`).
Reserve aux membres qui portent le role Discord `hermes` (nom configurable via
`HERMES_BRIDGE_ROLE_NAME`) et aux masters du bot. Parle a l'API server
OpenAI-compatible d'Hermes (services/hermes cote NixOS, loopback uniquement).
L'API server est un listener UNIQUE par hote : un profil secondaire n'a pas son
propre port, il est servi sur le listener du profil default sous /p/<profil>/...
Variables d'environnement (injectees par systemd, cf. services/discord-bot) :
HERMES_BRIDGE_URL defaut http://127.0.0.1:8642/p/discord-bridge/v1/chat/completions
HERMES_BRIDGE_API_KEY bearer token (sops secrets/discord-hermes-bridge.yml)
HERMES_BRIDGE_ROLE_NAME nom du role Discord autorise (defaut "hermes")
HERMES_BRIDGE_ASK_PORT port loopback de la surface de test (defaut 8052)
DISCORD_BOT_API_TOKEN si pose, exige en Bearer (convention de cogs/api.py)
"""
import asyncio
import os
import typing
import discord
import requests
from aiohttp import web
from discord.ext import commands
from config.config import prefix, masters
from utils.tools import create_role_if_missing
DEFAULT_BRIDGE_URL = "http://127.0.0.1:8642/p/discord-bridge/v1/chat/completions"
BRIDGE_URL = os.environ.get("HERMES_BRIDGE_URL", DEFAULT_BRIDGE_URL)
BRIDGE_KEY = os.environ.get("HERMES_BRIDGE_API_KEY")
ROLE_NAME = os.environ.get("HERMES_BRIDGE_ROLE_NAME", "hermes")
MAX_HISTORY = 20 # messages (user + assistant) gardes par utilisateur
REQUEST_TIMEOUT = 120
SYSTEM_PROMPT = (
"Tu es Hermes, relaye ici depuis Discord (pont dedie, profil discord-bridge). "
"Reponds de facon concise, adaptee au format Discord (2000 caracteres max par "
"message), sans mise en forme lourde."
)
class Hermes(commands.Cog):
"""Bridge to a dedicated Hermes Agent gateway (OpenAI-compatible API)."""
def __init__(self, bot: commands.Bot):
"""Initialize the cog with the bot."""
self.bot = bot
self.color: discord.Color = discord.Color.blurple()
self.histories: typing.Dict[int, list] = {}
# Surface de test locale (loopback) : joindre Hermes par HTTP sans client
# Discord, et LIRE sa reponse en JSON. Sert au test de bout en bout du
# pont depuis le serveur (systemd injecte le port).
self.ask_host = os.environ.get("HERMES_BRIDGE_ASK_HOST", "127.0.0.1")
self.ask_port = int(os.environ.get("HERMES_BRIDGE_ASK_PORT", "8052"))
self.api_token = os.environ.get("DISCORD_BOT_API_TOKEN")
self.ask_runner: typing.Optional[web.AppRunner] = None
self.ask_site: typing.Optional[web.TCPSite] = None
self.ask_lock = asyncio.Lock()
# --- autorisation ------------------------------------------------------
@staticmethod
def _has_role(user: discord.abc.User) -> bool:
"""Vrai si l'utilisateur porte le role Hermes (compare sans la casse).
En DM, py-cord rend un `Member` (avec ses roles) quand il a le serveur en
cache, sinon un simple `User` sans attribut `roles` : d'ou le getattr.
"""
return any(
role.name.lower() == ROLE_NAME.lower()
for role in getattr(user, "roles", None) or []
)
def _author_allowed(self, author: discord.abc.User) -> bool:
"""Autorise les masters du bot et les porteurs du role, en DM comme en serveur."""
if author.id in masters:
return True
if self._has_role(author):
return True
for guild in self.bot.guilds:
member = guild.get_member(author.id)
if member and self._has_role(member):
return True
return False
# --- appel Hermes ------------------------------------------------------
async def _ask(self, user_id: int, message: str) -> str:
"""Envoie un message a Hermes et renvoie sa reponse."""
if not BRIDGE_KEY:
return "> Pont Hermes non configure (HERMES_BRIDGE_API_KEY manquant)."
history = self.histories.setdefault(user_id, [])
history.append({"role": "user", "content": message})
payload = {
"model": "discord-bridge",
"messages": [{"role": "system", "content": SYSTEM_PROMPT}]
+ history[-MAX_HISTORY:],
"stream": False,
}
headers = {
"Authorization": f"Bearer {BRIDGE_KEY}",
"Content-Type": "application/json",
}
def _do_request():
"""Requete bloquante, executee dans un thread pour ne pas figer la boucle."""
response = requests.post(
BRIDGE_URL, json=payload, headers=headers, timeout=REQUEST_TIMEOUT
)
response.raise_for_status()
return response.json()
data = await asyncio.to_thread(_do_request)
answer = data["choices"][0]["message"]["content"] or "> (reponse vide)"
history.append({"role": "assistant", "content": answer})
return answer
# --- ecoute Discord ----------------------------------------------------
@commands.Cog.listener(name="on_message")
async def on_message(self, msg: discord.Message):
"""En DM : chaque message est transmis directement a Hermes."""
if msg.author == self.bot.user or msg.author.bot:
return
if not isinstance(msg.channel, discord.DMChannel):
return
if msg.content.startswith(prefix):
return
if not self._author_allowed(msg.author):
return
async with msg.channel.typing():
try:
answer = await self._ask(msg.author.id, msg.content)
except Exception as exc: # noqa: BLE001
answer = f"> Erreur pont Hermes : {exc}"
await self.send_long(msg.channel, answer)
# --- commandes ---------------------------------------------------------
@commands.command(name="hermes")
async def hermes(self, ctx: commands.Context, *, message: str):
"""Parle a Hermes (masters et porteurs du role hermes)."""
if ctx.guild and not self._author_allowed(ctx.author):
return await ctx.send(f"> Acces refuse : reserve au role `{ROLE_NAME}`.")
async with ctx.typing():
try:
answer = await self._ask(ctx.author.id, message)
except Exception as exc: # noqa: BLE001
answer = f"> Erreur pont Hermes : {exc}"
await self.send_long(ctx.channel, answer)
@commands.command(name="hermes-reset")
async def hermes_reset(self, ctx: commands.Context):
"""Oublie l'historique de conversation courant avec Hermes."""
if ctx.guild and not self._author_allowed(ctx.author):
return
self.histories.pop(ctx.author.id, None)
await ctx.send("> Historique Hermes reinitialise.")
@commands.command(name="hermes-setup")
async def hermes_setup(self, ctx: commands.Context):
"""Cree le role Discord hermes et l'attribue aux masters du bot."""
if ctx.author.id not in masters:
return await ctx.send("> Acces refuse : reserve aux masters.")
if not ctx.guild:
return await ctx.send("> Commande utilisable uniquement en serveur.")
await create_role_if_missing(ctx, ROLE_NAME, self.color)
await ctx.send(f"> Role `{ROLE_NAME}` pret.")
# --- surface de test locale (loopback) ---------------------------------
@web.middleware
async def _ask_auth(self, request: web.Request, handler):
"""Meme convention que cogs/api.py : Bearer exige seulement si le token est pose."""
if self.api_token and request.path != "/live":
if request.headers.get("Authorization") != f"Bearer {self.api_token}":
return web.json_response({"error": "Unauthorized"}, status=401)
return await handler(request)
@commands.Cog.listener(name="on_ready")
async def start_ask_server(self):
"""Demarre la surface de test (idempotent : on_ready peut se redeclencher)."""
async with self.ask_lock:
if self.ask_site is not None:
return
app = web.Application(middlewares=[self._ask_auth])
app.router.add_get("/live", self.ask_live)
app.router.add_post("/hermes/ask", self.ask_http)
self.ask_runner = web.AppRunner(app)
await self.ask_runner.setup()
site = web.TCPSite(self.ask_runner, self.ask_host, self.ask_port)
try:
await site.start()
except OSError:
await self.ask_runner.cleanup()
self.ask_runner = None
raise
self.ask_site = site
print(f"Hermes ask server listening on {self.ask_host}:{self.ask_port}")
def cog_unload(self):
"""Arrete la surface de test."""
if self.ask_runner:
asyncio.create_task(self.ask_runner.cleanup())
async def ask_live(self, request: web.Request):
"""Healthcheck."""
return web.Response(text="OK")
async def ask_http(self, request: web.Request):
"""POST {"message": "..."} -> {"answer": "..."} (historique par user_id)."""
try:
body = await request.json()
except Exception: # noqa: BLE001
return web.json_response({"error": "JSON invalide."}, status=400)
body = body or {}
message = body.get("message")
if not message:
return web.json_response({"error": "Champ 'message' manquant."}, status=400)
try:
user_id = int(body.get("user_id") or (masters[0] if masters else 0))
except (TypeError, ValueError):
return web.json_response({"error": "user_id invalide."}, status=400)
try:
answer = await self._ask(user_id, message)
except Exception as exc: # noqa: BLE001
return web.json_response(
{"error": f"{type(exc).__name__}: {exc}"}, status=502
)
return web.json_response({"answer": answer, "user_id": user_id})
# --- utilitaires -------------------------------------------------------
@staticmethod
async def send_long(channel, text: str):
"""Decoupe les reponses de plus de 2000 caracteres (limite Discord)."""
if not text:
text = "> (reponse vide)"
for i in range(0, len(text), 1990):
await channel.send(text[i : i + 1990])
def setup(bot: commands.Bot):
"""Setup du cog Hermes."""
bot.add_cog(Hermes(bot))
+112
View File
@@ -0,0 +1,112 @@
#!/bin/env python
from config.config import access, cluster
from discord.ext import commands, tasks
from discord.ext.commands import MessageNotFound
import os
import discord
import yaml
ROLE = "Library"
class Library(commands.Cog):
"""Store config on discord servers as attachments."""
tmp_directory = os.environ.get("TMP_DIRECTORY") or "/tmp"
def __init__(self, bot: commands.Bot):
self.bot = bot
self.filename = "config.yml"
self.filepath = f"/tmp/{self.filename}"
self.channel_id = int(os.environ.get("CHANNEL_ID_CONFIG") or 0)
# @property
# def storage(self):
# return self.bot.get_cog("Storage")
@property
def channel(self) -> discord.TextChannel:
return self.bot.get_channel(self.channel_id)
async def create_config(self):
"""Créer un fichier de config pour la librairie si une existe pas déjà."""
config = dict(library=dict(channel_id=self.channel_id))
with open(self.filepath, "w") as f:
yaml.safe_dump(config, f)
if not self.channel:
raise ValueError("Discord channel not found")
with open(self.filepath, 'rb') as f:
await self.channel.send(file=discord.File(f, filename=self.filename))
@commands.group(aliases=["lib"])
async def library(self, ctx: commands.Context):
"""Groupe de commandes du cog 'Backup'."""
if not ctx.invoked_subcommand:
if not self.channel_id:
await ctx.send("> Un salon textuel doit être claim pour utiliser la config.")
else:
await ctx.send("> Aucune sous-commande invokée.")
@library.command(name="claim")
@commands.has_role(ROLE)
async def claim_channel(self, ctx: commands.Context):
"""Déclare le salon textuel de la library."""
self.channel_id = ctx.channel.id
await self.create_config()
await ctx.send(f"> Salon textuel {ctx.channel.id} sélectionné pour le stockage de la config.")
@library.command(name="write")
@commands.has_role(ROLE)
async def write_cmd(self, ctx: commands.Context, key: str, value):
"""Écrit une valeur dans la library."""
await self.write(key, value)
# await ctx.send(f"> Salon textuel {ctx.channel.id} sélectionné pour le stockage de la config.")
async def read(self, attribute):
return (await self.get_library())[attribute]
async def write(self, attribute, value):
library = await self.get_library()
print(1, library)
library[attribute] = value
print(2, library)
await self.set_library(library)
async def search_attachment(self, limit: int):
"""Search for the attachment and message."""
async for message in self.channel.history(limit=limit, oldest_first=False): # Adjust limit as needed
for attachment in message.attachments:
print(f"attachement: {attachment.filename}")
if attachment.filename == self.filename:
return (message, attachment)
raise MessageNotFound("No library file found.")
async def get_library(self, limit=10) -> dict:
"""Get the library from discord"""
_, attachment = await self.search_attachment(limit)
await attachment.save(self.filepath)
with open(self.filepath, "r") as f:
d = yaml.safe_load(f)
return d
async def set_library(self, library, limit=10):
"""Set the library to discord"""
message, attachment = await self.search_attachment(limit)
# await message.remove_attachments(attachment)
await message.delete()
with open(self.filepath, "w") as f:
yaml.safe_dump(library, f)
with open(self.filepath, "rb") as f:
# await message.add_files()
file = discord.File(fp=f, filename=self.filename)
await self.channel.send(file=file)
def setup(bot: commands.Bot):
bot.add_cog(Library(bot))
@@ -172,6 +172,6 @@ class Mongo2(commands.Cog):
return await ctx.send("> Rien n'est sélectionné pour l'instant.")
def setup(bot):
async def setup(bot):
mongo = Mongo(bot)
bot.add_cog(mongo)
await bot.add_cog(mongo)
@@ -170,5 +170,5 @@ class Music2(commands.Cog):
await ctx.send(result['webpage_url'])
print("this ends now")
def setup(bot):
bot.add_cog(Music2(bot))
async def setup(bot):
await bot.add_cog(Music2(bot))
@@ -234,5 +234,5 @@ class Music3:
else:
skip_count = len(state.skip_votes)
await self.bot.say('Now playing {} [skips: {}/3]'.format(state.current, skip_count))
def setup(bot):
bot.add_cog(Music3(bot))
async def setup(bot):
await bot.add_cog(Music3(bot))
@@ -383,5 +383,5 @@ class Music(commands.Cog):
def setup(bot):
bot.add_cog(Music(bot))
async def setup(bot):
await bot.add_cog(Music(bot))
+66
View File
@@ -0,0 +1,66 @@
"""
All sound effects you want in one command.
"""
import time
import datetime
import discord
from utils.date import months, days
from config.config import cluster, masters
from discord.ext import commands
class Record(commands.Cog):
"""Record voices."""
def __init__(self, bot: commands.Bot):
"""Define the recorder"""
# self.sounds = cluster.sound_deck.sounds
self.bot = bot
self.connections = {}
# @property
# def music(self):
# return self.bot.get_cog("Music")
# @commands.group(name="son", aliases=["sound", "sn"])
# async def sound(self, ctx: commands.Context):
# """Groupe des commandes pour un son."""
# if not ctx.invoked_subcommand:
# return await ctx.send(
# f"> Commande inexistante."
# f"\n> Écrivez `{ctx.bot.command_prefix}help son` pour voir les commandes disponibles."
# ""
# )
# @sound.command(name="voir", aliases=["v", "show", "see"])
# async def show(self, ctx: commands.Context, *, name: str):
# """Affiche un son."""
# embed = self.embed_sound(name)
# await ctx.send(embed=embed)
@commands.command(name="record")
async def record(self, ctx): # If you're using commands.Bot, this will also work.
"""Enregistre une voix."""
voice = ctx.author.voice
if not voice:
await ctx.respond("You aren't in a voice channel!")
vc = await voice.channel.connect() # Connect to the voice channel the author is in.
self.connections.update({ctx.guild.id: vc}) # Updating the cache with the guild and channel.
vc.start_recording(
discord.sinks.WaveSink(), # The sink type to use.
self.once_done, # What to do once done.
ctx.channel # The channel to disconnect from.
)
await ctx.respond("Started recording!")
def once_done(self, *args):
print("its done", args)
def setup(bot):
bot.add_cog(Record(bot))
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python
"""HTTP web service to interact with the bot."""
__author__ = "Marc Partensky"
# from config.config import cluster, access, check
# from utils.check import check
# from utils import tools
import os, time, subprocess
import discord
import aiofiles
# from discord.errors import NotFound
from discord.ext.commands import MessageNotFound
from discord.ext import commands
from aiohttp import web
MAX_CHUNK_SIZE = 8 * 1024 * 1024 # 8 MB
class Storage(commands.Cog):
"""Store files on discord using HTTP web service to interact with the bot."""
tmp_directory = os.environ.get("TMP_DIRECTORY") or "/tmp"
def __init__(self, bot: commands.Bot):
self.bot = bot
self.channel_id = int(os.environ.get("CHANNEL_ID_NOTIF", "0"))
self.password = "password"
# self.tmp_directory = "/tmp"
def get_timestamp(self):
"""Return a human readable timestamp."""
current_time = time.localtime()
milliseconds = int((time.time() % 1) * 1000)
timestamp = time.strftime(f"%Y-%m-%d_%H-%M-%S_{milliseconds:03d}", current_time)
return timestamp
def sign(self, filepath: str) -> str:
"""Sign a file using its file path and return the signed file path."""
signed_filepath = f"{filepath}.sig"
cmd = f"gpg --output {signed_filepath} --sign {filepath}"
subprocess.run(cmd.split(" "))
return signed_filepath
def encrypt_and_compress(self, filepath: str) -> str:
"""Encrypt and compress a file using its file path
and return the encrypted and compressed file path."""
compressed_filepath = f"{filepath}.7z"
cmd = f"7z a -p{self.password} {compressed_filepath} {filepath}"
subprocess.run(cmd.split(" "))
return compressed_filepath
def compress(self, filepath: str) -> str:
"""Compress a file using its file path and return the compressed file path."""
compressed_filepath = f"{filepath}.7z"
cmd = f"7z -y a {compressed_filepath} {filepath}"
subprocess.run(cmd.split(" "))
return compressed_filepath
async def split_file(self, filepath: str, chunk_size: int = MAX_CHUNK_SIZE):
"""
Split a file into chunks of specified size.
"""
chunk_paths = []
filepath = self.encrypt_and_compress(filepath)
# filepath = self.sign(filepath)
with open(filepath, 'rb') as file:
part_number = 1
while True:
chunk = file.read(chunk_size)
if not chunk:
break
tmp = self.get_timestamp()
chunk_path = f"{Storage.tmp_directory}/{os.path.basename(filepath)}.{tmp}.{part_number}"
async with aiofiles.open(chunk_path, 'wb') as chunk_file:
await chunk_file.write(chunk)
chunk_paths.append(chunk_path)
part_number += 1
return chunk_paths
async def send_file_in_chunks(self, filepath: str, file_name: str, channel_id: int = 0, timestamp: str=""):
"""
Send a file to Discord in chunks using filepath, file_name and optional channel_id.
"""
channel_id = channel_id or self.channel_id
chunk_paths = await self.split_file(filepath)
channel = self.bot.get_channel(int(channel_id))
if not channel:
raise ValueError("Discord channel not found")
if not timestamp:
timestamp = self.get_timestamp()
for i, chunk_path in enumerate(chunk_paths, start=1):
chunk_file_name = f"{file_name}.{timestamp}.{i}"
with open(chunk_path, 'rb') as f:
await channel.send(file=discord.File(f, filename=chunk_file_name))
return chunk_paths
async def clean_files(self, filepaths: list):
"""Remove the specified files."""
for filepath in filepaths:
os.remove(filepath)
def generate_url(self, filename: str, channel_id: int, timestamp: str):
"""Generate an url to retrieve a file already sent."""
PUBLIC_API_URL = os.environ["PUBLIC_API_URL"]
return f"{PUBLIC_API_URL}/retrieve?filename={filename}&channel_id={channel_id}&timestamp={timestamp}"
async def send_file_to_channel(self, request: web.Request):
"""Endpoint to send a file to a specific channel by ID."""
filename: str
channel_id: int
reader = await request.multipart()
field = await reader.next()
if field.name == "channel_id":
channel_id = await field.text()
else:
return web.json_response({"error": "Channel ID missing"}, status=400)
field = await reader.next()
if field.name == "file":
filename = field.filename
filepath = f"{Storage.tmp_directory}/{filename}"
# Save the file temporarily
async with aiofiles.open(filepath, 'wb') as f:
while True:
chunk = await field.read_chunk() # 8192 bytes by default
if not chunk:
break
await f.write(chunk)
else:
return web.json_response({"error": "File missing"}, status=400)
channel: discord.TextChannel = await self.bot.fetch_channel(channel_id)
if not channel:
return web.json_response({"error": "Channel not found"}, status=404)
# await channel.send(file=discord.File(filepath))
storage = Storage(self.bot)
timestamp = storage.get_timestamp()
await storage.send_file_in_chunks(filepath, filename, channel_id, timestamp)
url = storage.generate_url(filename, channel_id, timestamp)
# Optionally, delete the temp file after sending it
os.remove(filepath)
message = f"Uploaded {filename}"
print(message)
print(url)
return web.json_response({"message": message, "url": url}, status=200)
async def retrieve_file_from_channel_request(self, request: web.Request):
"""
Endpoint to retrieve, reassemble, and download the original file from a channel
based on its filename, the timestamp and potentially channel_id.
"""
# Extract the JSON data from the request
body = request.query
# Extract the required information: channel ID and original filename
channel_id = body.get("channel_id") or self.channel_id
filename = body.get("filename")
timestamp = body.get("timestamp")
limit = body.get("limit")
print('body:', body)
print("channel_id:", channel_id)
print("filename:", filename)
print("timestamp:", timestamp)
print("limit:", limit)
if not channel_id:
return web.json_response({"error": "channel_id missing"}, status=400)
if not filename:
return web.json_response({"error": "filename missing"}, status=400)
if limit:
try:
limit = int(limit)
except:
return web.json_response({"error": "limit is not a number"}, status=400)
channel: discord.TextChannel = await self.bot.fetch_channel(channel_id)
if not channel:
return web.json_response({"error": "channel not found"}, status=404)
print("channel:", channel)
try:
reassembled_filepath = await Storage(self.bot).retrieve_file_from_channel(
filename,
channel,
timestamp,
limit
)
headers = {'Content-Disposition': f'attachment; filename="{filename}"'}
return web.FileResponse(reassembled_filepath, headers=headers)
except MessageNotFound as e:
return web.json_response({"error": str(e)}, status=404)
async def retrieve_file_from_channel(
self,
filename: str,
channel: discord.TextChannel,
timestamp: str,
limit: int = 1000,
):
"""Retrieve a file from a channel using:
- filename
- channel
- timestamp
- limit [1000] (number of messages checked)
"""
# Prepare the path to save the reassembled file
reassembled_filepath = f"{Storage.tmp_directory}/{filename}"
print("reassembled_filepath:", reassembled_filepath)
# Initialize list to collect all file chunks
file_chunks = []
stop = False
found = False
def is_well_formed(dfilename: str):
if not dfilename.startswith(filename):
return False
dextension = dfilename[len(filename)+1:]
if dextension.count(".") != 1:
return False
dtimestamp, dpart = dextension.split(".")
# print(f"dtimestamp: {dtimestamp}, {len(dtimestamp)}")
# print(f"dpart: {dpart}")
if len(dtimestamp) != 23:
return False
if not dpart:
return False
# print("passed:", dfilename)
return True
def deconstruct(dfilepath: str):
dfilename = dfilepath.split('/')[-1]
dextension = dfilename[len(filename)+1:]
dtimestamp, dpart = dextension.split(".")
return dextension, dtimestamp, dpart
# Iterate through the channel's message history to find the file chunks
i = 0
async for message in channel.history(limit=limit, oldest_first=False): # Adjust limit as needed
i+=1
if i%100==0: print(i, "messages")
if stop and found: break
for attachment in message.attachments:
print(f"attachement found: {attachment.filename}")
dfilename = attachment.filename
if not is_well_formed(dfilename):
# print(f"skip and delete {dfilename} because malformed")
await message.delete()
continue
if dfilename.startswith(filename):
dextension = dfilename[len(filename)+1:]
# print(filename, dfilename, dextension)
if dextension.count(".") != 1:
break
dtimestamp, dpart = dextension.split(".")
# print("dfilename", dfilename)
# print("dextension", dextension)
# print("dtimestamp", dtimestamp)
# print("dpart", dpart)
# print("timestamp", timestamp)
if not timestamp:
timestamp = dtimestamp
if timestamp == dtimestamp:
# print("found", dtimestamp)
found = True
elif found:
# print("stop", timestamp, "!=", dtimestamp)
stop = True
break
chunk_path = f"{Storage.tmp_directory}/{dfilename}"
await attachment.save(chunk_path)
file_chunks.append(chunk_path)
if not file_chunks:
raise MessageNotFound("No file chunks found in the channel.")
# Sort the chunks by part number
print("sorting chunks", file_chunks)
file_chunks.sort(key=lambda filepath: int(deconstruct(filepath)[-1]))
# Reassemble the file
async with aiofiles.open(reassembled_filepath, 'wb') as reassembled_file:
for chunk_path in file_chunks:
async with aiofiles.open(chunk_path, 'rb') as chunk_file:
data = await chunk_file.read()
await reassembled_file.write(data)
# Optionally clean up the chunk files after reassembly
for chunk_path in file_chunks:
os.remove(chunk_path)
# Serve the reassembled file as a download response
return reassembled_filepath
def setup(bot: commands.Bot):
bot.add_cog(Storage(bot))
@@ -15,7 +15,7 @@ link = "https://discordapp.com/oauth2/authorize?&client_id=703347349623144549&sc
prefix = os.environ.get("DISCORD_PREFIX") or "."
masters = [478552571510915072] # , 219949869220102147]
wolfram = wolframalpha.Client(wolfram_alpha_id)
ialab_bot_url = "https://ialab.marcpartensky.com"
ialab_bot_url = os.environ.get("IALAB_URL") or "https://ialab.marcpartensky.com"
roles = ["@Maître", "@Admin"]
@@ -6,6 +6,6 @@ client_id = os.environ["DISCORD_CLIENT_ID"]
mongo_username = os.environ["DISCORD_MONGO_USERNAME"]
mongo_password = os.environ["DISCORD_MONGO_PASSWORD"]
mongo_cluster = os.environ["DISCORD_MONGO_CLUSTER"]
wolfram_alpha_id = os.environ["WOLFRAM_ALPHA_ID"]
wolfram_alpha_id = os.environ.get("WOLFRAM_ALPHA_ID", "")
mongo_url = f"mongodb+srv://{mongo_username}:{mongo_password}@{mongo_cluster}"

Some files were not shown because too many files have changed in this diff Show More