Compare commits

...
Sign in to create a new pull request.

44 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
57b413dd1b
Added admin interface to manage channels and enabled plugins (#9)
* Added base admin login/logout flows

* Ignore local database

* Channel model

* Admin interface for channels and plugins

* Added database tests along with workflows

* Added some docstrings

* Ignore .coverage file

* Creating plugins docs WIP

* Documentation

* Black everything

* Some documentation

* Coverage for the plugins package as well

* DB Fixes

* Absolute FROM in Dockerfile

* Database and logging fixes

* Slack: Support private channels

* Added pre-commit

* black'd

* Fixed UserQuery.create

* Fixed ChannelPluginQuery.create exists call

* Added ChannelPlugin menu for debugging

* Ignore sqlite databases

* Updated contributing docs
2022-02-05 13:00:20 +01:00
456d144a7d Ignore tls-verification for internal registry 2020-11-04 13:25:39 +01:00
92f4696a15 contrib.fun.coin 2020-11-04 13:25:31 +01:00
490b07d5b4 Using waitress to serve wsgi 2020-11-04 13:14:14 +01:00
6d3ad14298 Removed async code 2020-10-28 11:49:40 +01:00
62fb0ec8d4 Ignore codespaces python env 2020-09-25 21:37:56 +00:00
903076de54
Added !dice command 2020-09-17 20:16:42 +02:00
9921d067ff
Typo 2020-09-17 20:16:29 +02:00
3d2b05db0f
Typo 2020-09-17 20:16:21 +02:00
d2857dc412
Updated Makefile to use podman 2020-09-17 16:12:56 +02:00
5df23c2f5a
Updated dependencies 2020-09-17 16:09:38 +02:00
8bf77f91f1
return -> yield 2020-09-17 16:09:31 +02:00
2e7326cef7
Slack platform now properly ignores bot messages 2020-09-17 16:09:21 +02:00
31df433420
Added a debug platform for debugging 2020-09-17 16:09:02 +02:00
bd3d948e8c
Using asyncio to handle messages as futures 2020-09-17 16:08:42 +02:00
08437e7a1c
Delete .drone.yml file 2020-08-18 12:57:07 +02:00
2546eb6f37
.drone.yml build dev instead of stable 2020-08-17 12:48:45 +02:00
859d5e7c62
Add context path to drone.yml 2020-08-17 12:29:25 +02:00
e1f5256641
Added .drone.yml file 2020-08-17 12:15:24 +02:00
cd8e552191
Allow empty author when sending messages 2020-08-11 13:52:04 +02:00
1ae02d2973
Ignore messages from bots 2020-08-11 13:37:17 +02:00
562d7138c0
Added author information to message 2020-08-11 13:29:24 +02:00
e1c158bd6c
message -> out_message 2020-08-11 13:16:34 +02:00
976c6a3ea3
Typo 2020-08-11 13:06:51 +02:00
c99aeba708
Bugfixes 2020-08-11 12:57:56 +02:00
1a77740845
Typo: platformInitError 2020-08-07 14:50:34 +02:00
702347708f
0.0.2a3 (#3) 2020-07-21 17:26:41 +02:00
73 changed files with 5353 additions and 1647 deletions

View file

@ -1,4 +1,4 @@
# For information about this variables check config.py
# For information about this variables check butterrobot/config.py
SLACK_TOKEN=xxx
TELEGRAM_TOKEN=xxx

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

9
.gitignore vendored
View file

@ -4,9 +4,10 @@ __pycache__
*~
*.cert
.env-local
test.py
.coverage
# Distribution
dist
*.egg-info
pip-wheel-metadata
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

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,20 +0,0 @@
FROM alpine:3.11
ENV PYTHON_VERSION=3.8.2-r1
ENV APP_PORT 8080
ENV BUILD_DIR /tmp/build
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 ${BUILD_DIR}/butterrobot
COPY ./butterrobot_plugins_contrib ${BUILD_DIR}/butterrobot_plugins_contrib
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}
COPY ./docker/bin/start-server.sh /usr/local/bin/start-server
CMD ["/usr/local/bin/start-server"]

114
Makefile
View file

@ -1,28 +1,100 @@
# Local development
setup:
poetry install
PROJECT_NAME := butterrobot
docker@build:
docker build -t fmartingr/butterrobot -f docker/Dockerfile docker
SOURCE_FILES ?=./...
docker@build-dev:
docker build -t fmartingr/butterrobot:dev -f Dockerfile.dev .
TEST_OPTIONS ?= -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out
TEST_TIMEOUT ?=1m
docker@tag-dev:
docker tag fmartingr/butterrobot:dev registry.int.fmartingr.network/fmartingr/butterrobot:dev
GOLANGCI_LINT_VERSION ?= v1.64.5
docker@push-dev:
docker push registry.int.fmartingr.network/fmartingr/butterrobot:dev
CLEAN_OPTIONS ?=-modcache -testcache
docker@dev:
make docker@build-dev
make docker@tag-dev
make docker@push-dev
CGO_ENABLED := 0
docker@save:
make docker@build
docker image save fmartingr/butterrobot -o fmartingr-butterrobot-docker-image.tar
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,71 +1,82 @@
# Butter Robot
![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)
![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?
## Supported platforms
## Features
| Name | Receive messages | Send messages |
| --------------- | ---------------- | ------------- |
| Slack (app) | Yes | Yes |
| Telegram | Yes | Yes |
- 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
## Provided plugins
## Documentation
[Go to documentation](./docs)
### Development
### Database Management
#### Ping
ButterRobot includes an automatic database migration system. Migrations are applied automatically when the application starts, ensuring your database schema is always up to date.
Say `!ping` to get response with time elapsed.
### Fun and entertainment
#### Loquito
What happens when you say _"lo quito"_...? (Spanish pun)
[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
@ -73,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,60 +0,0 @@
import asyncio
import traceback
import urllib.parse
from quart import Quart, request
import structlog
import butterrobot.logging
from butterrobot.config import SLACK_TOKEN, LOG_LEVEL, ENABLED_PLUGINS
from butterrobot.plugins import get_available_plugins
from butterrobot.platforms import PLATFORMS
from butterrobot.platforms.base import Platform
logger = structlog.get_logger(__name__)
app = Quart(__name__)
available_platforms = {}
plugins = get_available_plugins()
enabled_plugins = [plugin for plugin_name, plugin in plugins.items() if plugin_name in ENABLED_PLUGINS]
@app.before_serving
async def init_platforms():
for platform in PLATFORMS.values():
logger.debug("Setting up", platform=platform.ID)
try:
await platform.init(app=app)
available_platforms[platform.ID] = platform
logger.info("platform setup completed", platform=platform.ID)
except platform.platformInitError as error:
logger.error(f"platform init error", error=error, platform=platform.ID)
@app.route("/<platform>/incoming", methods=["POST"])
@app.route("/<platform>/incoming/<path:path>", methods=["POST"])
async def incoming_platform_message_view(platform, path=None):
if platform not in available_platforms:
return {"error": "Unknown platform"}, 400
try:
message = await 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(f"Error parsing message", platform=platform, error=error, traceback=traceback.format_exc())
return {"error": str(error)}, 400
if not message:
return {}
for plugin in enabled_plugins:
if result := await plugin.on_message(message):
await available_platforms[platform].methods.send_message(result)
return {}
@app.route("/healthz")
def healthz():
return {}

View file

@ -1,27 +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")
ENABLED_PLUGINS = os.environ.get("ENABLED_PLUGINS", "contrib/dev/ping").split(",")
# --- 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,39 +0,0 @@
from typing import Optional, Text
import aiohttp
import structlog
from butterrobot.config import SLACK_BOT_OAUTH_ACCESS_TOKEN
logger = structlog.get_logger()
class SlackAPI:
BASE_URL = "https://slack.com/api"
class SlackError(Exception):
pass
class SlackClientError(Exception):
pass
@classmethod
async def send_message(cls, channel, message, thread: Optional[Text] = None):
payload = {
"text": message,
"channel": channel,
}
if thread:
payload["thread_ts"] = thread
async with aiohttp.ClientSession() as session:
async with session.post(
f"{cls.BASE_URL}/chat.postMessage",
data=payload,
headers={"Authorization": f"Bearer {SLACK_BOT_OAUTH_ACCESS_TOKEN}"},
) as response:
response = await response.json()
if not response["ok"]:
raise cls.SlackClientError(response)

View file

@ -1,59 +0,0 @@
import aiohttp
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
async 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,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload) as response:
response = await response.json()
if not response["ok"]:
raise cls.TelegramClientError
@classmethod
async 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,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload) as response:
response = await response.json()
if not response["ok"]:
raise cls.TelegramClientError(response)

View file

@ -1,23 +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,13 +0,0 @@
from datetime import datetime
from dataclasses import dataclass, field
from typing import Text, Optional
@dataclass
class Message:
text: Text
chat: Text
date: Optional[datetime] = None
id: Optional[Text] = None
reply_to: Optional[Text] = None
raw: dict = field(default_factory=dict)

View file

@ -1,5 +0,0 @@
from butterrobot.platforms.slack import SlackPlatform
from butterrobot.platforms.telegram import TelegramPlatform
PLATFORMS = {platform.ID: platform for platform in (SlackPlatform, TelegramPlatform,)}

View file

@ -1,35 +0,0 @@
from abc import abstractclassmethod
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
async def init(cls, app):
pass
class PlatformMethods:
@abstractclassmethod
def send_message(cls, message):
pass
@abstractclassmethod
def reply_message(cls, message, reply_to):
pass

View file

@ -1,70 +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
from butterrobot.lib.slack import SlackAPI
logger = structlog.get_logger(__name__)
class SlackMethods(PlatformMethods):
@classmethod
async def send_message(self, message: Message):
logger.debug(
"Outgoing message", message=message.__dict__, platform=SlackPlatform.ID
)
try:
await 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
async 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
async def parse_incoming_message(cls, request):
data = await request.get_json()
logger.debug("Parsing message", platform=cls.ID, data=data)
# 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 bots
if "bot_id" in data["event"]:
return
if data["event"]["type"] != "message":
return
return Message(
id=data["event"].get("thread_ts", data["event"]["ts"]),
date=datetime.fromtimestamp(int(float(data["event"]["event_ts"]))),
text=data["event"]["text"],
chat=data["event"]["channel"],
raw=data,
)

View file

@ -1,66 +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
logger = structlog.get_logger(__name__)
class TelegramMethods(PlatformMethods):
@classmethod
async def send_message(self, message: Message):
logger.debug(
"Outgoing message", message=message.__dict__, platform=TelegramPlatform.ID
)
await 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
async 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:
await 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
async def parse_incoming_message(cls, request):
token = request.path.split("/")[-1]
if token != TELEGRAM_TOKEN:
raise cls.PlatformAuthError("Authentication error")
request_data = await request.get_json()
logger.debug("Parsing message", data=request_data, platform=cls.ID)
if "text" in request_data["message"]:
# Ignore all messages but text messages
return Message(
id=request_data["message"]["message_id"],
date=datetime.fromtimestamp(request_data["message"]["date"]),
text=str(request_data["message"]["text"]),
chat=str(request_data["message"]["chat"]["id"]),
raw=request_data,
)

View file

@ -1,37 +0,0 @@
import traceback
import pkg_resources
from abc import abstractclassmethod
import structlog
logger = structlog.get_logger(__name__)
class Plugin:
@abstractclassmethod
def on_message(cls, message):
pass
def get_available_plugins():
"""Retrieves every available plugin"""
plugins = {}
logger.debug("Loading 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,
)
logger.info(f"Plugins loaded", plugins=list(plugins.keys()))
return plugins

View file

@ -1,17 +0,0 @@
from datetime import datetime
from butterrobot.plugins import Plugin
from butterrobot.objects import Message
class PingPlugin(Plugin):
id = "contrib/dev/ping"
@classmethod
async def on_message(cls, message):
if message.text == "!ping":
delta = datetime.now() - message.date
delta_ms = delta.seconds * 1000 + delta.microseconds / 1000
return Message(
chat=message.chat, reply_to=message.id, text=f"pong! ({delta_ms}ms)",
)

View file

@ -1,11 +0,0 @@
from butterrobot.plugins import Plugin
from butterrobot.objects import Message
class LoquitoPlugin(Plugin):
id = "contrib/fun/loquito"
@classmethod
async def on_message(cls, message):
if "lo quito" in message.text.lower():
return Message(chat=message.chat, reply_to=message.id, text="Loquito tu.",)

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,14 +0,0 @@
FROM alpine:3.11
ENV PYTHON_VERSION=3.8.2-r1
ENV APP_PORT 8080
ENV BUTTERROBOT_VERSION 0.0.2a2
ENV EXTRA_DEPENDENCIES ""
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}
USER 1000
CMD ["/usr/local/bin/start-server"]

View file

@ -1,3 +0,0 @@
#!/bin/sh -xe
hypercorn butterrobot.app -b "0.0.0.0:${APP_PORT}"

8
docs/README.md Normal file
View file

@ -0,0 +1,8 @@
# Butterrobot Documentation
## Index
- [Contributing](./contributing.md)
- [Platforms](./platforms.md)
- Plugins
- [Creating a Plugin](./creating-a-plugin.md)
- [Provided plugins](./plugins.md)

29
docs/contributing.md Normal file
View file

@ -0,0 +1,29 @@
## Contributing
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).
```
SLACK_TOKEN=xxx
TELEGRAM_TOKEN=xxx
HOSTNAME=myhostname.com
...
```
And then you can run it directly:
```bash
# Run directly with Go
go run ./cmd/butterrobot/main.go
# Or run the built binary
./bin/butterrobot
```

163
docs/creating-a-plugin.md Normal file
View file

@ -0,0 +1,163 @@
# Creating a Plugin
## Plugin Categories
ButterRobot organizes plugins into different categories:
- **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.
## Plugin Examples
### Basic Example: Marco Polo
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
}
// 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
}
```

8
docs/platforms.md Normal file
View file

@ -0,0 +1,8 @@
## Supported platforms
TODO: Create better actions matrix
| Name | Receive messages | Send messages |
| --------------- | ---------------- | ------------- |
| Slack (app) | Yes | Yes |
| Telegram | Yes | Yes |

20
docs/plugins.md Normal file
View file

@ -0,0 +1,20 @@
## Provided plugins
### Development
- `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

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - ButterRobot Admin</title>
<link rel="stylesheet" href="https://unpkg.com/@tabler/core@latest/dist/css/tabler.min.css">
</head>
<body>
<div class="page">
<div class="sticky-top">
<header class="navbar navbar-expand-md navbar-light sticky-top d-print-none">
<div class="container-xl">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbar-menu">
<span class="navbar-toggler-icon"></span>
</button>
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pr-0 pr-md-3">
<a href="/admin/">
<h1>ButterRobot Admin</h1>
</a>
</h1>
<div class="navbar-nav flex-row order-md-last">
<div class="nav-item">
{{if not .LoggedIn}}
<a href="/admin/login">Log in</a>
{{else}}
<div class="d-none d-xl-block pl-2">
<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>
{{end}}
</div>
</div>
</div>
</header>
{{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 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"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="5" y1="9" x2="19" y2="9" />
<line x1="5" y1="15" x2="19" y2="15" />
<line x1="11" y1="4" x2="7" y2="20" />
<line x1="17" y1="4" x2="13" y2="20" /></svg>
</span>
<span class="nav-link-title">
Channels
</span>
</a>
</li>
<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"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1" />
</svg>
</span>
<span class="nav-link-title">
Plugins
</span>
</a>
</li>
<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"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M4 7h3a1 1 0 0 0 1 -1v-1a2 2 0 0 1 4 0v1a1 1 0 0 0 1 1h3a1 1 0 0 1 1 1v3a1 1 0 0 0 1 1h1a2 2 0 0 1 0 4h-1a1 1 0 0 0 -1 1v3a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-1a2 2 0 0 0 -4 0v1a1 1 0 0 1 -1 1h-3a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1h1a2 2 0 0 0 0 -4h-1a1 1 0 0 1 -1 -1v-3a1 1 0 0 1 1 -1" />
</svg>
</span>
<span class="nav-link-title">
Channel Plugins
</span>
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{{end}}
</div>
<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>
<div class="content">
<div class="container-xl">
{{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>

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

958
poetry.lock generated
View file

@ -1,958 +0,0 @@
[[package]]
category = "main"
description = "File support for asyncio."
name = "aiofiles"
optional = false
python-versions = "*"
version = "0.5.0"
[[package]]
category = "main"
description = "Async http client/server framework (asyncio)"
name = "aiohttp"
optional = false
python-versions = ">=3.5.3"
version = "3.6.2"
[package.dependencies]
async-timeout = ">=3.0,<4.0"
attrs = ">=17.3.0"
chardet = ">=2.0,<4.0"
multidict = ">=4.5,<5.0"
yarl = ">=1.0,<2.0"
[package.extras]
speedups = ["aiodns", "brotlipy", "cchardet"]
[[package]]
category = "dev"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
name = "appdirs"
optional = false
python-versions = "*"
version = "1.4.4"
[[package]]
category = "dev"
description = "Disable App Nap on OS X 10.9"
marker = "python_version >= \"3.4\" and sys_platform == \"darwin\""
name = "appnope"
optional = false
python-versions = "*"
version = "0.1.0"
[[package]]
category = "main"
description = "Timeout context manager for asyncio programs"
name = "async-timeout"
optional = false
python-versions = ">=3.5.3"
version = "3.0.1"
[[package]]
category = "main"
description = "Classes Without Boilerplate"
name = "attrs"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "19.3.0"
[package.extras]
azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
docs = ["sphinx", "zope.interface"]
tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
[[package]]
category = "dev"
description = "Specifications for callback functions passed in to an API"
marker = "python_version >= \"3.4\""
name = "backcall"
optional = false
python-versions = "*"
version = "0.2.0"
[[package]]
category = "dev"
description = "The uncompromising code formatter."
name = "black"
optional = false
python-versions = ">=3.6"
version = "19.10b0"
[package.dependencies]
appdirs = "*"
attrs = ">=18.1.0"
click = ">=6.5"
pathspec = ">=0.6,<1"
regex = "*"
toml = ">=0.9.4"
typed-ast = ">=1.4.0"
[package.extras]
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
[[package]]
category = "main"
description = "Fast, simple object-to-object and broadcast signaling"
name = "blinker"
optional = false
python-versions = "*"
version = "1.4"
[[package]]
category = "main"
description = "Universal encoding detector for Python 2 and 3"
name = "chardet"
optional = false
python-versions = "*"
version = "3.0.4"
[[package]]
category = "main"
description = "Composable command line interface toolkit"
name = "click"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "7.1.2"
[[package]]
category = "main"
description = "Cross-platform colored terminal text."
name = "colorama"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.4.3"
[[package]]
category = "dev"
description = "Decorators for Humans"
marker = "python_version >= \"3.4\""
name = "decorator"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*"
version = "4.4.2"
[[package]]
category = "dev"
description = "the modular source code checker: pep8 pyflakes and co"
name = "flake8"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
version = "3.8.3"
[package.dependencies]
mccabe = ">=0.6.0,<0.7.0"
pycodestyle = ">=2.6.0a1,<2.7.0"
pyflakes = ">=2.2.0,<2.3.0"
[package.dependencies.importlib-metadata]
python = "<3.8"
version = "*"
[[package]]
category = "main"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
name = "h11"
optional = false
python-versions = "*"
version = "0.9.0"
[[package]]
category = "main"
description = "HTTP/2 State-Machine based protocol implementation"
name = "h2"
optional = false
python-versions = "*"
version = "3.2.0"
[package.dependencies]
hpack = ">=3.0,<4"
hyperframe = ">=5.2.0,<6"
[[package]]
category = "main"
description = "Pure-Python HPACK header compression"
name = "hpack"
optional = false
python-versions = "*"
version = "3.0.0"
[[package]]
category = "main"
description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn."
name = "hypercorn"
optional = false
python-versions = ">=3.7"
version = "0.10.1"
[package.dependencies]
h11 = "*"
h2 = ">=3.1.0"
priority = "*"
toml = "*"
typing-extensions = "*"
wsproto = ">=0.14.0"
[package.extras]
h3 = ["aioquic (>=0.9.0,<1.0)"]
tests = ["hypothesis", "mock", "pytest", "pytest-asyncio", "pytest-cov", "pytest-trio", "trio"]
trio = ["trio (>=0.11.0)"]
uvloop = ["uvloop"]
[[package]]
category = "main"
description = "HTTP/2 framing layer for Python"
name = "hyperframe"
optional = false
python-versions = "*"
version = "5.2.0"
[[package]]
category = "main"
description = "Internationalized Domain Names in Applications (IDNA)"
name = "idna"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "2.10"
[[package]]
category = "dev"
description = "Read metadata from Python packages"
marker = "python_version < \"3.8\""
name = "importlib-metadata"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
version = "1.7.0"
[package.dependencies]
zipp = ">=0.5"
[package.extras]
docs = ["sphinx", "rst.linker"]
testing = ["packaging", "pep517", "importlib-resources (>=1.3)"]
[[package]]
category = "dev"
description = "IPython-enabled pdb"
name = "ipdb"
optional = false
python-versions = ">=2.7"
version = "0.13.3"
[package.dependencies]
setuptools = "*"
[package.dependencies.ipython]
python = ">=3.4"
version = ">=5.1.0"
[[package]]
category = "dev"
description = "IPython: Productive Interactive Computing"
marker = "python_version >= \"3.4\""
name = "ipython"
optional = false
python-versions = ">=3.6"
version = "7.16.1"
[package.dependencies]
appnope = "*"
backcall = "*"
colorama = "*"
decorator = "*"
jedi = ">=0.10"
pexpect = "*"
pickleshare = "*"
prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0"
pygments = "*"
setuptools = ">=18.5"
traitlets = ">=4.2"
[package.extras]
all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"]
doc = ["Sphinx (>=1.3)"]
kernel = ["ipykernel"]
nbconvert = ["nbconvert"]
nbformat = ["nbformat"]
notebook = ["notebook", "ipywidgets"]
parallel = ["ipyparallel"]
qtconsole = ["qtconsole"]
test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"]
[[package]]
category = "dev"
description = "Vestigial utilities from IPython"
marker = "python_version >= \"3.4\""
name = "ipython-genutils"
optional = false
python-versions = "*"
version = "0.2.0"
[[package]]
category = "dev"
description = "A Python utility / library to sort Python imports."
name = "isort"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "4.3.21"
[package.extras]
pipfile = ["pipreqs", "requirementslib"]
pyproject = ["toml"]
requirements = ["pipreqs", "pip-api"]
xdg_home = ["appdirs (>=1.4.0)"]
[[package]]
category = "main"
description = "Various helpers to pass data to untrusted environments and back."
name = "itsdangerous"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "1.1.0"
[[package]]
category = "dev"
description = "An autocompletion tool for Python that can be used for text editors."
marker = "python_version >= \"3.4\""
name = "jedi"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.17.2"
[package.dependencies]
parso = ">=0.7.0,<0.8.0"
[package.extras]
qa = ["flake8 (3.7.9)"]
testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"]
[[package]]
category = "main"
description = "A very fast and expressive template engine."
name = "jinja2"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "2.11.2"
[package.dependencies]
MarkupSafe = ">=0.23"
[package.extras]
i18n = ["Babel (>=0.8)"]
[[package]]
category = "main"
description = "Safely add untrusted strings to HTML/XML markup."
name = "markupsafe"
optional = false
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
version = "1.1.1"
[[package]]
category = "dev"
description = "McCabe checker, plugin for flake8"
name = "mccabe"
optional = false
python-versions = "*"
version = "0.6.1"
[[package]]
category = "main"
description = "multidict implementation"
name = "multidict"
optional = false
python-versions = ">=3.5"
version = "4.7.6"
[[package]]
category = "dev"
description = "A Python Parser"
marker = "python_version >= \"3.4\""
name = "parso"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "0.7.0"
[package.extras]
testing = ["docopt", "pytest (>=3.0.7)"]
[[package]]
category = "dev"
description = "Utility library for gitignore style pattern matching of file paths."
name = "pathspec"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "0.8.0"
[[package]]
category = "dev"
description = "Pexpect allows easy control of interactive console applications."
marker = "python_version >= \"3.4\" and sys_platform != \"win32\""
name = "pexpect"
optional = false
python-versions = "*"
version = "4.8.0"
[package.dependencies]
ptyprocess = ">=0.5"
[[package]]
category = "dev"
description = "Tiny 'shelve'-like database with concurrency support"
marker = "python_version >= \"3.4\""
name = "pickleshare"
optional = false
python-versions = "*"
version = "0.7.5"
[[package]]
category = "main"
description = "A pure-Python implementation of the HTTP/2 priority tree"
name = "priority"
optional = false
python-versions = "*"
version = "1.3.0"
[[package]]
category = "dev"
description = "Library for building powerful interactive command lines in Python"
marker = "python_version >= \"3.4\""
name = "prompt-toolkit"
optional = false
python-versions = ">=3.6.1"
version = "3.0.5"
[package.dependencies]
wcwidth = "*"
[[package]]
category = "dev"
description = "Run a subprocess in a pseudo terminal"
marker = "python_version >= \"3.4\" and sys_platform != \"win32\""
name = "ptyprocess"
optional = false
python-versions = "*"
version = "0.6.0"
[[package]]
category = "dev"
description = "Python style guide checker"
name = "pycodestyle"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "2.6.0"
[[package]]
category = "dev"
description = "passive checker of Python programs"
name = "pyflakes"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
version = "2.2.0"
[[package]]
category = "dev"
description = "Pygments is a syntax highlighting package written in Python."
marker = "python_version >= \"3.4\""
name = "pygments"
optional = false
python-versions = ">=3.5"
version = "2.6.1"
[[package]]
category = "main"
description = "A Python ASGI web microframework with the same API as Flask"
name = "quart"
optional = false
python-versions = ">=3.7.0"
version = "0.11.5"
[package.dependencies]
aiofiles = "*"
blinker = "*"
click = "*"
hypercorn = ">=0.7.0"
itsdangerous = "*"
jinja2 = "*"
toml = "*"
werkzeug = ">=1.0.0"
[package.extras]
dotenv = ["python-dotenv"]
[[package]]
category = "dev"
description = "Alternative regular expression module, to replace re."
name = "regex"
optional = false
python-versions = "*"
version = "2020.7.14"
[[package]]
category = "dev"
description = "a python refactoring library..."
name = "rope"
optional = false
python-versions = "*"
version = "0.16.0"
[package.extras]
dev = ["pytest"]
[[package]]
category = "main"
description = "Python 2 and 3 compatibility utilities"
name = "six"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
version = "1.15.0"
[[package]]
category = "main"
description = "Structured Logging for Python"
name = "structlog"
optional = false
python-versions = "*"
version = "20.1.0"
[package.dependencies]
six = "*"
[package.extras]
azure-pipelines = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "pytest-azurepipelines", "python-rapidjson", "pytest-asyncio"]
dev = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "sphinx", "twisted", "pre-commit", "python-rapidjson", "pytest-asyncio"]
docs = ["sphinx", "twisted"]
tests = ["coverage", "freezegun (>=0.2.8)", "pretend", "pytest (>=3.3.0)", "simplejson", "python-rapidjson", "pytest-asyncio"]
[[package]]
category = "main"
description = "Python Library for Tom's Obvious, Minimal Language"
name = "toml"
optional = false
python-versions = "*"
version = "0.10.1"
[[package]]
category = "dev"
description = "Traitlets Python config system"
marker = "python_version >= \"3.4\""
name = "traitlets"
optional = false
python-versions = "*"
version = "4.3.3"
[package.dependencies]
decorator = "*"
ipython-genutils = "*"
six = "*"
[package.extras]
test = ["pytest", "mock"]
[[package]]
category = "dev"
description = "a fork of Python 2 and 3 ast modules with type comment support"
name = "typed-ast"
optional = false
python-versions = "*"
version = "1.4.1"
[[package]]
category = "main"
description = "Backported and Experimental Type Hints for Python 3.5+"
name = "typing-extensions"
optional = false
python-versions = "*"
version = "3.7.4.2"
[[package]]
category = "dev"
description = "Measures the displayed width of unicode strings in a terminal"
marker = "python_version >= \"3.4\""
name = "wcwidth"
optional = false
python-versions = "*"
version = "0.2.5"
[[package]]
category = "main"
description = "The comprehensive WSGI web application library."
name = "werkzeug"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
version = "1.0.1"
[package.extras]
dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"]
watchdog = ["watchdog"]
[[package]]
category = "main"
description = "WebSockets state-machine based protocol implementation"
name = "wsproto"
optional = false
python-versions = ">=3.6.1"
version = "0.15.0"
[package.dependencies]
h11 = ">=0.8.1"
[[package]]
category = "main"
description = "Yet another URL library"
name = "yarl"
optional = false
python-versions = ">=3.5"
version = "1.4.2"
[package.dependencies]
idna = ">=2.0"
multidict = ">=4.0"
[[package]]
category = "dev"
description = "Backport of pathlib-compatible object wrapper for zip files"
marker = "python_version < \"3.8\""
name = "zipp"
optional = false
python-versions = ">=3.6"
version = "3.1.0"
[package.extras]
docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
testing = ["jaraco.itertools", "func-timeout"]
[metadata]
content-hash = "ceba58ecf5ec2b4dd95cc58ca7122659610ad89052f420d2e9f22223aff38dd4"
python-versions = "^3.7"
[metadata.files]
aiofiles = [
{file = "aiofiles-0.5.0-py3-none-any.whl", hash = "sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb"},
{file = "aiofiles-0.5.0.tar.gz", hash = "sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af"},
]
aiohttp = [
{file = "aiohttp-3.6.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e"},
{file = "aiohttp-3.6.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec"},
{file = "aiohttp-3.6.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48"},
{file = "aiohttp-3.6.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59"},
{file = "aiohttp-3.6.2-cp36-cp36m-win32.whl", hash = "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a"},
{file = "aiohttp-3.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17"},
{file = "aiohttp-3.6.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a"},
{file = "aiohttp-3.6.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd"},
{file = "aiohttp-3.6.2-cp37-cp37m-win32.whl", hash = "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"},
{file = "aiohttp-3.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654"},
{file = "aiohttp-3.6.2-py3-none-any.whl", hash = "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4"},
{file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"},
]
appdirs = [
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
]
appnope = [
{file = "appnope-0.1.0-py2.py3-none-any.whl", hash = "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0"},
{file = "appnope-0.1.0.tar.gz", hash = "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"},
]
async-timeout = [
{file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"},
{file = "async_timeout-3.0.1-py3-none-any.whl", hash = "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3"},
]
attrs = [
{file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
{file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
]
backcall = [
{file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"},
{file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"},
]
black = [
{file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"},
{file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"},
]
blinker = [
{file = "blinker-1.4.tar.gz", hash = "sha256:471aee25f3992bd325afa3772f1063dbdbbca947a041b8b89466dc00d606f8b6"},
]
chardet = [
{file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
{file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
]
click = [
{file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"},
{file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"},
]
colorama = [
{file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
{file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
]
decorator = [
{file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"},
{file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"},
]
flake8 = [
{file = "flake8-3.8.3-py2.py3-none-any.whl", hash = "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c"},
{file = "flake8-3.8.3.tar.gz", hash = "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208"},
]
h11 = [
{file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"},
{file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"},
]
h2 = [
{file = "h2-3.2.0-py2.py3-none-any.whl", hash = "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5"},
{file = "h2-3.2.0.tar.gz", hash = "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"},
]
hpack = [
{file = "hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89"},
{file = "hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"},
]
hypercorn = [
{file = "Hypercorn-0.10.1-py3-none-any.whl", hash = "sha256:728722914548f3ef1b2dded96a4c531bcc743c6bc8b549060a24255ddce2c0ad"},
{file = "Hypercorn-0.10.1.tar.gz", hash = "sha256:e3473eb1e4187b2468bd71eff5973736fc87a9bf49974da05925eb4ebed5aaff"},
]
hyperframe = [
{file = "hyperframe-5.2.0-py2.py3-none-any.whl", hash = "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40"},
{file = "hyperframe-5.2.0.tar.gz", hash = "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"},
]
idna = [
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
]
importlib-metadata = [
{file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"},
{file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"},
]
ipdb = [
{file = "ipdb-0.13.3.tar.gz", hash = "sha256:d6f46d261c45a65e65a2f7ec69288a1c511e16206edb2875e7ec6b2f66997e78"},
]
ipython = [
{file = "ipython-7.16.1-py3-none-any.whl", hash = "sha256:2dbcc8c27ca7d3cfe4fcdff7f45b27f9a8d3edfa70ff8024a71c7a8eb5f09d64"},
{file = "ipython-7.16.1.tar.gz", hash = "sha256:9f4fcb31d3b2c533333893b9172264e4821c1ac91839500f31bd43f2c59b3ccf"},
]
ipython-genutils = [
{file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"},
{file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"},
]
isort = [
{file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
{file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
]
itsdangerous = [
{file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"},
{file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"},
]
jedi = [
{file = "jedi-0.17.2-py2.py3-none-any.whl", hash = "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5"},
{file = "jedi-0.17.2.tar.gz", hash = "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20"},
]
jinja2 = [
{file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"},
{file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"},
]
markupsafe = [
{file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"},
{file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"},
{file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"},
{file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"},
{file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"},
{file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"},
{file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"},
{file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"},
{file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"},
{file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"},
{file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"},
{file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
{file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
]
mccabe = [
{file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"},
{file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"},
]
multidict = [
{file = "multidict-4.7.6-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000"},
{file = "multidict-4.7.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a"},
{file = "multidict-4.7.6-cp35-cp35m-win32.whl", hash = "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5"},
{file = "multidict-4.7.6-cp35-cp35m-win_amd64.whl", hash = "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3"},
{file = "multidict-4.7.6-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87"},
{file = "multidict-4.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2"},
{file = "multidict-4.7.6-cp36-cp36m-win32.whl", hash = "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7"},
{file = "multidict-4.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463"},
{file = "multidict-4.7.6-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"},
{file = "multidict-4.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255"},
{file = "multidict-4.7.6-cp37-cp37m-win32.whl", hash = "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507"},
{file = "multidict-4.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c"},
{file = "multidict-4.7.6-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b"},
{file = "multidict-4.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7"},
{file = "multidict-4.7.6-cp38-cp38-win32.whl", hash = "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d"},
{file = "multidict-4.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19"},
{file = "multidict-4.7.6.tar.gz", hash = "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430"},
]
parso = [
{file = "parso-0.7.0-py2.py3-none-any.whl", hash = "sha256:158c140fc04112dc45bca311633ae5033c2c2a7b732fa33d0955bad8152a8dd0"},
{file = "parso-0.7.0.tar.gz", hash = "sha256:908e9fae2144a076d72ae4e25539143d40b8e3eafbaeae03c1bfe226f4cdf12c"},
]
pathspec = [
{file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"},
{file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"},
]
pexpect = [
{file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},
{file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},
]
pickleshare = [
{file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"},
{file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"},
]
priority = [
{file = "priority-1.3.0-py2.py3-none-any.whl", hash = "sha256:be4fcb94b5e37cdeb40af5533afe6dd603bd665fe9c8b3052610fc1001d5d1eb"},
{file = "priority-1.3.0.tar.gz", hash = "sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe"},
]
prompt-toolkit = [
{file = "prompt_toolkit-3.0.5-py3-none-any.whl", hash = "sha256:df7e9e63aea609b1da3a65641ceaf5bc7d05e0a04de5bd45d05dbeffbabf9e04"},
{file = "prompt_toolkit-3.0.5.tar.gz", hash = "sha256:563d1a4140b63ff9dd587bda9557cffb2fe73650205ab6f4383092fb882e7dc8"},
]
ptyprocess = [
{file = "ptyprocess-0.6.0-py2.py3-none-any.whl", hash = "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"},
{file = "ptyprocess-0.6.0.tar.gz", hash = "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0"},
]
pycodestyle = [
{file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"},
{file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"},
]
pyflakes = [
{file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"},
{file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"},
]
pygments = [
{file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"},
{file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"},
]
quart = [
{file = "Quart-0.11.5-py3-none-any.whl", hash = "sha256:187427d1a2d7fed20dcb825dddbe20fd971efd7ec413639f95d2e28ff59a0cb1"},
{file = "Quart-0.11.5.tar.gz", hash = "sha256:bd93650fa856dcfbc3890952ab3ca53f7755ab506d453a209db63713eceeceda"},
]
regex = [
{file = "regex-2020.7.14-cp27-cp27m-win32.whl", hash = "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7"},
{file = "regex-2020.7.14-cp27-cp27m-win_amd64.whl", hash = "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd"},
{file = "regex-2020.7.14-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88"},
{file = "regex-2020.7.14-cp36-cp36m-win32.whl", hash = "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4"},
{file = "regex-2020.7.14-cp36-cp36m-win_amd64.whl", hash = "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7"},
{file = "regex-2020.7.14-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89"},
{file = "regex-2020.7.14-cp37-cp37m-win32.whl", hash = "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6"},
{file = "regex-2020.7.14-cp37-cp37m-win_amd64.whl", hash = "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204"},
{file = "regex-2020.7.14-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99"},
{file = "regex-2020.7.14-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e"},
{file = "regex-2020.7.14-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e"},
{file = "regex-2020.7.14-cp38-cp38-win32.whl", hash = "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341"},
{file = "regex-2020.7.14-cp38-cp38-win_amd64.whl", hash = "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840"},
{file = "regex-2020.7.14.tar.gz", hash = "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb"},
]
rope = [
{file = "rope-0.16.0-py2-none-any.whl", hash = "sha256:ae1fa2fd56f64f4cc9be46493ce54bed0dd12dee03980c61a4393d89d84029ad"},
{file = "rope-0.16.0-py3-none-any.whl", hash = "sha256:52423a7eebb5306a6d63bdc91a7c657db51ac9babfb8341c9a1440831ecf3203"},
{file = "rope-0.16.0.tar.gz", hash = "sha256:d2830142c2e046f5fc26a022fe680675b6f48f81c7fc1f03a950706e746e9dfe"},
]
six = [
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
]
structlog = [
{file = "structlog-20.1.0-py2.py3-none-any.whl", hash = "sha256:8a672be150547a93d90a7d74229a29e765be05bd156a35cdcc527ebf68e9af92"},
{file = "structlog-20.1.0.tar.gz", hash = "sha256:7a48375db6274ed1d0ae6123c486472aa1d0890b08d314d2b016f3aa7f35990b"},
]
toml = [
{file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
{file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
]
traitlets = [
{file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"},
{file = "traitlets-4.3.3.tar.gz", hash = "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"},
]
typed-ast = [
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"},
{file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"},
{file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"},
{file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"},
{file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"},
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"},
{file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"},
{file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"},
{file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"},
{file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"},
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"},
{file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"},
{file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"},
{file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"},
{file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"},
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"},
{file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"},
{file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"},
{file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"},
{file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"},
{file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"},
]
typing-extensions = [
{file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"},
{file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"},
{file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"},
]
wcwidth = [
{file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"},
{file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"},
]
werkzeug = [
{file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"},
{file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"},
]
wsproto = [
{file = "wsproto-0.15.0-py2.py3-none-any.whl", hash = "sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d"},
{file = "wsproto-0.15.0.tar.gz", hash = "sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d"},
]
yarl = [
{file = "yarl-1.4.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b"},
{file = "yarl-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1"},
{file = "yarl-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080"},
{file = "yarl-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a"},
{file = "yarl-1.4.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f"},
{file = "yarl-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea"},
{file = "yarl-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb"},
{file = "yarl-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70"},
{file = "yarl-1.4.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d"},
{file = "yarl-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce"},
{file = "yarl-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2"},
{file = "yarl-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce"},
{file = "yarl-1.4.2-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b"},
{file = "yarl-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae"},
{file = "yarl-1.4.2-cp38-cp38-win32.whl", hash = "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462"},
{file = "yarl-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6"},
{file = "yarl-1.4.2.tar.gz", hash = "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b"},
]
zipp = [
{file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"},
{file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"},
]

View file

@ -1,35 +0,0 @@
[tool.poetry]
name = "butterrobot"
version = "0.0.2a2"
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"
quart = "^0.11.3"
aiohttp = "^3.6.2"
structlog = "^20.1.0"
colorama = "^0.4.3"
[tool.poetry.dev-dependencies]
black = "^19.10b0"
flake8 = "^3.7.9"
rope = "^0.16.0"
isort = "^4.3.21"
ipdb = "^0.13.2"
[tool.poetry.plugins]
[tool.poetry.plugins."butterrobot.plugins"]
"fun.loquito" = "butterrobot_plugins_contrib.fun:LoquitoPlugin"
"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 = click,django,docker,factory,pydantic,pytest,requests,toml
sections = FUTURE, STDLIB, DJANGO, THIRDPARTY, FIRSTPARTY, LOCALFOLDER
no_lines_before = LOCALFOLDER