Compare commits
17 commits
dependabot
...
master
Author | SHA1 | Date | |
---|---|---|---|
fae6f35774 | |||
7dd02c0056 | |||
c9edb57505 | |||
763a451251 | |||
abcd3c3c44 | |||
323ea4e8cd | |||
72c6dd6982 | |||
21e4c434fd | |||
a0f12efd65 | |||
c920eb94a0 | |||
e0ae0c2a0b | |||
6aedfc794f | |||
ece8280358 | |||
84e5feeb81 | |||
bbb48f49e2 | |||
3426b668fe | |||
7c684af8c3 |
90 changed files with 6035 additions and 3279 deletions
27
.github/workflows/black.yaml
vendored
27
.github/workflows/black.yaml
vendored
|
@ -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
|
|
21
.github/workflows/docker-build-latest.yaml
vendored
21
.github/workflows/docker-build-latest.yaml
vendored
|
@ -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
|
|
32
.github/workflows/pytest.yaml
vendored
32
.github/workflows/pytest.yaml
vendored
|
@ -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
|
|
47
.github/workflows/release.yaml
vendored
47
.github/workflows/release.yaml
vendored
|
@ -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)
|
|
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -4,16 +4,10 @@ __pycache__
|
||||||
*~
|
*~
|
||||||
*.cert
|
*.cert
|
||||||
.env-local
|
.env-local
|
||||||
test.py
|
|
||||||
.coverage
|
.coverage
|
||||||
|
|
||||||
# Distribution
|
|
||||||
dist
|
dist
|
||||||
*.egg-info
|
bin
|
||||||
pip-wheel-metadata
|
|
||||||
|
|
||||||
# Github Codespaces
|
|
||||||
pythonenv3.8
|
|
||||||
|
|
||||||
# Butterrobot
|
# Butterrobot
|
||||||
*.sqlite*
|
*.sqlite*
|
||||||
|
butterrobot.db
|
||||||
|
|
150
.goreleaser.yml
Normal file
150
.goreleaser.yml
Normal file
|
@ -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 <me@fmartingr.com>
|
||||||
|
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
|
|
@ -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
|
|
23
.woodpecker/ci.yml
Normal file
23
.woodpecker/ci.yml
Normal file
|
@ -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
|
16
.woodpecker/release.yml
Normal file
16
.woodpecker/release.yml
Normal file
|
@ -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
|
6
Containerfile
Normal file
6
Containerfile
Normal file
|
@ -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"]
|
|
@ -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"]
|
|
113
Makefile
113
Makefile
|
@ -1,27 +1,100 @@
|
||||||
# Local development
|
PROJECT_NAME := butterrobot
|
||||||
setup:
|
|
||||||
poetry install
|
|
||||||
|
|
||||||
podman@build:
|
SOURCE_FILES ?=./...
|
||||||
podman build -t fmartingr/butterrobot -f docker/Dockerfile docker
|
|
||||||
|
|
||||||
podman@build-dev:
|
TEST_OPTIONS ?= -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out
|
||||||
podman build -t fmartingr/butterrobot:dev -f Dockerfile.dev .
|
TEST_TIMEOUT ?=1m
|
||||||
|
|
||||||
podman@tag-dev:
|
GOLANGCI_LINT_VERSION ?= v1.64.5
|
||||||
podman tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev
|
|
||||||
|
|
||||||
podman@push-dev:
|
CLEAN_OPTIONS ?=-modcache -testcache
|
||||||
podman push registry.int.fmartingr.network/fmartingr/butterrobot:dev --tls-verify=false
|
|
||||||
|
|
||||||
podman@dev:
|
CGO_ENABLED := 0
|
||||||
make podman@build-dev
|
|
||||||
make podman@tag-dev
|
|
||||||
make podman@push-dev
|
|
||||||
|
|
||||||
test:
|
BUILDS_PATH := ./dist
|
||||||
poetry run pytest --cov=butterrobot --cov=butterrobot_plugins_contrib
|
FROM_MAKEFILE := y
|
||||||
|
|
||||||
clean:
|
CONTAINERFILE_NAME := Containerfile
|
||||||
rm -rf dist
|
CONTAINER_ALPINE_VERSION := 3.21
|
||||||
rm -rf butterrobot.egg-info
|
CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}"
|
||||||
|
CONTAINER_MAINTAINER := "Felipe Martin <me@fmartingr.com>"
|
||||||
|
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}
|
||||||
|
|
81
README.md
81
README.md
|
@ -1,53 +1,82 @@
|
||||||
# Butter Robot
|
# Butter Robot
|
||||||
|
|
||||||
| Stable | Master |
|

|
||||||
| --- | --- |
|
|
||||||
|  |  |
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
Python framework to create bots for several platforms.
|
Go framework to create bots for several platforms.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> What is my purpose?
|
> 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
|
## Documentation
|
||||||
|
|
||||||
[Go to documentation](./docs)
|
[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
|
## Installation
|
||||||
|
|
||||||
### PyPi
|
### From Source
|
||||||
|
|
||||||
You can run it directly by installing the package and calling it
|
```bash
|
||||||
with `python` though this is not recommended and only intended for
|
# Clone the repository
|
||||||
development purposes.
|
git clone https://git.nakama.town/fmartingr/butterrobot.git
|
||||||
|
cd butterrobot
|
||||||
|
|
||||||
```
|
# Build the application
|
||||||
$ pip install --user butterrobot
|
go build -o butterrobot ./cmd/butterrobot
|
||||||
$ python -m butterrobot
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Containers
|
### 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
|
## Contributing
|
||||||
|
|
||||||
To run the project locally you will need [poetry](https://python-poetry.org/).
|
```bash
|
||||||
|
|
||||||
```
|
|
||||||
git clone git@github.com:fmartingr/butterrobot.git
|
git clone git@github.com:fmartingr/butterrobot.git
|
||||||
cd butterrobot
|
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
|
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
|
||||||
|
|
|
@ -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")
|
|
|
@ -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/<channel_id>", 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/<channel_id>/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/<channel_plugin_id>", 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/<channel_plugin_id>/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"))
|
|
|
@ -1,140 +0,0 @@
|
||||||
{% extends "_base.j2" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header d-print-none">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col">
|
|
||||||
<h2 class="page-title">
|
|
||||||
Channel: {{ channel.channel_name }}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row row-cards">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<ul class="nav nav-pills card-header-pills">
|
|
||||||
<li class="nav-item">
|
|
||||||
<form
|
|
||||||
action="{{ url_for('admin.channel_detail_view', channel_id=channel.id) }}"
|
|
||||||
method="POST">
|
|
||||||
<input type="hidden" name="enabled" value="{{ 'false' if channel.enabled else 'true' }}" />
|
|
||||||
<input class="btn btn-{% if channel.enabled %}danger{% else %}success{% endif %}"
|
|
||||||
type="submit" value="{{ "Enable" if not channel.enabled else "Disable" }}">
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<form action="{{ url_for('admin.channel_delete_view', channel_id=channel.id) }}" method="POST">
|
|
||||||
<input type="submit" value="Delete" class="btn btn-danger">
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th width="20%">ID</th>
|
|
||||||
<td>{{ channel.id }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Platform</th>
|
|
||||||
<td>{{ channel.platform }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Platform Channel ID</th>
|
|
||||||
<td>{{ channel.platform_channel_id }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>RAW</th>
|
|
||||||
<td>
|
|
||||||
<pre>{{ channel.channel_raw }}</pre>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title">Plugins</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="{{ url_for('admin.channel_plugin_list_view') }}" method="POST">
|
|
||||||
<input type="hidden" name="channel_id" value="{{ channel.id }}" />
|
|
||||||
<input type="hidden" name="enabled" value="y" />
|
|
||||||
<p>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-4">
|
|
||||||
Enable plugin
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<select class="form-select" name="plugin_id">
|
|
||||||
{% for plugin in plugins.values() %}
|
|
||||||
<option value="{{ plugin.id }}">{{ plugin.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<input type="submit" value="Enable" class="btn">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
<div>
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Configuration</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{% for channel_plugin in channel.plugins.values() %}
|
|
||||||
<tr>
|
|
||||||
<td width="20%">{{ plugins[channel_plugin.plugin_id].name }}</td>
|
|
||||||
<td>
|
|
||||||
<pre>{{ channel_plugin.config }}</pre>
|
|
||||||
</td>
|
|
||||||
<td width="20%">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-6">
|
|
||||||
<form
|
|
||||||
action="{{ url_for('admin.channel_plugin_detail_view', channel_plugin_id=channel_plugin.id) }}"
|
|
||||||
method="POST">
|
|
||||||
<input type="hidden" name="enabled"
|
|
||||||
value="{{ 'false' if channel_plugin.enabled else 'true' }}" />
|
|
||||||
<input
|
|
||||||
class="btn btn-{% if channel_plugin.enabled %}danger{% else %}success{% endif %}"
|
|
||||||
type="submit"
|
|
||||||
value="{{ "Enable" if not channel_plugin.enabled else "Disable" }}">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<form
|
|
||||||
action="{{ url_for('admin.channel_plugin_delete_view', channel_plugin_id=channel_plugin.id) }}"
|
|
||||||
method="POST">
|
|
||||||
<input type="submit" value="Delete" class="btn btn-danger">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="text-center">No plugin is enabled on this channel</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -1,45 +0,0 @@
|
||||||
{% extends "_base.j2" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header d-print-none">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col">
|
|
||||||
<h2 class="page-title">
|
|
||||||
Channel list
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Platform</th>
|
|
||||||
<th>Channel name</th>
|
|
||||||
<th>Channel ID</th>
|
|
||||||
<th>Enabled</th>
|
|
||||||
<th class="w-1"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{% for channel in channels %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ channel.platform }}</td>
|
|
||||||
<td>{{ channel.channel_name }}</td>
|
|
||||||
<td class="text-muted">
|
|
||||||
{{ channel.platform_channel_id }}
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">{{ channel.enabled }}</td>
|
|
||||||
<td>
|
|
||||||
<a href="{{ url_for("admin.channel_detail_view", channel_id=channel.id) }}">Edit</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -1,41 +0,0 @@
|
||||||
{% extends "_base.j2" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header d-print-none">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col">
|
|
||||||
<h2 class="page-title">
|
|
||||||
Channel list
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Channel ID</th>
|
|
||||||
<th>Plugin ID</th>
|
|
||||||
<th>Enabled</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{% for channel_plugin in channel_plugins %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ channel_plugin.id }}</td>
|
|
||||||
<td>{{ channel_plugin.channel_id }}</td>
|
|
||||||
<td class="text-muted">
|
|
||||||
{{ channel_plugin.plugin_id }}
|
|
||||||
</td>
|
|
||||||
<td class="text-muted">{{ channel_plugin.enabled }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -1,5 +0,0 @@
|
||||||
{% extends "_base.j2" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
|
@ -1,32 +0,0 @@
|
||||||
{% extends "_base.j2" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row">
|
|
||||||
|
|
||||||
{% if error %}<p class=error><strong>Error:</strong> {{ error }}{% endif %}
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">Login</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="" method="post">
|
|
||||||
<div class="form-group mb-3 ">
|
|
||||||
<label class="form-label">Username</label>
|
|
||||||
<div>
|
|
||||||
<input type="text" name="username" class="form-control" placeholder="Username">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group mb-3 ">
|
|
||||||
<label class="form-label">Password</label>
|
|
||||||
<div>
|
|
||||||
<input type="password" class="form-control" placeholder="Password" name="password">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-footer">
|
|
||||||
<button type="submit" class="btn btn-primary">Submit</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -1,33 +0,0 @@
|
||||||
{% extends "_base.j2" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page-header d-print-none">
|
|
||||||
<div class="row align-items-center">
|
|
||||||
<div class="col">
|
|
||||||
<h2 class="page-title">
|
|
||||||
Plugin list
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-vcenter card-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
|
||||||
{% for plugin in plugins %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ plugin.name }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
|
@ -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("/<platform>/incoming", methods=["POST"])
|
|
||||||
@app.route("/<platform>/incoming/<path:path>", 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 {}
|
|
|
@ -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")
|
|
|
@ -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]
|
|
|
@ -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)
|
|
|
@ -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)
|
|
|
@ -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)
|
|
|
@ -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,
|
|
||||||
)
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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={},
|
|
||||||
)
|
|
|
@ -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
|
|
|
@ -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"],
|
|
||||||
)
|
|
|
@ -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
|
|
|
@ -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()
|
|
|
@ -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)",
|
|
||||||
)
|
|
|
@ -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")),
|
|
||||||
)
|
|
48
cmd/butterrobot/main.go
Normal file
48
cmd/butterrobot/main.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"]
|
|
|
@ -1,3 +0,0 @@
|
||||||
#!/bin/sh -xe
|
|
||||||
|
|
||||||
waitress-serve --port=${APP_PORT} 'butterrobot.app:app'
|
|
|
@ -1,11 +1,12 @@
|
||||||
## Contributing
|
## 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
|
git clone git@github.com:fmartingr/butterrobot.git
|
||||||
cd butterrobot
|
cd butterrobot
|
||||||
make setup
|
make setup
|
||||||
|
make build
|
||||||
```
|
```
|
||||||
|
|
||||||
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, 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
|
SLACK_TOKEN=xxx
|
||||||
TELEGRAM_TOKEN=xxx
|
TELEGRAM_TOKEN=xxx
|
||||||
|
HOSTNAME=myhostname.com
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
And then you can run it directly with poetry:
|
And then you can run it directly:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
poetry run python -m butterrobot
|
# Run directly with Go
|
||||||
|
go run ./cmd/butterrobot/main.go
|
||||||
|
|
||||||
|
# Or run the built binary
|
||||||
|
./bin/butterrobot
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,37 +1,287 @@
|
||||||
# Creating a Plugin
|
# 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
|
- **Development**: Utility plugins like `ping`
|
||||||
# mypackage/plugins.py
|
- **Fun**: Entertainment plugins like dice rolling, coin flipping
|
||||||
from butterrobot.plugins import Plugin
|
- **Social**: Social media related plugins like URL transformers/expanders
|
||||||
from butterrobot.objects import Message
|
- **Security**: Moderation and protection features like domain blocking
|
||||||
|
|
||||||
|
When creating a new plugin, consider which category it fits into and place it in the appropriate directory.
|
||||||
|
|
||||||
class PingPlugin(Plugin):
|
## Plugin Examples
|
||||||
name = "Marco/Polo"
|
|
||||||
id = "test.marco"
|
|
||||||
|
|
||||||
@classmethod
|
### Basic Example: Marco Polo
|
||||||
def on_message(cls, message, **kwargs):
|
|
||||||
if message.text == "Marco":
|
|
||||||
yield Message(
|
|
||||||
chat=message.chat, reply_to=message.id, text=f"polo",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
``` python
|
This simple "Marco Polo" plugin will answer _Polo_ to the user that says _Marco_:
|
||||||
# setup.py
|
|
||||||
# ...
|
```go
|
||||||
entrypoints = {
|
package myplugin
|
||||||
"test.marco" = "mypackage.plugins:MarcoPlugin"
|
|
||||||
|
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(
|
// New creates a new MarcoPlugin instance
|
||||||
# ...
|
func New() *MarcoPlugin {
|
||||||
entry_points=entrypoints,
|
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}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration-Enabled Plugin
|
||||||
|
|
||||||
|
This plugin requires configuration to be set in the admin interface. It demonstrates how to create plugins that need channel-specific configuration:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DomainBlockPlugin is a plugin that blocks messages containing links from specific domains
|
||||||
|
type DomainBlockPlugin struct {
|
||||||
|
plugin.BasePlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new DomainBlockPlugin instance
|
||||||
|
func New() *DomainBlockPlugin {
|
||||||
|
return &DomainBlockPlugin{
|
||||||
|
BasePlugin: plugin.BasePlugin{
|
||||||
|
ID: "security.domainblock",
|
||||||
|
Name: "Domain Blocker",
|
||||||
|
Help: "Blocks messages containing links from configured domains",
|
||||||
|
ConfigRequired: true, // Mark this plugin as requiring configuration
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage processes incoming messages
|
||||||
|
func (p *DomainBlockPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
|
||||||
|
// Get blocked domains from config
|
||||||
|
blockedDomainsStr, ok := config["blocked_domains"].(string)
|
||||||
|
if !ok || blockedDomainsStr == "" {
|
||||||
|
return nil // No blocked domains configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split and clean blocked domains
|
||||||
|
blockedDomains := strings.Split(blockedDomainsStr, ",")
|
||||||
|
for i, domain := range blockedDomains {
|
||||||
|
blockedDomains[i] = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract domains from message
|
||||||
|
urlRegex := regexp.MustCompile(`https?://([^\s/$.?#].[^\s]*)`)
|
||||||
|
matches := urlRegex.FindAllStringSubmatch(msg.Text, -1)
|
||||||
|
|
||||||
|
// Check if any extracted domains are blocked
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := strings.ToLower(match[1])
|
||||||
|
|
||||||
|
for _, blockedDomain := range blockedDomains {
|
||||||
|
if blockedDomain == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(domain, blockedDomain) || domain == blockedDomain {
|
||||||
|
// Domain is blocked, create warning message
|
||||||
|
response := &model.Message{
|
||||||
|
Text: fmt.Sprintf("⚠️ Message contained a link to blocked domain: %s", blockedDomain),
|
||||||
|
Chat: msg.Chat,
|
||||||
|
ReplyTo: msg.ID,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
return []*model.Message{response}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
plugin.Register(New())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enabling Configuration for Plugins
|
||||||
|
|
||||||
|
To indicate that your plugin requires configuration:
|
||||||
|
|
||||||
|
1. Set `ConfigRequired: true` in the BasePlugin struct:
|
||||||
|
```go
|
||||||
|
BasePlugin: plugin.BasePlugin{
|
||||||
|
ID: "myplugin.id",
|
||||||
|
Name: "Plugin Name",
|
||||||
|
Help: "Help text",
|
||||||
|
ConfigRequired: true,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Access the configuration in the OnMessage method:
|
||||||
|
```go
|
||||||
|
func (p *MyPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
|
||||||
|
// Extract configuration values
|
||||||
|
configValue, ok := config["some_config_key"].(string)
|
||||||
|
if !ok || configValue == "" {
|
||||||
|
// Handle missing or empty configuration
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the configuration...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. The admin interface will show a "Configure" button for plugins that require configuration.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can register your plugin in its init() function:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func init() {
|
||||||
|
plugin.Register(New())
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
99
docs/migrations.md
Normal file
99
docs/migrations.md
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
```
|
|
@ -2,10 +2,23 @@
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
- `!ping`: Say `!ping` to get response with time elapsed.
|
- `ping`: Say `ping` to get response with time elapsed.
|
||||||
|
|
||||||
### Fun and entertainment
|
### Fun and entertainment
|
||||||
|
|
||||||
|
|
||||||
- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun)
|
- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun)
|
||||||
- Dice: Put `!dice` and wathever roll you want to perform.
|
- 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 <duration>` 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.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Domain Blocker: Blocks messages containing links from specified domains. Configure it per channel with a comma-separated list of domains to block. When a message contains a link matching any of the blocked domains, the bot will notify that the message contained a blocked domain. This plugin requires configuration through the admin interface.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
24
go.mod
Normal file
24
go.mod
Normal file
|
@ -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
|
||||||
|
)
|
57
go.sum
Normal file
57
go.sum
Normal file
|
@ -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=
|
802
internal/admin/admin.go
Normal file
802
internal/admin/admin.go
Normal file
|
@ -0,0 +1,802 @@
|
||||||
|
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",
|
||||||
|
"channel_plugin_config.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/config/", a.handleChannelPluginConfig)
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleChannelPluginConfig handles the channel plugin configuration route
|
||||||
|
func (a *Admin) handleChannelPluginConfig(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
|
||||||
|
channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/config/")
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the channel plugin
|
||||||
|
channelPlugin, err := a.db.GetChannelPluginByID(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Channel plugin not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the plugin
|
||||||
|
p, err := plugin.Get(channelPlugin.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Plugin not found", http.StatusNotFound)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config map from form values
|
||||||
|
config := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Process form values based on plugin type
|
||||||
|
if channelPlugin.PluginID == "security.domainblock" {
|
||||||
|
// Get blocked domains from form
|
||||||
|
blockedDomains := r.FormValue("blocked_domains")
|
||||||
|
config["blocked_domains"] = blockedDomains
|
||||||
|
} else {
|
||||||
|
// Generic handling for other plugins
|
||||||
|
for key, values := range r.Form {
|
||||||
|
if key == "form_submitted" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(values) == 1 {
|
||||||
|
config[key] = values[0]
|
||||||
|
} else {
|
||||||
|
config[key] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update plugin configuration
|
||||||
|
if err := a.db.UpdateChannelPluginConfig(id, config); err != nil {
|
||||||
|
http.Error(w, "Failed to update plugin configuration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the channel to redirect back to the channel detail page
|
||||||
|
channel, err := a.db.GetChannelByID(channelPlugin.ChannelID)
|
||||||
|
if err != nil {
|
||||||
|
a.addFlash(w, r, "Plugin configuration updated", "success")
|
||||||
|
http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
a.addFlash(w, r, "Plugin configuration updated", "success")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin/channels/%d", channel.ID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render template
|
||||||
|
a.render(w, r, "channel_plugin_config.html", TemplateData{
|
||||||
|
Title: "Configure Plugin: " + p.GetName(),
|
||||||
|
ChannelPlugin: channelPlugin,
|
||||||
|
Plugins: map[string]model.Plugin{channelPlugin.PluginID: p},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
|
@ -4,7 +4,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>ButterRobot Admin</title>
|
<title>{{.Title}} - ButterRobot Admin</title>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css">
|
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
@ -24,27 +24,29 @@
|
||||||
</h1>
|
</h1>
|
||||||
<div class="navbar-nav flex-row order-md-last">
|
<div class="navbar-nav flex-row order-md-last">
|
||||||
<div class="nav-item">
|
<div class="nav-item">
|
||||||
{% if not session.logged_in %}
|
{{if not .LoggedIn}}
|
||||||
<a href="{{ url_for('admin.login_view') }}">Log in</a>
|
<a href="/admin/login">Log in</a>
|
||||||
{% else %}
|
{{else}}
|
||||||
<div class="d-none d-xl-block pl-2">
|
<div class="d-none d-xl-block pl-2">
|
||||||
<div>{{ g.user.username }} - <a class="mt-1 small"
|
<div>{{.User.Username}} -
|
||||||
href="{{ url_for('admin.logout_view') }}">Log out</a></div>
|
<a class="mt-1 small" href="/admin/change-password">Change Password</a> |
|
||||||
|
<a class="mt-1 small" href="/admin/logout">Log out</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{% if session.logged_in %}
|
{{if .LoggedIn}}
|
||||||
<div class="navbar-expand-md">
|
<div class="navbar-expand-md">
|
||||||
<div class="collapse navbar-collapse" id="navbar-menu">
|
<div class="collapse navbar-collapse" id="navbar-menu">
|
||||||
<div class="navbar navbar-light">
|
<div class="navbar navbar-light">
|
||||||
<div class="container-xl">
|
<div class="container-xl">
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
<li class="nav-item {% if '/channels' in request.url %}active{% endif %}">
|
<li class="nav-item {{if contains .Path "/channels"}}active{{end}}">
|
||||||
<a class="nav-link" href="{{ url_for('admin.channel_list_view') }}">
|
<a class="nav-link" href="/admin/channels">
|
||||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
|
||||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||||
|
@ -60,8 +62,8 @@
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item {% if '/plugins' in request.url %}active{% endif %}">
|
<li class="nav-item {{if contains .Path "/plugins"}}active{{end}}">
|
||||||
<a class="nav-link" href="{{ url_for('admin.plugin_list_view') }}">
|
<a class="nav-link" href="/admin/plugins">
|
||||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
|
||||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||||
|
@ -76,8 +78,8 @@
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item {% if '/channelplugins' in request.url %}active{% endif %}">
|
<li class="nav-item {{if contains .Path "/channelplugins"}}active{{end}}">
|
||||||
<a class="nav-link" href="{{ url_for('admin.channel_plugin_list_view') }}">
|
<a class="nav-link" href="/admin/channelplugins">
|
||||||
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
<span class="nav-link-icon d-md-none d-lg-inline-block">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24"
|
||||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
|
||||||
|
@ -97,26 +99,40 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for category, message in get_flashed_messages(with_categories=True) %}
|
<div class="container-xl mt-3">
|
||||||
<div class="card">
|
{{range .Flash}}
|
||||||
<div class="card-status-top bg-{{ category }}"></div>
|
<div class="alert alert-{{.Category}} alert-dismissible" role="alert">
|
||||||
<div class="card-body">
|
{{.Message}}
|
||||||
<p>{{ message }}</p>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="container-xl">
|
<div class="container-xl">
|
||||||
{% block content %}
|
{{template "content" .}}
|
||||||
{% endblock %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer footer-transparent d-print-none">
|
||||||
|
<div class="container-xl">
|
||||||
|
<div class="row text-center align-items-center flex-row-reverse">
|
||||||
|
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
|
||||||
|
<ul class="list-inline list-inline-dots mb-0">
|
||||||
|
<li class="list-inline-item">
|
||||||
|
ButterRobot {{if .Version}}v{{.Version}}{{else}}(development){{end}}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
30
internal/admin/templates/change_password.html
Normal file
30
internal/admin/templates/change_password.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Change Password</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/change-password">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Current Password</label>
|
||||||
|
<input type="password" name="current_password" class="form-control" placeholder="Current Password" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">New Password</label>
|
||||||
|
<input type="password" name="new_password" class="form-control" placeholder="New Password" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Confirm New Password</label>
|
||||||
|
<input type="password" name="confirm_password" class="form-control" placeholder="Confirm New Password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Change Password</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
118
internal/admin/templates/channel_detail.html
Normal file
118
internal/admin/templates/channel_detail.html
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Channel #{{.Channel.ID}}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/channels/{{.Channel.ID}}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Platform</label>
|
||||||
|
<input type="text" class="form-control" value="{{.Channel.Platform}}" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Channel ID</label>
|
||||||
|
<input type="text" class="form-control" value="{{.Channel.PlatformChannelID}}" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Channel Name</label>
|
||||||
|
<input type="text" class="form-control" value="{{.Channel.ChannelName}}" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="enabled" value="true" {{if .Channel.Enabled}}checked{{end}}>
|
||||||
|
<span class="form-check-label">Channel Enabled</span>
|
||||||
|
</label>
|
||||||
|
<!-- Add a hidden field to ensure a value is sent even when checkbox is unchecked -->
|
||||||
|
<input type="hidden" name="form_submitted" value="true">
|
||||||
|
</div>
|
||||||
|
<div class="form-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="/admin/channels" class="btn btn-link">Back to Channels</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Channel Plugins</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Plugin</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $pluginID, $channelPlugin := .Channel.Plugins}}
|
||||||
|
<tr>
|
||||||
|
<td>{{$pluginID}}</td>
|
||||||
|
<td>
|
||||||
|
{{if $channelPlugin.Enabled}}
|
||||||
|
<span class="badge bg-success">Enabled</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge bg-danger">Disabled</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}" class="d-inline">
|
||||||
|
<input type="hidden" name="enabled" value="{{if $channelPlugin.Enabled}}false{{else}}true{{end}}">
|
||||||
|
<button type="submit" class="btn btn-sm {{if $channelPlugin.Enabled}}btn-danger{{else}}btn-success{{end}}">
|
||||||
|
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{$plugin := index $.Plugins $pluginID}}
|
||||||
|
{{if $plugin.RequiresConfig}}
|
||||||
|
<a href="/admin/channelplugins/config/{{$channelPlugin.ID}}" class="btn btn-info btn-sm">Configure</a>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm"
|
||||||
|
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center">No plugins for this channel</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h4>Add Plugin</h4>
|
||||||
|
<form method="post" action="/admin/channelplugins">
|
||||||
|
<input type="hidden" name="channel_id" value="{{.Channel.ID}}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Plugin</label>
|
||||||
|
<select name="plugin_id" class="form-select" required>
|
||||||
|
<option value="">Select a plugin</option>
|
||||||
|
{{range $id, $plugin := .Plugins}}
|
||||||
|
<option value="{{$id}}">{{$plugin.GetName}} ({{$id}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="enabled" value="y">
|
||||||
|
<span class="form-check-label">Enable plugin</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Plugin</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
64
internal/admin/templates/channel_list.html
Normal file
64
internal/admin/templates/channel_list.html
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Channels</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Platform</th>
|
||||||
|
<th>Channel ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Plugins</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Channels}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td>{{.Platform}}</td>
|
||||||
|
<td>{{.PlatformChannelID}}</td>
|
||||||
|
<td>{{.ChannelName}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .Enabled}}
|
||||||
|
<span class="badge bg-success">Enabled</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge bg-danger">Disabled</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{$count := len .Plugins}}
|
||||||
|
{{if eq $count 0}}
|
||||||
|
<span class="badge bg-yellow">No plugins</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge bg-blue">{{$count}} plugins</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/channels/{{.ID}}" class="btn btn-primary btn-sm">Edit</a>
|
||||||
|
<form method="post" action="/admin/channels/{{.ID}}/delete" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm"
|
||||||
|
onclick="return confirm('Are you sure you want to delete this channel?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center">No channels found</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
37
internal/admin/templates/channel_plugin_config.html
Normal file
37
internal/admin/templates/channel_plugin_config.html
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Configure Plugin: {{(index .Plugins .ChannelPlugin.PluginID).GetName}}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
<!-- Plugin configuration fields -->
|
||||||
|
{{if eq .ChannelPlugin.PluginID "security.domainblock"}}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Blocked Domains</label>
|
||||||
|
<input type="text" class="form-control" name="blocked_domains"
|
||||||
|
value="{{with .ChannelPlugin.Config}}{{index . "blocked_domains"}}{{end}}"
|
||||||
|
placeholder="example.com, evil.org, ads.com">
|
||||||
|
<div class="form-text text-muted">
|
||||||
|
Enter comma-separated list of domains to block (e.g., example.com, evil.org).
|
||||||
|
Messages containing links to these domains will be blocked.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
This plugin doesn't have specific configuration fields implemented yet.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="form-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Configuration</button>
|
||||||
|
<a href="/admin/channels/{{.ChannelPlugin.ChannelID}}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
97
internal/admin/templates/channel_plugins_list.html
Normal file
97
internal/admin/templates/channel_plugins_list.html
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Channel Plugins</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Plugin</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Channels}}
|
||||||
|
{{range $pluginID, $channelPlugin := .Plugins}}
|
||||||
|
<tr>
|
||||||
|
<td>{{$channelPlugin.ID}}</td>
|
||||||
|
<td><a href="/admin/channels/{{.ID}}">{{.ChannelName}}</a></td>
|
||||||
|
<td>{{$pluginID}}</td>
|
||||||
|
<td>
|
||||||
|
{{if $channelPlugin.Enabled}}
|
||||||
|
<span class="badge bg-success">Enabled</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge bg-danger">Disabled</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}" class="d-inline">
|
||||||
|
<input type="hidden" name="enabled" value="{{if $channelPlugin.Enabled}}false{{else}}true{{end}}">
|
||||||
|
<button type="submit" class="btn btn-sm {{if $channelPlugin.Enabled}}btn-danger{{else}}btn-success{{end}}">
|
||||||
|
{{if $channelPlugin.Enabled}}Disable{{else}}Enable{{end}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{$plugin := index $.Plugins $pluginID}}
|
||||||
|
{{if $plugin.ConfigRequired}}
|
||||||
|
<a href="/admin/channelplugins/config/{{$channelPlugin.ID}}" class="btn btn-info btn-sm">Configure</a>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/admin/channelplugins/{{$channelPlugin.ID}}/delete" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm"
|
||||||
|
onclick="return confirm('Are you sure you want to remove this plugin?')">Remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center">No channel plugins found</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h4>Add Plugin to Channel</h4>
|
||||||
|
<form method="post" action="/admin/channelplugins">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Channel</label>
|
||||||
|
<select name="channel_id" class="form-select" required>
|
||||||
|
<option value="">Select a channel</option>
|
||||||
|
{{range .Channels}}
|
||||||
|
<option value="{{.ID}}">{{.ChannelName}} ({{.Platform}}:{{.PlatformChannelID}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Plugin</label>
|
||||||
|
<select name="plugin_id" class="form-select" required>
|
||||||
|
<option value="">Select a plugin</option>
|
||||||
|
{{range $id, $plugin := .Plugins}}
|
||||||
|
<option value="{{$id}}">{{$plugin.GetName}} ({{$id}})</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="enabled" value="y">
|
||||||
|
<span class="form-check-label">Enable plugin</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Add Plugin</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
15
internal/admin/templates/index.html
Normal file
15
internal/admin/templates/index.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">ButterRobot Admin</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>Welcome to the ButterRobot admin interface.</p>
|
||||||
|
<p>Use the navigation above to manage channels and plugins.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
26
internal/admin/templates/login.html
Normal file
26
internal/admin/templates/login.html
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Login</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/login">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<input type="text" name="username" class="form-control" placeholder="Enter username" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="Password" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-footer">
|
||||||
|
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
45
internal/admin/templates/plugin_list.html
Normal file
45
internal/admin/templates/plugin_list.html
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Available Plugins</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Help</th>
|
||||||
|
<th>Requires Config</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $id, $plugin := .Plugins}}
|
||||||
|
<tr>
|
||||||
|
<td>{{$id}}</td>
|
||||||
|
<td>{{$plugin.GetName}}</td>
|
||||||
|
<td>{{$plugin.GetHelp}}</td>
|
||||||
|
<td>
|
||||||
|
{{if $plugin.RequiresConfig}}
|
||||||
|
<span class="badge bg-yellow">Yes</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge bg-green">No</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center">No plugins found</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
412
internal/app/app.go
Normal file
412
internal/app/app.go
Normal file
|
@ -0,0 +1,412 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime/debug"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/admin"
|
||||||
|
"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/platform"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin/domainblock"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin/fun"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin/ping"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin/reminder"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin/social"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/queue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App represents the application
|
||||||
|
type App struct {
|
||||||
|
config *config.Config
|
||||||
|
logger *slog.Logger
|
||||||
|
db *db.Database
|
||||||
|
router *http.ServeMux
|
||||||
|
queue *queue.Queue
|
||||||
|
admin *admin.Admin
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new App instance
|
||||||
|
func New(cfg *config.Config, logger *slog.Logger) (*App, error) {
|
||||||
|
// Initialize router
|
||||||
|
router := http.NewServeMux()
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
database, err := db.New(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize message queue
|
||||||
|
messageQueue := queue.New(logger)
|
||||||
|
|
||||||
|
// Get version information
|
||||||
|
version := ""
|
||||||
|
info, ok := debug.ReadBuildInfo()
|
||||||
|
if ok {
|
||||||
|
version = info.Main.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize admin interface
|
||||||
|
adminInterface := admin.New(cfg, database, version)
|
||||||
|
|
||||||
|
return &App{
|
||||||
|
config: cfg,
|
||||||
|
logger: logger,
|
||||||
|
db: database,
|
||||||
|
router: router,
|
||||||
|
queue: messageQueue,
|
||||||
|
admin: adminInterface,
|
||||||
|
version: version,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the application
|
||||||
|
func (a *App) Run() error {
|
||||||
|
// Initialize platforms
|
||||||
|
if err := platform.InitializePlatforms(a.config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register built-in plugins
|
||||||
|
plugin.Register(ping.New())
|
||||||
|
plugin.Register(fun.NewCoin())
|
||||||
|
plugin.Register(fun.NewDice())
|
||||||
|
plugin.Register(fun.NewLoquito())
|
||||||
|
plugin.Register(social.NewTwitterExpander())
|
||||||
|
plugin.Register(social.NewInstagramExpander())
|
||||||
|
plugin.Register(reminder.New(a.db))
|
||||||
|
plugin.Register(domainblock.New())
|
||||||
|
|
||||||
|
// Initialize routes
|
||||||
|
a.initializeRoutes()
|
||||||
|
|
||||||
|
// Start message queue worker
|
||||||
|
a.queue.Start(a.handleMessage)
|
||||||
|
|
||||||
|
// Start reminder scheduler
|
||||||
|
a.queue.StartReminderScheduler(a.handleReminder)
|
||||||
|
|
||||||
|
// Create server
|
||||||
|
addr := fmt.Sprintf(":%s", a.config.Port)
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: a.router,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server in a goroutine
|
||||||
|
go func() {
|
||||||
|
a.logger.Info("Server starting on", "addr", addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
a.logger.Error("Server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for interrupt signal
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
a.logger.Info("Shutting down server...")
|
||||||
|
|
||||||
|
// Create shutdown context with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Shutdown server
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop message queue
|
||||||
|
a.queue.Stop()
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
|
if err := a.db.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.logger.Info("Server stopped")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize HTTP routes
|
||||||
|
func (a *App) initializeRoutes() {
|
||||||
|
// Health check endpoint
|
||||||
|
a.router.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]interface{}{}); err != nil {
|
||||||
|
a.logger.Error("Error encoding response", "error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Platform webhook endpoints
|
||||||
|
for name := range platform.GetAvailablePlatforms() {
|
||||||
|
a.logger.Info("Registering webhook endpoint for platform", "platform", name)
|
||||||
|
platformName := name // Create a copy to avoid closure issues
|
||||||
|
a.router.HandleFunc("/"+platformName+"/incoming/", a.handleIncomingWebhook)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register admin routes
|
||||||
|
a.admin.RegisterRoutes(a.router)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming webhook
|
||||||
|
func (a *App) handleIncomingWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Extract platform name from path
|
||||||
|
platformName := extractPlatformName(r.URL.Path)
|
||||||
|
|
||||||
|
// Check if platform exists
|
||||||
|
if _, err := platform.Get(platformName); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]string{"error": "Unknown platform"}); err != nil {
|
||||||
|
a.logger.Error("Error encoding response", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]string{"error": "Failed to read request body"}); err != nil {
|
||||||
|
a.logger.Error("Error encoding response", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue message for processing
|
||||||
|
a.queue.Add(queue.Item{
|
||||||
|
Platform: platformName,
|
||||||
|
Request: map[string]any{
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"json": json.RawMessage(body),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Respond with success
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if err := json.NewEncoder(w).Encode(map[string]any{}); err != nil {
|
||||||
|
a.logger.Error("Error encoding response", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPlatformName extracts the platform name from the URL path
|
||||||
|
func extractPlatformName(path string) string {
|
||||||
|
// Remove leading slash
|
||||||
|
path = strings.TrimPrefix(path, "/")
|
||||||
|
|
||||||
|
// Split by slash
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
|
||||||
|
// First part is the platform name
|
||||||
|
if len(parts) > 0 {
|
||||||
|
// Special case for Telegram with token in the URL
|
||||||
|
if parts[0] == "telegram" && len(parts) > 1 && parts[1] == "incoming" {
|
||||||
|
return "telegram"
|
||||||
|
}
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle message processing
|
||||||
|
func (a *App) handleMessage(item queue.Item) {
|
||||||
|
// Get platform
|
||||||
|
p, err := platform.Get(item.Platform)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Error getting platform", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new request with the body
|
||||||
|
bodyJSON, ok := item.Request["json"].(json.RawMessage)
|
||||||
|
if !ok {
|
||||||
|
a.logger.Error("Invalid JSON in request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reqPath, ok := item.Request["path"].(string)
|
||||||
|
if !ok {
|
||||||
|
a.logger.Error("Invalid path in request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", reqPath, strings.NewReader(string(bodyJSON)))
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Error creating request", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Parse message
|
||||||
|
message, err := p.ParseIncomingMessage(req)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Error parsing message", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if message is from a bot
|
||||||
|
if message == nil || message.FromBot {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create channel
|
||||||
|
channel, err := a.db.GetChannelByPlatform(item.Platform, message.Chat)
|
||||||
|
if err == db.ErrNotFound {
|
||||||
|
channel, err = a.db.CreateChannel(item.Platform, message.Chat, false, message.Channel.ChannelRaw)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Error creating channel", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
a.logger.Error("Error getting channel", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if channel is disabled
|
||||||
|
if !channel.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process message with plugins
|
||||||
|
for pluginID, channelPlugin := range channel.Plugins {
|
||||||
|
if !channel.HasEnabledPlugin(pluginID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get plugin
|
||||||
|
p, err := plugin.Get(pluginID)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Error getting plugin", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process message and get actions
|
||||||
|
actions := p.OnMessage(message, channelPlugin.Config)
|
||||||
|
|
||||||
|
// Get platform for processing actions
|
||||||
|
platform, err := platform.Get(item.Platform)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Error getting platform", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each action
|
||||||
|
for _, action := range actions {
|
||||||
|
switch action.Type {
|
||||||
|
case model.ActionSendMessage:
|
||||||
|
// Send a message
|
||||||
|
if action.Message != nil {
|
||||||
|
if err := platform.SendMessage(action.Message); err != nil {
|
||||||
|
a.logger.Error("Error sending message", "error", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
a.logger.Error("Send message action with nil message")
|
||||||
|
}
|
||||||
|
|
||||||
|
case model.ActionDeleteMessage:
|
||||||
|
// Delete a message using direct DeleteMessage call
|
||||||
|
if err := platform.DeleteMessage(action.Chat, action.MessageID); err != nil {
|
||||||
|
a.logger.Error("Error deleting message", "error", err, "message_id", action.MessageID)
|
||||||
|
} else {
|
||||||
|
a.logger.Info("Message deleted", "message_id", action.MessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
a.logger.Error("Unknown action type", "type", action.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReminder handles reminder processing
|
||||||
|
func (a *App) handleReminder(reminder *model.Reminder) {
|
||||||
|
// When called with nil, it means we should check for pending reminders
|
||||||
|
if reminder == nil {
|
||||||
|
// Get pending reminders
|
||||||
|
reminders, err := a.db.GetPendingReminders()
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Error getting pending reminders", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each reminder
|
||||||
|
for _, r := range reminders {
|
||||||
|
a.processReminder(r)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, process the specific reminder
|
||||||
|
a.processReminder(reminder)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processReminder processes an individual reminder
|
||||||
|
func (a *App) processReminder(reminder *model.Reminder) {
|
||||||
|
a.logger.Info("Processing reminder",
|
||||||
|
"id", reminder.ID,
|
||||||
|
"platform", reminder.Platform,
|
||||||
|
"channel", reminder.ChannelID,
|
||||||
|
"trigger_at", reminder.TriggerAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get the platform handler
|
||||||
|
p, err := platform.Get(reminder.Platform)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Error getting platform for reminder", "error", err, "platform", reminder.Platform)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the channel
|
||||||
|
channel, err := a.db.GetChannelByPlatform(reminder.Platform, reminder.ChannelID)
|
||||||
|
if err != nil {
|
||||||
|
a.logger.Error("Error getting channel for reminder", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the reminder message
|
||||||
|
reminderText := fmt.Sprintf("@%s reminding you of this", reminder.Username)
|
||||||
|
|
||||||
|
message := &model.Message{
|
||||||
|
Text: reminderText,
|
||||||
|
Chat: reminder.ChannelID,
|
||||||
|
Channel: channel,
|
||||||
|
Author: "bot",
|
||||||
|
FromBot: true,
|
||||||
|
Date: time.Now(),
|
||||||
|
ReplyTo: reminder.ReplyToID, // Reply to the original message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the reminder message
|
||||||
|
if err := p.SendMessage(message); err != nil {
|
||||||
|
a.logger.Error("Error sending reminder", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the reminder as processed
|
||||||
|
if err := a.db.MarkReminderAsProcessed(reminder.ID); err != nil {
|
||||||
|
a.logger.Error("Error marking reminder as processed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
59
internal/config/config.go
Normal file
59
internal/config/config.go
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds all application configuration
|
||||||
|
type Config struct {
|
||||||
|
Debug bool
|
||||||
|
Hostname string
|
||||||
|
Port string
|
||||||
|
LogLevel string
|
||||||
|
SecretKey string
|
||||||
|
DatabasePath string
|
||||||
|
SlackConfig SlackConfig
|
||||||
|
TelegramConfig TelegramConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlackConfig holds Slack platform configuration
|
||||||
|
type SlackConfig struct {
|
||||||
|
Token string
|
||||||
|
BotOAuthAccessToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TelegramConfig holds Telegram platform configuration
|
||||||
|
type TelegramConfig struct {
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load loads configuration from environment variables
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
config := &Config{
|
||||||
|
Debug: getEnv("DEBUG", "n") == "y",
|
||||||
|
Hostname: getEnv("BUTTERROBOT_HOSTNAME", "butterrobot-dev.int.fmartingr.network"),
|
||||||
|
Port: getEnv("PORT", "8080"),
|
||||||
|
LogLevel: getEnv("LOG_LEVEL", "ERROR"),
|
||||||
|
SecretKey: getEnv("SECRET_KEY", "1234"),
|
||||||
|
DatabasePath: getEnv("DATABASE_PATH", "butterrobot.db"),
|
||||||
|
SlackConfig: SlackConfig{
|
||||||
|
Token: getEnv("SLACK_TOKEN", ""),
|
||||||
|
BotOAuthAccessToken: getEnv("SLACK_BOT_OAUTH_ACCESS_TOKEN", ""),
|
||||||
|
},
|
||||||
|
TelegramConfig: TelegramConfig{
|
||||||
|
Token: getEnv("TELEGRAM_TOKEN", ""),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnv retrieves an environment variable value or returns a default value
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
value := os.Getenv(key)
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
795
internal/db/db.go
Normal file
795
internal/db/db.go
Normal file
|
@ -0,0 +1,795 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/migration"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNotFound is returned when a record is not found
|
||||||
|
ErrNotFound = errors.New("record not found")
|
||||||
|
|
||||||
|
// ErrDuplicated is returned when a record already exists
|
||||||
|
ErrDuplicated = errors.New("record already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database handles database operations
|
||||||
|
type Database struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Database instance
|
||||||
|
func New(dbPath string) (*Database, error) {
|
||||||
|
// Open database connection
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
if err := initDatabase(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Database{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func (d *Database) Close() error {
|
||||||
|
return d.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelByID retrieves a channel by ID
|
||||||
|
func (d *Database) GetChannelByID(id int64) (*model.Channel, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, platform, platform_channel_id, enabled, channel_raw
|
||||||
|
FROM channels
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
row := d.db.QueryRow(query, id)
|
||||||
|
|
||||||
|
var (
|
||||||
|
platform string
|
||||||
|
platformChannelID string
|
||||||
|
enabled bool
|
||||||
|
channelRawJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse channel_raw JSON
|
||||||
|
var channelRaw map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(channelRawJSON), &channelRaw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create channel
|
||||||
|
channel := &model.Channel{
|
||||||
|
ID: id,
|
||||||
|
Platform: platform,
|
||||||
|
PlatformChannelID: platformChannelID,
|
||||||
|
Enabled: enabled,
|
||||||
|
ChannelRaw: channelRaw,
|
||||||
|
Plugins: make(map[string]*model.ChannelPlugin),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel plugins
|
||||||
|
plugins, err := d.GetChannelPlugins(id)
|
||||||
|
if err != nil && err != ErrNotFound {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, plugin := range plugins {
|
||||||
|
channel.Plugins[plugin.PluginID] = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelByPlatform retrieves a channel by platform and platform channel ID
|
||||||
|
func (d *Database) GetChannelByPlatform(platform, platformChannelID string) (*model.Channel, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, platform, platform_channel_id, enabled, channel_raw
|
||||||
|
FROM channels
|
||||||
|
WHERE platform = ? AND platform_channel_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
row := d.db.QueryRow(query, platform, platformChannelID)
|
||||||
|
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
enabled bool
|
||||||
|
channelRawJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := row.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse channel_raw JSON
|
||||||
|
var channelRaw map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(channelRawJSON), &channelRaw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create channel
|
||||||
|
channel := &model.Channel{
|
||||||
|
ID: id,
|
||||||
|
Platform: platform,
|
||||||
|
PlatformChannelID: platformChannelID,
|
||||||
|
Enabled: enabled,
|
||||||
|
ChannelRaw: channelRaw,
|
||||||
|
Plugins: make(map[string]*model.ChannelPlugin),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel plugins
|
||||||
|
plugins, err := d.GetChannelPlugins(id)
|
||||||
|
if err != nil && err != ErrNotFound {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, plugin := range plugins {
|
||||||
|
channel.Plugins[plugin.PluginID] = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateChannel creates a new channel
|
||||||
|
func (d *Database) CreateChannel(platform, platformChannelID string, enabled bool, channelRaw map[string]interface{}) (*model.Channel, error) {
|
||||||
|
// Convert channelRaw to JSON
|
||||||
|
channelRawJSON, err := json.Marshal(channelRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert channel
|
||||||
|
query := `
|
||||||
|
INSERT INTO channels (platform, platform_channel_id, enabled, channel_raw)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := d.db.Exec(query, platform, platformChannelID, enabled, string(channelRawJSON))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get inserted ID
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create channel
|
||||||
|
channel := &model.Channel{
|
||||||
|
ID: id,
|
||||||
|
Platform: platform,
|
||||||
|
PlatformChannelID: platformChannelID,
|
||||||
|
Enabled: enabled,
|
||||||
|
ChannelRaw: channelRaw,
|
||||||
|
Plugins: make(map[string]*model.ChannelPlugin),
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChannel updates a channel's enabled status
|
||||||
|
func (d *Database) UpdateChannel(id int64, enabled bool) error {
|
||||||
|
query := `
|
||||||
|
UPDATE channels
|
||||||
|
SET enabled = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := d.db.Exec(query, enabled, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteChannel deletes a channel
|
||||||
|
func (d *Database) DeleteChannel(id int64) error {
|
||||||
|
// First delete all channel plugins
|
||||||
|
if err := d.DeleteChannelPluginsByChannel(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then delete the channel
|
||||||
|
query := `
|
||||||
|
DELETE FROM channels
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := d.db.Exec(query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelPlugins retrieves all plugins for a channel
|
||||||
|
func (d *Database) GetChannelPlugins(channelID int64) ([]*model.ChannelPlugin, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, channel_id, plugin_id, enabled, config
|
||||||
|
FROM channel_plugin
|
||||||
|
WHERE channel_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := d.db.Query(query, channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
fmt.Printf("Error closing rows: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var plugins []*model.ChannelPlugin
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
channelID int64
|
||||||
|
pluginID string
|
||||||
|
enabled bool
|
||||||
|
configJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &channelID, &pluginID, &enabled, &configJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse config JSON
|
||||||
|
var config map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin := &model.ChannelPlugin{
|
||||||
|
ID: id,
|
||||||
|
ChannelID: channelID,
|
||||||
|
PluginID: pluginID,
|
||||||
|
Enabled: enabled,
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins = append(plugins, plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plugins) == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelPluginByID retrieves a channel plugin by ID
|
||||||
|
func (d *Database) GetChannelPluginByID(id int64) (*model.ChannelPlugin, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, channel_id, plugin_id, enabled, config
|
||||||
|
FROM channel_plugin
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
row := d.db.QueryRow(query, id)
|
||||||
|
|
||||||
|
var (
|
||||||
|
channelID int64
|
||||||
|
pluginID string
|
||||||
|
enabled bool
|
||||||
|
configJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := row.Scan(&id, &channelID, &pluginID, &enabled, &configJSON)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse config JSON
|
||||||
|
var config map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.ChannelPlugin{
|
||||||
|
ID: id,
|
||||||
|
ChannelID: channelID,
|
||||||
|
PluginID: pluginID,
|
||||||
|
Enabled: enabled,
|
||||||
|
Config: config,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateChannelPlugin creates a new channel plugin
|
||||||
|
func (d *Database) CreateChannelPlugin(channelID int64, pluginID string, enabled bool, config map[string]interface{}) (*model.ChannelPlugin, error) {
|
||||||
|
// Check if plugin already exists for this channel
|
||||||
|
query := `
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM channel_plugin
|
||||||
|
WHERE channel_id = ? AND plugin_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := d.db.QueryRow(query, channelID, pluginID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count > 0 {
|
||||||
|
return nil, ErrDuplicated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert config to JSON
|
||||||
|
configJSON, err := json.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert channel plugin
|
||||||
|
insertQuery := `
|
||||||
|
INSERT INTO channel_plugin (channel_id, plugin_id, enabled, config)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := d.db.Exec(insertQuery, channelID, pluginID, enabled, string(configJSON))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get inserted ID
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.ChannelPlugin{
|
||||||
|
ID: id,
|
||||||
|
ChannelID: channelID,
|
||||||
|
PluginID: pluginID,
|
||||||
|
Enabled: enabled,
|
||||||
|
Config: config,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChannelPlugin updates a channel plugin's enabled status
|
||||||
|
func (d *Database) UpdateChannelPlugin(id int64, enabled bool) error {
|
||||||
|
query := `
|
||||||
|
UPDATE channel_plugin
|
||||||
|
SET enabled = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := d.db.Exec(query, enabled, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChannelPluginConfig updates a channel plugin's configuration
|
||||||
|
func (d *Database) UpdateChannelPluginConfig(id int64, config map[string]interface{}) error {
|
||||||
|
// Convert config to JSON
|
||||||
|
configJSON, err := json.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE channel_plugin
|
||||||
|
SET config = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = d.db.Exec(query, string(configJSON), id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteChannelPlugin deletes a channel plugin
|
||||||
|
func (d *Database) DeleteChannelPlugin(id int64) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM channel_plugin
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := d.db.Exec(query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteChannelPluginsByChannel deletes all plugins for a channel
|
||||||
|
func (d *Database) DeleteChannelPluginsByChannel(channelID int64) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM channel_plugin
|
||||||
|
WHERE channel_id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := d.db.Exec(query, channelID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllChannels retrieves all channels
|
||||||
|
func (d *Database) GetAllChannels() ([]*model.Channel, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, platform, platform_channel_id, enabled, channel_raw
|
||||||
|
FROM channels
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := d.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
fmt.Printf("Error closing rows: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var channels []*model.Channel
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
platform string
|
||||||
|
platformChannelID string
|
||||||
|
enabled bool
|
||||||
|
channelRawJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &platform, &platformChannelID, &enabled, &channelRawJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse channel_raw JSON
|
||||||
|
var channelRaw map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(channelRawJSON), &channelRaw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create channel
|
||||||
|
channel := &model.Channel{
|
||||||
|
ID: id,
|
||||||
|
Platform: platform,
|
||||||
|
PlatformChannelID: platformChannelID,
|
||||||
|
Enabled: enabled,
|
||||||
|
ChannelRaw: channelRaw,
|
||||||
|
Plugins: make(map[string]*model.ChannelPlugin),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel plugins
|
||||||
|
plugins, err := d.GetChannelPlugins(id)
|
||||||
|
if err != nil && err != ErrNotFound {
|
||||||
|
continue // Skip this channel if plugins can't be retrieved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add plugins to channel
|
||||||
|
for _, plugin := range plugins {
|
||||||
|
channel.Plugins[plugin.PluginID] = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
channels = append(channels, channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(channels) == 0 {
|
||||||
|
channels = make([]*model.Channel, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID retrieves a user by ID
|
||||||
|
func (d *Database) GetUserByID(id int64) (*model.User, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, username, password
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
row := d.db.QueryRow(query, id)
|
||||||
|
|
||||||
|
var (
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := row.Scan(&id, &username, &password)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.User{
|
||||||
|
ID: id,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user
|
||||||
|
func (d *Database) CreateUser(username, password string) (*model.User, error) {
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := hashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert user
|
||||||
|
query := `
|
||||||
|
INSERT INTO users (username, password)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := d.db.Exec(query, username, hashedPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get inserted ID
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.User{
|
||||||
|
ID: id,
|
||||||
|
Username: username,
|
||||||
|
Password: hashedPassword,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckCredentials checks if the username and password are valid
|
||||||
|
func (d *Database) CheckCredentials(username, password string) (*model.User, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, username, password
|
||||||
|
FROM users
|
||||||
|
WHERE username = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
row := d.db.QueryRow(query, username)
|
||||||
|
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
dbUsername string
|
||||||
|
dbPassword string
|
||||||
|
)
|
||||||
|
|
||||||
|
err := row.Scan(&id, &dbUsername, &dbPassword)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password with bcrypt
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(dbPassword), []byte(password))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.User{
|
||||||
|
ID: id,
|
||||||
|
Username: dbUsername,
|
||||||
|
Password: dbPassword,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserPassword updates a user's password
|
||||||
|
func (d *Database) UpdateUserPassword(userID int64, newPassword string) error {
|
||||||
|
// Hash the new password
|
||||||
|
hashedPassword, err := hashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the user's password
|
||||||
|
query := `
|
||||||
|
UPDATE users
|
||||||
|
SET password = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err = d.db.Exec(query, hashedPassword, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReminder creates a new reminder
|
||||||
|
func (d *Database) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) {
|
||||||
|
query := `
|
||||||
|
INSERT INTO reminders (
|
||||||
|
platform, channel_id, message_id, reply_to_id,
|
||||||
|
user_id, username, created_at, trigger_at,
|
||||||
|
content, processed
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||||
|
`
|
||||||
|
|
||||||
|
createdAt := time.Now()
|
||||||
|
result, err := d.db.Exec(
|
||||||
|
query,
|
||||||
|
platform, channelID, messageID, replyToID,
|
||||||
|
userID, username, createdAt, triggerAt,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.Reminder{
|
||||||
|
ID: id,
|
||||||
|
Platform: platform,
|
||||||
|
ChannelID: channelID,
|
||||||
|
MessageID: messageID,
|
||||||
|
ReplyToID: replyToID,
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
TriggerAt: triggerAt,
|
||||||
|
Content: content,
|
||||||
|
Processed: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingReminders gets all pending reminders that need to be processed
|
||||||
|
func (d *Database) GetPendingReminders() ([]*model.Reminder, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, platform, channel_id, message_id, reply_to_id,
|
||||||
|
user_id, username, created_at, trigger_at, content, processed
|
||||||
|
FROM reminders
|
||||||
|
WHERE processed = 0 AND trigger_at <= ?
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := d.db.Query(query, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
fmt.Printf("Error closing rows: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var reminders []*model.Reminder
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id int64
|
||||||
|
platform, channelID, messageID, replyToID string
|
||||||
|
userID, username, content string
|
||||||
|
createdAt, triggerAt time.Time
|
||||||
|
processed bool
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := rows.Scan(
|
||||||
|
&id, &platform, &channelID, &messageID, &replyToID,
|
||||||
|
&userID, &username, &createdAt, &triggerAt, &content, &processed,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
reminder := &model.Reminder{
|
||||||
|
ID: id,
|
||||||
|
Platform: platform,
|
||||||
|
ChannelID: channelID,
|
||||||
|
MessageID: messageID,
|
||||||
|
ReplyToID: replyToID,
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
TriggerAt: triggerAt,
|
||||||
|
Content: content,
|
||||||
|
Processed: processed,
|
||||||
|
}
|
||||||
|
|
||||||
|
reminders = append(reminders, reminder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reminders) == 0 {
|
||||||
|
return make([]*model.Reminder, 0), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return reminders, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkReminderAsProcessed marks a reminder as processed
|
||||||
|
func (d *Database) MarkReminderAsProcessed(id int64) error {
|
||||||
|
query := `
|
||||||
|
UPDATE reminders
|
||||||
|
SET processed = 1
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := d.db.Exec(query, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to hash password
|
||||||
|
func hashPassword(password string) (string, error) {
|
||||||
|
// Use bcrypt for secure password hashing
|
||||||
|
// The cost parameter is the computational cost, higher is more secure but slower
|
||||||
|
// Recommended minimum is 12
|
||||||
|
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hashedBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database tables
|
||||||
|
func initDatabase(db *sql.DB) error {
|
||||||
|
// Ensure migration table exists
|
||||||
|
if err := migration.EnsureMigrationTable(db); err != nil {
|
||||||
|
return fmt.Errorf("failed to create migration table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get applied migrations
|
||||||
|
applied, err := migration.GetAppliedMigrations(db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all migration versions
|
||||||
|
allMigrations := make([]int, 0, len(migration.Migrations))
|
||||||
|
for version := range migration.Migrations {
|
||||||
|
allMigrations = append(allMigrations, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of applied migrations for quick lookup
|
||||||
|
appliedMap := make(map[int]bool)
|
||||||
|
for _, version := range applied {
|
||||||
|
appliedMap[version] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count pending migrations
|
||||||
|
pendingCount := 0
|
||||||
|
for _, version := range allMigrations {
|
||||||
|
if !appliedMap[version] {
|
||||||
|
pendingCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations if needed
|
||||||
|
if pendingCount > 0 {
|
||||||
|
fmt.Printf("Running %d pending database migrations...\n", pendingCount)
|
||||||
|
if err := migration.Migrate(db); err != nil {
|
||||||
|
return fmt.Errorf("migration failed: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Database migrations completed successfully.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Database schema is up to date.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
223
internal/migration/migration.go
Normal file
223
internal/migration/migration.go
Normal file
|
@ -0,0 +1,223 @@
|
||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Migration represents a database migration
|
||||||
|
type Migration struct {
|
||||||
|
Version int
|
||||||
|
Description string
|
||||||
|
Up func(db *sql.DB) error
|
||||||
|
Down func(db *sql.DB) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrations is a collection of registered migrations
|
||||||
|
var Migrations = make(map[int]Migration)
|
||||||
|
|
||||||
|
// Register adds a migration to the list of available migrations
|
||||||
|
func Register(version int, description string, up, down func(db *sql.DB) error) {
|
||||||
|
if _, exists := Migrations[version]; exists {
|
||||||
|
panic(fmt.Sprintf("migration version %d already exists", version))
|
||||||
|
}
|
||||||
|
|
||||||
|
Migrations[version] = Migration{
|
||||||
|
Version: version,
|
||||||
|
Description: description,
|
||||||
|
Up: up,
|
||||||
|
Down: down,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureMigrationTable creates the migration table if it doesn't exist
|
||||||
|
func EnsureMigrationTable(db *sql.DB) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAppliedMigrations returns a list of applied migration versions
|
||||||
|
func GetAppliedMigrations(db *sql.DB) ([]int, error) {
|
||||||
|
rows, err := db.Query("SELECT version FROM schema_migrations ORDER BY version")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
fmt.Printf("Error closing rows: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var versions []int
|
||||||
|
for rows.Next() {
|
||||||
|
var version int
|
||||||
|
if err := rows.Scan(&version); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
versions = append(versions, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsApplied checks if a migration version has been applied
|
||||||
|
func IsApplied(db *sql.DB, version int) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAsApplied marks a migration as applied
|
||||||
|
func MarkAsApplied(db *sql.DB, version int) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
||||||
|
version, time.Now(),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveApplied removes a migration from the applied list
|
||||||
|
func RemoveApplied(db *sql.DB, version int) error {
|
||||||
|
_, err := db.Exec("DELETE FROM schema_migrations WHERE version = ?", version)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate runs pending migrations up to the latest version
|
||||||
|
func Migrate(db *sql.DB) error {
|
||||||
|
// Ensure migration table exists
|
||||||
|
if err := EnsureMigrationTable(db); err != nil {
|
||||||
|
return fmt.Errorf("failed to create migration table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get applied migrations
|
||||||
|
applied, err := GetAppliedMigrations(db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of applied migrations for quick lookup
|
||||||
|
appliedMap := make(map[int]bool)
|
||||||
|
for _, version := range applied {
|
||||||
|
appliedMap[version] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all migration versions and sort them
|
||||||
|
var versions []int
|
||||||
|
for version := range Migrations {
|
||||||
|
versions = append(versions, version)
|
||||||
|
}
|
||||||
|
sort.Ints(versions)
|
||||||
|
|
||||||
|
// Apply each pending migration
|
||||||
|
for _, version := range versions {
|
||||||
|
if !appliedMap[version] {
|
||||||
|
migration := Migrations[version]
|
||||||
|
fmt.Printf("Applying migration %d: %s...\n", version, migration.Description)
|
||||||
|
|
||||||
|
// Start transaction for the migration
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction for migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the migration
|
||||||
|
if err := migration.Up(db); err != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
fmt.Printf("Error rolling back transaction: %v\n", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to apply migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as applied
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
||||||
|
version, time.Now(),
|
||||||
|
); err != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
fmt.Printf("Error rolling back transaction: %v\n", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to mark migration %d as applied: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("failed to commit migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Migration %d applied successfully\n", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateDown rolls back migrations down to the specified version
|
||||||
|
// If version is -1, it will roll back all migrations
|
||||||
|
func MigrateDown(db *sql.DB, targetVersion int) error {
|
||||||
|
// Ensure migration table exists
|
||||||
|
if err := EnsureMigrationTable(db); err != nil {
|
||||||
|
return fmt.Errorf("failed to create migration table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get applied migrations
|
||||||
|
applied, err := GetAppliedMigrations(db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort in descending order to roll back newest first
|
||||||
|
sort.Sort(sort.Reverse(sort.IntSlice(applied)))
|
||||||
|
|
||||||
|
// Roll back each migration until target version
|
||||||
|
for _, version := range applied {
|
||||||
|
if targetVersion == -1 || version > targetVersion {
|
||||||
|
migration, exists := Migrations[version]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("migration %d is applied but not found in codebase", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Rolling back migration %d: %s...\n", version, migration.Description)
|
||||||
|
|
||||||
|
// Start transaction for the rollback
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction for rollback %d: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the down migration
|
||||||
|
if err := migration.Down(db); err != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
fmt.Printf("Error rolling back transaction: %v\n", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to roll back migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from applied list
|
||||||
|
if _, err := tx.Exec("DELETE FROM schema_migrations WHERE version = ?", version); err != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
fmt.Printf("Error rolling back transaction: %v\n", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to remove migration %d from applied list: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("failed to commit rollback %d: %w", version, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Migration %d rolled back successfully\n", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
128
internal/migration/migrations.go
Normal file
128
internal/migration/migrations.go
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
package migration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Register migrations
|
||||||
|
Register(1, "Initial schema with bcrypt passwords", migrateInitialSchemaUp, migrateInitialSchemaDown)
|
||||||
|
Register(2, "Add reminders table", migrateRemindersUp, migrateRemindersDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial schema creation with bcrypt passwords - version 1
|
||||||
|
func migrateInitialSchemaUp(db *sql.DB) error {
|
||||||
|
// Create channels table
|
||||||
|
_, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
platform_channel_id TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
channel_raw TEXT NOT NULL,
|
||||||
|
UNIQUE(platform, platform_channel_id)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create channel_plugin table
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_plugin (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
channel_id INTEGER NOT NULL,
|
||||||
|
plugin_id TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
config TEXT NOT NULL DEFAULT '{}',
|
||||||
|
UNIQUE(channel_id, plugin_id),
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create users table with bcrypt passwords
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default admin user with bcrypt password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin"), 12)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if users table is empty before inserting
|
||||||
|
var count int
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
_, err = db.Exec(
|
||||||
|
"INSERT INTO users (username, password) VALUES (?, ?)",
|
||||||
|
"admin", string(hashedPassword),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateInitialSchemaDown(db *sql.DB) error {
|
||||||
|
// Drop tables in reverse order of dependencies
|
||||||
|
_, err := db.Exec(`DROP TABLE IF EXISTS channel_plugin`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`DROP TABLE IF EXISTS channels`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`DROP TABLE IF EXISTS users`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reminders table - version 2
|
||||||
|
func migrateRemindersUp(db *sql.DB) error {
|
||||||
|
_, err := db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
channel_id TEXT NOT NULL,
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
reply_to_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
trigger_at TIMESTAMP NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
processed BOOLEAN NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateRemindersDown(db *sql.DB) error {
|
||||||
|
_, err := db.Exec(`DROP TABLE IF EXISTS reminders`)
|
||||||
|
return err
|
||||||
|
}
|
121
internal/model/message.go
Normal file
121
internal/model/message.go
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionType defines the type of action to perform
|
||||||
|
type ActionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ActionSendMessage is for sending a message to the chat
|
||||||
|
ActionSendMessage ActionType = "send_message"
|
||||||
|
// ActionDeleteMessage is for deleting a message from the chat
|
||||||
|
ActionDeleteMessage ActionType = "delete_message"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageAction represents an action to be performed on the platform
|
||||||
|
type MessageAction struct {
|
||||||
|
Type ActionType
|
||||||
|
Message *Message // For send_message
|
||||||
|
MessageID string // For delete_message
|
||||||
|
Chat string // Chat where the action happens
|
||||||
|
Channel *Channel // Channel reference
|
||||||
|
Raw map[string]interface{} // Additional data for the action
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message represents a chat message
|
||||||
|
type Message struct {
|
||||||
|
Text string
|
||||||
|
Chat string
|
||||||
|
Channel *Channel
|
||||||
|
Author string
|
||||||
|
FromBot bool
|
||||||
|
Date time.Time
|
||||||
|
ID string
|
||||||
|
ReplyTo string
|
||||||
|
Raw map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel represents a chat channel
|
||||||
|
type Channel struct {
|
||||||
|
ID int64
|
||||||
|
Platform string
|
||||||
|
PlatformChannelID string
|
||||||
|
ChannelRaw map[string]interface{}
|
||||||
|
Enabled bool
|
||||||
|
Plugins map[string]*ChannelPlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasEnabledPlugin checks if a plugin is enabled for this channel
|
||||||
|
func (c *Channel) HasEnabledPlugin(pluginID string) bool {
|
||||||
|
plugin, exists := c.Plugins[pluginID]
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return plugin.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelName returns the channel name
|
||||||
|
func (c *Channel) ChannelName() string {
|
||||||
|
// In a real implementation, this would use the platform-specific
|
||||||
|
// ParseChannelNameFromRaw function
|
||||||
|
|
||||||
|
// For simplicity, we'll just use the PlatformChannelID if we can't extract a name
|
||||||
|
// Check if ChannelRaw has a name field
|
||||||
|
if c.ChannelRaw == nil {
|
||||||
|
return c.PlatformChannelID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check common name fields in ChannelRaw
|
||||||
|
if name, ok := c.ChannelRaw["name"].(string); ok && name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for nested objects like "chat" (used by Telegram)
|
||||||
|
if chat, ok := c.ChannelRaw["chat"].(map[string]interface{}); ok {
|
||||||
|
// Try different fields in order of preference
|
||||||
|
if title, ok := chat["title"].(string); ok && title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
if username, ok := chat["username"].(string); ok && username != "" {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
if firstName, ok := chat["first_name"].(string); ok && firstName != "" {
|
||||||
|
return firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.PlatformChannelID
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelPlugin represents a plugin enabled for a channel
|
||||||
|
type ChannelPlugin struct {
|
||||||
|
ID int64
|
||||||
|
ChannelID int64
|
||||||
|
PluginID string
|
||||||
|
Enabled bool
|
||||||
|
Config map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// User represents an admin user
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reminder represents a scheduled reminder
|
||||||
|
type Reminder struct {
|
||||||
|
ID int64
|
||||||
|
Platform string
|
||||||
|
ChannelID string
|
||||||
|
MessageID string
|
||||||
|
ReplyToID string
|
||||||
|
UserID string
|
||||||
|
Username string
|
||||||
|
CreatedAt time.Time
|
||||||
|
TriggerAt time.Time
|
||||||
|
Content string
|
||||||
|
Processed bool
|
||||||
|
}
|
49
internal/model/platform.go
Normal file
49
internal/model/platform.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrPlatform is a general platform error
|
||||||
|
ErrPlatform = errors.New("platform error")
|
||||||
|
|
||||||
|
// ErrPlatformInit is an error during platform initialization
|
||||||
|
ErrPlatformInit = errors.New("platform initialization error")
|
||||||
|
|
||||||
|
// ErrPlatformAuth is an authentication error
|
||||||
|
ErrPlatformAuth = errors.New("platform authentication error")
|
||||||
|
|
||||||
|
// ErrPlatformNotFound is returned when a requested platform doesn't exist
|
||||||
|
ErrPlatformNotFound = errors.New("platform not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthResponse represents a platform authentication response
|
||||||
|
type AuthResponse struct {
|
||||||
|
Data map[string]any
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform defines the interface all chat platforms must implement
|
||||||
|
type Platform interface {
|
||||||
|
// Init initializes the platform
|
||||||
|
Init(cfg *config.Config) error
|
||||||
|
|
||||||
|
// ParseIncomingMessage parses the incoming HTTP request into a Message
|
||||||
|
ParseIncomingMessage(r *http.Request) (*Message, error)
|
||||||
|
|
||||||
|
// ParseChannelNameFromRaw extracts a human-readable channel name from raw data
|
||||||
|
ParseChannelNameFromRaw(channelRaw map[string]any) string
|
||||||
|
|
||||||
|
// ParseChannelFromMessage extracts channel data from a message
|
||||||
|
ParseChannelFromMessage(body []byte) (map[string]any, error)
|
||||||
|
|
||||||
|
// SendMessage sends a message through the platform
|
||||||
|
SendMessage(msg *Message) error
|
||||||
|
|
||||||
|
// DeleteMessage deletes a message from the platform
|
||||||
|
DeleteMessage(channel string, messageID string) error
|
||||||
|
}
|
28
internal/model/plugin.go
Normal file
28
internal/model/plugin.go
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrPluginNotFound is returned when a requested plugin doesn't exist
|
||||||
|
ErrPluginNotFound = errors.New("plugin not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plugin defines the interface all chat plugins must implement
|
||||||
|
type Plugin interface {
|
||||||
|
// GetID returns the plugin ID
|
||||||
|
GetID() string
|
||||||
|
|
||||||
|
// GetName returns the plugin name
|
||||||
|
GetName() string
|
||||||
|
|
||||||
|
// GetHelp returns the plugin help text
|
||||||
|
GetHelp() string
|
||||||
|
|
||||||
|
// RequiresConfig indicates if the plugin requires configuration
|
||||||
|
RequiresConfig() bool
|
||||||
|
|
||||||
|
// OnMessage processes an incoming message and returns platform actions
|
||||||
|
OnMessage(msg *Message, config map[string]interface{}) []*MessageAction
|
||||||
|
}
|
32
internal/platform/init.go
Normal file
32
internal/platform/init.go
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/platform/slack"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/platform/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitializePlatforms initializes all available platforms
|
||||||
|
func InitializePlatforms(cfg *config.Config) error {
|
||||||
|
// Initialize Slack platform
|
||||||
|
if cfg.SlackConfig.Token != "" && cfg.SlackConfig.BotOAuthAccessToken != "" {
|
||||||
|
slackPlatform := slack.New(&cfg.SlackConfig)
|
||||||
|
if err := slackPlatform.Init(cfg); err == nil {
|
||||||
|
Register("slack", slackPlatform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Telegram platform
|
||||||
|
if cfg.TelegramConfig.Token != "" {
|
||||||
|
telegramPlatform := telegram.New(&cfg.TelegramConfig)
|
||||||
|
if err := telegramPlatform.Init(cfg); err == nil {
|
||||||
|
Register("telegram", telegramPlatform)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("telegram token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
49
internal/platform/registry.go
Normal file
49
internal/platform/registry.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// platforms holds all registered chat platforms
|
||||||
|
platforms = make(map[string]model.Platform)
|
||||||
|
|
||||||
|
// platformsMu protects the platforms map
|
||||||
|
platformsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register registers a platform with the given ID
|
||||||
|
func Register(id string, platform model.Platform) {
|
||||||
|
platformsMu.Lock()
|
||||||
|
defer platformsMu.Unlock()
|
||||||
|
platforms[id] = platform
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a platform by ID
|
||||||
|
func Get(id string) (model.Platform, error) {
|
||||||
|
platformsMu.RLock()
|
||||||
|
defer platformsMu.RUnlock()
|
||||||
|
|
||||||
|
platform, exists := platforms[id]
|
||||||
|
if !exists {
|
||||||
|
return nil, model.ErrPlatformNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return platform, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailablePlatforms returns all registered platforms
|
||||||
|
func GetAvailablePlatforms() map[string]model.Platform {
|
||||||
|
platformsMu.RLock()
|
||||||
|
defer platformsMu.RUnlock()
|
||||||
|
|
||||||
|
// Create a copy to avoid race conditions
|
||||||
|
result := make(map[string]model.Platform, len(platforms))
|
||||||
|
for id, platform := range platforms {
|
||||||
|
result[id] = platform
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
283
internal/platform/slack/slack.go
Normal file
283
internal/platform/slack/slack.go
Normal file
|
@ -0,0 +1,283 @@
|
||||||
|
package slack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SlackPlatform implements the Platform interface for Slack
|
||||||
|
type SlackPlatform struct {
|
||||||
|
config *config.SlackConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new SlackPlatform instance
|
||||||
|
func New(cfg *config.SlackConfig) *SlackPlatform {
|
||||||
|
return &SlackPlatform{
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the Slack platform
|
||||||
|
func (s *SlackPlatform) Init(_ *config.Config) error {
|
||||||
|
// Validate config
|
||||||
|
if s.config.Token == "" || s.config.BotOAuthAccessToken == "" {
|
||||||
|
return model.ErrPlatformInit
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseIncomingMessage parses an incoming Slack message
|
||||||
|
func (s *SlackPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) {
|
||||||
|
// Read request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := r.Body.Close(); err != nil {
|
||||||
|
fmt.Printf("Error closing request body: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
var requestData map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &requestData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Slack request
|
||||||
|
// This is a simplified version, production should include signature verification
|
||||||
|
urlVerify, ok := requestData["type"]
|
||||||
|
if ok && urlVerify == "url_verification" {
|
||||||
|
return nil, errors.New("url verification") // Handle separately
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process event
|
||||||
|
event, ok := requestData["event"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create message
|
||||||
|
msg := &model.Message{
|
||||||
|
Raw: requestData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get text
|
||||||
|
if text, ok := event["text"].(string); ok {
|
||||||
|
msg.Text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel
|
||||||
|
if channel, ok := event["channel"].(string); ok {
|
||||||
|
msg.Chat = channel
|
||||||
|
|
||||||
|
// Create Channel object
|
||||||
|
channelRaw, err := s.ParseChannelFromMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Channel = &model.Channel{
|
||||||
|
Platform: "slack",
|
||||||
|
PlatformChannelID: channel,
|
||||||
|
ChannelRaw: channelRaw,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if from bot
|
||||||
|
if botID, ok := event["bot_id"].(string); ok && botID != "" {
|
||||||
|
msg.FromBot = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
if user, ok := event["user"].(string); ok {
|
||||||
|
msg.Author = user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get timestamp
|
||||||
|
if ts, ok := event["ts"].(string); ok {
|
||||||
|
// Convert Unix timestamp
|
||||||
|
parts := strings.Split(ts, ".")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
if sec, err := parseInt64(parts[0]); err == nil {
|
||||||
|
msg.Date = time.Unix(sec, 0)
|
||||||
|
msg.ID = ts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseChannelNameFromRaw extracts a human-readable channel name from raw data
|
||||||
|
func (s *SlackPlatform) ParseChannelNameFromRaw(channelRaw map[string]interface{}) string {
|
||||||
|
// Extract name from channel raw data
|
||||||
|
if name, ok := channelRaw["name"].(string); ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to ID if available
|
||||||
|
if id, ok := channelRaw["id"].(string); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseChannelFromMessage extracts channel data from a message
|
||||||
|
func (s *SlackPlatform) ParseChannelFromMessage(body []byte) (map[string]any, error) {
|
||||||
|
// Parse JSON
|
||||||
|
var requestData map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &requestData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract channel info from event
|
||||||
|
event, ok := requestData["event"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid event data")
|
||||||
|
}
|
||||||
|
|
||||||
|
channelID, ok := event["channel"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("channel ID not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a real implementation, you might want to fetch more details about the channel
|
||||||
|
// using the Slack API, but for simplicity we'll just return the ID
|
||||||
|
channelRaw := map[string]interface{}{
|
||||||
|
"id": channelID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return channelRaw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a message to Slack
|
||||||
|
func (s *SlackPlatform) SendMessage(msg *model.Message) error {
|
||||||
|
if s.config.BotOAuthAccessToken == "" {
|
||||||
|
return errors.New("bot token not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for delete message action
|
||||||
|
if msg.Raw != nil && msg.Raw["action"] == "delete" {
|
||||||
|
// This is a request to delete a message
|
||||||
|
return s.deleteMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare payload
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"channel": msg.Chat,
|
||||||
|
"text": msg.Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add thread_ts if it's a reply
|
||||||
|
if msg.ReplyTo != "" {
|
||||||
|
payload["thread_ts"] = msg.ReplyTo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert payload to JSON
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send HTTP request
|
||||||
|
req, err := http.NewRequest("POST", "https://slack.com/api/chat.postMessage", strings.NewReader(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.BotOAuthAccessToken))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
fmt.Printf("Error closing response body: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("slack API error: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage deletes a message on Slack
|
||||||
|
func (s *SlackPlatform) DeleteMessage(channel string, messageID string) error {
|
||||||
|
// Prepare payload for chat.delete API
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"channel": channel,
|
||||||
|
"ts": messageID, // In Slack, the ts (timestamp) is the message ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert payload to JSON
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send HTTP request to chat.delete endpoint
|
||||||
|
req, err := http.NewRequest("POST", "https://slack.com/api/chat.delete", strings.NewReader(string(data)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.BotOAuthAccessToken))
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
fmt.Printf("Error closing response body: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("slack API error: %d - %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteMessage is a legacy method that uses the Raw message approach
|
||||||
|
func (s *SlackPlatform) deleteMessage(msg *model.Message) error {
|
||||||
|
// Get message ID to delete
|
||||||
|
messageID, ok := msg.Raw["message_id"]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no message ID provided for deletion")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to string if needed
|
||||||
|
messageIDStr := fmt.Sprintf("%v", messageID)
|
||||||
|
|
||||||
|
return s.DeleteMessage(msg.Chat, messageIDStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to parse int64
|
||||||
|
func parseInt64(s string) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
_, err := fmt.Sscanf(s, "%d", &n)
|
||||||
|
return n, err
|
||||||
|
}
|
370
internal/platform/telegram/telegram.go
Normal file
370
internal/platform/telegram/telegram.go
Normal file
|
@ -0,0 +1,370 @@
|
||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/config"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TelegramPlatform implements the Platform interface for Telegram
|
||||||
|
type TelegramPlatform struct {
|
||||||
|
config *config.TelegramConfig
|
||||||
|
apiURL string
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new TelegramPlatform instance
|
||||||
|
func New(cfg *config.TelegramConfig) *TelegramPlatform {
|
||||||
|
return &TelegramPlatform{
|
||||||
|
config: cfg,
|
||||||
|
apiURL: "https://api.telegram.org/bot" + cfg.Token,
|
||||||
|
log: slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})).With(slog.String("platform", "telegram")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the Telegram platform
|
||||||
|
func (t *TelegramPlatform) Init(cfg *config.Config) error {
|
||||||
|
if t.config.Token == "" {
|
||||||
|
t.log.Error("Missing Telegram token")
|
||||||
|
return model.ErrPlatformInit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set webhook URL based on hostname
|
||||||
|
webhookURL := fmt.Sprintf("https://%s/telegram/incoming/%s", cfg.Hostname, t.config.Token)
|
||||||
|
t.log.Info("Setting Telegram webhook", "url", webhookURL)
|
||||||
|
|
||||||
|
// Create webhook setup request
|
||||||
|
url := fmt.Sprintf("%s/setWebhook", t.apiURL)
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"url": webhookURL,
|
||||||
|
"max_connections": 40,
|
||||||
|
"allowed_updates": []string{"message"},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Failed to marshal webhook payload", "error", err)
|
||||||
|
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Post(url, "application/json", bytes.NewBuffer(data))
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Failed to set webhook", "error", err)
|
||||||
|
return fmt.Errorf("failed to set webhook: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
t.log.Error("Error closing response body", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
errMsg := string(bodyBytes)
|
||||||
|
t.log.Error("Telegram API error", "status", resp.StatusCode, "response", errMsg)
|
||||||
|
return fmt.Errorf("telegram API error: %d - %s", resp.StatusCode, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.log.Info("Telegram webhook successfully set")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseIncomingMessage parses an incoming Telegram message
|
||||||
|
func (t *TelegramPlatform) ParseIncomingMessage(r *http.Request) (*model.Message, error) {
|
||||||
|
t.log.Debug("Parsing incoming Telegram message")
|
||||||
|
|
||||||
|
// Read request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Failed to read request body", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := r.Body.Close(); err != nil {
|
||||||
|
t.log.Error("Error closing request body", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Parse JSON
|
||||||
|
var update struct {
|
||||||
|
Message struct {
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
From struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
IsBot bool `json:"is_bot"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
FirstName string `json:"first_name"`
|
||||||
|
} `json:"from"`
|
||||||
|
Chat struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
} `json:"chat"`
|
||||||
|
Date int `json:"date"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
ReplyToMessage struct {
|
||||||
|
MessageID int `json:"message_id"`
|
||||||
|
} `json:"reply_to_message"`
|
||||||
|
} `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &update); err != nil {
|
||||||
|
t.log.Error("Failed to unmarshal update", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to raw map for storage
|
||||||
|
var raw map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
t.log.Error("Failed to unmarshal raw data", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create message
|
||||||
|
msg := &model.Message{
|
||||||
|
Text: update.Message.Text,
|
||||||
|
Chat: strconv.FormatInt(update.Message.Chat.ID, 10),
|
||||||
|
Author: update.Message.From.Username,
|
||||||
|
FromBot: update.Message.From.IsBot,
|
||||||
|
Date: time.Unix(int64(update.Message.Date), 0),
|
||||||
|
ID: strconv.Itoa(update.Message.MessageID),
|
||||||
|
ReplyTo: strconv.Itoa(update.Message.ReplyToMessage.MessageID),
|
||||||
|
Raw: raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.log.Debug("Parsed message",
|
||||||
|
"id", msg.ID,
|
||||||
|
"chat", msg.Chat,
|
||||||
|
"author", msg.Author,
|
||||||
|
"from_bot", msg.FromBot,
|
||||||
|
"text_length", len(msg.Text))
|
||||||
|
|
||||||
|
// Create Channel object
|
||||||
|
channelRaw, err := t.ParseChannelFromMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Failed to parse channel data", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.Channel = &model.Channel{
|
||||||
|
Platform: "telegram",
|
||||||
|
PlatformChannelID: msg.Chat,
|
||||||
|
ChannelRaw: channelRaw,
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseChannelNameFromRaw extracts a human-readable channel name from raw data
|
||||||
|
func (t *TelegramPlatform) ParseChannelNameFromRaw(channelRaw map[string]interface{}) string {
|
||||||
|
// Try to get the title first (for groups)
|
||||||
|
if chatInfo, ok := channelRaw["chat"].(map[string]interface{}); ok {
|
||||||
|
if title, ok := chatInfo["title"].(string); ok && title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
// For private chats, use username
|
||||||
|
if username, ok := chatInfo["username"].(string); ok && username != "" {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to first_name if available
|
||||||
|
if firstName, ok := chatInfo["first_name"].(string); ok && firstName != "" {
|
||||||
|
return firstName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: use the ID
|
||||||
|
if id, ok := chatInfo["id"].(float64); ok {
|
||||||
|
return strconv.FormatInt(int64(id), 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseChannelFromMessage extracts channel data from a message
|
||||||
|
func (t *TelegramPlatform) ParseChannelFromMessage(body []byte) (map[string]any, error) {
|
||||||
|
// Parse JSON to extract chat info
|
||||||
|
var update struct {
|
||||||
|
Message struct {
|
||||||
|
Chat map[string]any `json:"chat"`
|
||||||
|
} `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &update); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if update.Message.Chat == nil {
|
||||||
|
return nil, errors.New("chat information not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"chat": update.Message.Chat,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a message to Telegram
|
||||||
|
func (t *TelegramPlatform) SendMessage(msg *model.Message) error {
|
||||||
|
// Check for delete message action (legacy method)
|
||||||
|
if msg.Raw != nil && msg.Raw["action"] == "delete" {
|
||||||
|
// This is a request to delete a message using the legacy method
|
||||||
|
return t.deleteMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular message sending
|
||||||
|
// Convert chat ID to int64
|
||||||
|
chatID, err := strconv.ParseInt(msg.Chat, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Failed to parse chat ID", "chat", msg.Chat, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare payload
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"text": msg.Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reply if needed
|
||||||
|
if msg.ReplyTo != "" {
|
||||||
|
replyToID, err := strconv.Atoi(msg.ReplyTo)
|
||||||
|
if err == nil {
|
||||||
|
payload["reply_to_message_id"] = replyToID
|
||||||
|
} else {
|
||||||
|
t.log.Warn("Failed to parse reply_to ID", "reply_to", msg.ReplyTo, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.log.Debug("Sending message to Telegram", "chat_id", chatID, "length", len(msg.Text))
|
||||||
|
|
||||||
|
// Convert payload to JSON
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Failed to marshal message payload", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send HTTP request
|
||||||
|
resp, err := http.Post(
|
||||||
|
t.apiURL+"/sendMessage",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewBuffer(data),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Failed to send message", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
t.log.Error("Error closing response body", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
errMsg := string(bodyBytes)
|
||||||
|
t.log.Error("Telegram API error", "status", resp.StatusCode, "response", errMsg)
|
||||||
|
return fmt.Errorf("telegram API error: %d - %s", resp.StatusCode, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.log.Debug("Message sent successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage deletes a message on Telegram
|
||||||
|
func (t *TelegramPlatform) DeleteMessage(channel string, messageID string) error {
|
||||||
|
// Convert chat ID to int64
|
||||||
|
chatID, err := strconv.ParseInt(channel, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Invalid chat ID for message deletion", "chat_id", channel, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert message ID to integer
|
||||||
|
msgID, err := strconv.Atoi(messageID)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Invalid message ID for deletion", "message_id", messageID, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare payload for deleteMessage API
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"message_id": msgID,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.log.Debug("Deleting message on Telegram", "chat_id", chatID, "message_id", msgID)
|
||||||
|
|
||||||
|
// Convert payload to JSON
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Failed to marshal delete message payload", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send HTTP request to deleteMessage endpoint
|
||||||
|
resp, err := http.Post(
|
||||||
|
t.apiURL+"/deleteMessage",
|
||||||
|
"application/json",
|
||||||
|
bytes.NewBuffer(data),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.log.Error("Failed to delete message", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := resp.Body.Close(); err != nil {
|
||||||
|
t.log.Error("Error closing response body", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
errMsg := string(bodyBytes)
|
||||||
|
t.log.Error("Telegram API error when deleting message", "status", resp.StatusCode, "response", errMsg)
|
||||||
|
return fmt.Errorf("telegram API error when deleting message: %d - %s", resp.StatusCode, errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.log.Debug("Message deleted successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteMessage is a legacy method that uses the Raw message approach
|
||||||
|
func (t *TelegramPlatform) deleteMessage(msg *model.Message) error {
|
||||||
|
// Get message ID to delete
|
||||||
|
messageIDInterface, ok := msg.Raw["message_id"]
|
||||||
|
if !ok {
|
||||||
|
t.log.Error("No message ID provided for deletion")
|
||||||
|
return fmt.Errorf("no message ID provided for deletion")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert message ID to string
|
||||||
|
var messageIDStr string
|
||||||
|
switch v := messageIDInterface.(type) {
|
||||||
|
case string:
|
||||||
|
messageIDStr = v
|
||||||
|
case int:
|
||||||
|
messageIDStr = strconv.Itoa(v)
|
||||||
|
case float64:
|
||||||
|
messageIDStr = strconv.Itoa(int(v))
|
||||||
|
default:
|
||||||
|
t.log.Error("Invalid message ID type for deletion", "type", fmt.Sprintf("%T", messageIDInterface))
|
||||||
|
return fmt.Errorf("invalid message ID type for deletion")
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.DeleteMessage(msg.Chat, messageIDStr)
|
||||||
|
}
|
132
internal/plugin/domainblock/domainblock.go
Normal file
132
internal/plugin/domainblock/domainblock.go
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
package domainblock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DomainBlockPlugin is a plugin that blocks messages containing links from specific domains
|
||||||
|
type DomainBlockPlugin struct {
|
||||||
|
plugin.BasePlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug helper to check if RequiresConfig is working
|
||||||
|
func (p *DomainBlockPlugin) RequiresConfig() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new DomainBlockPlugin instance
|
||||||
|
func New() *DomainBlockPlugin {
|
||||||
|
return &DomainBlockPlugin{
|
||||||
|
BasePlugin: plugin.BasePlugin{
|
||||||
|
ID: "security.domainblock",
|
||||||
|
Name: "Domain Blocker",
|
||||||
|
Help: "Blocks messages containing links from configured domains",
|
||||||
|
ConfigRequired: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDomains extracts domains from a message text
|
||||||
|
func extractDomains(text string) []string {
|
||||||
|
// URL regex pattern
|
||||||
|
urlPattern := regexp.MustCompile(`https?://([^\s/$.?#].[^\s]*)`)
|
||||||
|
matches := urlPattern.FindAllStringSubmatch(text, -1)
|
||||||
|
|
||||||
|
domains := make([]string, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse the URL to extract the domain
|
||||||
|
urlStr := match[0]
|
||||||
|
parsedURL, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the domain (host) from the URL
|
||||||
|
domain := parsedURL.Host
|
||||||
|
// Remove port if present
|
||||||
|
if i := strings.IndexByte(domain, ':'); i >= 0 {
|
||||||
|
domain = domain[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
domains = append(domains, strings.ToLower(domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
return domains
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage processes incoming messages
|
||||||
|
func (p *DomainBlockPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||||
|
// Skip messages from bots
|
||||||
|
if msg.FromBot {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get blocked domains from config
|
||||||
|
blockedDomainsStr, ok := config["blocked_domains"].(string)
|
||||||
|
if !ok || blockedDomainsStr == "" {
|
||||||
|
return nil // No blocked domains configured
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split and clean blocked domains
|
||||||
|
blockedDomains := strings.Split(blockedDomainsStr, ",")
|
||||||
|
for i, domain := range blockedDomains {
|
||||||
|
blockedDomains[i] = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract domains from message
|
||||||
|
messageDomains := extractDomains(msg.Text)
|
||||||
|
if len(messageDomains) == 0 {
|
||||||
|
return nil // No domains in message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any domains in the message are blocked
|
||||||
|
for _, msgDomain := range messageDomains {
|
||||||
|
for _, blockedDomain := range blockedDomains {
|
||||||
|
if blockedDomain == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(msgDomain, blockedDomain) || msgDomain == blockedDomain {
|
||||||
|
// Domain is blocked, create actions
|
||||||
|
|
||||||
|
// 1. Create a delete message action
|
||||||
|
deleteAction := &model.MessageAction{
|
||||||
|
Type: model.ActionDeleteMessage,
|
||||||
|
MessageID: msg.ID,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create a notification message action
|
||||||
|
notificationMsg := &model.Message{
|
||||||
|
Text: fmt.Sprintf("I don't like links from %s 🙈", blockedDomain),
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
sendAction := &model.MessageAction{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: notificationMsg,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{deleteAction, sendAction}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin is registered in app.go, not using init()
|
140
internal/plugin/domainblock/domainblock_test.go
Normal file
140
internal/plugin/domainblock/domainblock_test.go
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
package domainblock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractDomains(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "No URLs",
|
||||||
|
text: "Hello, world!",
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Single URL",
|
||||||
|
text: "Check out https://example.com for more info",
|
||||||
|
expected: []string{"example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple URLs",
|
||||||
|
text: "Check out https://example.com and http://test.example.org for more info",
|
||||||
|
expected: []string{"example.com", "test.example.org"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with path",
|
||||||
|
text: "Check out https://example.com/path/to/resource",
|
||||||
|
expected: []string{"example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with port",
|
||||||
|
text: "Check out https://example.com:8080/path/to/resource",
|
||||||
|
expected: []string{"example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with subdomain",
|
||||||
|
text: "Check out https://sub.example.com",
|
||||||
|
expected: []string{"sub.example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
domains := extractDomains(test.text)
|
||||||
|
|
||||||
|
if len(domains) != len(test.expected) {
|
||||||
|
t.Errorf("Expected %d domains, got %d", len(test.expected), len(domains))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, domain := range domains {
|
||||||
|
if domain != test.expected[i] {
|
||||||
|
t.Errorf("Expected domain %s, got %s", test.expected[i], domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnMessage(t *testing.T) {
|
||||||
|
plugin := New()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
blockedDomains string
|
||||||
|
expectBlocked bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "No blocked domains",
|
||||||
|
text: "Check out https://example.com",
|
||||||
|
blockedDomains: "",
|
||||||
|
expectBlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No matching domain",
|
||||||
|
text: "Check out https://example.com",
|
||||||
|
blockedDomains: "bad.com, evil.org",
|
||||||
|
expectBlocked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Matching domain",
|
||||||
|
text: "Check out https://example.com",
|
||||||
|
blockedDomains: "example.com, evil.org",
|
||||||
|
expectBlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Matching subdomain",
|
||||||
|
text: "Check out https://sub.example.com",
|
||||||
|
blockedDomains: "example.com",
|
||||||
|
expectBlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple domains, one matching",
|
||||||
|
text: "Check out https://example.com and https://good.org",
|
||||||
|
blockedDomains: "bad.com, example.com",
|
||||||
|
expectBlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Spaces in blocked domains list",
|
||||||
|
text: "Check out https://example.com",
|
||||||
|
blockedDomains: "bad.com, example.com , evil.org",
|
||||||
|
expectBlocked: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
msg := &model.Message{
|
||||||
|
Text: test.text,
|
||||||
|
Chat: "test-chat",
|
||||||
|
ID: "test-id",
|
||||||
|
Channel: &model.Channel{
|
||||||
|
ID: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
config := map[string]interface{}{
|
||||||
|
"blocked_domains": test.blockedDomains,
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := plugin.OnMessage(msg, config)
|
||||||
|
|
||||||
|
if test.expectBlocked {
|
||||||
|
if responses == nil || len(responses) == 0 {
|
||||||
|
t.Errorf("Expected message to be blocked, but it wasn't")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if responses != nil && len(responses) > 0 {
|
||||||
|
t.Errorf("Expected message not to be blocked, but it was")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
57
internal/plugin/fun/coin.go
Normal file
57
internal/plugin/fun/coin.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
package fun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoinPlugin flips a coin
|
||||||
|
type CoinPlugin struct {
|
||||||
|
plugin.BasePlugin
|
||||||
|
rand *rand.Rand
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCoin creates a new CoinPlugin instance
|
||||||
|
func NewCoin() *CoinPlugin {
|
||||||
|
source := rand.NewSource(time.Now().UnixNano())
|
||||||
|
return &CoinPlugin{
|
||||||
|
BasePlugin: plugin.BasePlugin{
|
||||||
|
ID: "fun.coin",
|
||||||
|
Name: "Coin Flip",
|
||||||
|
Help: "Flips a coin when you type 'flip a coin'",
|
||||||
|
},
|
||||||
|
rand: rand.New(source),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage handles incoming messages
|
||||||
|
func (p *CoinPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||||
|
if !strings.Contains(strings.ToLower(msg.Text), "flip a coin") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := "Heads"
|
||||||
|
if p.rand.Intn(2) == 0 {
|
||||||
|
result = "Tails"
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &model.Message{
|
||||||
|
Text: result,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
ReplyTo: msg.ID,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
action := &model.MessageAction{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: response,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{action}
|
||||||
|
}
|
126
internal/plugin/fun/dice.go
Normal file
126
internal/plugin/fun/dice.go
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
package fun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DicePlugin rolls dice based on standard dice notation
|
||||||
|
type DicePlugin struct {
|
||||||
|
plugin.BasePlugin
|
||||||
|
rand *rand.Rand
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDice creates a new DicePlugin instance
|
||||||
|
func NewDice() *DicePlugin {
|
||||||
|
source := rand.NewSource(time.Now().UnixNano())
|
||||||
|
return &DicePlugin{
|
||||||
|
BasePlugin: plugin.BasePlugin{
|
||||||
|
ID: "fun.dice",
|
||||||
|
Name: "Dice Roller",
|
||||||
|
Help: "Rolls dice when you type '!dice [formula]' (default: 1d20)",
|
||||||
|
},
|
||||||
|
rand: rand.New(source),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage handles incoming messages
|
||||||
|
func (p *DicePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(msg.Text)), "!dice") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract dice formula
|
||||||
|
formula := strings.TrimSpace(strings.TrimPrefix(msg.Text, "!dice"))
|
||||||
|
formula = strings.TrimSpace(strings.TrimPrefix(formula, "!dice"))
|
||||||
|
|
||||||
|
if formula == "" {
|
||||||
|
formula = "1d20" // Default formula
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and roll the dice
|
||||||
|
result, err := p.rollDice(formula)
|
||||||
|
responseText := ""
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
responseText = fmt.Sprintf("Error: %s", err.Error())
|
||||||
|
} else {
|
||||||
|
responseText = fmt.Sprintf("%d", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &model.Message{
|
||||||
|
Text: responseText,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
ReplyTo: msg.ID,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
action := &model.MessageAction{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: response,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{action}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollDice parses a dice formula string and returns the result
|
||||||
|
func (p *DicePlugin) rollDice(formula string) (int, error) {
|
||||||
|
// Support basic dice notation like "2d6", "1d20+5", etc.
|
||||||
|
diceRegex := regexp.MustCompile(`^(\d+)d(\d+)(?:([+-])(\d+))?$`)
|
||||||
|
matches := diceRegex.FindStringSubmatch(formula)
|
||||||
|
|
||||||
|
if matches == nil {
|
||||||
|
return 0, fmt.Errorf("invalid dice formula: %s", formula)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse number of dice
|
||||||
|
numDice, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil || numDice < 1 {
|
||||||
|
return 0, fmt.Errorf("invalid number of dice")
|
||||||
|
}
|
||||||
|
if numDice > 100 {
|
||||||
|
return 0, fmt.Errorf("too many dice (max 100)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse number of sides
|
||||||
|
sides, err := strconv.Atoi(matches[2])
|
||||||
|
if err != nil || sides < 1 {
|
||||||
|
return 0, fmt.Errorf("invalid number of sides")
|
||||||
|
}
|
||||||
|
if sides > 1000 {
|
||||||
|
return 0, fmt.Errorf("too many sides (max 1000)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll the dice
|
||||||
|
total := 0
|
||||||
|
for i := 0; i < numDice; i++ {
|
||||||
|
roll := p.rand.Intn(sides) + 1
|
||||||
|
total += roll
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply modifier if present
|
||||||
|
if len(matches) > 3 && matches[3] != "" {
|
||||||
|
modifier, err := strconv.Atoi(matches[4])
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid modifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch matches[3] {
|
||||||
|
case "+":
|
||||||
|
total += modifier
|
||||||
|
case "-":
|
||||||
|
total -= modifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total, nil
|
||||||
|
}
|
47
internal/plugin/fun/loquito.go
Normal file
47
internal/plugin/fun/loquito.go
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
package fun
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoquitoPlugin replies with "Loquito tu." when someone says "lo quito"
|
||||||
|
type LoquitoPlugin struct {
|
||||||
|
plugin.BasePlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoquito creates a new LoquitoPlugin instance
|
||||||
|
func NewLoquito() *LoquitoPlugin {
|
||||||
|
return &LoquitoPlugin{
|
||||||
|
BasePlugin: plugin.BasePlugin{
|
||||||
|
ID: "fun.loquito",
|
||||||
|
Name: "Loquito Reply",
|
||||||
|
Help: "Replies with 'Loquito tu.' when someone says 'lo quito'",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage handles incoming messages
|
||||||
|
func (p *LoquitoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||||
|
if !strings.Contains(strings.ToLower(msg.Text), "lo quito") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &model.Message{
|
||||||
|
Text: "Loquito tu.",
|
||||||
|
Chat: msg.Chat,
|
||||||
|
ReplyTo: msg.ID,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
action := &model.MessageAction{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: response,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{action}
|
||||||
|
}
|
49
internal/plugin/ping/ping.go
Normal file
49
internal/plugin/ping/ping.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package ping
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PingPlugin is a simple ping/pong plugin
|
||||||
|
type PingPlugin struct {
|
||||||
|
plugin.BasePlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new PingPlugin instance
|
||||||
|
func New() *PingPlugin {
|
||||||
|
return &PingPlugin{
|
||||||
|
BasePlugin: plugin.BasePlugin{
|
||||||
|
ID: "dev.ping",
|
||||||
|
Name: "Ping",
|
||||||
|
Help: "Responds to 'ping' with 'pong'",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage handles incoming messages
|
||||||
|
func (p *PingPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the response message
|
||||||
|
response := &model.Message{
|
||||||
|
Text: "pong",
|
||||||
|
Chat: msg.Chat,
|
||||||
|
ReplyTo: msg.ID,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an action to send the message
|
||||||
|
action := &model.MessageAction{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: response,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{action}
|
||||||
|
}
|
81
internal/plugin/plugin.go
Normal file
81
internal/plugin/plugin.go
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// plugins holds all registered plugins
|
||||||
|
plugins = make(map[string]model.Plugin)
|
||||||
|
|
||||||
|
// pluginsMu protects the plugins map
|
||||||
|
pluginsMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register registers a plugin with the given ID
|
||||||
|
func Register(plugin model.Plugin) {
|
||||||
|
pluginsMu.Lock()
|
||||||
|
defer pluginsMu.Unlock()
|
||||||
|
plugins[plugin.GetID()] = plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a plugin by ID
|
||||||
|
func Get(id string) (model.Plugin, error) {
|
||||||
|
pluginsMu.RLock()
|
||||||
|
defer pluginsMu.RUnlock()
|
||||||
|
|
||||||
|
plugin, exists := plugins[id]
|
||||||
|
if !exists {
|
||||||
|
return nil, model.ErrPluginNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailablePlugins returns all registered plugins
|
||||||
|
func GetAvailablePlugins() map[string]model.Plugin {
|
||||||
|
pluginsMu.RLock()
|
||||||
|
defer pluginsMu.RUnlock()
|
||||||
|
|
||||||
|
// Create a copy to avoid race conditions
|
||||||
|
result := make(map[string]model.Plugin, len(plugins))
|
||||||
|
maps.Copy(result, plugins)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasePlugin provides a common base for plugins
|
||||||
|
type BasePlugin struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Help string
|
||||||
|
ConfigRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetID returns the plugin ID
|
||||||
|
func (p *BasePlugin) GetID() string {
|
||||||
|
return p.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetName returns the plugin name
|
||||||
|
func (p *BasePlugin) GetName() string {
|
||||||
|
return p.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHelp returns the plugin help text
|
||||||
|
func (p *BasePlugin) GetHelp() string {
|
||||||
|
return p.Help
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequiresConfig indicates if the plugin requires configuration
|
||||||
|
func (p *BasePlugin) RequiresConfig() bool {
|
||||||
|
return p.ConfigRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage is the default implementation that does nothing
|
||||||
|
func (p *BasePlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||||
|
return nil
|
||||||
|
}
|
200
internal/plugin/reminder/reminder.go
Normal file
200
internal/plugin/reminder/reminder.go
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
package reminder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Duration regex patterns to match reminders
|
||||||
|
var (
|
||||||
|
remindMePattern = regexp.MustCompile(`(?i)^!remindme\s(\d+)(y|mo|d|h|m|s)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReminderCreator is an interface for creating reminders
|
||||||
|
type ReminderCreator interface {
|
||||||
|
CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reminder is a plugin that sets reminders for messages
|
||||||
|
type Reminder struct {
|
||||||
|
plugin.BasePlugin
|
||||||
|
creator ReminderCreator
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Reminder plugin
|
||||||
|
func New(creator ReminderCreator) *Reminder {
|
||||||
|
return &Reminder{
|
||||||
|
BasePlugin: plugin.BasePlugin{
|
||||||
|
ID: "reminder.remindme",
|
||||||
|
Name: "Remind Me",
|
||||||
|
Help: "Reply to a message with `!remindme <duration>` to set a reminder (e.g., `!remindme 2d` for 2 days, `!remindme 1y` for 1 year).",
|
||||||
|
ConfigRequired: false,
|
||||||
|
},
|
||||||
|
creator: creator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage processes incoming messages
|
||||||
|
func (r *Reminder) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||||
|
// Only process replies to messages
|
||||||
|
if msg.ReplyTo == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the message is a reminder command
|
||||||
|
match := remindMePattern.FindStringSubmatch(msg.Text)
|
||||||
|
if match == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the duration
|
||||||
|
amount, err := strconv.Atoi(match[1])
|
||||||
|
if err != nil {
|
||||||
|
errorMsg := &model.Message{
|
||||||
|
Text: "Invalid duration format. Please use a number followed by y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).",
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
Author: "bot",
|
||||||
|
FromBot: true,
|
||||||
|
Date: time.Now(),
|
||||||
|
ReplyTo: msg.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{
|
||||||
|
{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: errorMsg,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the trigger time
|
||||||
|
var duration time.Duration
|
||||||
|
unit := match[2]
|
||||||
|
switch strings.ToLower(unit) {
|
||||||
|
case "y":
|
||||||
|
duration = time.Duration(amount) * 365 * 24 * time.Hour
|
||||||
|
case "mo":
|
||||||
|
duration = time.Duration(amount) * 30 * 24 * time.Hour
|
||||||
|
case "d":
|
||||||
|
duration = time.Duration(amount) * 24 * time.Hour
|
||||||
|
case "h":
|
||||||
|
duration = time.Duration(amount) * time.Hour
|
||||||
|
case "m":
|
||||||
|
duration = time.Duration(amount) * time.Minute
|
||||||
|
case "s":
|
||||||
|
duration = time.Duration(amount) * time.Second
|
||||||
|
default:
|
||||||
|
errorMsg := &model.Message{
|
||||||
|
Text: "Invalid duration unit. Please use y (years), mo (months), d (days), h (hours), m (minutes), or s (seconds).",
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
Author: "bot",
|
||||||
|
FromBot: true,
|
||||||
|
Date: time.Now(),
|
||||||
|
ReplyTo: msg.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{
|
||||||
|
{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: errorMsg,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerAt := time.Now().Add(duration)
|
||||||
|
|
||||||
|
// Determine the username for the reminder
|
||||||
|
username := msg.Author
|
||||||
|
if username == "" {
|
||||||
|
// Try to extract username from message raw data
|
||||||
|
if authorData, ok := msg.Raw["author"].(map[string]interface{}); ok {
|
||||||
|
if name, ok := authorData["username"].(string); ok {
|
||||||
|
username = name
|
||||||
|
} else if name, ok := authorData["name"].(string); ok {
|
||||||
|
username = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the reminder
|
||||||
|
_, err = r.creator.CreateReminder(
|
||||||
|
msg.Channel.Platform,
|
||||||
|
msg.Chat,
|
||||||
|
msg.ID,
|
||||||
|
msg.ReplyTo,
|
||||||
|
msg.Author,
|
||||||
|
username,
|
||||||
|
"", // No additional content for now
|
||||||
|
triggerAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
errorMsg := &model.Message{
|
||||||
|
Text: fmt.Sprintf("Failed to create reminder: %v", err),
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
Author: "bot",
|
||||||
|
FromBot: true,
|
||||||
|
Date: time.Now(),
|
||||||
|
ReplyTo: msg.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{
|
||||||
|
{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: errorMsg,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the acknowledgment message
|
||||||
|
var confirmText string
|
||||||
|
switch strings.ToLower(unit) {
|
||||||
|
case "y":
|
||||||
|
confirmText = fmt.Sprintf("I'll remind you about this message in %d year(s) on %s", amount, triggerAt.Format("Mon, Jan 2, 2006 at 15:04"))
|
||||||
|
case "mo":
|
||||||
|
confirmText = fmt.Sprintf("I'll remind you about this message in %d month(s) on %s", amount, triggerAt.Format("Mon, Jan 2 at 15:04"))
|
||||||
|
case "d":
|
||||||
|
confirmText = fmt.Sprintf("I'll remind you about this message in %d day(s) on %s", amount, triggerAt.Format("Mon, Jan 2 at 15:04"))
|
||||||
|
case "h":
|
||||||
|
confirmText = fmt.Sprintf("I'll remind you about this message in %d hour(s) at %s", amount, triggerAt.Format("15:04"))
|
||||||
|
case "m":
|
||||||
|
confirmText = fmt.Sprintf("I'll remind you about this message in %d minute(s) at %s", amount, triggerAt.Format("15:04"))
|
||||||
|
case "s":
|
||||||
|
confirmText = fmt.Sprintf("I'll remind you about this message in %d second(s)", amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create confirmation message
|
||||||
|
confirmMsg := &model.Message{
|
||||||
|
Text: confirmText,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
Author: "bot",
|
||||||
|
FromBot: true,
|
||||||
|
Date: time.Now(),
|
||||||
|
ReplyTo: msg.ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{
|
||||||
|
{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: confirmMsg,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
175
internal/plugin/reminder/reminder_test.go
Normal file
175
internal/plugin/reminder/reminder_test.go
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
package reminder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockCreator is a mock implementation of ReminderCreator for testing
|
||||||
|
type MockCreator struct {
|
||||||
|
reminders []*model.Reminder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockCreator) CreateReminder(platform, channelID, messageID, replyToID, userID, username, content string, triggerAt time.Time) (*model.Reminder, error) {
|
||||||
|
reminder := &model.Reminder{
|
||||||
|
ID: int64(len(m.reminders) + 1),
|
||||||
|
Platform: platform,
|
||||||
|
ChannelID: channelID,
|
||||||
|
MessageID: messageID,
|
||||||
|
ReplyToID: replyToID,
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
Content: content,
|
||||||
|
TriggerAt: triggerAt,
|
||||||
|
}
|
||||||
|
m.reminders = append(m.reminders, reminder)
|
||||||
|
return reminder, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReminderOnMessage(t *testing.T) {
|
||||||
|
creator := &MockCreator{reminders: make([]*model.Reminder, 0)}
|
||||||
|
plugin := New(creator)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
message *model.Message
|
||||||
|
expectResponse bool
|
||||||
|
expectReminder bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid reminder command - years",
|
||||||
|
message: &model.Message{
|
||||||
|
Text: "!remindme 1y",
|
||||||
|
ReplyTo: "original-message-id",
|
||||||
|
Author: "testuser",
|
||||||
|
Channel: &model.Channel{Platform: "test"},
|
||||||
|
},
|
||||||
|
expectResponse: true,
|
||||||
|
expectReminder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid reminder command - months",
|
||||||
|
message: &model.Message{
|
||||||
|
Text: "!remindme 3mo",
|
||||||
|
ReplyTo: "original-message-id",
|
||||||
|
Author: "testuser",
|
||||||
|
Channel: &model.Channel{Platform: "test"},
|
||||||
|
},
|
||||||
|
expectResponse: true,
|
||||||
|
expectReminder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid reminder command - days",
|
||||||
|
message: &model.Message{
|
||||||
|
Text: "!remindme 2d",
|
||||||
|
ReplyTo: "original-message-id",
|
||||||
|
Author: "testuser",
|
||||||
|
Channel: &model.Channel{Platform: "test"},
|
||||||
|
},
|
||||||
|
expectResponse: true,
|
||||||
|
expectReminder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid reminder command - hours",
|
||||||
|
message: &model.Message{
|
||||||
|
Text: "!remindme 5h",
|
||||||
|
ReplyTo: "original-message-id",
|
||||||
|
Author: "testuser",
|
||||||
|
Channel: &model.Channel{Platform: "test"},
|
||||||
|
},
|
||||||
|
expectResponse: true,
|
||||||
|
expectReminder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid reminder command - minutes",
|
||||||
|
message: &model.Message{
|
||||||
|
Text: "!remindme 30m",
|
||||||
|
ReplyTo: "original-message-id",
|
||||||
|
Author: "testuser",
|
||||||
|
Channel: &model.Channel{Platform: "test"},
|
||||||
|
},
|
||||||
|
expectResponse: true,
|
||||||
|
expectReminder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid reminder command - seconds",
|
||||||
|
message: &model.Message{
|
||||||
|
Text: "!remindme 60s",
|
||||||
|
ReplyTo: "original-message-id",
|
||||||
|
Author: "testuser",
|
||||||
|
Channel: &model.Channel{Platform: "test"},
|
||||||
|
},
|
||||||
|
expectResponse: true,
|
||||||
|
expectReminder: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Not a reply",
|
||||||
|
message: &model.Message{
|
||||||
|
Text: "!remindme 2d",
|
||||||
|
ReplyTo: "",
|
||||||
|
Author: "testuser",
|
||||||
|
Channel: &model.Channel{Platform: "test"},
|
||||||
|
},
|
||||||
|
expectResponse: false,
|
||||||
|
expectReminder: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Not a reminder command",
|
||||||
|
message: &model.Message{
|
||||||
|
Text: "hello world",
|
||||||
|
ReplyTo: "original-message-id",
|
||||||
|
Author: "testuser",
|
||||||
|
Channel: &model.Channel{Platform: "test"},
|
||||||
|
},
|
||||||
|
expectResponse: false,
|
||||||
|
expectReminder: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid duration format",
|
||||||
|
message: &model.Message{
|
||||||
|
Text: "!remindme abc",
|
||||||
|
ReplyTo: "original-message-id",
|
||||||
|
Author: "testuser",
|
||||||
|
Channel: &model.Channel{Platform: "test"},
|
||||||
|
},
|
||||||
|
expectResponse: false,
|
||||||
|
expectReminder: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
initialCount := len(creator.reminders)
|
||||||
|
actions := plugin.OnMessage(tt.message, nil)
|
||||||
|
|
||||||
|
if tt.expectResponse && len(actions) == 0 {
|
||||||
|
t.Errorf("Expected response action, but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.expectResponse && len(actions) > 0 {
|
||||||
|
t.Errorf("Expected no actions, but got %d", len(actions))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify action type is correct when actions are returned
|
||||||
|
if len(actions) > 0 {
|
||||||
|
if actions[0].Type != model.ActionSendMessage {
|
||||||
|
t.Errorf("Expected action type to be %s, but got %s", model.ActionSendMessage, actions[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if actions[0].Message == nil {
|
||||||
|
t.Errorf("Expected message in action to not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.expectReminder && len(creator.reminders) != initialCount+1 {
|
||||||
|
t.Errorf("Expected reminder to be created, but it wasn't")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.expectReminder && len(creator.reminders) != initialCount {
|
||||||
|
t.Errorf("Expected no reminder to be created, but got %d", len(creator.reminders)-initialCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
81
internal/plugin/social/instagram.go
Normal file
81
internal/plugin/social/instagram.go
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
package social
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InstagramExpander transforms instagram.com links to ddinstagram.com links
|
||||||
|
type InstagramExpander struct {
|
||||||
|
plugin.BasePlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new InstagramExpander instance
|
||||||
|
func NewInstagramExpander() *InstagramExpander {
|
||||||
|
return &InstagramExpander{
|
||||||
|
BasePlugin: plugin.BasePlugin{
|
||||||
|
ID: "social.instagram",
|
||||||
|
Name: "Instagram Link Expander",
|
||||||
|
Help: "Automatically converts instagram.com links to ddinstagram.com links and removes tracking parameters",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnMessage handles incoming messages
|
||||||
|
func (p *InstagramExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.MessageAction {
|
||||||
|
// Skip empty messages
|
||||||
|
if strings.TrimSpace(msg.Text) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex to match instagram.com links
|
||||||
|
// Match both http://instagram.com and https://instagram.com formats
|
||||||
|
// Also match www.instagram.com
|
||||||
|
instagramRegex := regexp.MustCompile(`https?://(www\.)?(instagram\.com)/[^\s]+`)
|
||||||
|
|
||||||
|
// Check if the message contains an Instagram link
|
||||||
|
if !instagramRegex.MatchString(msg.Text) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace instagram.com with ddinstagram.com in the message and clean query parameters
|
||||||
|
transformed := instagramRegex.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, "instagram.com", "ddinstagram.com", 1)
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the host
|
||||||
|
parsedURL.Host = strings.Replace(parsedURL.Host, "instagram.com", "ddinstagram.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,
|
||||||
|
}
|
||||||
|
|
||||||
|
action := &model.MessageAction{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: response,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{action}
|
||||||
|
}
|
86
internal/plugin/social/twitter.go
Normal file
86
internal/plugin/social/twitter.go
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
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 NewTwitterExpander() *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.MessageAction {
|
||||||
|
// Skip empty messages
|
||||||
|
if strings.TrimSpace(msg.Text) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex to match twitter.com links
|
||||||
|
// Match both http://twitter.com and https://twitter.com formats
|
||||||
|
// Also match www.twitter.com
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace twitter.com with fxtwitter.com in the message and clean query parameters
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
action := &model.MessageAction{
|
||||||
|
Type: model.ActionSendMessage,
|
||||||
|
Message: response,
|
||||||
|
Chat: msg.Chat,
|
||||||
|
Channel: msg.Channel,
|
||||||
|
}
|
||||||
|
|
||||||
|
return []*model.MessageAction{action}
|
||||||
|
}
|
161
internal/queue/queue.go
Normal file
161
internal/queue/queue.go
Normal file
|
@ -0,0 +1,161 @@
|
||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.nakama.town/fmartingr/butterrobot/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Item represents a queue item
|
||||||
|
type Item struct {
|
||||||
|
Platform string
|
||||||
|
Request map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlerFunc defines a function that processes queue items
|
||||||
|
type HandlerFunc func(item Item)
|
||||||
|
|
||||||
|
// ReminderHandlerFunc defines a function that processes reminder items
|
||||||
|
type ReminderHandlerFunc func(reminder *model.Reminder)
|
||||||
|
|
||||||
|
// Queue represents a message queue
|
||||||
|
type Queue struct {
|
||||||
|
items chan Item
|
||||||
|
wg sync.WaitGroup
|
||||||
|
quit chan struct{}
|
||||||
|
logger *slog.Logger
|
||||||
|
running bool
|
||||||
|
runMutex sync.Mutex
|
||||||
|
reminderTicker *time.Ticker
|
||||||
|
reminderHandler ReminderHandlerFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Queue instance
|
||||||
|
func New(logger *slog.Logger) *Queue {
|
||||||
|
return &Queue{
|
||||||
|
items: make(chan Item, 100),
|
||||||
|
quit: make(chan struct{}),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts processing queue items
|
||||||
|
func (q *Queue) Start(handler HandlerFunc) {
|
||||||
|
q.runMutex.Lock()
|
||||||
|
defer q.runMutex.Unlock()
|
||||||
|
|
||||||
|
if q.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q.running = true
|
||||||
|
|
||||||
|
// Start worker
|
||||||
|
q.wg.Add(1)
|
||||||
|
go q.worker(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartReminderScheduler starts the reminder scheduler
|
||||||
|
func (q *Queue) StartReminderScheduler(handler ReminderHandlerFunc) {
|
||||||
|
q.runMutex.Lock()
|
||||||
|
defer q.runMutex.Unlock()
|
||||||
|
|
||||||
|
if q.reminderTicker != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q.reminderHandler = handler
|
||||||
|
|
||||||
|
// Check for reminders every minute
|
||||||
|
q.reminderTicker = time.NewTicker(1 * time.Minute)
|
||||||
|
|
||||||
|
q.wg.Add(1)
|
||||||
|
go q.reminderWorker()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops processing queue items
|
||||||
|
func (q *Queue) Stop() {
|
||||||
|
q.runMutex.Lock()
|
||||||
|
defer q.runMutex.Unlock()
|
||||||
|
|
||||||
|
if !q.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q.running = false
|
||||||
|
|
||||||
|
// Stop reminder ticker if it exists
|
||||||
|
if q.reminderTicker != nil {
|
||||||
|
q.reminderTicker.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
close(q.quit)
|
||||||
|
q.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds an item to the queue
|
||||||
|
func (q *Queue) Add(item Item) {
|
||||||
|
select {
|
||||||
|
case q.items <- item:
|
||||||
|
// Item added successfully
|
||||||
|
default:
|
||||||
|
// Queue is full
|
||||||
|
q.logger.Info("Queue is full, dropping message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker processes queue items
|
||||||
|
func (q *Queue) worker(handler HandlerFunc) {
|
||||||
|
defer q.wg.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case item := <-q.items:
|
||||||
|
// Process item
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
q.logger.Error("Panic in queue worker", "error", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
handler(item)
|
||||||
|
}()
|
||||||
|
case <-q.quit:
|
||||||
|
// Quit worker
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reminderWorker processes reminder items on a schedule
|
||||||
|
func (q *Queue) reminderWorker() {
|
||||||
|
defer q.wg.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-q.reminderTicker.C:
|
||||||
|
// This is triggered every minute to check for pending reminders
|
||||||
|
q.logger.Debug("Checking for pending reminders")
|
||||||
|
|
||||||
|
if q.reminderHandler != nil {
|
||||||
|
// The handler is responsible for fetching and processing reminders
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
q.logger.Error("Panic in reminder worker", "error", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Call the handler with a nil reminder to indicate it should check the database
|
||||||
|
q.reminderHandler(nil)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
case <-q.quit:
|
||||||
|
// Quit worker
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1345
poetry.lock
generated
1345
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,43 +0,0 @@
|
||||||
[tool.poetry]
|
|
||||||
name = "butterrobot"
|
|
||||||
version = "0.0.3"
|
|
||||||
description = "What is my purpose?"
|
|
||||||
authors = ["Felipe Martin <me@fmartingr.com>"]
|
|
||||||
license = "GPL-2.0"
|
|
||||||
packages = [
|
|
||||||
{ include = "butterrobot" },
|
|
||||||
{ include = "butterrobot_plugins_contrib" },
|
|
||||||
]
|
|
||||||
include = ["README.md"]
|
|
||||||
readme = "README.md"
|
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
|
||||||
python = "^3.7"
|
|
||||||
structlog = "^20.1.0"
|
|
||||||
colorama = "^0.4.3"
|
|
||||||
dice = "^3.1.0"
|
|
||||||
flask = ">=1.1.2,<3.0.0"
|
|
||||||
requests = "^2.24.0"
|
|
||||||
waitress = "^1.4.4"
|
|
||||||
dataset = "^1.3.2"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
|
||||||
black = "^19.10b0"
|
|
||||||
flake8 = "^3.7.9"
|
|
||||||
rope = "^0.16.0"
|
|
||||||
isort = "^4.3.21"
|
|
||||||
ipdb = "^0.13.2"
|
|
||||||
pytest = "^6.1.2"
|
|
||||||
pytest-cov = "^2.10.1"
|
|
||||||
pre-commit = "^2.10.0"
|
|
||||||
|
|
||||||
[tool.poetry.plugins]
|
|
||||||
[tool.poetry.plugins."butterrobot.plugins"]
|
|
||||||
"fun.loquito" = "butterrobot_plugins_contrib.fun:LoquitoPlugin"
|
|
||||||
"fun.dice" = "butterrobot_plugins_contrib.fun:DicePlugin"
|
|
||||||
"fun.coin" = "butterrobot_plugins_contrib.fun:CoinPlugin"
|
|
||||||
"dev.ping" = "butterrobot_plugins_contrib.dev:PingPlugin"
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry>=0.12"]
|
|
||||||
build-backend = "poetry.masonry.api"
|
|
16
setup.cfg
16
setup.cfg
|
@ -1,16 +0,0 @@
|
||||||
[flake8]
|
|
||||||
ignore = E203, E266, E501, W503, F403
|
|
||||||
max-line-length = 88
|
|
||||||
max-complexity = 18
|
|
||||||
select = B,C,E,F,W,T4,B9
|
|
||||||
|
|
||||||
[isort]
|
|
||||||
use_parentheses = True
|
|
||||||
multi_line_output = 3
|
|
||||||
include_trailing_comma = True
|
|
||||||
length_sort = 1
|
|
||||||
lines_between_types = 0
|
|
||||||
line_length = 88
|
|
||||||
known_third_party = dataset,dice,flask,pkg_resources,pytest,requests,structlog
|
|
||||||
sections = FUTURE, STDLIB, DJANGO, THIRDPARTY, FIRSTPARTY, LOCALFOLDER
|
|
||||||
no_lines_before = LOCALFOLDER
|
|
109
tests/test_db.py
109
tests/test_db.py
|
@ -1,109 +0,0 @@
|
||||||
import os.path
|
|
||||||
import tempfile
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import dataset
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from butterrobot import db
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DummyItem:
|
|
||||||
id: int
|
|
||||||
foo: str
|
|
||||||
|
|
||||||
|
|
||||||
class DummyQuery(db.Query):
|
|
||||||
tablename = "dummy"
|
|
||||||
obj = DummyItem
|
|
||||||
|
|
||||||
|
|
||||||
class MockDatabase:
|
|
||||||
def __init__(self):
|
|
||||||
self.temp_dir = tempfile.TemporaryDirectory()
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
db_path = os.path.join(self.temp_dir.name, "db.sqlite")
|
|
||||||
db.db = dataset.connect(f"sqlite:///{db_path}")
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
self.temp_dir.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_create_ok():
|
|
||||||
with MockDatabase():
|
|
||||||
assert DummyQuery.create(foo="bar")
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_delete_ok():
|
|
||||||
with MockDatabase():
|
|
||||||
item_id = DummyQuery.create(foo="bar")
|
|
||||||
assert DummyQuery.delete(item_id)
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_exists_by_id_ok():
|
|
||||||
with MockDatabase():
|
|
||||||
assert not DummyQuery.exists(id=1)
|
|
||||||
item_id = DummyQuery.create(foo="bar")
|
|
||||||
assert DummyQuery.exists(id=item_id)
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_exists_by_attribute_ok():
|
|
||||||
with MockDatabase():
|
|
||||||
assert not DummyQuery.exists(id=1)
|
|
||||||
item_id = DummyQuery.create(foo="bar")
|
|
||||||
assert DummyQuery.exists(foo="bar")
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_get_ok():
|
|
||||||
with MockDatabase():
|
|
||||||
item_id = DummyQuery.create(foo="bar")
|
|
||||||
item = DummyQuery.get(id=item_id)
|
|
||||||
assert item.id
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_all_ok():
|
|
||||||
with MockDatabase():
|
|
||||||
assert len(list(DummyQuery.all())) == 0
|
|
||||||
[DummyQuery.create(foo="bar") for i in range(0, 3)]
|
|
||||||
assert len(list(DummyQuery.all())) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_ok():
|
|
||||||
with MockDatabase():
|
|
||||||
expected = "bar2"
|
|
||||||
item_id = DummyQuery.create(foo="bar")
|
|
||||||
assert DummyQuery.update(item_id, foo=expected)
|
|
||||||
item = DummyQuery.get(id=item_id)
|
|
||||||
assert item.foo == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_user_sets_password_ok():
|
|
||||||
password = "password"
|
|
||||||
with MockDatabase():
|
|
||||||
user_id = db.UserQuery.create(username="foo", password=password)
|
|
||||||
user = db.UserQuery.get(id=user_id)
|
|
||||||
assert user.password == db.UserQuery._hash_password(password)
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_check_credentials_ok():
|
|
||||||
with MockDatabase():
|
|
||||||
username = "foo"
|
|
||||||
password = "bar"
|
|
||||||
user_id = db.UserQuery.create(username=username, password=password)
|
|
||||||
user = db.UserQuery.get(id=user_id)
|
|
||||||
user = db.UserQuery.check_credentials(username, password)
|
|
||||||
assert isinstance(user, db.UserQuery.obj)
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_check_credentials_ko():
|
|
||||||
with MockDatabase():
|
|
||||||
username = "foo"
|
|
||||||
password = "bar"
|
|
||||||
user_id = db.UserQuery.create(username=username, password=password)
|
|
||||||
user = db.UserQuery.get(id=user_id)
|
|
||||||
assert not db.UserQuery.check_credentials(username, "error")
|
|
||||||
assert not db.UserQuery.check_credentials("error", password)
|
|
||||||
assert not db.UserQuery.check_credentials("error", "error")
|
|
|
@ -1,18 +0,0 @@
|
||||||
from butterrobot.objects import Channel, ChannelPlugin
|
|
||||||
|
|
||||||
|
|
||||||
def test_channel_has_enabled_plugin_ok():
|
|
||||||
channel = Channel(
|
|
||||||
platform="debug",
|
|
||||||
platform_channel_id="debug",
|
|
||||||
channel_raw={},
|
|
||||||
plugins={
|
|
||||||
"enabled": ChannelPlugin(
|
|
||||||
id=1, channel_id="test", plugin_id="enabled", enabled=True
|
|
||||||
),
|
|
||||||
"existant": ChannelPlugin(id=2, channel_id="test", plugin_id="existant"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert not channel.has_enabled_plugin("non.existant")
|
|
||||||
assert not channel.has_enabled_plugin("existant")
|
|
||||||
assert channel.has_enabled_plugin("enabled")
|
|
Loading…
Add table
Add a link
Reference in a new issue