diff --git a/.github/workflows/black.yaml b/.github/workflows/black.yaml deleted file mode 100644 index 3d0810e..0000000 --- a/.github/workflows/black.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: Black - -on: - push: - branches: [ master, stable ] - pull_request: - branches: [ master, stable ] - -jobs: - black: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.8 - - - name: Install dependencies - run: | - pip install --upgrade pip - pip install black - - - name: Black check - run: | - black --check butterrobot diff --git a/.github/workflows/docker-build-latest.yaml b/.github/workflows/docker-build-latest.yaml deleted file mode 100644 index de0b1e9..0000000 --- a/.github/workflows/docker-build-latest.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: Build latest tag docker image - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Build the Docker image - run: docker build --tag butterrobot:$(git rev-parse --short HEAD) -f Dockerfile.dev . - - - name: Push into Github packages (latest) - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u fmartingr --password-stdin - docker tag butterrobot:$(git rev-parse --short HEAD) docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest - docker push docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml deleted file mode 100644 index 8db9c98..0000000 --- a/.github/workflows/pytest.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: Pytest - -on: - push: - branches: [ master, stable ] - pull_request: - branches: [ master, stable ] - -jobs: - pytest: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.8] - - steps: - - uses: actions/checkout@v2 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - pip install --upgrade pip poetry - poetry install - - - name: Test with pytest - run: | - ls - poetry run pytest --cov=butterrobot --cov=butterrobot_plugins_contrib diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 00984ea..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,47 +0,0 @@ -name: Release - -on: - push: - branches: - - stable - -jobs: - prepare: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - publish: - runs-on: ubuntu-latest - needs: - - prepare - steps: - - name: Set up Python 3.8 - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - - name: Install poetry - run: | - pip install poetry - - - name: Build and publish - run: | - poetry publish -u ${{ secrets.PYPI_USERNAME }} -p ${{ secrets.PYPI_PASSWORD }} --build - - build: - runs-on: ubuntu-latest - needs: - - prepare - - publish - steps: - - name: Build the Docker image - run: docker build --tag butterrobot:$(git rev-parse --short HEAD) docker - - - name: Push into Github packages (stable) - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u fmartingr --password-stdin - docker tag butterrobot:$(git rev-parse --short HEAD) docker.pkg.github.com/fmartingr/butterrobot/butterrobot:stable - docker tag butterrobot:$(git rev-parse --short HEAD) docker.pkg.github.com/fmartingr/butterrobot/butterrobot:$(cat pyproject.toml | grep version | cut -d "\"" -f 2) - docker push docker.pkg.github.com/fmartingr/butterrobot/butterrobot:stable - docker push docker.pkg.github.com/fmartingr/butterrobot/butterrobot:$(cat pyproject.toml | grep version | cut -d "\"" -f 2) diff --git a/.gitignore b/.gitignore index 309685e..d964ffb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,16 +4,10 @@ __pycache__ *~ *.cert .env-local -test.py .coverage -# Distribution dist -*.egg-info -pip-wheel-metadata - -# Github Codespaces -pythonenv3.8 - +bin # Butterrobot *.sqlite* +butterrobot.db diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..a3836e9 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,150 @@ +version: 2 + +gitea_urls: + api: https://git.nakama.town/api/v1 + download: https://git.nakama.town + +before: + hooks: + - go mod tidy + +git: + ignore_tags: + - "{{ if not .IsNightly }}*-rc*{{ end }}" + +builds: + - binary: butterrobot + main: ./cmd/butterrobot + env: + - CGO_ENABLED=0 + - GIN_MODE=release + tags: + - netgo + - osusergo + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm + - arm64 + goarm: + - "7" + ignore: + - goos: darwin + goarch: arm + - goos: windows + goarch: arm + - goos: windows + goarch: arm64 + +archives: + - id: butterrobot + name_template: >- + {{ .ProjectName }}_ + {{- if eq .Os "darwin" }}Darwin{{- else if eq .Os "linux" }}Linux{{- else if eq .Os "windows" }}Windows{{- else }}{{ .Os }}{{ end }}_ + {{- if eq .Arch "amd64" }}x86_64{{- else if eq .Arch "arm64" }}aarch64{{- else }}{{ .Arch }}{{ end }}_{{ .Version }} + format_overrides: + - goos: windows + formats: ['zip'] + +dockers: +- image_templates: + - &amd64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-amd64" + use: buildx + dockerfile: &dockerfile Containerfile + goos: linux + goarch: amd64 + build_flag_templates: + - "--pull" + - "--platform=linux/amd64" +- image_templates: + - &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-arm64" + use: buildx + dockerfile: *dockerfile + goos: linux + goarch: arm64 + build_flag_templates: + - "--pull" + - "--platform=linux/arm64" +- image_templates: + - &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-armv7" + use: buildx + dockerfile: *dockerfile + goos: linux + goarch: arm + goarm: "7" + build_flag_templates: + - "--pull" + - "--platform=linux/arm/v7" + +docker_manifests: + - name_template: "git.nakama.town/fmartingr/butterrobot:{{ .Version }}" + image_templates: + - *amd64_image + - *arm64_image + - *armv7_image + # - name_template: "git.nakama.town/fmartingr/butterrobot:latest" + # image_templates: + # - *amd64_image + # - *arm64_image + # - *armv7_image + +nfpms: + - maintainer: Felipe Martin + description: A chatbot server with customizable commands and triggers + homepage: https://git.nakama.town/fmartingr/butterrobot + license: AGPL-3.0 + formats: + - deb + - rpm + - apk + +upx: + - enabled: true + ids: + - butterrobot + goos: [linux, darwin] + goarch: [amd64, arm, arm64] + goarm: ["7"] + +checksum: + name_template: 'checksums.txt' + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + groups: + - title: Features + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 0 + - title: "Fixes" + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 1 + - title: "Performance" + regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' + order: 2 + - title: API + regexp: '^.*?api(\([[:word:]]+\))??!?:.+$' + order: 3 + - title: Documentation + regexp: '^.*?docs(\([[:word:]]+\))??!?:.+$' + order: 4 + - title: "Tests" + regexp: '^.*?test(\([[:word:]]+\))??!?:.+$' + order: 5 + - title: CI and Delivery + regexp: '^.*?ci(\([[:word:]]+\))??!?:.+$' + order: 6 + - title: Others + order: 999 + filters: + exclude: + - "^deps:" + - "^chore\\(deps\\):" + +release: + prerelease: auto diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 621aa22..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,22 +0,0 @@ -repos: -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.2.3 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: flake8 - -- repo: https://github.com/asottile/seed-isort-config - rev: v1.9.2 - hooks: - - id: seed-isort-config -- repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.20 - hooks: - - id: isort - -- repo: https://github.com/ambv/black - rev: stable - hooks: - - id: black - language_version: python3 diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml new file mode 100644 index 0000000..5b32d48 --- /dev/null +++ b/.woodpecker/ci.yml @@ -0,0 +1,23 @@ +when: + event: + - push + - pull_request + branch: + - master + +steps: + format: + image: golang:1.24 + commands: + - make format + - git diff --exit-code # Fail if files were changed + + lint: + image: golang:1.24 + commands: + - make ci-lint + + test: + image: golang:1.24 + commands: + - make test diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml new file mode 100644 index 0000000..39dbf65 --- /dev/null +++ b/.woodpecker/release.yml @@ -0,0 +1,16 @@ +when: + - event: tag + branch: master + +steps: + - name: Release + image: goreleaser/goreleaser:latest + environment: + GITEA_TOKEN: + from_secret: GITEA_TOKEN + DOCKER_HOST: unix:///var/run/docker.sock + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + commands: + - docker login -u fmartingr -p $GITEA_TOKEN git.nakama.town + - goreleaser release --clean --parallelism=2 diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..6a9ff7d --- /dev/null +++ b/Containerfile @@ -0,0 +1,6 @@ +# This file is used directly by the goreleaser build +# It is used to build the final container image +FROM scratch +WORKDIR / +COPY /butterrobot /usr/bin/butterrobot +ENTRYPOINT ["/usr/bin/butterrobot"] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index c1a4a16..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,26 +0,0 @@ -FROM docker.io/library/alpine:3.11 - -ENV PYTHON_VERSION=3.8.2-r1 -ENV APP_PORT 8080 -ENV BUILD_DIR /tmp/build -ENV APP_PATH /etc/butterrobot - -WORKDIR ${BUILD_DIR} -COPY README.md ${BUILD_DIR}/README.md -COPY poetry.lock ${BUILD_DIR}/poetry.lock -COPY pyproject.toml ${BUILD_DIR}/pyproject.toml -COPY ./butterrobot_plugins_contrib ${BUILD_DIR}/butterrobot_plugins_contrib -COPY ./butterrobot ${BUILD_DIR}/butterrobot -RUN apk --update add curl python3-dev==${PYTHON_VERSION} gcc musl-dev libffi-dev openssl-dev && \ - pip3 install poetry && \ - poetry build && \ - pip3 install ${BUILD_DIR}/dist/butterrobot-*.tar.gz && \ - rm -rf ${BUILD_DIR} && \ - mkdir ${APP_PATH} && \ - chown -R 1000:1000 ${APP_PATH} - -USER 1000 -WORKDIR ${APP_PATH} -COPY ./docker/bin/start-server.sh /usr/local/bin/start-server - -CMD ["/usr/local/bin/start-server"] diff --git a/Makefile b/Makefile index 2791e1c..ff08163 100644 --- a/Makefile +++ b/Makefile @@ -1,27 +1,100 @@ -# Local development -setup: - poetry install +PROJECT_NAME := butterrobot -podman@build: - podman build -t fmartingr/butterrobot -f docker/Dockerfile docker +SOURCE_FILES ?=./... -podman@build-dev: - podman build -t fmartingr/butterrobot:dev -f Dockerfile.dev . +TEST_OPTIONS ?= -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out +TEST_TIMEOUT ?=1m -podman@tag-dev: - podman tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev +GOLANGCI_LINT_VERSION ?= v1.64.5 -podman@push-dev: - podman push registry.int.fmartingr.network/fmartingr/butterrobot:dev --tls-verify=false +CLEAN_OPTIONS ?=-modcache -testcache -podman@dev: - make podman@build-dev - make podman@tag-dev - make podman@push-dev +CGO_ENABLED := 0 -test: - poetry run pytest --cov=butterrobot --cov=butterrobot_plugins_contrib +BUILDS_PATH := ./dist +FROM_MAKEFILE := y -clean: - rm -rf dist - rm -rf butterrobot.egg-info +CONTAINERFILE_NAME := Containerfile +CONTAINER_ALPINE_VERSION := 3.21 +CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}" +CONTAINER_MAINTAINER := "Felipe Martin " +CONTAINER_BIN_NAME := ${PROJECT_NAME} + +BUILDX_PLATFORMS := linux/amd64,linux/arm64,linux/arm/v7 + +export PROJECT_NAME +export FROM_MAKEFILE + +export CGO_ENABLED + +export SOURCE_FILES +export TEST_OPTIONS +export TEST_TIMEOUT +export BUILDS_PATH + +export CONTAINERFILE_NAME +export CONTAINER_ALPINE_VERSION +export CONTAINER_SOURCE_URL +export CONTAINER_MAINTAINER +export CONTAINER_BIN_NAME + +export BUILDX_PLATFORMS + +.PHONY: all +all: help + +# this is godly +# https://news.ycombinator.com/item?id=11939200 +.PHONY: help +help: ### this screen. Keep it first target to be default +ifeq ($(UNAME), Linux) + @grep -P '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' +else + @# this is not tested, but prepared in advance for you, Mac drivers + @awk -F ':.*###' '$$0 ~ FS {printf "%15s%s\n", $$1 ":", $$2}' \ + $(MAKEFILE_LIST) | grep -v '@awk' | sort +endif + +.PHONY: clean +clean: ### clean test cache, build files + $(info: Make: Clean) + @rm -rf ${BUILDS_PATH} + @go clean ${CLEAN_OPTIONS} + @-docker buildx rm ${PROJECT_NAME}_builder + +.PHONY: build +build: clean ### builds the project for the setup os/arch combinations + $(info: Make: Build) + @goreleaser --clean --snapshot + +.PHONY: quick-run +quick-run: ### Executes the project using golang + CGO_ENABLED=${CGO_ENABLED} go run ./cmd/${PROJECT_NAME}/*.go + +.PHONY: run +run: ### Executes the project build locally + @make build + ${BUILDS_PATH}/${PROJECT_NAME} + +.PHONY: format +format: ### Executes the formatting pipeline on the project + $(info: Make: Format) + @go fmt ./... + @go mod tidy + +.PHONY: ci-lint +ci-lint: ### Check the project for errors + $(info: Make: Lint) + @go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION} + @golangci-lint run ./... + +.PHONY: lint +lint: ### Check the project for errors + $(info: Make: Lint) + @golangci-lint run ./... + +.PHONY: test +test: ### Runs the test suite + $(info: Make: Test) + CGO_ENABLED=1 go test ${TEST_OPTIONS} -timeout=${TEST_TIMEOUT} ${SOURCE_FILES} diff --git a/README.md b/README.md index 7fb78d6..920d087 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,82 @@ # Butter Robot -| Stable | Master | -| --- | --- | -| ![Build stable tag docker image](https://github.com/fmartingr/butterrobot/workflows/Build%20stable%20tag%20docker%20image/badge.svg?branch=stable) | ![Build latest tag docker image](https://github.com/fmartingr/butterrobot/workflows/Build%20latest%20tag%20docker%20image/badge.svg?branch=master) | -| ![Pytest](https://github.com/fmartingr/butterrobot/workflows/Pytest/badge.svg?branch=stable) | ![Pytest](https://github.com/fmartingr/butterrobot/workflows/Pytest/badge.svg?branch=master) | +![Status badge](https://woodpecker.local.fmartingr.dev/api/badges/5/status.svg) -Python framework to create bots for several platforms. +Go framework to create bots for several platforms. ![Butter Robot](./assets/icon@120.png) > What is my purpose? +## Features + +- Support for multiple chat platforms (Slack (untested!), Telegram) +- Plugin system for easy extension +- Admin interface for managing channels and plugins +- Message queue for asynchronous processing + ## Documentation [Go to documentation](./docs) +### Database Management + +ButterRobot includes an automatic database migration system. Migrations are applied automatically when the application starts, ensuring your database schema is always up to date. + +[Learn more about migrations](./docs/migrations.md) + ## Installation -### PyPi +### From Source -You can run it directly by installing the package and calling it -with `python` though this is not recommended and only intended for -development purposes. +```bash +# Clone the repository +git clone https://git.nakama.town/fmartingr/butterrobot.git +cd butterrobot -``` -$ pip install --user butterrobot -$ python -m butterrobot +# Build the application +go build -o butterrobot ./cmd/butterrobot ``` ### Containers -The `fmartingr/butterrobot/butterrobot` container image is published on Github packages to use with your favourite tool: +The `fmartingr/butterrobot/butterrobot` container image is published on Github packages: +```bash +docker pull docker.pkg.git.nakama.town/fmartingr/butterrobot/butterrobot:latest +docker run -d --name butterrobot -p 8080:8080 docker.pkg.git.nakama.town/fmartingr/butterrobot/butterrobot:latest ``` -docker pull docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest -podman run -d --name fmartingr/butterrobot/butterrobot -p 8080:8080 -``` + +## Configuration + +Configuration is done through environment variables: + +- `DEBUG`: Set to "y" to enable debug mode +- `BUTTERROBOT_HOSTNAME`: Hostname for webhook URLs +- `LOG_LEVEL`: Logging level (DEBUG, INFO, WARN, ERROR) +- `SECRET_KEY`: Secret key for sessions and password hashing +- `DATABASE_PATH`: Path to SQLite database file + +### Platform-specific configuration + +#### Slack + +- `SLACK_TOKEN`: Slack app access token +- `SLACK_BOT_OAUTH_ACCESS_TOKEN`: Slack bot OAuth access token + +#### Telegram + +- `TELEGRAM_TOKEN`: Telegram bot token ## Contributing -To run the project locally you will need [poetry](https://python-poetry.org/). - -``` +```bash git clone git@github.com:fmartingr/butterrobot.git cd butterrobot -poetry install +go mod download ``` -Create a `.env-local` file with the required environment variables, you have [an example file](.env-example). +Create a `.env-local` file with the required environment variables: ``` SLACK_TOKEN=xxx @@ -55,8 +84,12 @@ TELEGRAM_TOKEN=xxx ... ``` -And then you can run it directly with poetry +And then you can run it directly: +```bash +go run ./cmd/butterrobot/main.go ``` -docker run -it --rm --env-file .env-local -p 5000:5000 -v $PWD/butterrobot:/etc/app/butterrobot local/butterrobot python -m butterrobot -``` + +## License + +GPL-2.0 diff --git a/butterrobot/__init__.py b/butterrobot/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/butterrobot/__main__.py b/butterrobot/__main__.py deleted file mode 100644 index 2f0182a..0000000 --- a/butterrobot/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -from butterrobot.app import app -from butterrobot.config import DEBUG - -# Only used for local development! -# python -m butterrobot -app.run(debug=DEBUG, host="0.0.0.0") diff --git a/butterrobot/admin/__init__.py b/butterrobot/admin/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/butterrobot/admin/blueprint.py b/butterrobot/admin/blueprint.py deleted file mode 100644 index b9442fd..0000000 --- a/butterrobot/admin/blueprint.py +++ /dev/null @@ -1,156 +0,0 @@ -import os.path -from functools import wraps - -import structlog -from flask import ( - Blueprint, - g, - flash, - request, - session, - url_for, - redirect, - render_template, -) - -from butterrobot.db import UserQuery, ChannelQuery, ChannelPluginQuery -from butterrobot.plugins import get_available_plugins - -admin = Blueprint("admin", __name__, url_prefix="/admin") -admin.template_folder = os.path.join(os.path.dirname(__name__), "templates") -logger = structlog.get_logger(__name__) - - -def login_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if g.user is None: - return redirect(url_for("admin.login_view", next=request.path)) - return f(*args, **kwargs) - - return decorated_function - - -@admin.before_app_request -def load_logged_in_user(): - user_id = session.get("user_id") - - if user_id is None: - g.user = None - else: - try: - user = UserQuery.get(id=user_id) - g.user = user - except UserQuery.NotFound: - g.user = None - - -@admin.route("/") -@login_required -def index_view(): - if not session.get("logged_in", False): - return redirect(url_for("admin.login_view")) - return redirect(url_for("admin.channel_list_view")) - - -@admin.route("/login", methods=["GET", "POST"]) -def login_view(): - error = None - if request.method == "POST": - user = UserQuery.check_credentials( - request.form["username"], request.form["password"] - ) - if not user: - flash("Incorrect credentials", category="danger") - else: - session["logged_in"] = True - session["user_id"] = user.id - flash("You were logged in", category="success") - _next = request.args.get("next", url_for("admin.index_view")) - return redirect(_next) - return render_template("login.j2", error=error) - - -@admin.route("/logout") -@login_required -def logout_view(): - session.clear() - flash("You were logged out", category="success") - return redirect(url_for("admin.index_view")) - - -@admin.route("/plugins") -@login_required -def plugin_list_view(): - return render_template("plugin_list.j2", plugins=get_available_plugins().values()) - - -@admin.route("/channels") -@login_required -def channel_list_view(): - return render_template("channel_list.j2", channels=ChannelQuery.all()) - - -@admin.route("/channels/", methods=["GET", "POST"]) -@login_required -def channel_detail_view(channel_id): - if request.method == "POST": - ChannelQuery.update( - channel_id, - enabled=request.form["enabled"] == "true", - ) - flash("Channel updated", "success") - - channel = ChannelQuery.get(channel_id) - return render_template( - "channel_detail.j2", channel=channel, plugins=get_available_plugins() - ) - - -@admin.route("/channel//delete", methods=["POST"]) -@login_required -def channel_delete_view(channel_id): - ChannelQuery.delete(channel_id) - flash("Channel removed", category="success") - return redirect(url_for("admin.channel_list_view")) - - -@admin.route("/channelplugins", methods=["GET", "POST"]) -@login_required -def channel_plugin_list_view(): - if request.method == "POST": - data = request.form - try: - ChannelPluginQuery.create( - data["channel_id"], data["plugin_id"], enabled=data["enabled"] == "y" - ) - flash(f"Plugin {data['plugin_id']} added to the channel", "success") - except ChannelPluginQuery.Duplicated: - flash( - f"Plugin {data['plugin_id']} is already present on the channel", "error" - ) - return redirect(request.headers.get("Referer")) - - channel_plugins = ChannelPluginQuery.all() - return render_template("channel_plugins_list.j2", channel_plugins=channel_plugins) - - -@admin.route("/channelplugins/", methods=["GET", "POST"]) -@login_required -def channel_plugin_detail_view(channel_plugin_id): - if request.method == "POST": - ChannelPluginQuery.update( - channel_plugin_id, - enabled=request.form["enabled"] == "true", - ) - flash("Plugin updated", category="success") - - return redirect(request.headers.get("Referer")) - - -@admin.route("/channelplugins//delete", methods=["POST"]) -@login_required -def channel_plugin_delete_view(channel_plugin_id): - ChannelPluginQuery.delete(channel_plugin_id) - flash("Plugin removed", category="success") - return redirect(request.headers.get("Referer")) diff --git a/butterrobot/admin/templates/channel_detail.j2 b/butterrobot/admin/templates/channel_detail.j2 deleted file mode 100644 index 409a230..0000000 --- a/butterrobot/admin/templates/channel_detail.j2 +++ /dev/null @@ -1,140 +0,0 @@ -{% extends "_base.j2" %} - -{% block content %} - -
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - -
ID{{ channel.id }}
Platform{{ channel.platform }}
Platform Channel ID{{ channel.platform_channel_id }}
RAW -
{{ channel.channel_raw }}
-
-
-
-
-
-
-
-

Plugins

-
-
-
- - -

-

-
- Enable plugin -
-
- -
-
- -
-
-

-
-
- - - - - - - - - - - {% for channel_plugin in channel.plugins.values() %} - - - - - - {% else %} - - - - {% endfor %} - -
NameConfigurationActions
{{ plugins[channel_plugin.plugin_id].name }} -
{{ channel_plugin.config }}
-
-
-
-
- - -
-
-
-
- -
-
-
-
No plugin is enabled on this channel
-
-
-
-
-
-{% endblock %} diff --git a/butterrobot/admin/templates/channel_list.j2 b/butterrobot/admin/templates/channel_list.j2 deleted file mode 100644 index 6bb0887..0000000 --- a/butterrobot/admin/templates/channel_list.j2 +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "_base.j2" %} - -{% block content %} - - -
-
- - - - - - - - - - - - - {% for channel in channels %} - - - - - - - - {% endfor %} - -
PlatformChannel nameChannel IDEnabled
{{ channel.platform }}{{ channel.channel_name }} - {{ channel.platform_channel_id }} - {{ channel.enabled }} - Edit -
-
-
-{% endblock %} diff --git a/butterrobot/admin/templates/channel_plugins_list.j2 b/butterrobot/admin/templates/channel_plugins_list.j2 deleted file mode 100644 index 2ee69bb..0000000 --- a/butterrobot/admin/templates/channel_plugins_list.j2 +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "_base.j2" %} - -{% block content %} - - -
-
- - - - - - - - - - - - {% for channel_plugin in channel_plugins %} - - - - - - - {% endfor %} - -
IDChannel IDPlugin IDEnabled
{{ channel_plugin.id }}{{ channel_plugin.channel_id }} - {{ channel_plugin.plugin_id }} - {{ channel_plugin.enabled }}
-
-
-{% endblock %} diff --git a/butterrobot/admin/templates/index.j2 b/butterrobot/admin/templates/index.j2 deleted file mode 100644 index 70b55ea..0000000 --- a/butterrobot/admin/templates/index.j2 +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "_base.j2" %} - -{% block content %} - -{% endblock %} diff --git a/butterrobot/admin/templates/login.j2 b/butterrobot/admin/templates/login.j2 deleted file mode 100644 index eb78117..0000000 --- a/butterrobot/admin/templates/login.j2 +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "_base.j2" %} - -{% block content %} -
- - {% if error %}

Error: {{ error }}{% endif %} -

-
-

Login

-
-
-
-
- -
- -
-
-
- -
- -
-
- -
-
-
-
-{% endblock %} diff --git a/butterrobot/admin/templates/plugin_list.j2 b/butterrobot/admin/templates/plugin_list.j2 deleted file mode 100644 index 68532fb..0000000 --- a/butterrobot/admin/templates/plugin_list.j2 +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "_base.j2" %} - -{% block content %} - - -
-
- - - - - - - - - {% for plugin in plugins %} - - - - {% endfor %} - -
Name
{{ plugin.name }}
-
-
-{% endblock %} diff --git a/butterrobot/app.py b/butterrobot/app.py deleted file mode 100644 index 22344c0..0000000 --- a/butterrobot/app.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio - -import structlog -from flask import Flask, request - -import butterrobot.logging # noqa -from butterrobot.http import ExternalProxyFix -from butterrobot.queue import q -from butterrobot.config import SECRET_KEY -from butterrobot.platforms import get_available_platforms -from butterrobot.admin.blueprint import admin as admin_bp - -loop = asyncio.get_event_loop() -logger = structlog.get_logger(__name__) -app = Flask(__name__) -app.config.update(SECRET_KEY=SECRET_KEY) -app.register_blueprint(admin_bp) -app.wsgi_app = ExternalProxyFix(app.wsgi_app) - - -@app.route("//incoming", methods=["POST"]) -@app.route("//incoming/", methods=["POST"]) -def incoming_platform_message_view(platform, path=None): - if platform not in get_available_platforms(): - return {"error": "Unknown platform"}, 400 - - q.put( - { - "platform": platform, - "request": {"path": request.path, "json": request.get_json()}, - } - ) - - return {} - - -@app.route("/healthz") -def healthz(): - return {} diff --git a/butterrobot/config.py b/butterrobot/config.py deleted file mode 100644 index cefa366..0000000 --- a/butterrobot/config.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -# --- Butter Robot ----------------------------------------------------------------- -DEBUG = os.environ.get("DEBUG", "n") == "y" - -HOSTNAME = os.environ.get( - "BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network" -) - -LOG_LEVEL = os.environ.get("LOG_LEVEL", "ERROR") - -SECRET_KEY = os.environ.get("SECRET_KEY", "1234") - -# --- DATABASE --------------------------------------------------------------------- -DATABASE_PATH = os.environ.get("DATABASE_PATH", "sqlite:///butterrobot.sqlite") - -# --- PLATFORMS --------------------------------------------------------------------- -# --- -# Slack -# --- -# Slack app access token -SLACK_TOKEN = os.environ.get("SLACK_TOKEN") - -# Slack app oauth access token to send messages on the bot behalf -SLACK_BOT_OAUTH_ACCESS_TOKEN = os.environ.get("SLACK_BOT_OAUTH_ACCESS_TOKEN") - -# --- -# Telegram -# --- -# Telegram auth token -TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN") diff --git a/butterrobot/db.py b/butterrobot/db.py deleted file mode 100644 index fe29920..0000000 --- a/butterrobot/db.py +++ /dev/null @@ -1,163 +0,0 @@ -import hashlib -from typing import Union - -import dataset - -from butterrobot.config import SECRET_KEY, DATABASE_PATH -from butterrobot.objects import User, Channel, ChannelPlugin - -db = dataset.connect(DATABASE_PATH) - - -class Query: - class NotFound(Exception): - pass - - class Duplicated(Exception): - pass - - @classmethod - def all(cls): - """ - Iterate over all rows on a table. - """ - for row in db[cls.tablename].all(): - yield cls.obj(**row) - - @classmethod - def get(cls, **kwargs): - """ - Returns the object representation of an specific row in a table. - Allows retrieving object by multiple columns. - Raises `NotFound` error if query return no results. - """ - row = db[cls.tablename].find_one(**kwargs) - if not row: - raise cls.NotFound - return cls.obj(**row) - - @classmethod - def create(cls, **kwargs): - """ - Creates a new row in the table with the provided arguments. - Returns the row_id - TODO: Return obj? - """ - return db[cls.tablename].insert(kwargs) - - @classmethod - def exists(cls, **kwargs) -> bool: - """ - Check for the existence of a row with the provided columns. - """ - try: - cls.get(**kwargs) - except cls.NotFound: - return False - return True - - @classmethod - def update(cls, row_id, **fields): - fields.update({"id": row_id}) - return db[cls.tablename].update(fields, ("id",)) - - @classmethod - def delete(cls, id): - return db[cls.tablename].delete(id=id) - - -class UserQuery(Query): - tablename = "users" - obj = User - - @classmethod - def _hash_password(cls, password): - return hashlib.pbkdf2_hmac( - "sha256", password.encode("utf-8"), str.encode(SECRET_KEY), 100000 - ).hex() - - @classmethod - def check_credentials(cls, username, password) -> Union[User, "False"]: - user = db[cls.tablename].find_one(username=username) - if user: - hash_password = cls._hash_password(password) - if user["password"] == hash_password: - return cls.obj(**user) - return False - - @classmethod - def create(cls, **kwargs): - kwargs["password"] = cls._hash_password(kwargs["password"]) - return super().create(**kwargs) - - -class ChannelQuery(Query): - tablename = "channels" - obj = Channel - - @classmethod - def create(cls, platform, platform_channel_id, enabled=False, channel_raw={}): - params = { - "platform": platform, - "platform_channel_id": platform_channel_id, - "enabled": enabled, - "channel_raw": channel_raw, - } - super().create(**params) - return cls.obj(**params) - - @classmethod - def get(cls, _id): - channel = super().get(id=_id) - plugins = ChannelPluginQuery.get_from_channel_id(_id) - channel.plugins = {plugin.plugin_id: plugin for plugin in plugins} - return channel - - @classmethod - def get_by_platform(cls, platform, platform_channel_id): - result = db[cls.tablename].find_one( - platform=platform, platform_channel_id=platform_channel_id - ) - if not result: - raise cls.NotFound - - plugins = ChannelPluginQuery.get_from_channel_id(result["id"]) - - return cls.obj( - plugins={plugin.plugin_id: plugin for plugin in plugins}, **result - ) - - @classmethod - def delete(cls, _id): - ChannelPluginQuery.delete_by_channel(channel_id=_id) - super().delete(_id) - - -class ChannelPluginQuery(Query): - tablename = "channel_plugin" - obj = ChannelPlugin - - @classmethod - def create(cls, channel_id, plugin_id, enabled=False, config={}): - if cls.exists(channel_id=channel_id, plugin_id=plugin_id): - raise cls.Duplicated - - params = { - "channel_id": channel_id, - "plugin_id": plugin_id, - "enabled": enabled, - "config": config, - } - obj_id = super().create(**params) - return cls.obj(id=obj_id, **params) - - @classmethod - def get_from_channel_id(cls, channel_id): - yield from [ - cls.obj(**row) for row in db[cls.tablename].find(channel_id=channel_id) - ] - - @classmethod - def delete_by_channel(cls, channel_id): - channel_plugins = cls.get_from_channel_id(channel_id) - [cls.delete(item.id) for item in channel_plugins] diff --git a/butterrobot/http.py b/butterrobot/http.py deleted file mode 100644 index 9086155..0000000 --- a/butterrobot/http.py +++ /dev/null @@ -1,15 +0,0 @@ -class ExternalProxyFix(object): - """ - Custom proxy helper to get the external hostname from the `X-External-Host` header - used by one of the reverse proxies in front of this in production. - It does nothing if the header is not present. - """ - - def __init__(self, app): - self.app = app - - def __call__(self, environ, start_response): - host = environ.get("HTTP_X_EXTERNAL_HOST", "") - if host: - environ["HTTP_HOST"] = host - return self.app(environ, start_response) diff --git a/butterrobot/lib/__init__.py b/butterrobot/lib/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/butterrobot/lib/slack.py b/butterrobot/lib/slack.py deleted file mode 100644 index b8cc7d3..0000000 --- a/butterrobot/lib/slack.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import Optional, Text - -import requests -import structlog - -from butterrobot.config import SLACK_BOT_OAUTH_ACCESS_TOKEN - - -logger = structlog.get_logger() - - -class SlackAPI: - BASE_URL = "https://slack.com/api" - HEADERS = {"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"} - - class SlackError(Exception): - pass - - class SlackClientError(Exception): - pass - - @classmethod - def get_conversations_info(cls, chat_id) -> dict: - params = {"channel": chat_id} - response = requests.get( - f"{cls.BASE_URL}/conversations.info", params=params, headers=cls.HEADERS, - ) - response_json = response.json() - if not response_json["ok"]: - raise cls.SlackClientError(response_json) - - return response_json["channel"] - - @classmethod - def get_user_info(cls, chat_id) -> dict: - params = {"user": chat_id} - response = requests.get( - f"{cls.BASE_URL}/users.info", params=params, headers=cls.HEADERS, - ) - response_json = response.json() - if not response_json["ok"]: - raise cls.SlackClientError(response_json) - - return response_json["user"] - - @classmethod - def send_message(cls, channel, message, thread: Optional[Text] = None): - payload = { - "text": message, - "channel": channel, - } - - if thread: - payload["thread_ts"] = thread - - response = requests.post( - f"{cls.BASE_URL}/chat.postMessage", data=payload, headers=cls.HEADERS, - ) - response_json = response.json() - if not response_json["ok"]: - raise cls.SlackClientError(response_json) diff --git a/butterrobot/lib/telegram.py b/butterrobot/lib/telegram.py deleted file mode 100644 index a10ecfa..0000000 --- a/butterrobot/lib/telegram.py +++ /dev/null @@ -1,58 +0,0 @@ -import requests -import structlog - -from butterrobot.config import TELEGRAM_TOKEN - - -logger = structlog.get_logger(__name__) - - -class TelegramAPI: - BASE_URL = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}" - - DEFAULT_ALLOWED_UPDATES = ["message"] - - class TelegramError(Exception): - pass - - class TelegramClientError(Exception): - pass - - @classmethod - def set_webhook(cls, webhook_url, max_connections=40, allowed_updates=None): - allowed_updates = allowed_updates or cls.DEFAULT_ALLOWED_UPDATES - url = f"{cls.BASE_URL}/setWebhook" - payload = { - "url": webhook_url, - "max_connections": max_connections, - "allowed_updates": allowed_updates, - } - response = requests.post(url, json=payload) - response_json = response.json() - if not response_json["ok"]: - raise cls.TelegramClientError(response_json) - - @classmethod - def send_message( - cls, - chat_id, - text, - parse_mode="markdown", - disable_web_page_preview=False, - disable_notification=False, - reply_to_message_id=None, - ): - url = f"{cls.BASE_URL}/sendMessage" - payload = { - "chat_id": chat_id, - "text": text, - "parse_mode": parse_mode, - "disable_web_page_preview": disable_web_page_preview, - "disable_notification": disable_notification, - "reply_to_message_id": reply_to_message_id, - } - - response = requests.post(url, json=payload) - response_json = response.json() - if not response_json["ok"]: - raise cls.TelegramClientError(response_json) diff --git a/butterrobot/logging.py b/butterrobot/logging.py deleted file mode 100644 index f31e5b8..0000000 --- a/butterrobot/logging.py +++ /dev/null @@ -1,25 +0,0 @@ -import logging - -import structlog - -from butterrobot.config import LOG_LEVEL, DEBUG - - -logging.basicConfig(format="%(message)s", level=LOG_LEVEL) -structlog.configure( - processors=[ - structlog.stdlib.add_log_level, - structlog.stdlib.add_logger_name, - structlog.dev.set_exc_info, - structlog.processors.StackInfoRenderer(), - structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M.%S"), - structlog.processors.format_exc_info, - structlog.dev.ConsoleRenderer() - if DEBUG - else structlog.processors.JSONRenderer(), - ], - context_class=dict, - logger_factory=structlog.stdlib.LoggerFactory(), - wrapper_class=structlog.BoundLogger, - cache_logger_on_first_use=True, -) diff --git a/butterrobot/objects.py b/butterrobot/objects.py deleted file mode 100644 index 215924b..0000000 --- a/butterrobot/objects.py +++ /dev/null @@ -1,61 +0,0 @@ -from datetime import datetime -from dataclasses import dataclass, field -from typing import Text, Optional, Dict - -import structlog - - -logger = structlog.get_logger(__name__) - - -@dataclass -class ChannelPlugin: - id: int - channel_id: int - plugin_id: str - enabled: bool = False - config: dict = field(default_factory=dict) - - -@dataclass -class Channel: - platform: str - platform_channel_id: str - channel_raw: dict - enabled: bool = False - id: Optional[int] = None - plugins: Dict[str, ChannelPlugin] = field(default_factory=dict) - - def has_enabled_plugin(self, plugin_id): - if plugin_id not in self.plugins: - logger.debug("No enabled!", plugin_id=plugin_id, plugins=self.plugins) - return False - - return self.plugins[plugin_id].enabled - - @property - def channel_name(self): - from butterrobot.platforms import PLATFORMS - - return PLATFORMS[self.platform].parse_channel_name_from_raw(self.channel_raw) - - -@dataclass -class Message: - text: Text - chat: Text - # TODO: Move chat references to `.channel.platform_channel_id` - channel: Optional[Channel] = None - author: Text = None - from_bot: bool = False - date: Optional[datetime] = None - id: Optional[Text] = None - reply_to: Optional[Text] = None - raw: dict = field(default_factory=dict) - - -@dataclass -class User: - id: int - username: Text - password: Text diff --git a/butterrobot/platforms/__init__.py b/butterrobot/platforms/__init__.py deleted file mode 100644 index dad1360..0000000 --- a/butterrobot/platforms/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -from functools import lru_cache - -import structlog - -from butterrobot.platforms.slack import SlackPlatform -from butterrobot.platforms.telegram import TelegramPlatform -from butterrobot.platforms.debug import DebugPlatform - - -logger = structlog.get_logger(__name__) -PLATFORMS = { - platform.ID: platform - for platform in (SlackPlatform, TelegramPlatform, DebugPlatform) -} - - -@lru_cache -def get_available_platforms(): - from butterrobot.platforms import PLATFORMS - - available_platforms = {} - for platform in PLATFORMS.values(): - logger.debug("Setting up", platform=platform.ID) - try: - platform.init(app=None) - available_platforms[platform.ID] = platform - logger.info("platform setup completed", platform=platform.ID) - except platform.PlatformInitError as error: - logger.error("Platform init error", error=error, platform=platform.ID) - return available_platforms diff --git a/butterrobot/platforms/base.py b/butterrobot/platforms/base.py deleted file mode 100644 index a5d778e..0000000 --- a/butterrobot/platforms/base.py +++ /dev/null @@ -1,67 +0,0 @@ -from abc import abstractmethod -from dataclasses import dataclass - - -class Platform: - class PlatformError(Exception): - pass - - class PlatformInitError(PlatformError): - pass - - class PlatformAuthError(PlatformError): - pass - - @dataclass - class PlatformAuthResponse(PlatformError): - """ - Used when the platform needs to make a response right away instead of async. - """ - - data: dict - status_code: int = 200 - - @classmethod - def init(cls, app): - """ - Initialises the platform. - - Used at the application launch to prepare anything required for - the platform to work.. - - It receives the flask application via parameter in case the platform - requires for custom webservice endpoints or configuration. - """ - pass - - @classmethod - @abstractmethod - def parse_incoming_message(cls, request): - """ - Parses the incoming request and returns a :class:`butterrobot.objects.Message` instance. - """ - pass - - @classmethod - @abstractmethod - def parse_channel_name_from_raw(cls, channel_raw) -> str: - """ - Extracts the Channel name from :class:`butterrobot.objects.Channel.channel_raw`. - """ - pass - - @classmethod - @abstractmethod - def parse_channel_from_message(cls, channel_raw): - """ - Extracts the Channel raw data from the message received in the incoming webhook. - """ - pass - - -class PlatformMethods: - @classmethod - @abstractmethod - def send_message(cls, message): - """Method used to send a message via the platform""" - pass diff --git a/butterrobot/platforms/debug.py b/butterrobot/platforms/debug.py deleted file mode 100644 index 9982011..0000000 --- a/butterrobot/platforms/debug.py +++ /dev/null @@ -1,44 +0,0 @@ -import uuid -from datetime import datetime - -import structlog - -from butterrobot.platforms.base import Platform, PlatformMethods -from butterrobot.objects import Message, Channel - - -logger = structlog.get_logger(__name__) - - -class DebugMethods(PlatformMethods): - @classmethod - def send_message(self, message: Message): - logger.debug( - "Outgoing message", message=message.__dict__, platform=DebugPlatform.ID - ) - - -class DebugPlatform(Platform): - ID = "debug" - - methods = DebugMethods - - @classmethod - def parse_incoming_message(cls, request): - request_data = request["json"] - logger.debug("Parsing message", data=request_data, platform=cls.ID) - - return Message( - id=str(uuid.uuid4()), - date=datetime.now(), - text=request_data["text"], - from_bot=bool(request_data.get("from_bot", False)), - author=request_data.get("author", "Debug author"), - chat=request_data.get("chat", "Debug chat ID"), - channel=Channel( - platform=cls.ID, - platform_channel_id=request_data.get("chat"), - channel_raw={}, - ), - raw={}, - ) diff --git a/butterrobot/platforms/slack.py b/butterrobot/platforms/slack.py deleted file mode 100644 index aa1b133..0000000 --- a/butterrobot/platforms/slack.py +++ /dev/null @@ -1,105 +0,0 @@ -from datetime import datetime - -import structlog - -from butterrobot.platforms.base import Platform, PlatformMethods -from butterrobot.config import SLACK_TOKEN, SLACK_BOT_OAUTH_ACCESS_TOKEN -from butterrobot.objects import Message, Channel -from butterrobot.lib.slack import SlackAPI - - -logger = structlog.get_logger(__name__) - - -class SlackMethods(PlatformMethods): - @classmethod - def send_message(self, message: Message): - logger.debug( - "Outgoing message", message=message.__dict__, platform=SlackPlatform.ID - ) - try: - SlackAPI.send_message( - channel=message.chat, message=message.text, thread=message.reply_to - ) - except SlackAPI.SlackClientError as error: - logger.error( - "Send message error", - platform=SlackPlatform.ID, - error=error, - message=message.__dict__, - ) - - -class SlackPlatform(Platform): - ID = "slack" - - methods = SlackMethods - - @classmethod - def init(cls, app): - if not (SLACK_TOKEN and SLACK_BOT_OAUTH_ACCESS_TOKEN): - logger.error("Missing token. platform not enabled.", platform=cls.ID) - return - - @classmethod - def parse_channel_name_from_raw(cls, channel_raw): - return channel_raw["name"] - - @classmethod - def parse_channel_from_message(cls, message): - # Call different APIs for a channel or DM - if message["event"]["channel_type"] == "im": - chat_raw = SlackAPI.get_user_info(message["event"]["user"]) - else: - chat_raw = SlackAPI.get_conversations_info(message["event"]["channel"]) - - return Channel( - platform=cls.ID, - platform_channel_id=message["event"]["channel"], - channel_raw=chat_raw, - ) - - @classmethod - def parse_incoming_message(cls, request): - data = request["json"] - - # Auth - if data.get("token") != SLACK_TOKEN: - raise cls.PlatformAuthError("Authentication error") - - # Confirms challenge request to configure webhook - if "challenge" in data: - raise cls.PlatformAuthResponse(data={"challenge": data["challenge"]}) - - # Discard messages by webhooks and apps - if "bot_id" in data["event"]: - logger.debug("Discarding message", data=data) - return - - logger.debug("Parsing message", platform=cls.ID, data=data) - - if data["event"]["type"] not in ("message", "message.groups"): - return - - # Surprisingly, this *can* happen. - if "text" not in data["event"]: - return - - message = Message( - id=data["event"].get("thread_ts", data["event"]["ts"]), - author=data["event"].get("user"), - from_bot="bot_id" in data["event"], - date=datetime.fromtimestamp(int(float(data["event"]["event_ts"]))), - text=data["event"]["text"], - chat=data["event"]["channel"], - channel=cls.parse_channel_from_message(data), - raw=data, - ) - - logger.info( - "New message", - platform=message.channel.platform, - channel=cls.parse_channel_name_from_raw(message.channel.channel_raw), - ) - - return message diff --git a/butterrobot/platforms/telegram.py b/butterrobot/platforms/telegram.py deleted file mode 100644 index ddf7607..0000000 --- a/butterrobot/platforms/telegram.py +++ /dev/null @@ -1,87 +0,0 @@ -from datetime import datetime - -import structlog - -from butterrobot.platforms.base import Platform, PlatformMethods -from butterrobot.config import TELEGRAM_TOKEN, HOSTNAME -from butterrobot.lib.telegram import TelegramAPI -from butterrobot.objects import Message, Channel - - -logger = structlog.get_logger(__name__) - - -class TelegramMethods(PlatformMethods): - @classmethod - def send_message(self, message: Message): - logger.debug( - "Outgoing message", message=message.__dict__, platform=TelegramPlatform.ID - ) - TelegramAPI.send_message( - chat_id=message.chat, - text=message.text, - reply_to_message_id=message.reply_to, - ) - - -class TelegramPlatform(Platform): - ID = "telegram" - - methods = TelegramMethods - - @classmethod - def init(cls, app): - """ - Initializes the Telegram webhook endpoint to receive updates - """ - - if not TELEGRAM_TOKEN: - logger.error("Missing token. platform not enabled.", platform=cls.ID) - return - - webhook_url = f"https://{HOSTNAME}/telegram/incoming/{TELEGRAM_TOKEN}" - try: - TelegramAPI.set_webhook(webhook_url) - except TelegramAPI.TelegramError as error: - logger.error(f"Error setting Telegram webhook: {error}", platform=cls.ID) - raise Platform.PlatformInitError() - - @classmethod - def parse_channel_name_from_raw(cls, channel_raw): - if channel_raw["id"] < 0: - return channel_raw["title"] - else: - if channel_raw["username"]: - return f"@{channel_raw['username']}" - return f"{channel_raw['first_name']} {channel_raw['last_name']}" - - @classmethod - def parse_channel_from_message(cls, channel_raw): - return Channel( - platform=cls.ID, - platform_channel_id=channel_raw["id"], - channel_raw=channel_raw, - ) - - @classmethod - def parse_incoming_message(cls, request): - token = request["path"].split("/")[-1] - if token != TELEGRAM_TOKEN: - raise cls.PlatformAuthError("Authentication error") - - logger.debug("Parsing message", data=request["json"], platform=cls.ID) - - if "text" in request["json"]["message"]: - # Ignore all messages but text messages - return Message( - id=request["json"]["message"]["message_id"], - date=datetime.fromtimestamp(request["json"]["message"]["date"]), - text=str(request["json"]["message"]["text"]), - from_bot=request["json"]["message"]["from"]["is_bot"], - author=request["json"]["message"]["from"]["id"], - chat=str(request["json"]["message"]["chat"]["id"]), - channel=cls.parse_channel_from_message( - request["json"]["message"]["chat"] - ), - raw=request["json"], - ) diff --git a/butterrobot/plugins.py b/butterrobot/plugins.py deleted file mode 100644 index 0ee3b55..0000000 --- a/butterrobot/plugins.py +++ /dev/null @@ -1,67 +0,0 @@ -import traceback -import pkg_resources -from abc import abstractclassmethod -from functools import lru_cache -from typing import Optional, Dict - -import structlog - -from butterrobot.objects import Message - - -logger = structlog.get_logger(__name__) - - -class Plugin: - """ - Base Plugin class. - - All attributes are required except for `requires_config`. - """ - - id: str - name: str - help: str - requires_config: bool = False - - @abstractclassmethod - def on_message(cls, message: Message, channel_config: Optional[Dict] = None): - """ - Function called for each message received on the chat. - - It should exit as soon as possible (usually checking for a keyword or something) - similar just at the start. - - If the plugin needs to be executed (keyword matches), keep it as fast as possible - as this currently blocks the execution of the rest of the plugins on the channel - until this does not finish. - TODO: Update this once we go proper async plugin/message integration - - In case something needs to be answered to the channel, you can `yield` a `Message` - instance and it will be relayed using the appropriate provider. - """ - pass - - -@lru_cache -def get_available_plugins(): - """ - Retrieves every available auto discovered plugin - """ - plugins = {} - for ep in pkg_resources.iter_entry_points("butterrobot.plugins"): - try: - plugin_cls = ep.load() - plugins[plugin_cls.id] = plugin_cls - except Exception as error: - logger.error( - "Error loading plugin", - exception=str(error), - traceback=traceback.format_exc(), - plugin=ep.name, - project_name=ep.dist.project_name, - entry_point=ep, - module=ep.module_name, - ) - - return plugins diff --git a/butterrobot/queue.py b/butterrobot/queue.py deleted file mode 100644 index 82f99a7..0000000 --- a/butterrobot/queue.py +++ /dev/null @@ -1,64 +0,0 @@ -import threading -import traceback -import queue - -import structlog - -from butterrobot.db import ChannelQuery -from butterrobot.platforms import get_available_platforms -from butterrobot.platforms.base import Platform -from butterrobot.plugins import get_available_plugins - -logger = structlog.get_logger(__name__) -q = queue.Queue() - - -def handle_message(platform: str, request: dict): - try: - message = get_available_platforms()[platform].parse_incoming_message( - request=request - ) - except Platform.PlatformAuthResponse as response: - return response.data, response.status_code - except Exception as error: - logger.error( - "Error parsing message", - platform=platform, - error=error, - traceback=traceback.format_exc(), - ) - return - - if not message or message.from_bot: - return - - try: - channel = ChannelQuery.get_by_platform(platform, message.chat) - except ChannelQuery.NotFound: - # If channel is still not present on the database, create it (defaults to disabled) - channel = ChannelQuery.create( - platform, message.chat, channel_raw=message.channel.channel_raw - ) - - if not channel.enabled: - return - - for plugin_id, channel_plugin in channel.plugins.items(): - if not channel.has_enabled_plugin(plugin_id): - continue - - for response_message in get_available_plugins()[plugin_id].on_message( - message, plugin_config=channel_plugin.config - ): - get_available_platforms()[platform].methods.send_message(response_message) - - -def worker_thread(): - while True: - item = q.get() - handle_message(item["platform"], item["request"]) - q.task_done() - - -# turn-on the worker thread -worker = threading.Thread(target=worker_thread, daemon=True).start() diff --git a/butterrobot_plugins_contrib/__init__.py b/butterrobot_plugins_contrib/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/butterrobot_plugins_contrib/dev.py b/butterrobot_plugins_contrib/dev.py deleted file mode 100644 index c03f6c7..0000000 --- a/butterrobot_plugins_contrib/dev.py +++ /dev/null @@ -1,18 +0,0 @@ -from datetime import datetime - -from butterrobot.plugins import Plugin -from butterrobot.objects import Message - - -class PingPlugin(Plugin): - name = "Ping command" - id = "contrib.dev.ping" - - @classmethod - def on_message(cls, message, **kwargs): - if message.text == "!ping": - delta = datetime.now() - message.date - delta_ms = delta.seconds * 1000 + delta.microseconds / 1000 - yield Message( - chat=message.chat, reply_to=message.id, text=f"pong! ({delta_ms}ms)", - ) diff --git a/butterrobot_plugins_contrib/fun.py b/butterrobot_plugins_contrib/fun.py deleted file mode 100644 index 4a5b3bf..0000000 --- a/butterrobot_plugins_contrib/fun.py +++ /dev/null @@ -1,51 +0,0 @@ -import random - -import dice -import structlog - -from butterrobot.plugins import Plugin -from butterrobot.objects import Message - - -logger = structlog.get_logger(__name__) - - -class LoquitoPlugin(Plugin): - name = "Loquito reply" - id = "contrib.fun.loquito" - - @classmethod - def on_message(cls, message, **kwargs): - if "lo quito" in message.text.lower(): - yield Message( - chat=message.chat, reply_to=message.id, text="Loquito tu.", - ) - - -class DicePlugin(Plugin): - name = "Dice command" - id = "contrib.fun.dice" - DEFAULT_FORMULA = "1d20" - - @classmethod - def on_message(cls, message: Message, **kwargs): - if message.text.startswith("!dice"): - dice_formula = message.text.replace("!dice", "").strip() - if not dice_formula: - dice_formula = cls.DEFAULT_FORMULA - roll = int(dice.roll(dice_formula)) - yield Message(chat=message.chat, reply_to=message.id, text=roll) - - -class CoinPlugin(Plugin): - name = "Coin command" - id = "contrib.fun.coin" - - @classmethod - def on_message(cls, message: Message, **kwargs): - if message.text.startswith("!coin"): - yield Message( - chat=message.chat, - reply_to=message.id, - text=random.choice(("heads", "tails")), - ) diff --git a/cmd/butterrobot/main.go b/cmd/butterrobot/main.go new file mode 100644 index 0000000..3bc56cb --- /dev/null +++ b/cmd/butterrobot/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + "runtime/debug" + + "git.nakama.town/fmartingr/butterrobot/internal/app" + "git.nakama.town/fmartingr/butterrobot/internal/config" + + _ "golang.org/x/crypto/x509roots/fallback" +) + +func main() { + // Initialize logger + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + // Load configuration + cfg, err := config.Load() + if err != nil { + logger.Error("Failed to load configuration", "error", err) + os.Exit(1) + } + + // Handle version command + if len(os.Args) > 1 && os.Args[1] == "version" { + info, ok := debug.ReadBuildInfo() + if ok { + fmt.Printf("ButterRobot version %s\n", info.Main.Version) + } else { + fmt.Println("ButterRobot. Can't determine build information.") + } + return + } + + // Initialize and run application + application, err := app.New(cfg, logger) + if err != nil { + logger.Error("Failed to initialize application", "error", err) + os.Exit(1) + } + + if err := application.Run(); err != nil { + logger.Error("Application error", "error", err) + os.Exit(1) + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 7c95591..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -FROM alpine:3.11 - -ENV PYTHON_VERSION=3.8.2-r1 -ENV APP_PORT 8080 -ENV BUTTERROBOT_VERSION 0.0.3 -ENV EXTRA_DEPENDENCIES "" -ENV APP_PATH /etc/butterrobot - -COPY bin/start-server.sh /usr/local/bin/start-server -RUN apk --update add curl python3-dev==${PYTHON_VERSION} gcc musl-dev libffi-dev openssl-dev && \ - pip3 install butterrobot==${BUTTERROBOT_VERSION} ${EXTRA_DEPENDENCIES} && \ - mkdir ${APP_PATH} && \ - chown -R 1000:1000 ${APP_PATH} - -USER 1000 - -CMD ["/usr/local/bin/start-server"] diff --git a/docker/bin/start-server.sh b/docker/bin/start-server.sh deleted file mode 100755 index aa5ea13..0000000 --- a/docker/bin/start-server.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -xe - -waitress-serve --port=${APP_PORT} 'butterrobot.app:app' \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md index ae11cef..8c7ff76 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,11 +1,12 @@ ## Contributing -To run the project locally you will need [poetry](https://python-poetry.org/). +To run the project locally you will need Go 1.19 or higher. -``` +```bash git clone git@github.com:fmartingr/butterrobot.git cd butterrobot make setup +make build ``` Create a `.env-local` file with the required environment variables, you have [an example file](.env-example). @@ -13,11 +14,16 @@ Create a `.env-local` file with the required environment variables, you have [an ``` SLACK_TOKEN=xxx TELEGRAM_TOKEN=xxx +HOSTNAME=myhostname.com ... ``` -And then you can run it directly with poetry: +And then you can run it directly: -``` -poetry run python -m butterrobot +```bash +# Run directly with Go +go run ./cmd/butterrobot/main.go + +# Or run the built binary +./bin/butterrobot ``` diff --git a/docs/creating-a-plugin.md b/docs/creating-a-plugin.md index 0f4cee6..469491a 100644 --- a/docs/creating-a-plugin.md +++ b/docs/creating-a-plugin.md @@ -1,37 +1,163 @@ # Creating a Plugin -## Example +## Plugin Categories -This simple "Marco Polo" plugin will answer _Polo_ to the user that say _Marco_: +ButterRobot organizes plugins into different categories: -``` python -# mypackage/plugins.py -from butterrobot.plugins import Plugin -from butterrobot.objects import Message +- **Development**: Utility plugins like `ping` +- **Fun**: Entertainment plugins like dice rolling, coin flipping +- **Social**: Social media related plugins like URL transformers/expanders +When creating a new plugin, consider which category it fits into and place it in the appropriate directory. -class PingPlugin(Plugin): - name = "Marco/Polo" - id = "test.marco" +## Plugin Examples - @classmethod - def on_message(cls, message, **kwargs): - if message.text == "Marco": - yield Message( - chat=message.chat, reply_to=message.id, text=f"polo", - ) -``` +### Basic Example: Marco Polo -``` python -# setup.py -# ... -entrypoints = { - "test.marco" = "mypackage.plugins:MarcoPlugin" +This simple "Marco Polo" plugin will answer _Polo_ to the user that says _Marco_: + +```go +package myplugin + +import ( + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// MarcoPlugin is a simple Marco/Polo plugin +type MarcoPlugin struct { + plugin.BasePlugin } -setup( - # ... - entry_points=entrypoints, - # ... -) +// New creates a new MarcoPlugin instance +func New() *MarcoPlugin { + return &MarcoPlugin{ + BasePlugin: plugin.BasePlugin{ + ID: "test.marco", + Name: "Marco/Polo", + Help: "Responds to 'Marco' with 'Polo'", + }, + } +} + +// OnMessage handles incoming messages +func (p *MarcoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + if !strings.EqualFold(strings.TrimSpace(msg.Text), "Marco") { + return nil + } + + response := &model.Message{ + Text: "Polo", + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} +``` + +### Advanced Example: URL Transformer + +This more complex plugin transforms URLs, useful for improving media embedding in chat platforms: + +```go +package social + +import ( + "net/url" + "regexp" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" +) + +// TwitterExpander transforms twitter.com links to fxtwitter.com links +type TwitterExpander struct { + plugin.BasePlugin +} + +// New creates a new TwitterExpander instance +func NewTwitter() *TwitterExpander { + return &TwitterExpander{ + BasePlugin: plugin.BasePlugin{ + ID: "social.twitter", + Name: "Twitter Link Expander", + Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters", + }, + } +} + +// OnMessage handles incoming messages +func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message { + // Skip empty messages + if strings.TrimSpace(msg.Text) == "" { + return nil + } + + // Regex to match twitter.com links + twitterRegex := regexp.MustCompile(`https?://(www\.)?(twitter\.com|x\.com)/[^\s]+`) + + // Check if the message contains a Twitter link + if !twitterRegex.MatchString(msg.Text) { + return nil + } + + // Transform the URL + transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string { + // Parse the URL + parsedURL, err := url.Parse(link) + if err != nil { + // If parsing fails, just do the simple replacement + link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1) + link = strings.Replace(link, "x.com", "fxtwitter.com", 1) + return link + } + + // Change the host + if strings.Contains(parsedURL.Host, "twitter.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1) + } else if strings.Contains(parsedURL.Host, "x.com") { + parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1) + } + + // Remove query parameters + parsedURL.RawQuery = "" + + // Return the cleaned URL + return parsedURL.String() + }) + + // Create response message + response := &model.Message{ + Text: transformed, + Chat: msg.Chat, + ReplyTo: msg.ID, + Channel: msg.Channel, + } + + return []*model.Message{response} +} +``` + +## Registering Plugins + +To use the plugin, register it in your application: + +```go +// In app.go or similar initialization file +func (a *App) Run() error { + // ... + + // Register plugins + plugin.Register(ping.New()) // Development plugin + plugin.Register(fun.NewCoin()) // Fun plugin + plugin.Register(social.NewTwitter()) // Social media plugin + plugin.Register(myplugin.New()) // Your custom plugin + + // ... +} ``` diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..65fcd99 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,99 @@ +# Database Migrations + +ButterRobot uses a simple database migration system to manage database schema changes. This document explains how the migration system works and how to extend it. + +## Automatic Migrations + +Migrations in ButterRobot are applied automatically when the application starts. This ensures your database schema is always up to date without requiring manual intervention. + +The migration system: +1. Checks which migrations have been applied +2. Applies any pending migrations in sequential order +3. Records each successful migration in the `schema_migrations` table + +## Initial State + +The initial migration (version 1) sets up the database with the following: + +- `channels` table for chat platforms +- `channel_plugin` table for plugins associated with channels +- `users` table for admin users with bcrypt password hashing +- Default admin user with username "admin" and password "admin" + +This migration represents the current state of the database schema. It is not backwards compatible with previous versions of ButterRobot. + +## Creating New Migrations + +To add a new migration, follow these steps: + +1. Open `/internal/migration/migrations.go` +2. Add a new migration version in the `init()` function: + +```go +Register(2, "Add example table", migrateAddExampleTableUp, migrateAddExampleTableDown) +``` + +3. Implement the up and down functions for your migration: + +```go +// Migration to add example table - version 2 +func migrateAddExampleTableUp(db *sql.DB) error { + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS example ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + return err +} + +func migrateAddExampleTableDown(db *sql.DB) error { + _, err := db.Exec(`DROP TABLE IF EXISTS example`) + return err +} +``` + +## Migration Guidelines + +1. **Incremental Changes**: Each migration should make a small, focused change to the database schema. +2. **Backward Compatibility**: Ensure migrations are backward compatible with existing code when possible. +3. **Test Thoroughly**: Test both up and down migrations before deploying. +4. **Document Changes**: Add comments explaining the purpose of each migration. +5. **Version Numbers**: Use sequential version numbers for migrations. + +## How Migrations Work + +The migration system tracks applied migrations in a `schema_migrations` table. When you run migrations, the system: + +1. Checks which migrations have been applied +2. Applies any pending migrations in order +3. Records each successful migration in the `schema_migrations` table + +When rolling back, it performs the down migrations in reverse order. + +## In Code Usage + +The application automatically runs pending migrations when starting up. This is done in the `initDatabase` function. + +You can also programmatically work with migrations: + +```go +// Get database instance +database, err := db.New(cfg.DatabasePath) +if err != nil { + // Handle error +} +defer database.Close() + +// Run migrations +if err := database.MigrateUp(); err != nil { + // Handle error +} + +// Check migration status +applied, pending, err := database.MigrationStatus() +if err != nil { + // Handle error +} +``` \ No newline at end of file diff --git a/docs/plugins.md b/docs/plugins.md index e4fcd29..84578e5 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -2,10 +2,19 @@ ### Development -- `!ping`: Say `!ping` to get response with time elapsed. +- `ping`: Say `ping` to get response with time elapsed. ### Fun and entertainment - - Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) - Dice: Put `!dice` and wathever roll you want to perform. +- Coin: Flip a coin and get heads or tails. + +### Utility + +- Remind Me: Reply to a message with `!remindme ` to set a reminder. Supported duration units: y (years), mo (months), d (days), h (hours), m (minutes), s (seconds). Examples: `!remindme 1y` for 1 year, `!remindme 3mo` for 3 months, `!remindme 2d` for 2 days, `!remindme 3h` for 3 hours. The bot will mention you with a reminder after the specified time. + +### Social Media + +- Twitter Link Expander: Automatically converts twitter.com and x.com links to fxtwitter.com links and removes tracking parameters. This allows for better media embedding in chat platforms. +- Instagram Link Expander: Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters. This allows for better media embedding in chat platforms. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cd1bee5 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module git.nakama.town/fmartingr/butterrobot + +go 1.24 + +require ( + github.com/gorilla/sessions v1.4.0 + golang.org/x/crypto v0.37.0 + golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df + modernc.org/sqlite v1.37.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.32.0 // indirect + modernc.org/libc v1.63.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.10.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..00c4a3c --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df h1:SwgTucX8ajPE0La2ELpYOIs8jVMoCMpAvYB6mDqP9vk= +golang.org/x/crypto/x509roots/fallback v0.0.0-20250418111936-9c1aa6af88df/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA= +modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc= +modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.63.0 h1:wKzb61wOGCzgahQBORb1b0dZonh8Ufzl/7r4Yf1D5YA= +modernc.org/libc v1.63.0/go.mod h1:wDzH1mgz1wUIEwottFt++POjGRO9sgyQKrpXaz3x89E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4= +modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/admin/admin.go b/internal/admin/admin.go new file mode 100644 index 0000000..69c769b --- /dev/null +++ b/internal/admin/admin.go @@ -0,0 +1,710 @@ +package admin + +import ( + "embed" + "encoding/gob" + "fmt" + "html/template" + "net/http" + "strconv" + "strings" + + "git.nakama.town/fmartingr/butterrobot/internal/config" + "git.nakama.town/fmartingr/butterrobot/internal/db" + "git.nakama.town/fmartingr/butterrobot/internal/model" + "git.nakama.town/fmartingr/butterrobot/internal/plugin" + "github.com/gorilla/sessions" +) + +//go:embed templates/*.html +var templateFS embed.FS + +const ( + // Session store key + sessionKey = "butterrobot-session" +) + +// FlashMessage represents a flash message +type FlashMessage struct { + Category string + Message string +} + +func init() { + // Register the FlashMessage type with gob package for session serialization + gob.Register(FlashMessage{}) +} + +// TemplateData holds data for rendering templates +type TemplateData struct { + User *model.User + LoggedIn bool + Title string + Path string + Flash []FlashMessage + Plugins map[string]model.Plugin + Channels []*model.Channel + Channel *model.Channel + ChannelPlugin *model.ChannelPlugin + Version string +} + +// Admin represents the admin interface +type Admin struct { + config *config.Config + db *db.Database + store *sessions.CookieStore + templates map[string]*template.Template + baseTemplate *template.Template + version string +} + +// New creates a new Admin instance +func New(cfg *config.Config, database *db.Database, version string) *Admin { + // Create session store with appropriate options + store := sessions.NewCookieStore([]byte(cfg.SecretKey)) + store.Options = &sessions.Options{ + Path: "/admin", + MaxAge: 3600 * 24 * 7, // 1 week + HttpOnly: true, + } + + // Load templates + templates := make(map[string]*template.Template) + + // Create a template function map with helper functions + funcMap := template.FuncMap{ + "contains": strings.Contains, + } + + // Read base template from embedded filesystem + baseContent, err := templateFS.ReadFile("templates/_base.html") + if err != nil { + panic(err) + } + + // Create a custom template with functions + baseTemplate, err := template.New("_base.html").Funcs(funcMap).Parse(string(baseContent)) + if err != nil { + panic(err) + } + + // Parse and register all templates + templateFiles := []string{ + "index.html", + "login.html", + "change_password.html", + "channel_list.html", + "channel_detail.html", + "plugin_list.html", + "channel_plugins_list.html", + } + + for _, tf := range templateFiles { + // Read template content from embedded filesystem + content, err := templateFS.ReadFile("templates/" + tf) + if err != nil { + panic(err) + } + + // Create a clone of the base template + t, err := baseTemplate.Clone() + if err != nil { + panic(err) + } + + // Parse the template content + t, err = t.Parse(string(content)) + if err != nil { + panic(err) + } + + templates[tf] = t + } + + return &Admin{ + config: cfg, + db: database, + store: store, + templates: templates, + baseTemplate: baseTemplate, + version: version, + } +} + +// RegisterRoutes registers admin routes on the given router +func (a *Admin) RegisterRoutes(mux *http.ServeMux) { + // Register admin routes + mux.HandleFunc("/admin/", a.handleIndex) + mux.HandleFunc("/admin/login", a.handleLogin) + mux.HandleFunc("/admin/logout", a.handleLogout) + mux.HandleFunc("/admin/change-password", a.handleChangePassword) + mux.HandleFunc("/admin/plugins", a.handlePluginList) + mux.HandleFunc("/admin/channels", a.handleChannelList) + mux.HandleFunc("/admin/channels/", a.handleChannelDetail) + mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList) + mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete) +} + +// getCurrentUser gets the current user from the session +func (a *Admin) getCurrentUser(r *http.Request) *model.User { + session, err := a.store.Get(r, sessionKey) + if err != nil { + fmt.Printf("Error getting session for user retrieval: %v\n", err) + return nil + } + + // Check if user is logged in + userID, ok := session.Values["user_id"].(int64) + if !ok { + return nil + } + + // Get user from database + user, err := a.db.GetUserByID(userID) + if err != nil { + fmt.Printf("Error retrieving user from database: %v\n", err) + return nil + } + + return user +} + +// isLoggedIn checks if the user is logged in +func (a *Admin) isLoggedIn(r *http.Request) bool { + session, err := a.store.Get(r, sessionKey) + if err != nil { + fmt.Printf("Error getting session for login check: %v\n", err) + return false + } + return session.Values["logged_in"] == true +} + +// addFlash adds a flash message to the session +func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, category string) { + session, err := a.store.Get(r, sessionKey) + if err != nil { + // If there's an error getting the session, create a new one + session = sessions.NewSession(a.store, sessionKey) + session.Options = &sessions.Options{ + Path: "/admin", + MaxAge: 3600 * 24 * 7, // 1 week + HttpOnly: true, + } + } + + // Map internal categories to Bootstrap alert classes + var alertClass string + switch category { + case "success": + alertClass = "success" + case "danger": + alertClass = "danger" + case "warning": + alertClass = "warning" + case "info": + alertClass = "info" + default: + alertClass = "info" + } + + flash := FlashMessage{ + Category: alertClass, + Message: message, + } + + session.AddFlash(flash) + err = session.Save(r, w) + if err != nil { + // Log the error or handle it appropriately + fmt.Printf("Error saving session: %v\n", err) + } +} + +// getFlashes gets all flash messages from the session +func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessage { + session, err := a.store.Get(r, sessionKey) + if err != nil { + // If there's an error getting the session, return an empty slice + fmt.Printf("Error getting session for flashes: %v\n", err) + return []FlashMessage{} + } + + // Get flash messages + flashes := session.Flashes() + messages := make([]FlashMessage, 0, len(flashes)) + + for _, f := range flashes { + if flash, ok := f.(FlashMessage); ok { + messages = append(messages, flash) + } + } + + // Save session to clear flashes + err = session.Save(r, w) + if err != nil { + fmt.Printf("Error saving session after getting flashes: %v\n", err) + } + + return messages +} + +// render renders a template with the given data +func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) { + // Add current user data + data.User = a.getCurrentUser(r) + data.LoggedIn = a.isLoggedIn(r) + data.Path = r.URL.Path + data.Flash = a.getFlashes(w, r) + data.Version = a.version + + // Get template + tmpl, ok := a.templates[templateName] + if !ok { + http.Error(w, "Template not found", http.StatusInternalServerError) + return + } + + // Render template + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// handleIndex handles the admin index route +func (a *Admin) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/admin/" { + http.NotFound(w, r) + return + } + + // Redirect to login if not logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Redirect to channel list + http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) +} + +// handleLogin handles the login route +func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) { + // If already logged in, redirect to index + if a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + + // Handle login form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Check credentials + username := r.FormValue("username") + password := r.FormValue("password") + + user, err := a.db.CheckCredentials(username, password) + if err != nil || user == nil { + a.addFlash(w, r, "Incorrect credentials", "danger") + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Set session + session, _ := a.store.Get(r, sessionKey) + session.Values["logged_in"] = true + session.Values["user_id"] = user.ID + + // Set session expiration + session.Options.MaxAge = 3600 * 24 * 7 // 1 week + err = session.Save(r, w) + if err != nil { + fmt.Printf("Error saving session: %v\n", err) + } + + a.addFlash(w, r, "You were logged in", "success") + + // Redirect to index + next := r.URL.Query().Get("next") + if next == "" { + next = "/admin/" + } + http.Redirect(w, r, next, http.StatusSeeOther) + return + } + + // Render login template + a.render(w, r, "login.html", TemplateData{ + Title: "Login", + }) +} + +// handleLogout handles the logout route +func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) { + // Clear session + session, err := a.store.Get(r, sessionKey) + if err != nil { + fmt.Printf("Error getting session for logout: %v\n", err) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + session.Values = make(map[interface{}]interface{}) + session.Options.MaxAge = -1 // Delete session + err = session.Save(r, w) + if err != nil { + fmt.Printf("Error saving session for logout: %v\n", err) + } + + a.addFlash(w, r, "You were logged out", "success") + + // Redirect to login + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) +} + +// handleChangePassword handles the change password route +func (a *Admin) handleChangePassword(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Get current user + user := a.getCurrentUser(r) + if user == nil { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Handle form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Get form values + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + // Validate current password + _, err := a.db.CheckCredentials(user.Username, currentPassword) + if err != nil { + a.addFlash(w, r, "Current password is incorrect", "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + // Validate new password and confirmation + if newPassword == "" { + a.addFlash(w, r, "New password cannot be empty", "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + if newPassword != confirmPassword { + a.addFlash(w, r, "New passwords do not match", "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + // Update password + if err := a.db.UpdateUserPassword(user.ID, newPassword); err != nil { + a.addFlash(w, r, "Failed to update password: "+err.Error(), "danger") + http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther) + return + } + + // Success + a.addFlash(w, r, "Password changed successfully", "success") + http.Redirect(w, r, "/admin/", http.StatusSeeOther) + return + } + + // Render change password template + a.render(w, r, "change_password.html", TemplateData{ + Title: "Change Password", + }) +} + +// handlePluginList handles the plugin list route +func (a *Admin) handlePluginList(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Get available plugins + plugins := plugin.GetAvailablePlugins() + + // Render template + a.render(w, r, "plugin_list.html", TemplateData{ + Title: "Plugins", + Plugins: plugins, + }) +} + +// handleChannelList handles the channel list route +func (a *Admin) handleChannelList(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Get all channels + channels, err := a.db.GetAllChannels() + if err != nil { + http.Error(w, "Failed to get channels", http.StatusInternalServerError) + return + } + + // Render template + a.render(w, r, "channel_list.html", TemplateData{ + Title: "Channels", + Channels: channels, + }) +} + +// handleChannelDetail handles the channel detail route +func (a *Admin) handleChannelDetail(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Extract channel ID from path + path := r.URL.Path + if path == "/admin/channels/" { + http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) + return + } + + channelID := strings.TrimPrefix(path, "/admin/channels/") + if strings.Contains(channelID, "/") { + // Handle delete request + if strings.HasSuffix(path, "/delete") && r.Method == http.MethodPost { + channelID = strings.TrimSuffix(channelID, "/delete") + + // Delete channel + id, err := strconv.ParseInt(channelID, 10, 64) + if err != nil { + http.Error(w, "Invalid channel ID", http.StatusBadRequest) + return + } + + if err := a.db.DeleteChannel(id); err != nil { + http.Error(w, "Failed to delete channel", http.StatusInternalServerError) + return + } + + a.addFlash(w, r, "Channel removed", "success") + http.Redirect(w, r, "/admin/channels", http.StatusSeeOther) + return + } + + http.NotFound(w, r) + return + } + + // Convert channel ID to int64 + id, err := strconv.ParseInt(channelID, 10, 64) + if err != nil { + http.Error(w, "Invalid channel ID", http.StatusBadRequest) + return + } + + // Handle form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Check if the form was submitted + if r.FormValue("form_submitted") == "true" { + // Update channel + enabled := r.FormValue("enabled") == "true" + if err := a.db.UpdateChannel(id, enabled); err != nil { + http.Error(w, "Failed to update channel", http.StatusInternalServerError) + return + } + + a.addFlash(w, r, "Channel updated", "success") + http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther) + return + } + } + + // Get channel + channel, err := a.db.GetChannelByID(id) + if err != nil { + http.Error(w, "Channel not found", http.StatusNotFound) + return + } + + // Get available plugins + plugins := plugin.GetAvailablePlugins() + + // Render template + a.render(w, r, "channel_detail.html", TemplateData{ + Title: "Channel: " + channel.PlatformChannelID, + Channel: channel, + Plugins: plugins, + }) +} + +// handleChannelPluginList handles the channel plugin list route +func (a *Admin) handleChannelPluginList(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Handle form submission + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Extract form data + channelID, err := strconv.ParseInt(r.FormValue("channel_id"), 10, 64) + if err != nil { + http.Error(w, "Invalid channel ID", http.StatusBadRequest) + return + } + + pluginID := r.FormValue("plugin_id") + enabled := r.FormValue("enabled") == "y" + + // Create channel plugin + config := make(map[string]interface{}) + _, err = a.db.CreateChannelPlugin(channelID, pluginID, enabled, config) + if err == db.ErrDuplicated { + a.addFlash(w, r, "Plugin "+pluginID+" is already present on the channel", "danger") + } else if err != nil { + http.Error(w, "Failed to create channel plugin", http.StatusInternalServerError) + return + } else { + a.addFlash(w, r, "Plugin "+pluginID+" added to the channel", "success") + } + + // Redirect back + referer := r.Header.Get("Referer") + if referer == "" { + referer = "/admin/channelplugins" + } + http.Redirect(w, r, referer, http.StatusSeeOther) + return + } + + // Get all channels + channels, err := a.db.GetAllChannels() + if err != nil { + http.Error(w, "Failed to get channels", http.StatusInternalServerError) + return + } + + // Render template + a.render(w, r, "channel_plugins_list.html", TemplateData{ + Title: "Channel Plugins", + Channels: channels, + Plugins: plugin.GetAvailablePlugins(), + }) +} + +// handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route +func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) { + // Check if user is logged in + if !a.isLoggedIn(r) { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + // Extract channel plugin ID from path + path := r.URL.Path + if path == "/admin/channelplugins/" { + http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) + return + } + + channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/") + + // Handle delete request + if strings.HasSuffix(channelPluginID, "/delete") && r.Method == http.MethodPost { + channelPluginID = strings.TrimSuffix(channelPluginID, "/delete") + + // Delete channel plugin + id, err := strconv.ParseInt(channelPluginID, 10, 64) + if err != nil { + http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest) + return + } + + if err := a.db.DeleteChannelPlugin(id); err != nil { + http.Error(w, "Failed to delete channel plugin", http.StatusInternalServerError) + return + } + + a.addFlash(w, r, "Plugin removed", "success") + + // Redirect back + referer := r.Header.Get("Referer") + if referer == "" { + referer = "/admin/channelplugins" + } + http.Redirect(w, r, referer, http.StatusSeeOther) + return + } + + // Handle update request + if r.Method == http.MethodPost { + // Parse form + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Convert channel plugin ID to int64 + id, err := strconv.ParseInt(channelPluginID, 10, 64) + if err != nil { + http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest) + return + } + + // Update channel plugin + enabled := r.FormValue("enabled") == "true" + if err := a.db.UpdateChannelPlugin(id, enabled); err != nil { + http.Error(w, "Failed to update channel plugin", http.StatusInternalServerError) + return + } + + a.addFlash(w, r, "Plugin updated", "success") + + // Redirect back + referer := r.Header.Get("Referer") + if referer == "" { + referer = "/admin/channelplugins" + } + http.Redirect(w, r, referer, http.StatusSeeOther) + return + } + + // Redirect to channel plugins list + http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther) +} diff --git a/butterrobot/admin/templates/_base.j2 b/internal/admin/templates/_base.html similarity index 71% rename from butterrobot/admin/templates/_base.j2 rename to internal/admin/templates/_base.html index d1db800..3ebdf85 100644 --- a/butterrobot/admin/templates/_base.j2 +++ b/internal/admin/templates/_base.html @@ -4,7 +4,7 @@ - ButterRobot Admin + {{.Title}} - ButterRobot Admin @@ -24,27 +24,29 @@ - {% if session.logged_in %} + {{if .LoggedIn}}