Compare commits

..

17 commits

Author SHA1 Message Date
c9edb57505
fix: make format
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2025-04-22 11:56:57 +02:00
763a451251
fix: lint errors 2025-04-22 11:56:33 +02:00
abcd3c3c44
docs: updated README 2025-04-22 11:41:56 +02:00
323ea4e8cd
fix(ci): updated woodpecker triggers
Some checks failed
ci/woodpecker/push/ci Pipeline failed
2025-04-22 11:40:10 +02:00
72c6dd6982
feat: remindme plugin
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-22 11:29:39 +02:00
21e4c434fd
docs: updated plugin docs
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 18:10:30 +02:00
a0f12efd65
feat: show version in admin page 2025-04-21 18:08:40 +02:00
c920eb94a0
feat: added twitter and instagram link expanders
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 18:03:07 +02:00
e0ae0c2a0b
fix: missing ca-certs
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 17:12:29 +02:00
6aedfc794f
feat: allow password change
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 15:44:45 +02:00
ece8280358
feat: db migrations, encrypted passwords
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 15:32:46 +02:00
84e5feeb81
ci: limit goreleaser to two tasks per release 2025-04-21 15:12:03 +02:00
bbb48f49e2
fix: embed templates directly
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-21 15:10:08 +02:00
3426b668fe
ci: fix deprecated attribute in goreleaser 2025-04-21 15:09:54 +02:00
7c684af8c3
refactor: python -> go
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2025-04-20 14:13:44 +02:00
dependabot[bot]
9c78ea2d48
Bump werkzeug from 1.0.1 to 2.2.3 (#19)
Bumps [werkzeug](https://github.com/pallets/werkzeug) from 1.0.1 to 2.2.3.
- [Release notes](https://github.com/pallets/werkzeug/releases)
- [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/werkzeug/compare/1.0.1...2.2.3)

---
updated-dependencies:
- dependency-name: werkzeug
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-21 12:22:10 +01:00
dependabot[bot]
7a3b4434e1
Bump certifi from 2020.12.5 to 2022.12.7 (#18)
Bumps [certifi](https://github.com/certifi/python-certifi) from 2020.12.5 to 2022.12.7.
- [Release notes](https://github.com/certifi/python-certifi/releases)
- [Commits](https://github.com/certifi/python-certifi/compare/2020.12.05...2022.12.07)

---
updated-dependencies:
- dependency-name: certifi
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-10 09:16:33 +01:00
87 changed files with 5200 additions and 3244 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -4,16 +4,10 @@ __pycache__
*~
*.cert
.env-local
test.py
.coverage
# Distribution
dist
*.egg-info
pip-wheel-metadata
# Github Codespaces
pythonenv3.8
bin
# Butterrobot
*.sqlite*
butterrobot.db

150
.goreleaser.yml Normal file
View 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

View file

@ -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
View 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
View 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
View 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"]

View file

@ -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
View file

@ -1,27 +1,100 @@
# Local development
setup:
poetry install
PROJECT_NAME := butterrobot
podman@build:
podman build -t fmartingr/butterrobot -f docker/Dockerfile docker
SOURCE_FILES ?=./...
podman@build-dev:
podman build -t fmartingr/butterrobot:dev -f Dockerfile.dev .
TEST_OPTIONS ?= -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out
TEST_TIMEOUT ?=1m
podman@tag-dev:
podman tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev
GOLANGCI_LINT_VERSION ?= v1.64.5
podman@push-dev:
podman push registry.int.fmartingr.network/fmartingr/butterrobot:dev --tls-verify=false
CLEAN_OPTIONS ?=-modcache -testcache
podman@dev:
make podman@build-dev
make podman@tag-dev
make podman@push-dev
CGO_ENABLED := 0
test:
poetry run pytest --cov=butterrobot --cov=butterrobot_plugins_contrib
BUILDS_PATH := ./dist
FROM_MAKEFILE := y
clean:
rm -rf dist
rm -rf butterrobot.egg-info
CONTAINERFILE_NAME := Containerfile
CONTAINER_ALPINE_VERSION := 3.21
CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}"
CONTAINER_MAINTAINER := "Felipe Martin <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}

View file

@ -1,53 +1,82 @@
# Butter Robot
| Stable | Master |
| --- | --- |
| ![Build stable tag docker image](https://github.com/fmartingr/butterrobot/workflows/Build%20stable%20tag%20docker%20image/badge.svg?branch=stable) | ![Build latest tag docker image](https://github.com/fmartingr/butterrobot/workflows/Build%20latest%20tag%20docker%20image/badge.svg?branch=master) |
| ![Pytest](https://github.com/fmartingr/butterrobot/workflows/Pytest/badge.svg?branch=stable) | ![Pytest](https://github.com/fmartingr/butterrobot/workflows/Pytest/badge.svg?branch=master) |
![Status badge](https://woodpecker.local.fmartingr.dev/api/badges/5/status.svg)
Python framework to create bots for several platforms.
Go framework to create bots for several platforms.
![Butter Robot](./assets/icon@120.png)
> What is my purpose?
## Features
- Support for multiple chat platforms (Slack (untested!), Telegram)
- Plugin system for easy extension
- Admin interface for managing channels and plugins
- Message queue for asynchronous processing
## Documentation
[Go to documentation](./docs)
### Database Management
ButterRobot includes an automatic database migration system. Migrations are applied automatically when the application starts, ensuring your database schema is always up to date.
[Learn more about migrations](./docs/migrations.md)
## Installation
### PyPi
### From Source
You can run it directly by installing the package and calling it
with `python` though this is not recommended and only intended for
development purposes.
```bash
# Clone the repository
git clone https://git.nakama.town/fmartingr/butterrobot.git
cd butterrobot
```
$ pip install --user butterrobot
$ python -m butterrobot
# Build the application
go build -o butterrobot ./cmd/butterrobot
```
### Containers
The `fmartingr/butterrobot/butterrobot` container image is published on Github packages to use with your favourite tool:
The `fmartingr/butterrobot/butterrobot` container image is published on Github packages:
```bash
docker pull docker.pkg.git.nakama.town/fmartingr/butterrobot/butterrobot:latest
docker run -d --name butterrobot -p 8080:8080 docker.pkg.git.nakama.town/fmartingr/butterrobot/butterrobot:latest
```
docker pull docker.pkg.github.com/fmartingr/butterrobot/butterrobot:latest
podman run -d --name fmartingr/butterrobot/butterrobot -p 8080:8080
```
## Configuration
Configuration is done through environment variables:
- `DEBUG`: Set to "y" to enable debug mode
- `BUTTERROBOT_HOSTNAME`: Hostname for webhook URLs
- `LOG_LEVEL`: Logging level (DEBUG, INFO, WARN, ERROR)
- `SECRET_KEY`: Secret key for sessions and password hashing
- `DATABASE_PATH`: Path to SQLite database file
### Platform-specific configuration
#### Slack
- `SLACK_TOKEN`: Slack app access token
- `SLACK_BOT_OAUTH_ACCESS_TOKEN`: Slack bot OAuth access token
#### Telegram
- `TELEGRAM_TOKEN`: Telegram bot token
## Contributing
To run the project locally you will need [poetry](https://python-poetry.org/).
```
```bash
git clone git@github.com:fmartingr/butterrobot.git
cd butterrobot
poetry install
go mod download
```
Create a `.env-local` file with the required environment variables, you have [an example file](.env-example).
Create a `.env-local` file with the required environment variables:
```
SLACK_TOKEN=xxx
@ -55,8 +84,12 @@ TELEGRAM_TOKEN=xxx
...
```
And then you can run it directly with poetry
And then you can run it directly:
```bash
go run ./cmd/butterrobot/main.go
```
docker run -it --rm --env-file .env-local -p 5000:5000 -v $PWD/butterrobot:/etc/app/butterrobot local/butterrobot python -m butterrobot
```
## License
GPL-2.0

View file

@ -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")

View file

@ -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"))

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -1,5 +0,0 @@
{% extends "_base.j2" %}
{% block content %}
{% endblock %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 {}

View file

@ -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")

View file

@ -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]

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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={},
)

View file

@ -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

View file

@ -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"],
)

View file

@ -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

View file

@ -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()

View file

@ -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)",
)

View file

@ -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
View 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)
}
}

View file

@ -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"]

View file

@ -1,3 +0,0 @@
#!/bin/sh -xe
waitress-serve --port=${APP_PORT} 'butterrobot.app:app'

View file

@ -1,11 +1,12 @@
## Contributing
To run the project locally you will need [poetry](https://python-poetry.org/).
To run the project locally you will need Go 1.19 or higher.
```
```bash
git clone git@github.com:fmartingr/butterrobot.git
cd butterrobot
make setup
make build
```
Create a `.env-local` file with the required environment variables, you have [an example file](.env-example).
@ -13,11 +14,16 @@ Create a `.env-local` file with the required environment variables, you have [an
```
SLACK_TOKEN=xxx
TELEGRAM_TOKEN=xxx
HOSTNAME=myhostname.com
...
```
And then you can run it directly with poetry:
And then you can run it directly:
```
poetry run python -m butterrobot
```bash
# Run directly with Go
go run ./cmd/butterrobot/main.go
# Or run the built binary
./bin/butterrobot
```

View file

@ -1,37 +1,163 @@
# Creating a Plugin
## Example
## Plugin Categories
This simple "Marco Polo" plugin will answer _Polo_ to the user that say _Marco_:
ButterRobot organizes plugins into different categories:
``` python
# mypackage/plugins.py
from butterrobot.plugins import Plugin
from butterrobot.objects import Message
- **Development**: Utility plugins like `ping`
- **Fun**: Entertainment plugins like dice rolling, coin flipping
- **Social**: Social media related plugins like URL transformers/expanders
When creating a new plugin, consider which category it fits into and place it in the appropriate directory.
class PingPlugin(Plugin):
name = "Marco/Polo"
id = "test.marco"
## Plugin Examples
@classmethod
def on_message(cls, message, **kwargs):
if message.text == "Marco":
yield Message(
chat=message.chat, reply_to=message.id, text=f"polo",
)
```
### Basic Example: Marco Polo
``` python
# setup.py
# ...
entrypoints = {
"test.marco" = "mypackage.plugins:MarcoPlugin"
This simple "Marco Polo" plugin will answer _Polo_ to the user that says _Marco_:
```go
package myplugin
import (
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// MarcoPlugin is a simple Marco/Polo plugin
type MarcoPlugin struct {
plugin.BasePlugin
}
setup(
# ...
entry_points=entrypoints,
# ...
)
// New creates a new MarcoPlugin instance
func New() *MarcoPlugin {
return &MarcoPlugin{
BasePlugin: plugin.BasePlugin{
ID: "test.marco",
Name: "Marco/Polo",
Help: "Responds to 'Marco' with 'Polo'",
},
}
}
// OnMessage handles incoming messages
func (p *MarcoPlugin) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
if !strings.EqualFold(strings.TrimSpace(msg.Text), "Marco") {
return nil
}
response := &model.Message{
Text: "Polo",
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
return []*model.Message{response}
}
```
### Advanced Example: URL Transformer
This more complex plugin transforms URLs, useful for improving media embedding in chat platforms:
```go
package social
import (
"net/url"
"regexp"
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
)
// TwitterExpander transforms twitter.com links to fxtwitter.com links
type TwitterExpander struct {
plugin.BasePlugin
}
// New creates a new TwitterExpander instance
func NewTwitter() *TwitterExpander {
return &TwitterExpander{
BasePlugin: plugin.BasePlugin{
ID: "social.twitter",
Name: "Twitter Link Expander",
Help: "Automatically converts twitter.com links to fxtwitter.com links and removes tracking parameters",
},
}
}
// OnMessage handles incoming messages
func (p *TwitterExpander) OnMessage(msg *model.Message, config map[string]interface{}) []*model.Message {
// Skip empty messages
if strings.TrimSpace(msg.Text) == "" {
return nil
}
// Regex to match twitter.com links
twitterRegex := regexp.MustCompile(`https?://(www\.)?(twitter\.com|x\.com)/[^\s]+`)
// Check if the message contains a Twitter link
if !twitterRegex.MatchString(msg.Text) {
return nil
}
// Transform the URL
transformed := twitterRegex.ReplaceAllStringFunc(msg.Text, func(link string) string {
// Parse the URL
parsedURL, err := url.Parse(link)
if err != nil {
// If parsing fails, just do the simple replacement
link = strings.Replace(link, "twitter.com", "fxtwitter.com", 1)
link = strings.Replace(link, "x.com", "fxtwitter.com", 1)
return link
}
// Change the host
if strings.Contains(parsedURL.Host, "twitter.com") {
parsedURL.Host = strings.Replace(parsedURL.Host, "twitter.com", "fxtwitter.com", 1)
} else if strings.Contains(parsedURL.Host, "x.com") {
parsedURL.Host = strings.Replace(parsedURL.Host, "x.com", "fxtwitter.com", 1)
}
// Remove query parameters
parsedURL.RawQuery = ""
// Return the cleaned URL
return parsedURL.String()
})
// Create response message
response := &model.Message{
Text: transformed,
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
return []*model.Message{response}
}
```
## Registering Plugins
To use the plugin, register it in your application:
```go
// In app.go or similar initialization file
func (a *App) Run() error {
// ...
// Register plugins
plugin.Register(ping.New()) // Development plugin
plugin.Register(fun.NewCoin()) // Fun plugin
plugin.Register(social.NewTwitter()) // Social media plugin
plugin.Register(myplugin.New()) // Your custom plugin
// ...
}
```

99
docs/migrations.md Normal file
View 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
}
```

View file

@ -2,10 +2,19 @@
### Development
- `!ping`: Say `!ping` to get response with time elapsed.
- `ping`: Say `ping` to get response with time elapsed.
### Fun and entertainment
- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun)
- Dice: Put `!dice` and wathever roll you want to perform.
- Coin: Flip a coin and get heads or tails.
### Utility
- Remind Me: Reply to a message with `!remindme <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.
### 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
View 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
View 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=

710
internal/admin/admin.go Normal file
View file

@ -0,0 +1,710 @@
package admin
import (
"embed"
"encoding/gob"
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"git.nakama.town/fmartingr/butterrobot/internal/config"
"git.nakama.town/fmartingr/butterrobot/internal/db"
"git.nakama.town/fmartingr/butterrobot/internal/model"
"git.nakama.town/fmartingr/butterrobot/internal/plugin"
"github.com/gorilla/sessions"
)
//go:embed templates/*.html
var templateFS embed.FS
const (
// Session store key
sessionKey = "butterrobot-session"
)
// FlashMessage represents a flash message
type FlashMessage struct {
Category string
Message string
}
func init() {
// Register the FlashMessage type with gob package for session serialization
gob.Register(FlashMessage{})
}
// TemplateData holds data for rendering templates
type TemplateData struct {
User *model.User
LoggedIn bool
Title string
Path string
Flash []FlashMessage
Plugins map[string]model.Plugin
Channels []*model.Channel
Channel *model.Channel
ChannelPlugin *model.ChannelPlugin
Version string
}
// Admin represents the admin interface
type Admin struct {
config *config.Config
db *db.Database
store *sessions.CookieStore
templates map[string]*template.Template
baseTemplate *template.Template
version string
}
// New creates a new Admin instance
func New(cfg *config.Config, database *db.Database, version string) *Admin {
// Create session store with appropriate options
store := sessions.NewCookieStore([]byte(cfg.SecretKey))
store.Options = &sessions.Options{
Path: "/admin",
MaxAge: 3600 * 24 * 7, // 1 week
HttpOnly: true,
}
// Load templates
templates := make(map[string]*template.Template)
// Create a template function map with helper functions
funcMap := template.FuncMap{
"contains": strings.Contains,
}
// Read base template from embedded filesystem
baseContent, err := templateFS.ReadFile("templates/_base.html")
if err != nil {
panic(err)
}
// Create a custom template with functions
baseTemplate, err := template.New("_base.html").Funcs(funcMap).Parse(string(baseContent))
if err != nil {
panic(err)
}
// Parse and register all templates
templateFiles := []string{
"index.html",
"login.html",
"change_password.html",
"channel_list.html",
"channel_detail.html",
"plugin_list.html",
"channel_plugins_list.html",
}
for _, tf := range templateFiles {
// Read template content from embedded filesystem
content, err := templateFS.ReadFile("templates/" + tf)
if err != nil {
panic(err)
}
// Create a clone of the base template
t, err := baseTemplate.Clone()
if err != nil {
panic(err)
}
// Parse the template content
t, err = t.Parse(string(content))
if err != nil {
panic(err)
}
templates[tf] = t
}
return &Admin{
config: cfg,
db: database,
store: store,
templates: templates,
baseTemplate: baseTemplate,
version: version,
}
}
// RegisterRoutes registers admin routes on the given router
func (a *Admin) RegisterRoutes(mux *http.ServeMux) {
// Register admin routes
mux.HandleFunc("/admin/", a.handleIndex)
mux.HandleFunc("/admin/login", a.handleLogin)
mux.HandleFunc("/admin/logout", a.handleLogout)
mux.HandleFunc("/admin/change-password", a.handleChangePassword)
mux.HandleFunc("/admin/plugins", a.handlePluginList)
mux.HandleFunc("/admin/channels", a.handleChannelList)
mux.HandleFunc("/admin/channels/", a.handleChannelDetail)
mux.HandleFunc("/admin/channelplugins", a.handleChannelPluginList)
mux.HandleFunc("/admin/channelplugins/", a.handleChannelPluginDetailOrDelete)
}
// getCurrentUser gets the current user from the session
func (a *Admin) getCurrentUser(r *http.Request) *model.User {
session, err := a.store.Get(r, sessionKey)
if err != nil {
fmt.Printf("Error getting session for user retrieval: %v\n", err)
return nil
}
// Check if user is logged in
userID, ok := session.Values["user_id"].(int64)
if !ok {
return nil
}
// Get user from database
user, err := a.db.GetUserByID(userID)
if err != nil {
fmt.Printf("Error retrieving user from database: %v\n", err)
return nil
}
return user
}
// isLoggedIn checks if the user is logged in
func (a *Admin) isLoggedIn(r *http.Request) bool {
session, err := a.store.Get(r, sessionKey)
if err != nil {
fmt.Printf("Error getting session for login check: %v\n", err)
return false
}
return session.Values["logged_in"] == true
}
// addFlash adds a flash message to the session
func (a *Admin) addFlash(w http.ResponseWriter, r *http.Request, message string, category string) {
session, err := a.store.Get(r, sessionKey)
if err != nil {
// If there's an error getting the session, create a new one
session = sessions.NewSession(a.store, sessionKey)
session.Options = &sessions.Options{
Path: "/admin",
MaxAge: 3600 * 24 * 7, // 1 week
HttpOnly: true,
}
}
// Map internal categories to Bootstrap alert classes
var alertClass string
switch category {
case "success":
alertClass = "success"
case "danger":
alertClass = "danger"
case "warning":
alertClass = "warning"
case "info":
alertClass = "info"
default:
alertClass = "info"
}
flash := FlashMessage{
Category: alertClass,
Message: message,
}
session.AddFlash(flash)
err = session.Save(r, w)
if err != nil {
// Log the error or handle it appropriately
fmt.Printf("Error saving session: %v\n", err)
}
}
// getFlashes gets all flash messages from the session
func (a *Admin) getFlashes(w http.ResponseWriter, r *http.Request) []FlashMessage {
session, err := a.store.Get(r, sessionKey)
if err != nil {
// If there's an error getting the session, return an empty slice
fmt.Printf("Error getting session for flashes: %v\n", err)
return []FlashMessage{}
}
// Get flash messages
flashes := session.Flashes()
messages := make([]FlashMessage, 0, len(flashes))
for _, f := range flashes {
if flash, ok := f.(FlashMessage); ok {
messages = append(messages, flash)
}
}
// Save session to clear flashes
err = session.Save(r, w)
if err != nil {
fmt.Printf("Error saving session after getting flashes: %v\n", err)
}
return messages
}
// render renders a template with the given data
func (a *Admin) render(w http.ResponseWriter, r *http.Request, templateName string, data TemplateData) {
// Add current user data
data.User = a.getCurrentUser(r)
data.LoggedIn = a.isLoggedIn(r)
data.Path = r.URL.Path
data.Flash = a.getFlashes(w, r)
data.Version = a.version
// Get template
tmpl, ok := a.templates[templateName]
if !ok {
http.Error(w, "Template not found", http.StatusInternalServerError)
return
}
// Render template
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// handleIndex handles the admin index route
func (a *Admin) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/admin/" {
http.NotFound(w, r)
return
}
// Redirect to login if not logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Redirect to channel list
http.Redirect(w, r, "/admin/channels", http.StatusSeeOther)
}
// handleLogin handles the login route
func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
// If already logged in, redirect to index
if a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// Handle login form submission
if r.Method == http.MethodPost {
// Parse form
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Check credentials
username := r.FormValue("username")
password := r.FormValue("password")
user, err := a.db.CheckCredentials(username, password)
if err != nil || user == nil {
a.addFlash(w, r, "Incorrect credentials", "danger")
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Set session
session, _ := a.store.Get(r, sessionKey)
session.Values["logged_in"] = true
session.Values["user_id"] = user.ID
// Set session expiration
session.Options.MaxAge = 3600 * 24 * 7 // 1 week
err = session.Save(r, w)
if err != nil {
fmt.Printf("Error saving session: %v\n", err)
}
a.addFlash(w, r, "You were logged in", "success")
// Redirect to index
next := r.URL.Query().Get("next")
if next == "" {
next = "/admin/"
}
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
// Render login template
a.render(w, r, "login.html", TemplateData{
Title: "Login",
})
}
// handleLogout handles the logout route
func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
// Clear session
session, err := a.store.Get(r, sessionKey)
if err != nil {
fmt.Printf("Error getting session for logout: %v\n", err)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
session.Values = make(map[interface{}]interface{})
session.Options.MaxAge = -1 // Delete session
err = session.Save(r, w)
if err != nil {
fmt.Printf("Error saving session for logout: %v\n", err)
}
a.addFlash(w, r, "You were logged out", "success")
// Redirect to login
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
// handleChangePassword handles the change password route
func (a *Admin) handleChangePassword(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Get current user
user := a.getCurrentUser(r)
if user == nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Handle form submission
if r.Method == http.MethodPost {
// Parse form
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Get form values
currentPassword := r.FormValue("current_password")
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
// Validate current password
_, err := a.db.CheckCredentials(user.Username, currentPassword)
if err != nil {
a.addFlash(w, r, "Current password is incorrect", "danger")
http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther)
return
}
// Validate new password and confirmation
if newPassword == "" {
a.addFlash(w, r, "New password cannot be empty", "danger")
http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther)
return
}
if newPassword != confirmPassword {
a.addFlash(w, r, "New passwords do not match", "danger")
http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther)
return
}
// Update password
if err := a.db.UpdateUserPassword(user.ID, newPassword); err != nil {
a.addFlash(w, r, "Failed to update password: "+err.Error(), "danger")
http.Redirect(w, r, "/admin/change-password", http.StatusSeeOther)
return
}
// Success
a.addFlash(w, r, "Password changed successfully", "success")
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// Render change password template
a.render(w, r, "change_password.html", TemplateData{
Title: "Change Password",
})
}
// handlePluginList handles the plugin list route
func (a *Admin) handlePluginList(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Get available plugins
plugins := plugin.GetAvailablePlugins()
// Render template
a.render(w, r, "plugin_list.html", TemplateData{
Title: "Plugins",
Plugins: plugins,
})
}
// handleChannelList handles the channel list route
func (a *Admin) handleChannelList(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Get all channels
channels, err := a.db.GetAllChannels()
if err != nil {
http.Error(w, "Failed to get channels", http.StatusInternalServerError)
return
}
// Render template
a.render(w, r, "channel_list.html", TemplateData{
Title: "Channels",
Channels: channels,
})
}
// handleChannelDetail handles the channel detail route
func (a *Admin) handleChannelDetail(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Extract channel ID from path
path := r.URL.Path
if path == "/admin/channels/" {
http.Redirect(w, r, "/admin/channels", http.StatusSeeOther)
return
}
channelID := strings.TrimPrefix(path, "/admin/channels/")
if strings.Contains(channelID, "/") {
// Handle delete request
if strings.HasSuffix(path, "/delete") && r.Method == http.MethodPost {
channelID = strings.TrimSuffix(channelID, "/delete")
// Delete channel
id, err := strconv.ParseInt(channelID, 10, 64)
if err != nil {
http.Error(w, "Invalid channel ID", http.StatusBadRequest)
return
}
if err := a.db.DeleteChannel(id); err != nil {
http.Error(w, "Failed to delete channel", http.StatusInternalServerError)
return
}
a.addFlash(w, r, "Channel removed", "success")
http.Redirect(w, r, "/admin/channels", http.StatusSeeOther)
return
}
http.NotFound(w, r)
return
}
// Convert channel ID to int64
id, err := strconv.ParseInt(channelID, 10, 64)
if err != nil {
http.Error(w, "Invalid channel ID", http.StatusBadRequest)
return
}
// Handle form submission
if r.Method == http.MethodPost {
// Parse form
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Check if the form was submitted
if r.FormValue("form_submitted") == "true" {
// Update channel
enabled := r.FormValue("enabled") == "true"
if err := a.db.UpdateChannel(id, enabled); err != nil {
http.Error(w, "Failed to update channel", http.StatusInternalServerError)
return
}
a.addFlash(w, r, "Channel updated", "success")
http.Redirect(w, r, "/admin/channels/"+channelID, http.StatusSeeOther)
return
}
}
// Get channel
channel, err := a.db.GetChannelByID(id)
if err != nil {
http.Error(w, "Channel not found", http.StatusNotFound)
return
}
// Get available plugins
plugins := plugin.GetAvailablePlugins()
// Render template
a.render(w, r, "channel_detail.html", TemplateData{
Title: "Channel: " + channel.PlatformChannelID,
Channel: channel,
Plugins: plugins,
})
}
// handleChannelPluginList handles the channel plugin list route
func (a *Admin) handleChannelPluginList(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Handle form submission
if r.Method == http.MethodPost {
// Parse form
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Extract form data
channelID, err := strconv.ParseInt(r.FormValue("channel_id"), 10, 64)
if err != nil {
http.Error(w, "Invalid channel ID", http.StatusBadRequest)
return
}
pluginID := r.FormValue("plugin_id")
enabled := r.FormValue("enabled") == "y"
// Create channel plugin
config := make(map[string]interface{})
_, err = a.db.CreateChannelPlugin(channelID, pluginID, enabled, config)
if err == db.ErrDuplicated {
a.addFlash(w, r, "Plugin "+pluginID+" is already present on the channel", "danger")
} else if err != nil {
http.Error(w, "Failed to create channel plugin", http.StatusInternalServerError)
return
} else {
a.addFlash(w, r, "Plugin "+pluginID+" added to the channel", "success")
}
// Redirect back
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/admin/channelplugins"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
return
}
// Get all channels
channels, err := a.db.GetAllChannels()
if err != nil {
http.Error(w, "Failed to get channels", http.StatusInternalServerError)
return
}
// Render template
a.render(w, r, "channel_plugins_list.html", TemplateData{
Title: "Channel Plugins",
Channels: channels,
Plugins: plugin.GetAvailablePlugins(),
})
}
// handleChannelPluginDetailOrDelete handles the channel plugin detail or delete route
func (a *Admin) handleChannelPluginDetailOrDelete(w http.ResponseWriter, r *http.Request) {
// Check if user is logged in
if !a.isLoggedIn(r) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Extract channel plugin ID from path
path := r.URL.Path
if path == "/admin/channelplugins/" {
http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther)
return
}
channelPluginID := strings.TrimPrefix(path, "/admin/channelplugins/")
// Handle delete request
if strings.HasSuffix(channelPluginID, "/delete") && r.Method == http.MethodPost {
channelPluginID = strings.TrimSuffix(channelPluginID, "/delete")
// Delete channel plugin
id, err := strconv.ParseInt(channelPluginID, 10, 64)
if err != nil {
http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest)
return
}
if err := a.db.DeleteChannelPlugin(id); err != nil {
http.Error(w, "Failed to delete channel plugin", http.StatusInternalServerError)
return
}
a.addFlash(w, r, "Plugin removed", "success")
// Redirect back
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/admin/channelplugins"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
return
}
// Handle update request
if r.Method == http.MethodPost {
// Parse form
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Convert channel plugin ID to int64
id, err := strconv.ParseInt(channelPluginID, 10, 64)
if err != nil {
http.Error(w, "Invalid channel plugin ID", http.StatusBadRequest)
return
}
// Update channel plugin
enabled := r.FormValue("enabled") == "true"
if err := a.db.UpdateChannelPlugin(id, enabled); err != nil {
http.Error(w, "Failed to update channel plugin", http.StatusInternalServerError)
return
}
a.addFlash(w, r, "Plugin updated", "success")
// Redirect back
referer := r.Header.Get("Referer")
if referer == "" {
referer = "/admin/channelplugins"
}
http.Redirect(w, r, referer, http.StatusSeeOther)
return
}
// Redirect to channel plugins list
http.Redirect(w, r, "/admin/channelplugins", http.StatusSeeOther)
}

View file

@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<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">
</head>
@ -24,27 +24,29 @@
</h1>
<div class="navbar-nav flex-row order-md-last">
<div class="nav-item">
{% if not session.logged_in %}
<a href="{{ url_for('admin.login_view') }}">Log in</a>
{% else %}
{{if not .LoggedIn}}
<a href="/admin/login">Log in</a>
{{else}}
<div class="d-none d-xl-block pl-2">
<div>{{ g.user.username }} - <a class="mt-1 small"
href="{{ url_for('admin.logout_view') }}">Log out</a></div>
<div>{{.User.Username}} -
<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>
</a>
{% endif %}
{{end}}
</div>
</div>
</div>
</header>
{% if session.logged_in %}
{{if .LoggedIn}}
<div class="navbar-expand-md">
<div class="collapse navbar-collapse" id="navbar-menu">
<div class="navbar navbar-light">
<div class="container-xl">
<ul class="navbar-nav">
<li class="nav-item {% if '/channels' in request.url %}active{% endif %}">
<a class="nav-link" href="{{ url_for('admin.channel_list_view') }}">
<li class="nav-item {{if contains .Path "/channels"}}active{{end}}">
<a class="nav-link" href="/admin/channels">
<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"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
@ -60,8 +62,8 @@
</span>
</a>
</li>
<li class="nav-item {% if '/plugins' in request.url %}active{% endif %}">
<a class="nav-link" href="{{ url_for('admin.plugin_list_view') }}">
<li class="nav-item {{if contains .Path "/plugins"}}active{{end}}">
<a class="nav-link" href="/admin/plugins">
<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"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
@ -76,8 +78,8 @@
</span>
</a>
</li>
<li class="nav-item {% if '/channelplugins' in request.url %}active{% endif %}">
<a class="nav-link" href="{{ url_for('admin.channel_plugin_list_view') }}">
<li class="nav-item {{if contains .Path "/channelplugins"}}active{{end}}">
<a class="nav-link" href="/admin/channelplugins">
<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"
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
@ -97,26 +99,40 @@
</div>
</div>
</div>
{% endif %}
{{end}}
</div>
{% for category, message in get_flashed_messages(with_categories=True) %}
<div class="card">
<div class="card-status-top bg-{{ category }}"></div>
<div class="card-body">
<p>{{ message }}</p>
<div class="container-xl mt-3">
{{range .Flash}}
<div class="alert alert-{{.Category}} alert-dismissible" role="alert">
{{.Message}}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{{end}}
</div>
{% endfor %}
<div class="content">
<div class="container-xl">
{% block content %}
{% endblock %}
{{template "content" .}}
</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>
<script src="https://unpkg.com/@tabler/core@latest/dist/js/tabler.min.js"></script>
</body>
</html>
</html>

View 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}}

View file

@ -0,0 +1,114 @@
{{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>
<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}}

View 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}}

View file

@ -0,0 +1,93 @@
{{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>
<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}}

View 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}}

View 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}}

View 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}}

393
internal/app/app.go Normal file
View file

@ -0,0 +1,393 @@
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/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())
// Register reminder plugin
reminderPlugin := reminder.New(a.db)
plugin.Register(reminderPlugin)
// 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
responses := p.OnMessage(message, channelPlugin.Config)
// Send responses
platform, err := platform.Get(item.Platform)
if err != nil {
a.logger.Error("Error getting platform", "error", err)
continue
}
for _, response := range responses {
if err := platform.SendMessage(response); err != nil {
a.logger.Error("Error sending message", "error", err)
}
}
}
}
// 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
View 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
}

777
internal/db/db.go Normal file
View file

@ -0,0 +1,777 @@
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
}
// 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
}

View 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
}

View 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
}

101
internal/model/message.go Normal file
View file

@ -0,0 +1,101 @@
package model
import (
"time"
)
// 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]interface{}
}
// 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
}

View file

@ -0,0 +1,46 @@
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
}

28
internal/model/plugin.go Normal file
View 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 response messages
OnMessage(msg *Message, config map[string]interface{}) []*Message
}

32
internal/platform/init.go Normal file
View 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
}

View 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
}

View file

@ -0,0 +1,220 @@
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")
}
// 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
}
// Helper function to parse int64
func parseInt64(s string) (int64, error) {
var n int64
_, err := fmt.Sscanf(s, "%d", &n)
return n, err
}

View file

@ -0,0 +1,278 @@
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 {
// 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
}

View file

@ -0,0 +1,50 @@
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.Message {
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,
}
return []*model.Message{response}
}

119
internal/plugin/fun/dice.go Normal file
View file

@ -0,0 +1,119 @@
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.Message {
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,
}
return []*model.Message{response}
}
// 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
}

View file

@ -0,0 +1,40 @@
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.Message {
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,
}
return []*model.Message{response}
}

View file

@ -0,0 +1,40 @@
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.Message {
if !strings.EqualFold(strings.TrimSpace(msg.Text), "ping") {
return nil
}
response := &model.Message{
Text: "pong",
Chat: msg.Chat,
ReplyTo: msg.ID,
Channel: msg.Channel,
}
return []*model.Message{response}
}

82
internal/plugin/plugin.go Normal file
View file

@ -0,0 +1,82 @@
package plugin
import (
"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))
for id, plugin := range plugins {
result[id] = plugin
}
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.Message {
return nil
}

View file

@ -0,0 +1,171 @@
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.Message {
// 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 {
return []*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,
},
}
}
// 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:
return []*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,
},
}
}
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 {
return []*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,
},
}
}
// 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)
}
return []*model.Message{
{
Text: confirmText,
Chat: msg.Chat,
Channel: msg.Channel,
Author: "bot",
FromBot: true,
Date: time.Now(),
ReplyTo: msg.ID,
},
}
}

View file

@ -0,0 +1,164 @@
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)
responses := plugin.OnMessage(tt.message, nil)
if tt.expectResponse && len(responses) == 0 {
t.Errorf("Expected response, but got none")
}
if !tt.expectResponse && len(responses) > 0 {
t.Errorf("Expected no response, but got %d", len(responses))
}
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)
}
})
}
}

View file

@ -0,0 +1,74 @@
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.Message {
// 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,
}
return []*model.Message{response}
}

View file

@ -0,0 +1,79 @@
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.Message {
// 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,
}
return []*model.Message{response}
}

161
internal/queue/queue.go Normal file
View 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
}
}
}

1310
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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"
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"

View file

@ -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

View file

@ -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")

View file

@ -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")